Skip to content

fix(metadata-protocol): a seam that cannot answer is absent, not empty (#10789) - #10964

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10789-seed-tenancy-absent-seam
Aug 22, 2026
Merged

fix(metadata-protocol): a seam that cannot answer is absent, not empty (#10789)#10964
os-elon merged 1 commit into
mainfrom
claude/issue-10789-seed-tenancy-absent-seam

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10789

The defect

backfillSeedTenancy runs at boot from runtime/src/app-plugin.ts and
metadata-protocol/src/plugin.ts. On the memory driver it returned
status: 'no-split'"I looked, there is no split" — having looked at nothing,
and the module's own absent branch was unreachable there despite that branch's
comment saying "Absent on a memory engine"
. A provably broken intent, not a
design choice.

InMemoryDriver.execute() logs Raw execution not supported in InMemory driver
and returns null. It neither throws nor is absent, so resolveSeedTenancySeam's
canRun (typeof d.execute === 'function' — a question about the driver's SHAPE)
is satisfied and if (!seam?.exec) return { status: 'no-driver' } never fires; the
presence probe RETURNS instead of throwing, so its catch never runs; and
normalizeRows(null) is [], which is also what a real driver returns for a
SELECT that matched nothing.

A seam that cannot answer is absent, not empty.null is a fourth thing
beside the three dialect result-set shapes normalizeRows flattens — 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 was
modified
(the 2026-08-05 investment freeze, #5499).

The shape #10677 / PR #10788 landed for os migrate duplicates, applied to this
module's own probes: judge the seam by whether it returns a result set, not by
whether execute exists. isResultSet accepts the same three shapes
normalizeRows flattens — a bare row array, { rows }, and the [rows, fields]
tuple — including their empty spellings, and a private selectRows helper
throws when a READ probe hands back anything else. The throw lands in the catch
each 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:

  1. No extra preflight statement. Step 1's counter-table presence probe is
    already a SELECT that every boot runs, so it carries the check itself. The
    precedent needed a separate select 1 because its seam is resolved without one.
  2. The wrapper is not applied to every exec. The precedent wraps the whole
    seam 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 write
    statements 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.

resolveSeedTenancySeam is deliberately unchanged: a resolver that rejected a
callable would have to CALL it to know, which is a probe, not a shape test.

Also corrected: the no-driver and absent doc comments on
SeedTenancyBackfillStatus. Both claimed the memory engine; no-driver's claim was
wrong before this change too (the engine landed on no-split), and is now wrong in
a 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 — absent on a non-answering seam. Red on the pre-fix tree, on
the real code path, with no ablation involved:

 × [defect control] a no-op seam reports absent — never no-split
× [defect control] a seam answering the presence probe but not the split probe is absent too
× [defect control] a healthy install and an unreadable one are no longer the same answer
AssertionError: expected 'no-split' to be 'absent' (x3)
Test Files 1 failed | 131 passed | 2 skipped (134)
Tests 3 failed | 1810 passed | 10 skipped (1823)

Post-fix, the same suite: Tests 1813 passed | 10 skipped (1823) — exactly those
three flipped, nothing else moved.

PRESERVED-BEHAVIOUR CONTROL — no-split on a real seam with no split rows. This
is what stops the fix being vacuous: a change that answered absent whenever it was
unsure 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):

 × accepts every dialect result-set shape, INCLUDING the empty spellings
× [preserved] an empty result set in the sqlite spelling is an ANSWER, not a non-answer
× [preserved] an empty result set in the pg spelling is an ANSWER, not a non-answer
× [preserved] an empty result set in the mysql spelling is an ANSWER, not a non-answer
× [preserved] the applied path still applies — write statements are NOT held to "must answer"
× [#10789 preserved] a real seam with no split rows still answers no-split, not absent
Tests 14 failed | 45 passed (59) (metadata-protocol, mutant)
Tests 8 failed | 1 passed (9) (runtime real-driver file, mutant)
→ expected 'absent' to be 'no-split'

Under that mutant the absent defect controls stay green — which is precisely
the failure mode the two-sided bar exists to catch.

The real-driver half runs on a real SqlDriver over better-sqlite3 and takes the
load-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 a
bare [] and must be read as an ANSWER. If it were not, every healthy SQL install on
earth would report absent at boot.

src/dist reasoned in both directions: packages/runtime resolves
@objectstack/metadata-protocol through dist (KNOWN_UNALIASED_TEST_IMPORTS), so
metadata-protocol was rebuilt before every read, and each leg was proved on the
artifact — the fix live via ablation-dist-preflight (marker present in 2 built files) and behaviourally (a script importing dist/index.js printing
DIST_SAYS=no-split), the mutant leg likewise (DIST_SAYS=absent). Restoration
proved on disk: git hash-object == git rev-parse HEAD:PATH
(d7daae02416d191ff89ddbf8476e8d5be648ce94), git diff --exit-code 0, porcelain
empty, 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-482 registers a real
InMemoryDriver when no database is configured), plus service-datasource's
memory arm and its rung-3 sqlite fallback. examples/embed-objectql is the one
example 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-split nor
absent emits a log line, and neither writes a sys_migration receipt — the receipt
is written only on the applied path. A memory-driver boot is byte-identical in its
output; 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 rather
than glossed.
Both boot callers await backfillSeedTenancy(...) and discard the
result. Today no caller distinguishes no-split from absent, so this changes a
reported 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
absent branch is reachable at all — the card's own framing ("a status nobody
currently reads"). Calling it a fix rather than a rename rests on the absent
branch having been dead code, not on a caller changing behaviour.

Expected NON-effects, named before each run and then compared

PredictedObserved
The SQL-driver path does not move at all✅ all 8 pre-existing cases in the real-driver integration file green; full @objectstack/runtime suite 2680 passed
no-driver still fires where it fires today✅ pinned — resolveSeedTenancySeam({}) still undefined, still no-driver
The mongodb branch untouched✅ not loaded, not asserted anywhere, not measured — left explicitly unverified
No example's boot broken✅ farm build 70 successful, 70 total; no example resolves this migration on memory
A seam that THROWS keeps its behaviour✅ pinned, absent with no non-answer detail

One prediction missed, reported rather than quietly corrected: I predicted the mutant
would redden 4 cases in the sibling seed-tenancy-backfill.test.ts and it reddened
6[no ledger] and [the receipt is out of the repair's own reach] also drive
the applied path through a fixture whose presence probe returns [], which I had not
counted. Same mechanism, wider blast than predicted.

Deliberately not done

@objectstack/driver-memory is not imported by the new tests. Every module
binding of that specifier is gated by pnpm check:driver-memory-census against
scripts/driver-memory-census.ledger.json, whose header rules that an unledgered
arrival 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() -> null shape is already pinned on a REAL booted memory driver by
packages/cli/src/commands/migrate/duplicates.null-seam.test.ts (#10677). The new
file pins what that one cannot — what backfillSeedTenancy does with such a seam.

isResultSet is not added to the package index. It has no consumer outside its
own 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 quoteIdent records it for the same pair.

Verification

Gate union derived by node scripts/pm/dispatch-gates.mjs with no path arguments
after the final commit, run at 608905ce60 (porcelain empty). All 19 green — 14
path-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-measure

check:driver-memory-census was 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:

check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 343.0s,
1908 raw tsc error(s) total, none above its recorded number.

(Its plugin-auth surplus note is pre-existing, unrelated to these paths, and carries
its 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-protocol ships no typecheck script — a
--filter for one would match zero scripts and exit 0 silently — so its type surface
is 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

#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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 926778bce01620ae56a9b0eecbf0400c43a64aa8packageMentionDocs.

Which tree this was computed on

This run read content/docs from dbe47fb36f565a6780112f33ce82bce7343273e7 — the merge of head 608905ce602cef94f082e7eb4e35371057cfff85 into base 926778bce01620ae56a9b0eecbf0400c43a64aa8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 22, 2026
@os-elon
os-elon marked this pull request as ready for review August 22, 2026 00:45
@os-elon
os-elon enabled auto-merge August 22, 2026 00:46
@os-elon
os-elon added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit 38bc74eAug 22, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10789-seed-tenancy-absent-seam branch August 22, 2026 01:06
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32541740850 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 5.81s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 53 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

os-warren pushed a commit that referenced this pull request Aug 22, 2026
`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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backfillSeedTenancy reports no-split over a driver it never queried — its own absent branch is unreachable on the memory driver

2 participants

@os-elon@claude