Repository navigation
feat(spec,objectql): ReadableIds, the branded $in comparand leaf no request can spell, refused by the engine until composed (ADR-0139 D1, E1) - #22809
Conversation
…mitted only at the engine seam and refused there until composed (ADR-0139 D1) - packages/spec/src/data/readable-ids.ts declares READABLE_IDS_BRAND (a Symbol.for key, non-enumerable), the ReadableIds type, ReadableIds.of() (a frozen branded leaf) and isReadableIds(). - The comparand-shape door admits a branded leaf as $in's comparand only for a caller passing admitReadableIds; every other caller keeps refusing it as a non-list. An unbranded copy is refused as before. - The engine binding opts in, then refuses every branded leaf with INTERNAL_ERROR / 500 before any driver call, until composition lands. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
… — unbranded 400, branded 500 uncomposed, list reaches the driver (ADR-0139 D1) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…s; changeset and ADR-0139 anchors Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…l anchor Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…instead of casting them Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check12 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 140 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 17d7d90d78a0651abe3682c3044ec0bdcfb1ee01 && git checkout 17d7d90d78a0651abe3682c3044ec0bdcfb1ee01
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 098481744fd7dd9d0eade7cb61701891c5a890cb 5a4e282ffbf19ed71611c58abacfd9a79281d35f && git checkout -B drift-repro 098481744fd7dd9d0eade7cb61701891c5a890cb && git merge --no-ff 5a4e282ffbf19ed71611c58abacfd9a79281d35f
node scripts/docs-audit/affected-docs.mjs --json 098481744fd7dd9d0eade7cb61701891c5a890cb |
Contract reviewServed-tier: Inputs read: card #22787 (body, claim ① Derived judgmentsD1 (a), no request can spell it — RIGHT.
D1 (b), losing the brand fails closed — RIGHT. The spec door's D1 (c), it is a leaf — RIGHT, one pin gap flagged in ③. Admission is at The opt-in route — RIGHT. At the head the spec function is the rule's only implementation: objectql's The fail-closed window, E1 to E2 — RIGHT. 500 Dev out-of-scope finding 1 — VERIFIED, ESCALATED to E2 (#22788), not a PASS condition. At the head, Dev out-of-scope finding 2 — VERIFIED, ESCALATED to E2, same fix. Dev out-of-scope finding 3 — NOTED for E3 and E4. The direct-driver answers are the measured pre-state D3's envelope replaces; nothing for E1. Published surface — RIGHT. The ② Semver levelRIGHT. ③ Boundary flags
Gates on Implemented-by: VERDICT: PASS |
Fixes #22787
Clause-②: yes (widening)
ADR-0139 E1: the
ReadableIdsleaf is declared in@objectstack/spec, no request can spell it, and the engine's comparand door admits the branded value and refuses the unbranded one. Until E2 composes the leaf, the engine refuses an admitted leaf withINTERNAL_ERROR/ 500 before any driver is called, so no branded leaf reaches a driver through the door uncompiled.What lands
packages/spec/src/data/readable-ids.ts(exported from the data barrel):READABLE_IDS_BRAND:Symbol.for('objectstack.filter.readableIds').ReadableIds: an interface withreadonly object: stringand the brand, so an object literal does not type-check as one. It is also a frozen const withof(object).isReadableIds(value): answers true only for the brand as an own key with the valuetrue, on a non-array object whoseobjectis a non-empty string.ReadableIds.of(object)returns a frozen{ object }. The brand is defined non-enumerable, non-writable and non-configurable, the same waymarkFilterSubtreeProvenancedefines its mark. JSON,structuredClone, a spread andObject.assignall produce an unbranded{ object }, and the tests check each one.of()throws aTypeErrorfor an object name that is not a non-empty string.packages/spec/src/data/filter-comparand-shape.ts:assertListComparandShapes(node, context?, path?, options?)takes a new optionalListComparandShapeOptions. With{ admitReadableIds: true }, a branded leaf is admitted as the comparand of$in, and nowhere else ($ninadmits nothing). Without the option the leaf is refused as a non-list, exactly as before. Every existing caller passes no option. An unbranded copy is refusedINVALID_FILTER/ 400 even when the option is set.packages/objectql/src/filter-comparand-shape.ts: the engine's binding of the door is the one caller that passes the option. Right after the door,refuseUncomposedReadableIdswalks the node. If it finds a branded leaf anywhere (the$incomparand, implicit equality,$eq, a list member, a$betweenendpoint or a nested-relation condition), it throwsINTERNAL_ERROR/ 500. The message names the position, the leaf's object, and that no driver was asked. The binding is called by all six verbs'where, by an aggregation'sfilterand byhaving. This is the refusal E2 replaces with composition: E2 drops it at the read-verb$inposition and keeps it everywhere else, as D1's last paragraph says.FieldOperatorsSchemaorVALID_AST_OPERATORS. The schema doors refuse{ $in: leaf }(pinned).api-surface/data.jsonandexport-origins/data.jsondiffs are generator output (check:generated --fix): five added names and nothing else.scripts/adr-anchors/), one each forreadable-ids.tsand objectql'sfilter-comparand-shape.ts.The window between E1 and E2
What each driver does with a leaf-shaped (non-array object)
$incomparand, handed to it directly. Measured ata2e94c2a05with the built drivers: plain, under$or, under$not, and as the comparand of$nin.driver-memoryTypeError: input[(input.length - 1)] is not iterable, with nocodeand nostatusdriver-sql(better-sqlite3)500 DATABASE_ERROR, a syntax error at the bound?driver-mongodbtranslateFilter{ $in: { object } }($norunder$not). What a live server answers is not measured.None of them widened a read, and none answered the way the leaf means. So the door's admission is paired with the engine-side refusal above. Ablation AB2 measures the window that refusal closes: with the refusal deleted, a branded
$inleaf passes the door and the read resolves against the driver.Where the door is (PM item 2):
assertListComparandShapesin spec is the rule's only implementation. objectql's function of the same name is a wrapper that delegates to it and adds thefind('x')prefix. The engine calls the wrapper at three sites (lowerWhereFilterArray's object branch,aggregations[i].filterandhaving). The spec door is also called byparseFilterAST(the authoring lowering, which includes the engine's array branch), the save door, the analyticswhereand read-scope doors, the RLS compiler, the lint rule and memory analytics. It is the same check at every one of those callers, not a separate ingress-time check. That is why admission is opt-in: if it were universal,parseFilterASTand memory analytics would let the leaf through to a driver.Tests (head
5a4e282ffb)packages/spec/src/data/readable-ids.test.ts: 15 tests (the declaration, the copies and forgeries, and the door with and without the option).packages/objectql/src/engine-readable-ids-door.test.ts: 7 tests, with a recording driver.INVALID_FILTER/ 400 on every read verb.INTERNAL_ERROR/ 500 under$and/$or/$not, on all six verbs, in an aggregation'sfilter, and at five positions the shape door does not judge.origin/mainat5fc57b382e:@objectstack/spectest: 647 files, 19,327 passed.@objectstack/objectqltest: 399 files, 7,802 passed (run atdea2c0abc1; the merge that followed brought no objectql change).typecheckpassed for both packages,check:test-typecheckincluded.Ablations. Each one went through
scripts/ablation-replace.mjs, which checks on disk that the anchor hit and that the file was restored, plus a trap. Spec mutations were rebuilt and checked withscripts/ablation-dist-preflight.mjs: marker present indist/while mutated, absent after the restore rebuild, tree clean. The red/green pattern was predicted before each run.... && op === 'ABLATED_AB1')refuseUncomposedReadableIds(...)call deleted$inread resolves; other positions get 400)Every run matched its prediction. The first AB3 attempt was a no-op and is not counted: its replacement kept the anchor text, so the tool refused the mutation (anchor 1 → 1) before running anything. It was rerun with an anchor the mutation removes.
Gates.
dispatch-gates --commandswas derived on this diff and run in full on5a4e282ffb(the gate list is in the report on the card). Green includescheck:generated(all 14 artifacts),check:adr-anchors,check:doc-authoring,check:nul-bytes,check:engine-double-contract,check:driver-memory-census,check:query-options-erasure,check-spec-docblock-symbol-anchorsandcheck-adr-0087-registration. That last one reads "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)": a widening needs no disposition marker. Not measured:check:dual-build-cjs-loads(PREREQUISITE NOT MET, it needs every package'sdist/) andcheck:type-check-debt(its full build was killed atplugin-audit#build, exit 137, and the gate exited 3). Both are left to CI. Lint was narrowed and the narrowing is proven:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}.eslint --no-inline-config --format jsonover the 6 lintable files in the diff: 6 files, 0 errors, 0 warnings, at5a4e282ffb.parserOptions.project) and reads only two baseline files, neither touched here, so this diff cannot change the verdict on any file it does not touch.Acceptance notes
engine.ts#findrunsresolveWhereTokens, which includeslowerRelationConditions(the seam D2 names), beforeexecuteWithMiddleware. The D4 consumers write their filter intoctx.ast.wherefrom inside that chain (activity-read-visibility.ts#installActivityReadVisibility→engine.registerMiddleware). A leaf a consumer emits there therefore never meets this door or that seam. E2 has to compose after the middleware chain, or the emitters have to write before the seam. This is reported, not built: it needs a change inengine.tsbeyond this file.wherebranch oflowerWhereFilterArrayreaches the spec door only throughparseFilterAST, which does not opt in, so a$inleaf there is refused 400. A leaf at another position in the array form never meetsrefuseUncomposedReadableIds. D1's position refusal has to cover that lowered condition too.Generated by Claude Code