Skip to content

feat(spec,objectql): ReadableIds, the branded $in comparand leaf no request can spell, refused by the engine until composed (ADR-0139 D1, E1) - #22809

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22787-readable-ids-leaf
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22787-readable-ids-leaf

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22787
Clause-②: yes (widening)

ADR-0139 E1: the ReadableIds leaf 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 with INTERNAL_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 with readonly object: string and the brand, so an object literal does not type-check as one. It is also a frozen const with of(object).
    • isReadableIds(value): answers true only for the brand as an own key with the value true, on a non-array object whose object is a non-empty string.
    • ReadableIds.of(object) returns a frozen { object }. The brand is defined non-enumerable, non-writable and non-configurable, the same way markFilterSubtreeProvenance defines its mark. JSON, structuredClone, a spread and Object.assign all produce an unbranded { object }, and the tests check each one. of() throws a TypeError for an object name that is not a non-empty string.
    • The leaf carries only the object name. That is all D1 declares; what D2 needs is left to E2.
  • packages/spec/src/data/filter-comparand-shape.ts: assertListComparandShapes(node, context?, path?, options?) takes a new optional ListComparandShapeOptions. With { admitReadableIds: true }, a branded leaf is admitted as the comparand of $in, and nowhere else ($nin admits 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 refused INVALID_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, refuseUncomposedReadableIds walks the node. If it finds a branded leaf anywhere (the $in comparand, implicit equality, $eq, a list member, a $between endpoint or a nested-relation condition), it throws INTERNAL_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's filter and by having. This is the refusal E2 replaces with composition: E2 drops it at the read-verb $in position and keeps it everywhere else, as D1's last paragraph says.
  • No operator is added to FieldOperatorsSchema or VALID_AST_OPERATORS. The schema doors refuse { $in: leaf } (pinned).
  • The api-surface/data.json and export-origins/data.json diffs are generator output (check:generated --fix): five added names and nothing else.
  • Two ADR-0139 anchors are added (scripts/adr-anchors/), one each for readable-ids.ts and objectql's filter-comparand-shape.ts.

The window between E1 and E2

What each driver does with a leaf-shaped (non-array object) $in comparand, handed to it directly. Measured at a2e94c2a05 with the built drivers: plain, under $or, under $not, and as the comparand of $nin.

driver answer
driver-memory raw TypeError: input[(input.length - 1)] is not iterable, with no code and no status
driver-sql (better-sqlite3) 500 DATABASE_ERROR, a syntax error at the bound ?
driver-mongodb translateFilter emitted unchanged as { $in: { object } } ($nor under $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 $in leaf passes the door and the read resolves against the driver.

Where the door is (PM item 2): assertListComparandShapes in spec is the rule's only implementation. objectql's function of the same name is a wrapper that delegates to it and adds the find('x') prefix. The engine calls the wrapper at three sites (lowerWhereFilterArray's object branch, aggregations[i].filter and having). The spec door is also called by parseFilterAST (the authoring lowering, which includes the engine's array branch), the save door, the analytics where and 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, parseFilterAST and 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.
    • An unbranded copy (JSON round-trip, structured clone, spread) and the plain-object spelling are refused INVALID_FILTER / 400 on every read verb.
    • A branded leaf is refused INTERNAL_ERROR / 500 under $and / $or / $not, on all six verbs, in an aggregation's filter, and at five positions the shape door does not judge.
    • Every refusal is checked to record zero driver calls.
    • A lit control checks that a list comparand does reach the driver.
  • Full suites, after the merge of origin/main at 5fc57b382e:
    • @objectstack/spec test: 647 files, 19,327 passed.
    • @objectstack/objectql test: 399 files, 7,802 passed (run at dea2c0abc1; the merge that followed brought no objectql change).
    • typecheck passed for both packages, check:test-typecheck included.

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 with scripts/ablation-dist-preflight.mjs: marker present in dist/ while mutated, absent after the restore rebuild, tree clean. The red/green pattern was predicted before each run.

ablation spec suite objectql suite
AB1: door admission disabled (... && op === 'ABLATED_AB1') 1 red / 14 green (the "admits" pin) 2 red / 5 green (the branded pins get 400 where 500 is expected)
AB2: refuseUncomposedReadableIds(...) call deleted not run (objectql source only) 3 red / 4 green (the branded $in read resolves; other positions get 400)
AB3: guard accepts by shape, brand check removed 6 red / 9 green (copies, forgeries, JSON round-trip, clone, plain spelling) 3 red / 4 green (JSON round-trip, clone/spread and the plain spelling get 500 where 400 is expected)

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 --commands was derived on this diff and run in full on 5a4e282ffb (the gate list is in the report on the card). Green includes check: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-anchors and check-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's dist/) and check:type-check-debt (its full build was killed at plugin-audit#build, exit 137, and the gate exited 3). Both are left to CI. Lint was narrowed and the narrowing is proven:

  • Population: the config's lintable population is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}.
  • What ran: eslint --no-inline-config --format json over the 6 lintable files in the diff: 6 files, 0 errors, 0 warnings, at 5a4e282ffb.
  • Why that covers the rest: the config enables no type-aware linting (no 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

  • For E2, the D2 seam comes before the D4 emitters. engine.ts#find runs resolveWhereTokens, which includes lowerRelationConditions (the seam D2 names), before executeWithMiddleware. The D4 consumers write their filter into ctx.ast.where from 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 in engine.ts beyond this file.
  • For E2, the array branch. The array-form where branch of lowerWhereFilterArray reaches the spec door only through parseFilterAST, which does not opt in, so a $in leaf there is refused 400. A leaf at another position in the array form never meets refuseUncomposedReadableIds. D1's position refusal has to cover that lowered condition too.
  • For E3 and E4. The direct-driver answers in the table above are the shapes that D2's "refused by the driver as uncomposed" replaces.
  • ADR-0139 execution: the engine-internal semi-join leaf across spec, engine, drivers and consumers (E0–E7 coordination) #22788 remains open; this PR delivers its E1 line only.

Generated by Claude Code

…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>
…instead of casting them

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 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 098481744fd7dd9d0eade7cb61701891c5a890cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 17d7d90d78a0651abe3682c3044ec0bdcfb1ee01 — the merge of head 5a4e282ffbf19ed71611c58abacfd9a79281d35f into base 098481744fd7dd9d0eade7cb61701891c5a890cb, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5a4e282ffbf19ed71611c58abacfd9a79281d35f
Local-runs: none

Inputs read: card #22787 (body, claim 6106434946, report 6107295475), ADR-0139 at origin/main (D1–D6, Execution plan), #22788, PR #22809 (body, 11 files, +699/−4, git diff origin/main...5a4e282ffb), the head's engine.ts, both filter-comparand-shape.ts, filter-subtree-provenance.ts, activity-read-visibility.ts, parent-record-read-gate.ts#andIntoWhere, filter.zod.ts#parseFilterAST, scripts/check-adr-0087-registration.mjs, scripts/check-adr-anchors.mjs, scripts/pm/check-governed-merges.mjs, .changeset/config.json, .github/workflows/{ci,lint}.yml, and the check-runs on the head. Judged as a read-authorization change.

① Derived judgments

D1 (a), no request can spell it — RIGHT.

  • No operator added: filter.zod.ts is not in the file list; FieldOperatorsSchema and VALID_AST_OPERATORS are unchanged at the head; the schema doors refuse { $in: leaf } (spec pin).
  • No ingress produces a value isReadableIds accepts: the brand is an own Symbol.for key, non-enumerable, non-writable, non-configurable, on a frozen object, defined exactly as markFilterSubtreeProvenance defines its mark (filter-subtree-provenance.ts:176-180). REST body, query string, the RPC and Virtual Data Engine wires, stored metadata and a flow or formula filter all arrive through JSON or a structured clone, which drop symbol keys; isReadableIds also demands the value true and a non-empty string object, so a forged string key or a prototype-inherited brand answers false (spec pins: json, structuredClone, spread, Object.assign, inherited, array, wrong value).
  • In-process construction through the exported ReadableIds.of (or a hand-made Symbol.for property, which the spec test itself does) is possible by design, exactly as the provenance mark is: the brand separates "built in this process" from "crossed a wire", not trusted from untrusted modules. It is not an escalation route: the leaf carries only an object name and no grant; today the engine refuses it 500 before any driver resolves (objectql pins, zero driver calls), and a direct driver call bypassing the engine answers a raw TypeError (memory), 500 DATABASE_ERROR (sql) or an unchanged non-array $in (mongodb), none of which widens (dev measurement at a2e94c2a05; not re-run here). What E2 must keep true: the composed form is derived ONLY from the reading caller's canReadObject and getReadFilter on leaf.object under the request's own context, so a constructed leaf can never resolve to more than that caller could read of P directly; the uncomposed leaf (no scope) stays refused at the engine AND, after E3 and E4, at every driver as D2's "never evaluated as no restriction"; a system-context read composing a leaf is decided explicitly, never by falling through.

D1 (b), losing the brand fails closed — RIGHT. The spec door's $in branch (filter-comparand-shape.ts:1088-1090) admits only op === '$in' && admitReadableIds && isReadableIds(comparand) and otherwise throws nonListComparandError; nothing drops the $in. Pins exist for JSON (spec and objectql), a structured clone (both), a spread (both) and Object.assign (spec), all refused INVALID_FILTER / 400 even with the option set, with zero driver calls on the engine side.

D1 (c), it is a leaf — RIGHT, one pin gap flagged in ③. Admission is at $in alone; $nin shares the branch and is refused with the option (spec pin). $between is judged before the admission, so a leaf endpoint passes the shape door and meets the engine refusal (objectql pin where.amount.$between[1]). having and aggregations[i].filter reach the objectql wrapper (engine.ts:19049, :18912) and so the refusal; the aggregation filter is pinned, having is not. Write verbs: update and delete call the same door (engine.ts:15188, :18165) and are pinned at 500 with multi: true. Implicit equality, $eq, a list member and a nested-relation condition are pinned at 500.

The opt-in route — RIGHT. At the head the spec function is the rule's only implementation: objectql's assertListComparandShapes delegates and adds the find('x') prefix (packages/objectql/src/filter-comparand-shape.ts:119). Every other caller passes no option and keeps refusing the leaf as a non-list, verified by name: filter.zod.ts:2825 (parseFilterAST), filter-save-door-refusals.ts:131 (the save door), service-analytics/src/strategies/filter-normalizer.ts:1689,1691,1771 and read-scope-sql.ts:1078 (analytics), plugin-security/src/rls-compiler.ts:378, lint/src/validate-rls-predicate-enforceability.ts:910, driver-memory/src/memory-analytics.ts:1824. Placing a universal admission in the shared function, or the admission in objectql's wrapper, would either let parseFilterAST and memory analytics pass a leaf to a driver or fork the rule into two implementations; the opt-in in the one implementation is the correct home. ListComparandShapeOptions as a public interface is acceptable: assertListComparandShapes is already a published spec export, so its parameter type must be published for the declaration to build, and the docblock names the one caller that may pass it. It is an engine-internal knob on the authoring contract, but one that admits nothing on its own: a third-party caller that passes it still hands the leaf to the engine's door (500) or to a driver that refuses or errors; after E3 the drivers must keep refusing the uncomposed form so the option can never become a bypass.

The fail-closed window, E1 to E2 — RIGHT. 500 INTERNAL_ERROR is the right code: no request can spell the leaf, so the fault is in-process code, and the envelope is the one ADR-0139 D3 gives a driver's own refusal (status 500, INTERNAL_ERROR, message naming the leaf, its position and the remedy); packages/rest/src/error-response.ts:244,338 withholds 5xx prose from the wire and logs it. Coverage at the head: find, findOne, count, aggregate (where, aggregations[i].filter, having), update and delete all pass lowerWhereFilterArray's object branch into the wrapper before getDriver or any driver call; count's no-driver.count fallback re-enters find; the engine's internal driver.find calls (resolveSecretField, resolveInternalField, the update and delete pre-reads) take engine-built wheres, not caller filters. Paths that skip the objectql binding are exactly the two the dev reported (next two items) plus a plugin holding a driver reference directly, which is outside the engine by construction and is what D2's driver-side refusal exists for.

Dev out-of-scope finding 1 — VERIFIED, ESCALATED to E2 (#22788), not a PASS condition. At the head, find runs lowerWhereFilterArray (the door), then resolveWhereTokens (engine.ts:12920), whose resolveRelateThenLowerWhere is where lowerRelationConditions runs (:12509, D2's named seam), and only then executeWithMiddleware (:12922); installActivityReadVisibility registers an object-scoped middleware (activity-read-visibility.ts:128-147) whose andIntoWhere assigns ctx.ast.where (parent-record-read-gate.ts:157), and inside the executor triggerHooks('beforeFind') runs on the same AST before driver.find(object, hookContext.input.ast) (:12938). So a leaf written by a D4 emitter, or by any beforeFind hook, meets neither the door nor D2's seam. No live exposure today: computeParentRecordFilter emits $in id lists from its pre-scan, and git grep at the head finds no ReadableIds.of( outside readable-ids.ts, its docblock mention in the spec door, and the two test files. E1's claim scoped engine.ts out of the surface, so stop-and-report was the right act. What E2 must do: compose once, on the FINAL AST, at the executor boundary between the before* hooks and the driver.* call for each read verb (after the middleware chain is not enough, because hooks run inside the executor), and move the uncomposed refusal to that same point so a consumer-emitted leaf that is not composed is refused rather than handed down; the alternative, making the D4 emitters write before the seam, contradicts the object-scoped middleware the six gates are installed through and should not be taken.

Dev out-of-scope finding 2 — VERIFIED, ESCALATED to E2, same fix. lowerWhereFilterArray's array branch lowers through parseFilterAST (engine.ts:1202), which runs the spec door with no option, and the objectql wrapper is not called on the lowered condition (:1215 records why the second call was deleted). Consequence: a $in leaf in the array form is refused 400 (spec pin), so no leaf passes the door there; a leaf at a non-$in position in the array form, e.g. the tuple record_id = leaf, lowers to implicit equality (filter.zod.ts#convertComparison), passes the shape door as a plain object with no $ key, and reaches resolveWhereTokens and the driver without meeting refuseUncomposedReadableIds. Reach: in-process callers using the array sugar directly on the engine, none in-repo; an equality against an object matches nothing on any driver, so it does not widen. The position refusal does not cover that lowered condition; one seam at the executor boundary (finding 1) covers both branches, so E2 should place it there rather than add a second call to the array branch.

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 api-surface/data.json delta is exactly five names, READABLE_IDS_BRAND (const), ReadableIds (const), ReadableIds (interface), isReadableIds (function), ListComparandShapeOptions (interface); export-origins/data.json adds the four origins (ReadableIds keyed once, the value and the interface being one declaration-merged name in one file). The data barrel gains export * from './readable-ids' with an ADR-0139 comment. No any is exported; ReadableIds.of is a frozen const. No governed surface is touched (GOVERNED_SURFACES rows: docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md; scripts/adr-anchors/ and .changeset/ are on none). 703 changed lines, under the 3,000 line.

② Semver level

RIGHT. .changeset/22787-readable-ids-leaf.md declares @objectstack/spec minor and @objectstack/objectql minor with Clause-②: yes (widening), and that is what the diff publishes: four new spec exports and one widened accept-set (a branded $in comparand, for the one caller that opts in), nothing removed or narrowed, and objectql's src moved (the wrapper now opts in and refuses). A Clause-② yes takes at least minor; both packages sit in the same fixed group in .changeset/config.json, so the objectql bump is consistent rather than extra. The PR body's Clause-②: yes (widening) line matches. No ADR-0087 disposition is owed: breakingDeclaration in scripts/check-adr-0087-registration.mjs:645-657 reads only major, a BREAKING banner, a ! summary or a narrowing arm, and this changeset carries none, so the gate takes its G1 branch (the dev's quoted pass line). Check Changeset is green on the head.

③ Boundary flags

  • Deviation, route (admission in the spec door behind an opt-in, not in objectql's file): ANSWERED RIGHT, see ① the opt-in route.
  • Deviation, no ADR-0087 marker: ANSWERED RIGHT, see ②.
  • Deviation, two scripts/adr-anchors/*.json files outside the claim's surface: ANSWERED RIGHT. AGENTS.md Prime Directive 13 requires an implemented ADR decision to be anchored, one JSON per path, no index; scripts/check-adr-anchors.mjs requires a non-empty adrs, a non-empty invariant, an existing file and the ADR id in that file's body, and audits each D letter against the record's decisions; both anchored files cite ADR-0139 in their headers, and the invariants state real decision text, the readable-ids one D1's brand and fail-closed properties, the objectql one D1's single-caller admission plus the E1 interim refusal, which E2 rewrites when it composes. Not governed.
  • Deviation, @objectstack/objectql minor: ANSWERED RIGHT, see ②.
  • Deviation, two NOT-MEASURED gates: ANSWERED by CI. check:dual-build-cjs-loads runs in ci.yml's build-core job (Build Core, required), green on the head; check:type-check-debt runs in lint.yml's Type Check · debt ledger, one of the four jobs the required TypeScript Type Check aggregator needs; its conclusion is in the gate line below.
  • Deviation, zero label writes and one disjoint main commit after the last merge: NOTED, no action; the queue rebuilds on current main.
  • Out-of-scope findings 1 and 2: ESCALATED to the E2 card under ADR-0139 execution: the engine-internal semi-join leaf across spec, engine, drivers and consumers (E0–E7 coordination) #22788 with the instruction in ①; finding 3: NOTED for E3 and E4.
  • Pin gap, raised here: the having position is refused at the head (engine.ts:19049 into the wrapper) but no pin asserts it, and ADR-0139 D1 names having as a refused position. Not a FAIL: the behaviour exists and the position cannot widen a read; E2, which moves the refusal and drops the read-verb $in case, must pin having with the other kept positions.
  • Note for E2's reviewer: the activity gate's catch folds any filter-compute failure into denyAll (activity-read-visibility.ts:138-144); ADR-0139 D3 says a consumer does not catch the composition refusal into its deny-all sentinel. That is E5's seam, flagged now so it is not lost.

Gates on 5a4e282ffb (check-runs read at 2026-10-11T09:20Z, not re-run): all 35 runs concluded, none red; the seven required contexts are success: Lint & Repo Gates, TypeScript Type Check (the debt ledger job included), Test Core, Dogfood Regression Gate, Build Core (check:dual-build-cjs-loads included), Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Skipped, advisory and path-filtered: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in). Check Changeset, Spec property liveness and the claim and single-writer checks are success.

Implemented-by: claude/issue-22787-readable-ids-leaf
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(spec): ADR-0139 E1 — the ReadableIds branded comparand leaf, declared in spec and unspellable by a request

2 participants