fix(metadata-protocol): zero organizations is a third state, not the ambiguous one (#12395) - #12594
Conversation
…ambiguous one (#12395) The #8686 split diagnostic guarded on `organizationIds.length !== 1`, folding "no organizations yet" together with "several organizations". They are opposite conditions: with several the owner is underdetermined, but with none there is no second partition, so each object runs exactly one `__global__` counter and the line's claim of two live counters and an active duplicate-minting hazard was false at the one moment a fresh install actually read it. Zero now returns `no-organization-yet` at `info` — reported, not silenced, and named after the 0/1/several line objectql's `resolveSystemWriteOrganization` already draws. A FAILED organization probe keeps the loud path (#9261): unknown is not zero. The repair threshold is unchanged — data still moves on exactly `length === 1`. The affected-object list is now described as a probe-time snapshot: `kernel:ready` can be reached while an over-budget inline seed is still writing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin df263cb2b36222b50fdbff54452620982dbb3023 && git checkout df263cb2b36222b50fdbff54452620982dbb3023
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b72db019852dbd0f088d44310b07cc437a66845f a3b952f2378897a5628f54d947845392fe5d9ed3 && git checkout -B drift-repro b72db019852dbd0f088d44310b07cc437a66845f && git merge --no-ff a3b952f2378897a5628f54d947845392fe5d9ed3
node scripts/docs-audit/affected-docs.mjs --json b72db019852dbd0f088d44310b07cc437a66845f |
PM review — accepted, flipped ready, auto-merge armed. And my serial ruling's stated mechanism was wrong.⛔ The correction, verified independently before I wrote thisMy brief told this dispatch that #12394's counter destruction "used to fire at It never fired at The guard is byte-identical across #12394, and it returns before the counter loop. At zero organizations the merge/delete was structurally unreachable. #12554's own commit message names the real trigger: "On a fresh install there is no organization-scoped row yet, so the UPDATE matched nothing … the DELETE ran anyway" — that is So the destruction was gated on 1, not on 0. What survives and what does not. The decision survives — serial was reasonable insurance and cost nothing. The reasoning does not: I asserted a causal gate that measurement disproves, and I asserted it as the thing that made the ordering necessary rather than merely prudent. The dev's conclusion is the stronger one: silencing at zero was always safe with respect to counter destruction, because it never gated it. This is the third premise I supplied today that a dev had to correct by measuring — after the dissolved #5499 freeze and the #12554 root cause. Three is a pattern, not three slips, and the pattern is that I assert mechanisms from reading the code rather than measuring it. Recorded in §7 of the seat post as one systemic entry. The PRProblem 1 solved as reported-not-silenced. Zero becomes its own state — no-organization-yet, at ⭐ The 0/1/several shape is not invented here. A regression the fix could have introduced, guarded. A failed organization probe returns the same empty array as a genuine zero. It is tracked separately ( Problem 2 — the judgment read triage asked for, and a deliberate non-change. The race is real: And the two halves compose: the only boot that can observe a partial list on a fresh install is the zero-organization boot, which no longer warns at all. Clause ② judged against the diff, not asserted. Filtering the source diff for every write path and threshold returns exactly two additive lines; the Ablation predicted 3 failures by name and observed exactly those three, with 0 pre-existing tests changing state. The no-rebuild decision is justified by import form — the pin uses a relative specifier that never travels the exports map — and the runtime integration test, which does resolve through Two honest instrument notes: Two standing repo facts correctly not filedThe CI is the remaining gate. Generated by Claude Code |
Fixes #12395
All gates below were run on the final commit
a3b952f237(tree clean, nothing committed after the verification runs).Where the symbols actually are
Every line number on this card is stale — #12394 (PR #12554) grew
seed-tenancy-backfill.tsfrom 1171 to 1407 lines. Located by symbol on mergedorigin/main:da1126a032organizationIds.length !== 1guard + payload1016–10251263buildOrgCounterProbeSqlI was told to establish on the current tree that "the counter destruction that used to fire at
organizationCount: 0no longer happens". It never fired atorganizationCount: 0, before or after #12394.The guard returns
skipped-ambiguous-organizationbefore step 5/6, so the counter loop was unreachable at zero organizations. Proven by extracting the guard body from both refs (PRE.tsfromda1126a032^,POST.tsfromda1126a032) and diffing:#12554's own commit message names the real trigger: "On a fresh install there is no organization-scoped row yet, so the UPDATE matched nothing … the DELETE ran anyway" — that is
organizationCount1 with no organization-scoped counter row, which is reached only throughlength === 1.The conclusion is unchanged and in fact stronger: silencing at zero was always safe with respect to counter destruction, because it never gated it. The serial hold cost nothing and was reasonable insurance, but its stated mechanism was not the real one.
Problem 1 — the guard conflated two opposite conditions
Reproduced first, against the real exported function:
Same status, same warning, opposite conditions. With zero organizations there is no second partition: every object runs one
__global__counter, so both halves of the hazard sentence are false exactly when a fresh install reads them. TheorganizationLastValue: 0alongside isbuildSplitProbeSql'sLEFT JOINfinding no second row — confirmed in the builder, which yieldsNULL→toNumber→0.Fix: zero becomes its own state,
no-organization-yet, logged atinfo. Not silence — the split is still reported, because the observation is real even though the hazard is not.The name and the 0 / 1 / several shape are not invented here.
objectql'sresolveSystemWriteOrganizationalready draws exactly this line, and its own comment says "⛔ Refusing here would refuse first boot itself." Its neighbouring comment claims "#8686's backfill draws the same line (skipped-ambiguous-organization)" — which was false until this PR and is true after it, so it needed no edit.A regression this fix could have introduced, and does not
The organization probe returns the same empty array for "none" and for "threw". Routing a failed probe into the benign path would turn an outage into a reassuring
infoline — the exact confusionobjectqlfixed in #9261.organizationProbeErrornow keeps a failed probe on the loud path and says so. Pinned.Problem 2 — the judgment read of the boot sequence
Reproduced: the affected list is read at probe time, so on one and the same database
The race is real:
app-plugin.tsrunsPromise.race([seedPromise, budget])and on overrun logs[Seeder] Inline seed exceeded 8000ms budget … continuing in backgroundwhilekernel:readyproceeds.I did not reorder the backfill, deliberately. The read that decides it is the call-site comment already in
metadata-protocol/src/plugin.ts:So the boot pass exists for installs whose rows are already written and have nothing to wait for, and the fresh-install pass is delivered by the
sys_organization-insert handoff, which by construction runs after sign-up on a settled database. Making boot block on seed settlement would delay a repair that has no reason to wait, and would be the one change here that is accept/reject-relevant. Instead the line now says what it is: a probe-time snapshot, not a census. Applied to every list-bearing branch.Note the two halves compose: the only boot that can observe a partial list on a fresh install is the zero-organization boot, which no longer warns at all.
Clause ② — not an accept/reject change
Judged against the diff, not asserted bare. Filtering the source diff for every write path and threshold —
organizationIds.length,buildStampSql,mergeSplitCounter,buildGlobalCounterDelete*,buildCounterMerge*,buildCounterInsert*— returns exactly two lines, both additive: the new=== 0guard, and one payload key. No SQL builder and no write call site moved, and the!== 1repair threshold is untouched. The set of inputs on which this migration modifies data is identical: exactlyorganizationIds.length === 1. What changed is a log level, log text, and the status string returned on a path that already wrote nothing. Pinned executably by[no writes].Ablation — direction and exact count predicted in writing first
Mutation
organizationIds.length === 0→=== -1, restoring the old conflation.Predicted before running: RED, exactly 3 failures —
[zero],[the claim moved with the state],[snapshot]— with[several],[no writes],[#9261]still passing and 0 pre-existing tests moving.Mutation proved on disk before any result was read:
Observed: baseline
57 passed; mutated3 failed | 54 passed— the three predicted, by name. Restored undertrap … EXIT INT TERM; restore verified withgit diff --quiet→ exit0.No rebuild, justified by import form: the test imports
'./seed-tenancy-backfill.js', a relative specifier vitest resolves to the TS source beside it — it never travels the packageexportsmap, sodist/is not on this path. (The runtime integration test does import@objectstack/metadata-protocoland so resolves throughdist/; it is deliberately not part of this ablation, and was run separately after a build.)Pinning the discrimination, not the message
Both arms are asserted in every case that can carry both, so a rewrite that deletes the sentence everywhere cannot go green:
zero.infomust not contain the hazard sentence whileseveral.warnmust. No existing assertion was changed — the two pre-existing pins onskipped-ambiguous-organization(null-seam.test.ts, runtime's integration test) both drive the two-organization arm, which keeps its status and its warning. No test covered the zero arm before this PR.Gates
Union derived over the real changeset with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(provenance line confirmedobjectstack-ai/objectstack@a3b952f237, 3 paths, three-dot semantics). Each exit code captured by redirect-then-capture, never after a pipe.Green (27) — 20 path-matched + convention-triggered for editing a test file, plus
check:nul-bytes:nul-bytes·durability-log-level·changeset-gate-self-tests·cross-package-test-inputs(both) ·objectql-double-limit·objectui-changeset·page-declaration-shape·published-files·slot-lookup·test-source-alias·type-source-resolution·query-options-erasure·engine-double-contract·where-matcher·type-check-coverage·adr-0087-registration·changeset-no-major·ci-filter-parity·comment-mask-adoption·empty-changeset·plugin-teardown-shape·docs-affected·release-rehearsal-clonecheck:type-check-debt --re-measure: refused first on an unbuilt worktree (reported as not measured, never "not applicable"); closure then built (turbo run build, 70/70 successful) and re-run —31 ledger entries re-measured, 1687 raw errors, none above its recorded number.@objectstack/metadata-protocolis ledgered at 63, so this was a live risk for new test code; it did not drift.Repo-scale
pnpm lint(eslint . --no-inline-config): run in full, exit 0 in 65s. No narrowing, so no narrowing argument is owed.Tests:
@objectstack/metadata-protocol1964 passed | 10 skipped, exit 0. Runtime'sseed-tenancy-autonumber-split.integration.test.ts11 passed, exit 0, run after its dependency closure was built (it resolves throughdist/). There is notypecheckscript in this package —pnpm --filter … typecheckfailed loudly withERR_PNPM_RECURSIVE_RUN_NO_SCRIPTrather than the silent exit-0 zero-match;buildemits DTS and is the typecheck here.Not measured locally, left to CI:
check:drift-comment(needs PR context) and the 156 families the path derivation did not place.Changeset
patchon@objectstack/metadata-protocol. Defended: published behaviour changes (awarnbecomes aninfoon fresh installs) and the exportedSeedTenancyBackfillStatusunion gains a member. Additive on a return type — no consumer narrows it exhaustively (verified: the only non-test references are two comments and two test assertions, all on the ambiguous arm) — so notminor.Generated by Claude Code