Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): a seam that cannot answer is absent, not empty (#10789) - #10964
Conversation
#10789) `backfillSeedTenancy` reported `no-split` over a driver it never queried. A no-op `execute` returns `null` — it neither throws nor is absent — so the `no-driver` guard's shape test passed, `normalizeRows(null)` flattened to `[]`, and the module's own `absent` branch was unreachable on a memory engine despite its comment naming exactly that case. The READ probes are now held to the standard that separates the two: a driver that answers returns a RESULT SET. Write statements stay on the bare seam (an UPDATE returns no result set on every dialect), a throwing seam keeps its existing `absent` route, and an empty result set in all three dialect spellings is still an ANSWER — so a healthy SQL install still reports `no-split`. Consumer-side only; no driver package was modified (#5499 freeze). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ 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 dbe47fb36f565a6780112f33ce82bce7343273e7 && git checkout dbe47fb36f565a6780112f33ce82bce7343273e7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 926778bce01620ae56a9b0eecbf0400c43a64aa8 608905ce602cef94f082e7eb4e35371057cfff85 && git checkout -B drift-repro 926778bce01620ae56a9b0eecbf0400c43a64aa8 && git merge --no-ff 608905ce602cef94f082e7eb4e35371057cfff85
node scripts/docs-audit/affected-docs.mjs --json 926778bce01620ae56a9b0eecbf0400c43a64aa8 |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32541740850 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
`get()` returned `null` and `list()` returned `[]` over a storage seam that accepted the query and ran nothing. `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`; the local `normalizeRows` maps that to `[]`, which is also what a working driver returns for a SELECT that matched nothing. Both read paths reported that emptiness as a product answer callers act on, and `start()`'s own `sys_packages` rehydration skipped in silence because of it. Reads now establish the seam ANSWERED before reading emptiness as a fact: a result that is not a result set is refused with a declared ADR-0112 envelope (`SERVICE_UNAVAILABLE` / 503), and the skipped boot rehydration is logged at warn. A seam that answers with genuinely zero rows is unchanged. Third instance of one class (#10677 / PR #10788, #10789 / PR #10964): a seam that cannot answer is absent, not empty. The predicate is a local copy — `metadata-protocol` deliberately does not publish its own, and this package does not depend on it. Part of #10965 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
Fixes#10789
The defect
backfillSeedTenancyruns at boot fromruntime/src/app-plugin.tsandmetadata-protocol/src/plugin.ts. On the memory driver it returnedstatus: 'no-split'— "I looked, there is no split" — having looked at nothing,and the module's own
absentbranch was unreachable there despite that branch'scomment saying
"Absent on a memory engine". A provably broken intent, not adesign choice.
InMemoryDriver.execute()logsRaw execution not supported in InMemory driverand returns
null. It neither throws nor is absent, soresolveSeedTenancySeam'scanRun(typeof d.execute === 'function'— a question about the driver's SHAPE)is satisfied and
if (!seam?.exec) return { status: 'no-driver' }never fires; thepresence probe RETURNS instead of throwing, so its
catchnever runs; andnormalizeRows(null)is[], which is also what a real driver returns for aSELECT that matched nothing.
⭐ A seam that cannot answer is absent, not empty.
nullis a fourth thingbeside the three dialect result-set shapes
normalizeRowsflattens — it means"I did not run your query", and it was mapped onto "your query returned no rows".
The fix
Consumer-side only, in
packages/metadata-protocol. No driver package wasmodified (the 2026-08-05 investment freeze, #5499).
The shape #10677 / PR #10788 landed for
os migrate duplicates, applied to thismodule's own probes: judge the seam by whether it returns a result set, not by
whether
executeexists.isResultSetaccepts the same three shapesnormalizeRowsflattens — a bare row array,{ rows }, and the[rows, fields]tuple — including their empty spellings, and a private
selectRowshelperthrows when a READ probe hands back anything else. The throw lands in the
catcheach probe already had, so no new branch and no new status: a probe that cannot
answer takes the route a probe that THREW has always taken.
Two divergences from the precedent's shape, both deliberate:
already a SELECT that every boot runs, so it carries the check itself. The
precedent needed a separate
select 1because its seam is resolved without one.exec. The precedent wraps the wholeseam because its collector only issues SELECTs. This module also issues UPDATE
and DELETE, which do not return a result set on every dialect (better-sqlite3
through knex reports a change count, mysql2 a
ResultSetHeader), so the writestatements in step 6 stay on the bare seam. Wrapping them would have broken the
repair on exactly the installs it exists for — there is a test pinning this.
resolveSeedTenancySeamis deliberately unchanged: a resolver that rejected acallable would have to CALL it to know, which is a probe, not a shape test.
Also corrected: the
no-driverandabsentdoc comments onSeedTenancyBackfillStatus. Both claimed the memory engine;no-driver's claim waswrong before this change too (the engine landed on
no-split), and is now wrong ina different way, so it says what actually reaches it.
Evidence — the two-sided bar
Both readings are pinned, and they falsify in opposite directions.
DEFECT CONTROL —
absenton a non-answering seam. Red on the pre-fix tree, onthe real code path, with no ablation involved:
Post-fix, the same suite:
Tests 1813 passed | 10 skipped (1823)— exactly thosethree flipped, nothing else moved.
PRESERVED-BEHAVIOUR CONTROL —
no-spliton a real seam with no split rows. Thisis what stops the fix being vacuous: a change that answered
absentwhenever it wasunsure would satisfy the defect control perfectly and destroy the status. Passes
pre-fix, so it is falsified by mutating the fix — making the non-answer detection
over-trigger (an empty result set read as a non-answer):
Under that mutant the
absentdefect controls stay green — which is preciselythe failure mode the two-sided bar exists to catch.
The real-driver half runs on a real
SqlDriverover better-sqlite3 and takes theload-bearing measurement from the driver rather than assuming it: the presence probe
(
WHERE 1 = 0, matches nothing by construction, runs on every boot) comes back as abare
[]and must be read as an ANSWER. If it were not, every healthy SQL install onearth would report
absentat boot.src/distreasoned in both directions:packages/runtimeresolves@objectstack/metadata-protocolthroughdist(KNOWN_UNALIASED_TEST_IMPORTS), sometadata-protocolwas rebuilt before every read, and each leg was proved on theartifact — the fix live via
ablation-dist-preflight(marker present in 2 built files) and behaviourally (a script importingdist/index.jsprintingDIST_SAYS=no-split), the mutant leg likewise (DIST_SAYS=absent). Restorationproved on disk:
git hash-object==git rev-parse HEAD:PATH(
d7daae02416d191ff89ddbf8476e8d5be648ce94),git diff --exit-code0, porcelainempty, and the mutant marker
absent from all 22 built files.Blast radius — concretely
Which installs. The memory driver reaches this migration through DevPlugin's
zero-install dev stack (
plugin-dev/src/dev-plugin.ts:473-482registers a realInMemoryDriverwhen no database is configured), plusservice-datasource'smemoryarm and its rung-3 sqlite fallback.examples/embed-objectqlis the oneexample that names the driver, and it embeds ObjectQL directly rather than booting
the plugin that runs this migration.
What they log, before and after: nothing, in both cases. Neither
no-splitnorabsentemits a log line, and neither writes asys_migrationreceipt — the receiptis written only on the
appliedpath. A memory-driver boot is byte-identical in itsoutput; there is a test asserting the unreadable seam stays silent, because a new
boot-time warning on every dev server would be a worse regression than the defect.
Does anything downstream branch on
status? No — and that is stated plainly ratherthan glossed. Both boot callers
await backfillSeedTenancy(...)and discard theresult. Today no caller distinguishes
no-splitfromabsent, so this changes areported value that has no live consumer. What it buys is that the value is now
honest for the first consumer that reads it, and that the module's documented
absentbranch is reachable at all — the card's own framing ("a status nobodycurrently reads"). Calling it a fix rather than a rename rests on the
absentbranch having been dead code, not on a caller changing behaviour.
Expected NON-effects, named before each run and then compared
@objectstack/runtimesuite2680 passedno-driverstill fires where it fires todayresolveSeedTenancySeam({})stillundefined, stillno-driver70 successful, 70 total; no example resolves this migration on memoryabsentwith no non-answerdetailOne prediction missed, reported rather than quietly corrected: I predicted the mutant
would redden 4 cases in the sibling
seed-tenancy-backfill.test.tsand it reddened6 —
[no ledger]and[the receipt is out of the repair's own reach]also drivethe applied path through a fixture whose presence probe returns
[], which I had notcounted. Same mechanism, wider blast than predicted.
Deliberately not done
@objectstack/driver-memoryis not imported by the new tests. Every modulebinding of that specifier is gated by
pnpm check:driver-memory-censusagainstscripts/driver-memory-census.ledger.json, whose header rules that an unledgeredarrival is "NOT a bookkeeping chore to silence" — it needs a disposition through
#5704 Q2 / #6664 A-B-C first, and the ledger is shrink-only. Nothing is lost: the
execute() -> nullshape is already pinned on a REAL booted memory driver bypackages/cli/src/commands/migrate/duplicates.null-seam.test.ts(#10677). The newfile pins what that one cannot — what
backfillSeedTenancydoes with such a seam.isResultSetis not added to the package index. It has no consumer outside itsown module, and that file's existing comment names "an export added so a
cross-package TEST can import a value" as exactly the shape to catch before it ships.
The CLI keeps its own copy for its own probes; unifying the two is a separate
decision, the same way
quoteIdentrecords it for the same pair.Verification
Gate union derived by
node scripts/pm/dispatch-gates.mjswith no path argumentsafter the final commit, run at
608905ce60(porcelain empty). All 19 green — 14path-matched, 5 convention-triggered — exits captured before any pipe:
check:changeset-gate-self-tests·check:cross-package-test-inputs·check:durability-log-level·check:objectui-changeset·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-cross-package-test-inputs·check-empty-changeset·check-plugin-teardown-shape·check-affected-docs·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:type-check-coverage·check:type-check-debt --re-measurecheck:driver-memory-censuswas run too though the derivation calls it unreachable —it is the gate that shaped the design decision above:
EXIT=0.The ratchet's own verdict line, on the built closure:
(Its
plugin-authsurplus note is pre-existing, unrelated to these paths, and carriesits own tracking issue #6376 — not touched here.)
Suites:
@objectstack/metadata-protocolTests 1813 passed | 10 skipped (1823);@objectstack/runtimeTests 2680 passed (2680);@objectstack/runtime typecheck(
tsc --noEmit) exit 0.metadata-protocolships notypecheckscript — a--filterfor one would match zero scripts and exit 0 silently — so its type surfaceis covered by
tsup's DTS build (DTS ⚡️ Build success) and by the ratchet above.Changeset:
.changeset/seed-tenancy-absent-seam.md(patch,@objectstack/metadata-protocol)— boot-time reported status is user-visible, so this is not a tests-only change.
Generated by Claude Code