Uh oh!
There was an error while loading. Please reload this page.
fix(service-package): refuse get() / list() over a seam that never answered - #11064
Conversation
`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
… `any`
`check:slot-lookup` flagged the new null-seam pin: `getService<any>('objectql')`
is a service-lookup erasure, and the baseline never grows (#4251). The slot's
shape is now spelled out — the raw-SQL seam plus the registry half whose
presence is what lets `start()`'s hydration loop run.
Part of #10965
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx📓 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 — 1 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 ebc6dbf992b1f9194045fe9d086c9f8cc6a48211 && git checkout ebc6dbf992b1f9194045fe9d086c9f8cc6a48211
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4257e4e4e385bdd098424850dc57337cdfa1de8c 5820d092226f5d7dce1818f7c35ef00616fd70b2 && git checkout -B drift-repro 4257e4e4e385bdd098424850dc57337cdfa1de8c && git merge --no-ff 5820d092226f5d7dce1818f7c35ef00616fd70b2
node scripts/docs-audit/affected-docs.mjs --json 4257e4e4e385bdd098424850dc57337cdfa1de8c |
… package `check:driver-memory-census` refused this file as a THIRD consumer of a package whose investment is frozen (#5499), arriving after #5704 migrated the test backends and #6664 replaced the prose census with a ledger. Disposition taken: MIGRATE, not ledger. The two ruled-permanent consumers are kept because nothing can stand in for them — one needs the schemaless arm of a divergence pin, the other a driver whose `supports = {}` hands autonumber seeding back to the engine. This file needs neither: it needs a seam whose `execute()` returns without answering, which is one return value rather than a capability profile, and `protocol-recorded-by-null.test.ts` already models exactly that with a local `makeStubDriver` — the convention #5704/#5784 established so a grep for the driver lands on real consumers only. The kernel, the ObjectQL engine and registry, and PackageServicePlugin.start() all stay real; only the seam's non-answer is doubled. What the double does NOT model is stated in the file: it is not evidence about driver-memory, whose null-return stays pinned on a real boot by the CLI sibling (#10677). Ledger untouched; ruled set still 2. Part of #10965 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
os-warren
commented
Aug 22, 2026
PM review — round 2 rework: ACCEPTEDReviewed against the diff on head The disposition held: migrate, not ledgerThe round-1 failure was The counts say which one happened: 13 bindings to 12, the ruled set still 2, the ledger byte-unchanged. A migration leaves exactly that signature; bookkeeping would have left the ruled set at 3 and the ledger modified. This is the discriminator I checked, and it is the reason the fix is accepted rather than merely green. Grounds established before the migration, not afterFour, in the order that matters:
The ablation wrote down its own falsification conditionThis is the part that makes the proof worth something. Before running it, the seat stated what would refute it:
It then observed the same 2 of 4 runtime cases reddening, by name, identical to the real-driver version. A double that cannot reproduce the failure is not a substitute — it is a weaker test wearing the same name — and this one reproduces it. The bound of what the double does not model is stated in the test file's own header rather than left implicit. Class #10309 — a second structurally-unreachable gateThe seat contributed the finding that Recording the seat's own correction verbatim, because it is the right shape:
Quoting a line and drawing its consequence are different acts, and the gap between them is where this class of miss lives. CI — verified by job name, and a correction to my own earlier readingAll 34 check runs on
Auto-merge stays armed; nothing further is required from the seat. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32583551725 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10965
service-packageanswered "this package is not installed" / "no packages are installed" over a storage seam it never queried. Its localnormalizeRowsmaps a non-answering seam onto zero rows, and both read paths hand that back as a product answer a caller acts on.Third instance of one settled class — #10677 / PR #10788 (
os migrate duplicates), #10789 / PR #10964 (backfillSeedTenancy): a seam that cannot ANSWER is absent, not empty. The reasoning is reused; the code is a local copy (below).Step 1 — the measurement the card said it had not taken
The card established the conflation by reading, and named the boot path as unverified:
It was booted before anything was changed: a
LiteKernelwith the realObjectQLPluginand a realInMemoryDriver({ persistence: false }), withPackageServicePlugin.start()driven against that engine,executeinstrumented to record every statement and its return. Measured on2866d5f97:Answer: yes, the boot path reaches it.
start()'s ownsys_packagesrehydration callslist(), the seam returns no result set, and the loop iterated zero times — silently, because its only log sits behindif (hydrated > 0).Does anything WRITE on that reading? No.
Every consumer of the two methods was enumerated:
service-package/src/index.tsstart()boot rehydrationlist()registry.installPackage) is per-row, so zero rows ⇒ zero writesrest/src/package-routes.ts:644GET /api/v1/packageslist()rest/src/package-routes.ts:685GET /api/v1/packages/:idget()The three protocol paths that do write (
metadata-protocol/src/protocol.tsinstallPackage:19496,updatePackage:19542,deletePackage:16473) callpublish/deleteunconditionally — none is gated on this read, andpublishis anON CONFLICT … DO UPDATEupsert regardless.So: no re-install, no destructive write. The blast radius is a silent boot hydration skip plus two false product answers on the HTTP read doors. That is above cosmetic — these are product values, and the skip was inaudible — and below the data-loss case the card flagged as the reason to triage. Reporting it plainly rather than leaving it implied: the severity comes down. The fix is still right; a method that reports an absence it never measured is wrong regardless.
The fix
Both reads establish that the seam answered before reading emptiness as a fact:
isResultSet— the shapesnormalizeRowsaccepts, asked as a yes/no. An empty result set in any spelling is stilltrue.codeandstatus:SERVICE_UNAVAILABLE/ 503, the standard catalog's own pairing and the spellingmetadata-protocol'smetadataStoreUnavailableErroralready uses for exactly this condition (store unreachable ⇒ existence unknown). No new code is registered and nothing inpackages/specis touched. The message interpolates nothing — no driver text, no statement, no table name — and says the one thing a caller can act on: the answer is UNKNOWN, not "no".get()/list()each swallow intonull/[], so a bare throw would have been swallowed by the very catch the refusal exists to escape. Only the branded seam refusal is re-thrown. Deliberately not the existingdeclaresHttpAnswer: widening these two catches to re-throw every declared envelope would change how the service answers driver faults it has always swallowed — a behaviour change this card did not measure and does not need.warn, naming what is unknown. Boot still continues: an unreadable seam must not brick the environment.The dialect shapes, enumerated FROM THE CODE
The card describes this as "the same three-dialect flattener
metadata-protocolexports". Read against the source, that is not what this file has. Its localnormalizeRows(:177-181) has exactly two accepting branches:normalizeRowsArray.isArray(result)→result{ rows, rowCount, … }Array.isArray(result.rows)→result.rows[rows, fields]tupleArray.isArray(result[0])branchmetadata-protocol's copy has all three. The doc comment's three bullets name only two distinct shapes (bullets 2 and 3 are both{ rows }). The missing tuple unwrap is a separate defect of a different class (a seam that answered being misread, not one that could not answer), so it is filed as #11062 rather than fixed as a rider here. What this PR does pin is that a tuple-shaped result is still treated as an answer, so the new guard cannot misfire on a dialect it does not fully flatten — no false 503.The safety property holds against the local flattener: every shape
isResultSetrejects is onenormalizeRowsalready maps to[], so no row can be lost. The only change is "refused as unreadable" replacing "reported as zero rows".Why the predicate is a local copy
metadata-protocoldeliberately does not publish itsisResultSetfrom the package index, the CLI keeps a third for its own probes — and@objectstack/metadata-protocolis not a dependency of this package at all. Unifying the three is its own decision, not a rider on this fix; raised as an open question for the maintainer instead.What is pinned — both directions
packages/services/service-package/src/null-seam.test.ts(28 cases) andpackages/runtime/src/package-service.null-seam.test.ts(4 cases, on a real booted kernel + engine + plugin — see the census note below for why its driver is a local double):nullfirst: the measuredInMemoryDriverreturn; plusundefined, an echoed statement,{},{ rows: 'not-an-array' }, a number) ×get()/list(), assertingcodeandstatusand the message. Never a baretoThrow(): an unfixed path throwing a plainErrorwould satisfy that and pin nothing.node:sqlitedatabase running the real statements fromindex.ts: withSELECT COUNT(*) = 0proven against the db itself,get()still returnsnullandlist()still returns[], and no warn is emitted. An implementation that treated every empty result as a broken seam would score green on the refusal cases alone and break every legitimately-empty deployment. Beside it: a populated database still returns its rows, and an absent package beside a present one is stillnull.{ rows }, populated and empty, driven end-to-end throughget()/list(); plus the tuple-shaped non-misfire above.Ablation — signature predicted in writing FIRST
Prediction, written before mutating: delete the two
if (!isResultSet(result)) throw packageSeamUnreadableError();lines, leaving everything else. Direction: turns red.service-packagesuiteexpected a refusal, but the call returned, the log-assertion case, and the boot "SAYS it could not read" case; describes 2 and 3 stay greenruntimeboot suiteRe-run after the census migration below, against the local double instead of the real driver: the same 2 of 4, by name. The double reproduces the failure the pin exists to catch.
Restored and proved byte-identical:
…and the restore leg was then re-run to a real verdict rather than trusted on the hash:
RESTORE-LEG service-package=0 runtime=0, 63/63 and 4/4.srcvsdist— argued from the files, and held falsifiableBoth suites read
src, and this was not merely argued:packages/services/service-package/src/null-seam.test.tsimports./index.js— a relative import to its own sibling, which vitest resolves tosrc/index.ts. It never consults the package'sexports.packages/runtime/src/package-service.null-seam.test.tsimports the bare specifier, andpackages/runtime/vitest.config.ts:110-112declares an explicit alias to../services/service-package/src/index.ts.packages/services/service-package/dist/was present but stale — built at 13:33, before the edits — andgrep -c PACKAGE_SEAM_UNREADABLE_MESSAGE dist/index.jsreturned 0 whilesrc/index.tsreturned 2. A suite readingdistwould have importedundefinedfor that symbol and failed every message assertion;isResultSetwould not have existed at all. Both suites passed against that stalediston disk.distwas rebuilt afterwards, for the typechecks.No
ablation-dist-preflightrun is claimed, and none applies: neither leg resolves the subject throughexports→dist/, which is the condition that check exists for.check:driver-memory-census— the runtime pin was MIGRATED off the frozen driver, not ledgeredThe first version of the runtime pin booted the real
InMemoryDriver, which made it a third consumer of a package whose investment is frozen (#5499).check:driver-memory-censusrefused it — correctly, and it is the one gate the local derivation cannot reach (its declared population is the package specifier@objectstack/driver-memory, not a tracked path, so the script itself lists it under unreachable by construction: it scores the same quiet green for every card in the tree). CI caught what the derived union structurally could not.Disposition taken: migrate. Adding a ledger entry was refused for the same reason the
check:slot-lookupbaseline was left alone earlier in this PR. The evidence that the real driver was not load-bearing, established before migrating:ruled-permanententries are kept because nothing can stand in for them — one needs the schemaless arm of the L2 hook body 写集不透明:把「写了目标对象上不存在的字段」从 accepted gap 变成作者时 lint 告警 #4271 divergence pin, the other a driver whosesupports = {}hands autonumber seeding back to the engine ("No SQL backend can stand in"). That discriminator is a capability profile. This pin needs no capability profile — only a seam whoseexecute()returns without answering, which is one return value.packages/objectql/src/protocol-recorded-by-null.test.tscarries amakeStubDriverwithsupports: {}andasync execute() { return null; }.makeStubDriveris the convention driver-memory 测试面替代:项目内测试后端迁到 sqlite:memory:(#5499 重启条件 · memory 半边,维护者 2026-08-06 立项) #5704/[#5704 批次2] B 类替身改名 makeMemoryDriver→makeStubDriver(objectql 22 + runtime 2)+ Q2-B 保留注释 #5784 established so a grep for the driver lands on real consumers only.engine.executedelegates straight todriver.execute(...)after checking only that the method exists (packages/objectql/src/engine.ts:11687) — the very half of the defect that made the conflation invisible — so a double reaches the guard through the real engine path.packages/cli/src/commands/migrate/duplicates.null-seam.test.ts(migrate duplicates: no_sql_seam loud-refusal is dead code for the memory driver (false all-clear) #10677), which reaches it through the datasource factory rather than by importing it.The kernel, the ObjectQL engine and registry, and⚠️ The bound, stated in the file itself: this pin is not evidence about
PackageServicePlugin.start()all stay real; only the seam's non-answer is doubled.@objectstack/driver-memoryand would not notice if that driver changed. It models the shape — a seam that accepts a statement and returns no result set — which is the only property the guard keys on, since the implementation judges by return value and never by driver identity.Census verdict, before and after:
The ledger is byte-unchanged and the ruled set is still 2. Thirteen bindings became twelve — a migration, not bookkeeping.
Zero-hit counter-check — positive control FIRST
Before reading any silence as evidence, the instrument was proved on the corpus. The consumer sweep
grep -rn "get('package')|getService('package')|getService<…>('package')" --include=*.ts packages/ apps/was run and did return the known consumers (rest/src/direct-mount-composition.ts:110,metadata-protocol/src/protocol.ts:16473, :19496, :19542,objectql/src/registry.ts). Only then was the absence of any write gated onget()/list()read as a fact — and each surviving hit was opened and read rather than counted. No grep hit count is reported here as a fact count; the table above comes from reading all four call sites.Gates
Derived on the final commit
5820d0922, clean tree,node scripts/pm/dispatch-gates.mjswith no path arguments (the script takes the change set from the merge base itself). Class #10309 is live and was live here: the dispatch prompt named no gate family for this card at all. The derivation named 13 path-matched plus 5 convention-triggered; all 18 were run explicitly and each verdict below is the gate's own printed line, with exit codes captured before any pipe.check:changeset-gate-self-testscheck:cross-package-test-inputscheck:objectui-changesetcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck:test-source-aliascheck:type-source-resolutioncheck-adr-0087-registration.mjscheck-changeset-no-major.mjscheck-ci-filter-parity.mjscheck-cross-package-test-inputs.mjscheck-empty-changeset.mjscheck-plugin-teardown-shape.mjsdocs-audit/check-affected-docs.mjscheck:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck:engine-double-contractOK — 377 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher✓ where-matcher conformance holds: 276 matcher(s) discovered … none newcheck:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root)check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 246.7s, 1908 raw tsc error(s) total, none above its recorded numbercheck-nul-bytes.mjsOK (scanned 6392 text file(s) … no raw ASCII control bytes)check-driver-memory-census.mjsOK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers"check:slot-lookupfailed first — ananytype parameter on thegetServicecall in the new runtime pin was a NEW service-lookup erasure (#4251, the baseline never grows). Fixed by declaring the slot's contract shape, not by touching the baseline.check:type-check-debtfirst refused:--re-measure cannot run: 27 workspace dependenc(ies) … have no built type entry point on disk. That refusal is NOT MEASURED, never a pass — the closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and the gate re-run to the real verdict above. Itsℹ @objectstack/plugin-auth: TEST_DEBT records 109, tsc now reports 97 (-12)surplus is pre-existing and in a package this PR does not touch; not lowered here.Tests, same commit, exit codes captured before any pipe:
Declared narrowings
pnpm test/pnpm typecheck. Only the two affected packages were run, plus the 18 derived gate families. CI runs the farm regardless.runtime. This PR changes no exported type signature — it only adds one export (PACKAGE_SEAM_UNREADABLE_MESSAGE), which cannot break a consumer's types, and the behavioural change (a throw) is invisible totsc.restandcliwere not typechecked locally.Scope
Two findings are filed and stay open — out of scope: #11062 (the mysql2 tuple gap) and #11063 (the door that swallows the new refusal). Neither is addressed here. Nothing under
content/docs/releases/**orpackages/specis touched; the refusal reuses an existing standard-catalog code precisely so no spec edit is needed.Left for review, NOT decided here
isResultSetcopies. The card is explicit that a third consumer arriving is the first real evidence unification is worth doing — which makes it a proposal, not a licence. Implemented locally.metadata-protocol's index comment says its copy is deliberately unpublished, and nothing here reaches into it. Measurement and recommendation are in the report; service-package's localnormalizeRowsnever unwraps the mysql2[rows, fields]tuple — a populated result reads as "not installed" #11062 is a second data point for it.GET /api/v1/packagesswallows the new refusal.package-routes.ts:644wrapspackageService.list()incatch { /* Database query failed — continue with registry-only packages */ }, so the 503 does not reach the wire there and the door still answers 200 with registry-only packages.GET /packages/:idis unaffected — itsget()throw reachessendThrownErrorand the declared envelope goes out intact. That catch is a different file and a different consumer; filed asGET /api/v1/packagesswallows a failed database read into a 200 registry-only answer — the caller cannot tell a partial listing from a complete one #11063, not fixed here.Generated by Claude Code