Skip to content

fix(cli): a raw-SQL seam that cannot answer is absent, not empty (#10677) - #10788

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10677-migrate-duplicates-null-seam
Aug 21, 2026
Merged

fix(cli): a raw-SQL seam that cannot answer is absent, not empty (#10677)#10788
os-elon merged 1 commit into
mainfrom
claude/issue-10677-migrate-duplicates-null-seam

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10677

The defect

os migrate duplicates reported a clean bill of health over a driver it never queried — the exact false all-clear the #8928 ruling's no_sql_seam refusal exists to prevent. Reproduced at head before the change:

$ os migrate duplicates --database-url memory://qa ; echo $?
{ "duplicates": [], "skipped": [], "counters": { "table": "_objectstack_sequences", "status": "read" }, ... }
0
stderr:
WARN Raw execution not supported in InMemory driver {"command":"SELECT \"case_number\" AS dup_value, ... "}
WARN Raw execution not supported in InMemory driver {"command":"SELECT tenant_id FROM \"_objectstack_sequences\" WHERE 1 = 0"}
WARN Raw execution not supported in InMemory driver {"command":"SELECT g.object AS object, ... "}

Three statements swallowed, and the report says the install is clean.

InMemoryDriver.execute() (memory-driver.ts:300-302, re-derived at head) logs that warning and returns null — it neither throws nor is absent. Two consequences compounded:

  • resolveSeedTenancySeam's canRun (seed-tenancy-backfill.ts:271 at head; the card cited :247 from 79ebb37) asks typeof d.execute === 'function'. That is a question about the driver's shape, and a no-op execute has the right shape — so resolveSeedTenancyExec returned a usable exec and the if (!exec) guard at duplicates.ts:697 never fired.
  • normalizeRows(null) is [] (seed-tenancy-backfill.ts:351, if (!result) return [];), and [] is also what a real driver returns for a SELECT that matched nothing.

So the refusal was dead code for one of the two drivers the clause names.

What the guard keys on

Not "is this the memory driver" and not "does memory report no_sql_seam". It keys on the distinction the old guard conflated:

A seam that cannot ANSWER is absent. A seam that answered "no rows" is empty.

A driver that answers returns a result set — a bare row array (better-sqlite3 through knex), { rows } (pg), or the [rows, fields] tuple (mysql2) — and an empty result set in any of those spellings is still a result set. A driver that cannot answer returns no result set at all. No driver is named anywhere in the implementation, so any host with the same no-op shape is covered without an allowlist to maintain.

Two guards, both on that one predicate:

  1. Preflight at the refusal site. One trivial statement is put to the resolved seam before the scan starts; a seam that returns no result set takes the existing no_sql_seam branch. This is the card's fix.
  2. The resolved seam is wrapped before it reaches the collector, so an individual probe that returns no result set fails rather than reading as zero rows. It lands in the catch blocks the collector already has, which is what this command's contract has always promised for a target it could not read: "Anything that could not be probed is listed in skipped with the reason" (the file's own header). No new refusal, no new report key. Named here because it is beyond the literal card text — same defect class, same file, same gate family, and it is what keeps counters.status from claiming "read" over a table nothing read.

After:

$ os migrate duplicates --database-url memory://qa ; echo $?
{"error":"no_sql_seam","detail":"The active driver exposes no usable raw SQL seam — it is either absent, or present but returning no result set — so the data-side duplicate probe cannot run. This report supports the SQL drivers (sqlite / postgres / mysql / turso)."}
1

Two behaviours deliberately NOT changed

Scope

No driver package is modified. The maintainer's 2026-08-05 investment freeze covers the driver-memory/driver-mongodb family; making InMemoryDriver.execute() throw is the tidier contract and the card offers it first, but it is fenced. This is the consumer-side route triage directed, and it works — no escalation needed.

The mongodb branch is not claimed. That driver was not loaded for this fix, so no test here asserts anything about it, and the checklist correction says so explicitly rather than assuming it behaves like memory.

The checklist carries the one-line clause-7 knownGap correction the card names — the memory driver exposes a no-op seam, not no seam — plus the duplicates.ts line citations it holds, re-derived because this change moved them. Item revision bumped with a history entry, per that ledger's rule.

Evidence

Gate union re-derived from the real changeset with node scripts/pm/dispatch-gates.mjs (no paths passed) and run at 2a74cb35df. Every gate below reports its own verdict line:

gateverdict
check:nul-bytesOK (scanned 6239 text file(s) ... no raw ASCII control bytes)
check:changeset-gate-self-testsexit 0, three self-tests pass
check:objectui-changeset✓ objectui-range --self-test: all checks passed
check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared
check:test-source-aliasOK — 72 packages with tests scanned
check:type-source-resolutionOK — 76 packages with a tsconfig.json scanned
check:slot-lookup✓ ratchet holds: 107 unswept site(s) ... none new
check:doc-authoring✓ 389 files clean
check:doc-formula-expressions✓ 22 record-scoped formula example(s) ... judged clean
check:query-options-erasure✓ ratchet holds: 67 unswept non-test site(s) ... none new
check:engine-double-contractOK — 371 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher✓ 271 matcher(s) discovered ... none new
check:platform-checklistOK — 15 areas, 205 items (not in CI by maintainer decision; run by hand because this PR edits the ledger)
check-adr-0087-registration / check-changeset-no-major / check-empty-changeset / check-affected-docsexit 0

Tests and typecheck, both through scripts/pm/os-verify-lock.sh:

pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicates.{contract,integration,pre-repair,probe-sql,null-seam}.test.ts
Test Files 5 passed (5)
Tests 28 passed (28)
os-verify-lock: VERDICT command-exit 0
pnpm --filter @objectstack/cli run typecheck # echoed: @objectstack/cli@17.1.0 typecheck / tsc --noEmit
os-verify-lock: VERDICT command-exit 0
npx eslint (the two changed TS files) # exit 0

Declared narrowing: the full @objectstack/cli suite (137 files / ~495s, the largest item on the Test Core critical path per that package's vitest.config.ts) was not run locally — the five duplicates suites plus the package typecheck and ESLint were. check:type-check-debt --re-measure was likewise not run: it needs the whole ./packages/* closure built. CI runs both.

The reverse verification is also inside the test file rather than only in this description: duplicates.null-seam.test.ts boots a real memory:// stack, collects the report through the bare exec (asserting duplicates: [], skipped: [], counters.status: "read" — the defect, reproduced), then collects it again through the wrapped seam and asserts the same targets now land in skipped. It cannot rot into a test that only proves a real SQL driver still works.

A live sqlite run confirms the preflight does not refuse a real driver: it cleared the guard, ran the scan, and produced an honest skipped entry for a table that did not exist — duplicate probe failed: ... no such table: crm_case — rather than a refusal.


Generated by Claude Code

)
`os migrate duplicates --database-url memory://qa` exited 0 with
`duplicates:[]`, `skipped:[]`, `counters.status:"read"` — the exact false
all-clear the #8928 ruling's `no_sql_seam` refusal exists to prevent. That
refusal was dead code for the memory driver.
`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory
driver` and returns `null`: it neither throws nor is absent. The seam
resolver asks whether the driver has the SHAPE of a seam (`typeof
d.execute === 'function'`), which that satisfies, so the `if (!exec)`
guard never fired — and `normalizeRows(null)` is `[]`, which is also what
a real driver returns for a SELECT that matched nothing.
The guard now keys on the distinction that actually separates the two: a
seam that ANSWERS returns a result set, and one that cannot answer
returns no result set at all. One trivial statement is put to the
resolved seam before the scan starts, and every individual probe is held
to the same standard, so a probe that returns no result set becomes a
`skipped` entry with its reason instead of zero findings.
No driver package is touched — the 2026-08-05 investment freeze covers
the driver-memory/driver-mongodb family, so this is the consumer-side
route triage directed. A seam that THROWS is deliberately left alone:
that is a driver present and refusing loudly, already reported honestly
by the per-probe `skipped` path, and claiming it here would invent a
refusal #8928 never mandated.
The clause-7 knownGap in the QA checklist said the memory driver exposes
no raw-SQL seam. It exposes a no-op one; corrected, with the drifted
duplicates.ts line citations re-derived. The mongodb branch is left
explicitly unverified — that driver was not loaded for this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via os migrate duplicates (command))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 23 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 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9fb8cb33b99881eaeee7c8d1322032d71bfda6f0 — the merge of head 2a74cb35df9f089418c56312fad1db34dbba430a into base 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81, 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 9fb8cb33b99881eaeee7c8d1322032d71bfda6f0 && git checkout 9fb8cb33b99881eaeee7c8d1322032d71bfda6f0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 2a74cb35df9f089418c56312fad1db34dbba430a && git checkout -B drift-repro 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 && git merge --no-ff 2a74cb35df9f089418c56312fad1db34dbba430a
node scripts/docs-audit/affected-docs.mjs --json 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@os-elon
os-elon marked this pull request as ready for review August 21, 2026 12:01
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 21, 2026
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 3a7ec2dAug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10677-migrate-duplicates-null-seam branch August 21, 2026 14:48
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    ✗ Build failed in 5.84s
    

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

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

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

历史信号:

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

分诊清单:

  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/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

migrate duplicates: no_sql_seam loud-refusal is dead code for the memory driver (false all-clear)

2 participants

@os-elon@claude