Skip to content

feat(objectql): the ReadableIds leaf inside a composed read scope is composed too — the nested master chain (ADR-0139 D6 as amended) - #25072

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-25010-readable-ids-nested-compose
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-25010-readable-ids-nested-compose

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25010

Built on PR #25032 (#25009), which has landed. This branch was cut from claude/issue-25009-readable-ids-delegated-compose at 2a8d0d5b4 (the delegated composition and the ADR-0139 revision), so the commits up to 2a8d0d5b4 are PR #25032's; PR #25032 merged into main as f8c5fb396 and this branch then merged origin/main (one true merge, 70d95b3e2, the one conflict in engine.ts resolved by keeping this card's hunks over the identical #25009 text main now carries). git diff origin/main...HEAD is this card's diff alone: five files under packages/objectql, packages/plugins/plugin-security and .changeset/; docs/adr/** is not in it. Every reading below is on the post-merge head unless it says otherwise.

Engine card (b)(ii) of the maintainer's ruling B on #24932 (comment 6303721609): a ReadableIds leaf that sits INSIDE a composed read scope — the scope getReadFilter(P) answers for a parent whose own read scope is derived from ITS master, a two-level controlled_by_parent chain — is composed by the engine, recursively, instead of reaching the driver uncomposed. The plugin-security switch of controlled_by_parent (its call sites, the id-list pre-scan's retirement, ADR-0055's text) is card (c), #24932, and the consumers' probe routing is #25034; none of it is touched here.

Clause-②: no (an engine-internal leaf, not a published accept set; the runtime read-scope change is the human floor's, decided by ruling 6303721609)

What changed

packages/objectql/src/engine.ts — the ReadableIds leaf composition only. composeReadableIdsAtBoundary keeps its five steps per parent and becomes the chain's driver: it resolves the security service once for the read and hands the sites to the new composeReadableIdsChain, which composes the leaves at one level and, FIRST, every leaf a scope answered for one of their parents carries — the same five steps, the same caller context at every level (a delegated context included, onBehalfOf intact), recursively. The parent's leaf is then composed with the REWRITTEN scope (copy-on-write; the 'policy' provenance mark the scope was given survives the rewrite), so the driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested. Per read, one ReadableIdsChain carries the object and verb, the security resolution, the context and the answers, so a parent is asked once at whichever level it first appears and its composed scope is reused by reference. Three refusals by name (INTERNAL_ERROR / 500, D5's envelope, before the service is asked about that parent): D5's two classes at a nested level (refuseUncomposableReadableIdsClasses, unchanged, judges the nested sites exactly as the top-level ones — the whole chain compiles into the read's one statement on the read's datasource); a chain that returns to a parent already on its branch (a cycle); a chain deeper than READABLE_IDS_MAX_CHAIN_DEPTH (8 scopes — the same cut as plugin-security's CBP_MAX_CHAIN_DEPTH: a detail with seven derived masters above it composes, an eighth is refused there and here; a cost ceiling, its derivation in the docblock). A nested leaf outside a $in comparand, or a composed leaf the engine did not compose, inside an answered scope is refused by the same walk that refuses it in a caller's where: the scope is the where of a read of its parent and is judged as one. readableIdsScopeOf and the closed doors are unchanged: canReadObject false, the deny sentinel, a throw on either half or an unregistered parent answer $in: [] inside the composed scope, and the outer leaf is still composed.

packages/objectql/src/filter-comparand-shape.ts. collectComposableReadableIds gains an optional at label (default: path) so the boundary can walk a composed scope with path 'where' (the position judgment: a scope IS the where of a read of its parent) while its sites and refusals read where.record_id.$in.scope.grand.$in — the leaf, its scope, the field inside it. No caller changed; the door and the boundary pass what they passed.

Tests. packages/objectql/src/engine-readable-ids-nested-composition.test.ts (new, beside the composition pins, the recording driver): each verb hands its driver one call whose composed leaf carries a composed leaf, the chain asked outermost first, each parent once, both halves with the caller context as it stands; a delegated context at every level; an undefined grandparent scope; a middleware-written leaf; the closed doors at the nested level answer $in: [] inside the composed scope; the parent's own door closed leaves the grandparent unasked; D5.2 / D5.3 at the nested level refuse with code and status, no driver call, the grandparent unasked; a nested leaf at every other position and a forged composed leaf inside a scope refuse by name; two cycles refuse by name; a chain of eight scopes composes whole and a ninth is refused before its parent is asked; a parent at two levels is asked once and reused by reference; a control: a scope with no leaf composes by reference, one level asked. packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts (new, beside the delegated real-stack pins): ObjectQL over better-sqlite3, the real SecurityPlugin and SharingService, the chain child → PARENT (controlled_by_parent, master GRAND) → GRAND (private), the SAME rows read two ways — today's id-list path (a controlled_by_parent child, scoped by the middleware chain's pre-scan) and the leaf path (a child whose reads carry a leaf over PARENT, with the engine's security source wrapped so getReadFilter(PARENT) answers the real scope with its pre-resolved master id list replaced by ReadableIds.of(GRAND) — the spelling the plugin-security switch will answer; the wrapper stands in for that switch and touches nothing else). Pinned: a direct caller (two identities) and a delegated caller (the delegator intersection at both levels) get row-for-row the rows today's path serves, find and count agree; the wrapper replaced the id list once per leg; the composed read is ONE statement, the parent and the grandparent inside nested subqueries, with no pre-scan of the parent, against today's pre-scans of the grandparent and the parent and a child statement that binds a list; the closed doors: a caller the grandparent object does not admit, a delegator who does not exist, a delegator the grandparent object does not admit; a system control.

Changeset .changeset/25010-readable-ids-nested-compose.md: @objectstack/objectql minor — a capability, levelled as PR #25032's and the earlier ADR-0139 composition cards were. Only @objectstack/objectql's source changed; the plugin-security file is a test.

Measured first (the PM's three hypotheses)

  • H1 (reproduced on the base, 2a8d0d5b4, before any engine edit): with the engine's two files restored tree-only to the base (blobs 2879fdae7f2d and b5a9e1aa8d95 verified on disk, composeReadableIdsChain 0 occurrences) and the real-stack pins run, every leaf-path read of the chain — direct, delegated, and both closed-door cases — answered driver-sql's refusal: [driver-sql] where.grand.$in holds a ReadableIds leaf, "the ids of 'qa_rid_nest_grand' this caller may read", UNCOMPOSED: it reached the driver without passing the engine's composition seam … The query was NOT run (INTERNAL_ERROR / 500); 5 failed | 4 passed (9). Restore to HEAD proven (blobs equal HEAD's, git diff HEAD empty).
  • H2 (holds): the composition is recursive, the same context at every level; measured on the real stack, the composed statement is select * from qa_rid_nest_child_leaf where parent in (select id from qa_rid_nest_parent where grand in (select id from qa_rid_nest_grand where ((owner_id = ?) or (id in (?))))). The cycle guard is real and measured (two cycle shapes refuse by name).
  • H3 (statement count, measured): the composed read is one statement per find and one per count. Today's path on the same rig: one select id from GRAND, one select id from PARENT where grand in (?, …), then the child's statement binding the parent ids — three statements that read the chain per find, none a subquery. (On this rig the real getReadFilter(PARENT) still runs its own grandparent pre-scan while ANSWERING the scope the wrapper rewrites, and plugin-sharing reads the share rows; those are the security service's answering, not the read, and the pre-scan retires with card (c).)

Verification (head 549f0bcc8, the post-merge head; every heavy run through scripts/pm/os-verify-lock.sh, verdict read off its VERDICT line; a shared box, so the durations are not idle-box figures)

  • Build of the dependency closure, pnpm exec turbo run build --filter='@objectstack/plugin-security...' --concurrency=2: 19 tasks successful (15 cached), VERDICT command-exit 0. The closure includes @objectstack/objectql; the plugin-security suite reads its changed source through the package's vitest alias to src/, and the objectql suite imports ./engine.js.
  • pnpm --filter @objectstack/objectql test (vitest run --project local): Test Files 454 passed (454), Tests 8545 passed (8545), 692 s, exit 0.
  • pnpm --filter @objectstack/plugin-security test: Test Files 201 passed (201), Tests 4133 passed | 45 skipped (4178), 259 s, exit 0. The ablation leg, the two suites and the two typechecks ran as one locked command: VERDICT command-exit 0 · held the lock 1070s. (On the pre-merge head 9cee19a5d the same two suites read 452 / 8523 and 201 / 4133 + 45 skipped, both exit 0.)
  • pnpm --filter @objectstack/objectql typecheck exit 0 (tsc, and check:test-typecheck: OK — 39 file(s) / 233 error(s) / 64 pinned signature(s) held, the pre-existing ledger, no new file in it); pnpm --filter @objectstack/plugin-security typecheck exit 0 (check:test-typecheck: OK — 0 file(s) / 0 error(s)).
  • The two new files alone, run on the pre-merge head 9cee19a5d (their only change since is the four-site cast move above): objectql nested + composition pins Test Files 2 passed (2), Tests 39 passed (39); the real-stack nested pins Tests 9 passed (9). On the final head they ran inside the full suites above.
  • test:repo (the vitest repo project) not run locally: CI's Test Core runs it on the PR.

Ablation (one behaviour changed, one leg; scripts/ablation-replace.mjs, mutation and restore proven on disk)

The recursion removed (packages/objectql/src/engine.ts, anchor : { scope: await this.composeReadableIdsChain(chain, answer.scope, nested, [...ancestors, parent]) }); → : answer); /* ABLATION_25010_NO_RECURSION */, so a scope that carries a leaf is handed through as answered — the base behaviour), on the post-merge head 549f0bcc8: ok mutation landed: anchor 1 → 0, blob 51393862e428 → 999f76cdee36 (on disk inside the leg: the recursion call 0 occurrences, the marker 1). Under it: engine-readable-ids-nested-composition.test.ts 9 failed | 30 passed (39) with the composition pins beside it — every chain pin red (the composed chain, the delegated chain, the undefined grandparent scope, the middleware-written leaf, the nested closed doors, D5.2 / D5.3 at the nested level, the cycles, the bound, the parent at two levels; the three that stay green need no recursion: the parent's own door, the in-scope position refusals, the control); readable-ids-nested-compose.test.ts 5 failed | 14 passed (19) with the delegated real-stack pins beside it — the direct parity, the delegated parity, the one-statement pin and both nested closed doors red on driver-sql's UNCOMPOSED refusal, as on the base. Restore: ok restored: blob == HEAD (51393862e428) and git diff HEAD is empty, the marker 0 occurrences after. (The same leg on the pre-merge head 9cee19a5d, engine blob 455faa95a5fb, gave the same two readings.) Both suites ran against src/ (objectql's own test imports ./engine.js; the plugin-security suite aliases @objectstack/objectql to src/index.ts), so no dist rebuild was on the resolution path; git status --porcelain empty after the leg.

Gates (derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, on this head; exit codes captured to a file before any pipe; reconciled with --ran)

✓ dispatch-gates --ran: 75 derived famil(ies) accounted for — 72 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3), 0 UNRUN on 549f0bcc8. All 72 run families exit 0, including check:query-options-erasure (the ratchet holds at 236 test sites: an earlier revision of the objectql pins cast the options object as any at four call sites, which the ratchet counts; the cast moved to the context value, the sibling test's spelling), check:changeset-gate-self-tests, check:empty-changeset, check:changeset-no-major, check:adr-0087-registration, check:test-source-alias, check:cross-package-test-inputs, check:where-matcher, check:objectql-double-limit, check:engine-double-contract, check:durability-log-level, check:swallow-census-controls, check:stack-collection-maps, check:type-check-coverage, check:nul-bytes, check:undeclared-dep-imports. NOT MEASURED locally, each a refusal of its own prerequisite (exit 3), not a red: pnpm check:dual-build-cjs-loads and pnpm check:i18n (both read every package's dist/; this tree built the plugin-security closure only — CI's Build Core and the lint legs carry them) and pnpm check:type-check-debt (it rebuilds the closure under the shared verify lock and its queue budget ran out behind this PR's own locked suite run, and a second attempt after that run timed out behind another seat's hold — --re-measure cannot run: the closure build never ran, exit 3 both times; CI's Type Check · debt ledger carries it).

The derivation runs off the merge base with origin/main, which after the merge is main's own head, so the union covers this PR's five files alone (75 families; the earlier derivation on the stacked base, before PR #25032 landed, covered its eleven files too and named 83). The full farm is CI's; read the PR's check-runs.

Acceptance notes

  • Driver side of the nested shape (ADR-0139 D3), measured, not changed here — carrier: plugin-security: a controlled_by_parent child's read loads every readable master id with no limit (twice per request), so its CPU grows with the master table and the read answers 500 "too many SQL variables" past ~32.8k masters #24932 (card (c)), noted, not filed. driver-sql compiles a composed scope that holds a composed leaf through its own applyFilters (the nested subquery above), and driver-memory resolves it through convertToMongoQuery; both by construction, no edit. The Turso driver's two faces disagree: probed on this head with the spec's conformance rows and a composed leaf whose scope carries a composed leaf, the LOCAL face answers rows (c1, c3, c6, c7, the semi-join evaluated), the REMOTE face refuses INTERNAL_ERROR / 500 — [driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it — because TursoDriver.toRemoteWhere resolves only the top-level composed leaves (replaceComposedReadableIds does not descend into a composed leaf's scope) and hands the nested one to the transport through toRemoteFilter. The remedy is one line (resolve the scope through toRemoteWhere with the outer read's options) plus a nested case in the spec conformance table so every driver suite measures the shape. Not done here: no shipped producer writes a nested leaf until card (c) lands (nothing reaches it today), and the fix adds a driver verification surface this card does not own. It belongs to card (c)'s landing or a sub-card of it, before the switch makes the shape live on a Turso remote deployment.
  • Today's id-list path still pre-scans inside the real getReadFilter(PARENT) on this rig (the wrapper replaces its output); that is ADR-0055 §2 mechanism (b) standing until the switch, as the ruling orders — not a finding.
  • The ADR-0139 D2 text says the composed scope "is run through the same lowering P's own direct read runs"; the engine marks the scope 'policy' and hands it through as the security service answered it (the service answers driver-ready filters), at the nested level exactly as at the top level. Source reading, unchanged here; carrier: none — noted.
  • AGENTS.md §2 (Multi-agent discipline) says a stacked series "is NOT a supported form" and names a trunk branch; the dispatch claim (6307546021) chose the stack explicitly so this card builds on objectql: the ReadableIds leaf composes for a delegated (on-behalf-of) caller, with the delegator intersection (ADR-0139 D5.1's exit), and ADR-0139 records O5 answered #25009's composition in the same region. Recorded as the dispatch's decision; the landing order is in the first paragraph.

维护者速读(草稿)

改了什么。 引擎的 ReadableIds 叶子组合现在是递归的:当某个父对象 P 的读范围(getReadFilter(P))本身又带着一枚指向它自己主对象 G 的叶子时(两级主从链:子表 → P 为 controlled_by_parent → G),引擎先把 G 那枚叶子组合好,再用改写后的范围组合 P 的叶子,每一级都用调用者原样的上下文(代理调用者也一样)。驱动拿到的是一枚范围里嵌着另一枚已组合叶子的叶子,编译成一条嵌套子查询的 SQL。每个父对象每次读只问一次;环(范围绕回自己)与超过 8 层的链按名拒绝(500,D5 的信封),不半截组合。

为什么改。 这是维护者在 #24932 上裁决 B 的第 (b)(ii) 步,也是 #25009(PR #25032)的姊妹:两步合起来,引擎对每一种形态都能组合叶子,plugin-security 才能在卡 (c) 一次性把 controlled_by_parent 切到叶子、退役主表 id 列表预扫描(HotCRM 规模下实测 500 的那条路径)。实测:改之前,两级链上的嵌套叶子原样到达 driver-sql,被拒 500;改之后一条语句读完整条链,直接调用者与代理调用者的行集与今天的 id 列表路径逐行一致,find 与 count 一致。

风险与代价(含回滚)。 线上已发布面无行为变化:今天没有任何安全服务在读范围里写叶子(controlled_by_parent 仍是 id 列表,等卡 (c)),六个消费者的单层叶子按引用原样组合(对照 pin 已钉住)。新增的拒绝只在服务答出环或过深链时触发。真机测试:objectql 与 plugin-security 两包全量套件绿;消融(去掉递归)把嵌套 pin 打红后复原。一个已测量但未处理的点:Turso 远程面不编译嵌套叶子(本地面可以),在卡 (c) 让这种形态上线之前需要一行修复加一条一致性用例,见 Acceptance notes。回滚:revert 本 PR 即回到单层组合,无数据或 schema 变更;changeset 为 minor。

席位意见。

你要做的。 本 PR 建立在已落地的 PR #25032 之上,分支已合入 origin/main,diff 只剩本卡的五个文件,不触及受管面(ADR 文本随 #25032 已落地);席位复核后入队落地即可。另请决定 Turso 远程面的嵌套叶子修复挂在卡 (c) 还是单开子卡。


Generated by Claude Code

objectstack-fleet Bot and others added 10 commits October 21, 2026 01:10
…legated caller; getReadFilter threads the D10 intersection

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…9 revision draft, taken over unverified

The delegated-compose pins beside the leaf tests (objectql) and the real-stack
test in plugin-security, the three plugin-security pins respelled for a
delegator who does not exist, and the ADR-0139 draft (O5 answered, D5.1
amended). Committed as found in the interrupted run's worktree; verified in
the commits that follow.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…elegated caller with the delegator intersection; the changeset, the ADR E2 row and the dangling-delegator report line

The missing-delegator refusal in computeDelegatedReadFilter carries the
D10 invariant sentence the report-sink pin reads; the two bare-instance
pins' docblocks say which refusal they now reach. The changeset for
@objectstack/objectql and @objectstack/plugin-security (minor, as the
earlier ADR-0139 composition cards). ADR-0139's E2 row notes D5.1's
amendment beside its historical "three classes".

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…0 invariant sentence verbatim, as the report-sink pin reads it

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…ded D5.1; the ancestors docblock counts six call sites

- Open items: O5 is marked answered, pointing at D6 where the ruling is recorded.
- E2 row, Pins cell: a delegated caller composes (both halves asked with the link
  intact, the closed doors still `$in: []`); a middleware-gated or federated parent
  keeps the loud refusal, a delegated context included.
- security-plugin.ts: computeControlledByParentFilter's `ancestors` docblock counts
  the six call sites (five with empty ancestors, the recursive hop the sixth). Comment only.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…sed too — the nested master chain (WIP: pins and real-stack measurement unrun)

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…sed read's own statement and spell an undefined grandparent scope explicitly

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
… (PR #25032, the stack base, landed as f8c5fb3)

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
… options object (the query-options-erasure ratchet counts an erased options argument)

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-flow.mdx (via findOne (literal, a string literal in operation))
  • content/docs/automation/hook-bodies.mdx (via findOne (literal, a string literal in operation))
  • content/docs/automation/webhooks.mdx (via findOne (literal, a string literal in operation))
  • content/docs/kernel/contracts/data-engine.mdx (via findOne (literal, a string literal in operation))
  • content/docs/kernel/contracts/index.mdx (via findOne (literal, a string literal in operation))
  • content/docs/kernel/events.mdx (via findOne (literal, a string literal in operation))
  • content/docs/permissions/attachments-access.mdx (via findOne (literal, a string literal in operation))
  • content/docs/permissions/field-level-security.mdx (via findOne (literal, a string literal in operation))
  • content/docs/permissions/record-view-auditing.mdx (via findOne (literal, a string literal in operation))
  • content/docs/permissions/rls.mdx (via findOne (literal, a string literal in operation))
  • content/docs/permissions/system-context.mdx (via findOne (literal, a string literal in operation))
  • content/docs/protocol/objectql/query-syntax.mdx (via findOne (literal, a string literal in operation))
  • content/docs/protocol/objectql/schema.mdx (via findOne (literal, a string literal in operation))
  • content/docs/ui/react-pages.mdx (via findOne (literal, a string literal in operation))

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

  • content/docs/releases/v15.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v16.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v17/17-0.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v17/17-5.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v17/17-6.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v17/17-7.mdx (via findOne (literal, a string literal in operation))
  • content/docs/releases/v17/index.mdx (via findOne (literal, a string literal in operation))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 4 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 — 18 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 688052e147f23a15e127070ff1bc7c1572de09ab → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 540c91ac7da34c58686c9c0e2ac3f8af4d32c579 — the merge of head 549f0bcc89068b9dcf81ff8efc403d64f35b43f8 into base 688052e147f23a15e127070ff1bc7c1572de09ab, 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 540c91ac7da34c58686c9c0e2ac3f8af4d32c579 && git checkout 540c91ac7da34c58686c9c0e2ac3f8af4d32c579
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 688052e147f23a15e127070ff1bc7c1572de09ab 549f0bcc89068b9dcf81ff8efc403d64f35b43f8 && git checkout -B drift-repro 688052e147f23a15e127070ff1bc7c1572de09ab && git merge --no-ff 549f0bcc89068b9dcf81ff8efc403d64f35b43f8

node scripts/docs-audit/affected-docs.mjs --json 688052e147f23a15e127070ff1bc7c1572de09ab

⚠️ 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 688052e147f23a15e127070ff1bc7c1572de09ab → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Seat review · domain:engine seat 2 · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T07:21Z · head 549f0bcc8 · verdict: ACCEPT (TIER_DEFAULT, this lane seat's own review). The PR touches none of the four contract faces: no packages/spec/src/**, no governed text, Clause-②: no, and no yes declaration.


Generated by Claude Code

github-merge-queue Bot pushed a commit that referenced this pull request Oct 21, 2026
…red expression replaces is refused, and nothing is written (#25086)

Closes #25000
Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose
value a stored expression replaces is refused instead of reported; it
ships now under the amended 「甲」, #6015 `6303865025`)

## What changes

`strictReadonlyWrites` refuses every drop that `onFieldsDropped`
reports. Since the `stored_expression` reason landed (#24690, PR #24778
`7532ea3c08`), one reported drop was left out of the refusal: a caller
value for a stored (non-`formula`) field that declares an `expression`,
which the engine replaces with the computed value. Under strict such a
write completed and was only reported. The amended ruling 「甲」 (#6015
`6303865025`) ships the refusal in 18.x as a BREAKING minor, and triage
re-timed this card to 18.x (`6305854988`).

- **Insert (single, batch, `insertMany`).** The strict check in
`engine.insert` now judges `insertDrops`, the one event list that
includes the stored-expression replacement, after the compute pass.
Before, it judged only the strips' drops, ahead of that pass. A replaced
value throws `ReadonlyFieldRejectedError` (`operation: 'insert'`) before
validation and before any driver dispatch, and no listener fires.
`insertMany` delegates to `insert`, so it refuses the whole call, as it
does for the other strict drops.
- **Update by id and predicate update.** The `stored_expression` report
now goes through `emitDrop`, the channel every strip reports through,
and no longer calls `notifyDrop` directly. On both branches the one
`assertNoStrictDrops()` call now comes after the stored-expression pass.
So under strict the write is refused before the statement and nothing is
written. An object that declares no stored expression skips that pass,
so its refusal happens exactly where it did before.
- **One refusal names everything.** A payload that carries another
strip's drop as well as a replaced value is now refused once, naming
both, with `stored_expression` last. Before, it was refused for the
other drop alone.
- **Wording.** `REASON_PHRASE.stored_expression` (written by #24690
ahead of this refusal) is reused. The per-reason message gains a
`stored_expression` remedy (leave the field out, or send the computed
value). That remedy is spoken only when the reason is present, so every
other refusal's message is unchanged byte for byte, by the same rule the
`computed` remedy follows.
- **Docs.** The TSDoc in `packages/spec/src/contracts/data-engine.ts`
(`strictReadonlyWrites`) and `packages/spec/src/data/data-engine.zod.ts`
(`DroppedFieldsEvent.reason`) no longer names `stored_expression` as the
arm strict does not refuse. The objectql comments in `engine.ts` and
`readonly-strict-errors.ts` follow. No `.describe()` text moved:
`check:generated` reports all 14 artifacts current, so the generated
reference docs have nothing to regenerate.

**Unchanged:** a strict write that sends the computed value, or no
value, is not refused. A stored expression field that is also `readonly`
is still refused once, as `readonly`. Without strict, the write
completes with the computed value and is reported. The error code is
unchanged.

## Tests


`packages/objectql/src/engine-stored-expression-dropped-fields.test.ts`:
the strict block changes from "reported, not refused" to "refused,
nothing written". It covers update by id, insert single, batch,
`insertMany` and predicate update. Each refusal asserts the `code`,
`drops` (and `operation`/`fields` where they matter), that the store is
unchanged, and that the listener stayed silent. A non-strict control
sits beside each refusal (the write succeeds and is reported), together
with the strict controls for "computed value sent / no value". The union
case is also covered (`readonly_when` + `stored_expression` in one
refusal), and so is the `readonly`-only insert refusal.

All runs were taken on the final code commit `a8e9d3611c`:

- `pnpm --filter @objectstack/objectql test`: 455 files / 8569 tests
passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit`
clean, and `check:test-typecheck` OK (ledger held).
- `pnpm --filter @objectstack/spec build && check:generated`: all 14
generated artifacts up to date. `spec test`: 705 files / 21424 passed (1
todo). `spec typecheck`: OK. `spec test:repo`: 72 files / 1237 passed.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands
from merge base `688052e14`, and all 94 exited 0. On the first pass,
`check:dual-build-cjs-loads` and `check:type-check-debt` printed
PREREQUISITE NOT MET (exit 3, nothing measured). I re-ran
`check:type-check-debt` under the verify lock: its closure build ran and
it re-measured OK, `none above its recorded number`. That full build
also produced the missing `dist/` trees, and
`check:dual-build-cjs-loads` then passed (112 entry points across 69
packages load). `dispatch-gates --ran` reconciles 94 derived, 94 run, 0
NOT-MEASURED, 0 UNRUN. The changeset gates are among the 94:
`check-adr-0087-registration`, `check-changeset-no-major`,
`check-empty-changeset`, `check:changeset-gate-self-tests`.

**Ablation.** The fix was committed first. Each leg went through
`scripts/ablation-replace.mjs`, with an anchor hit of 1 to 0 and the
blob proven back to the HEAD blob `3e8c0ec2bd1a` with `git diff HEAD`
empty. The suite imports `./engine.js` from source, so no build is
involved.

- A, update paths: `emitDrop` lets `stored_expression` past strict.
Three tests go red (update by id, predicate update, the union case), and
the other 22 stay green.
- B, insert: the strict check ignores a `stored_expression`-only list.
Three tests go red (insert single, batch, `insertMany`), and the other
22 stay green.

## Producers measured

No non-test caller in this repository passes `strictReadonlyWrites`: the
examples, `packages/qa` dogfood and the runtime and REST doors pass
none. The option is also not on the serializable options bag, so no wire
caller can set it. No in-repo producer moves. In the objectql suite, the
in-package strict tests on stored-expression objects
(`engine-stored-expression-write.test.ts`,
`engine-write-formula-hydration.test.ts`,
`engine-formula-write-strip.test.ts`) stay green unchanged.

## Changeset

`.changeset/25000-strict-stored-expression-refusal.md`:
`@objectstack/objectql` `minor`, with a BREAKING section that maps FROM
(completed, reported) TO (refused, nothing written), gives the remedy,
and carries the claim's `Clause-②` line and an ADR-0087 `not-required
(no-migration-prescription)` marker (the precedent shape of
`bb4f5cc005`). `@objectstack/spec` is not listed: only TSDoc comments
moved there, and no `.describe()` text or export did.

## Acceptance notes

- The release-owned 18.x upgrade checklist (`content/docs/releases/`) is
not edited here. The changeset carries the text that release compilation
reads.
- The pending `.changeset/24690-stored-expression-dropped-fields.md` (in
the open Version Packages PR) says strict "is unchanged" and that the
refusal is deferred to 19.0. That file is outside this card's claimed
file surface, so this PR leaves it alone. The new changeset says it
supersedes that bullet. The report names the one-line amendment for the
seat to decide.
- `content/docs/data-modeling/validation-rules.mdx` (the `expression` on
a stored field paragraph) still says a sent value is "not reported as
dropped", which has been false since #24690. That is a hand-written doc
outside this card's surface, and it is noted in the report.

Open PRs touching the same files were checked: #25072 and #24893 edit
`engine.ts` in disjoint hunks (lines 4628–6300 and 9043–12250). No open
PR touches `data-engine.zod.ts`, `contracts/data-engine.ts` or
`readonly-strict-errors.ts`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 size/xl tests tooling

Projects

None yet

2 participants