Skip to content

fix(plugin-security)!: a predicate-scoped update or delete matches only the rows the caller can read - #21900

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21829-predicate-write-matches-readable-rows
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21829-predicate-write-matches-readable-rows

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21829
Clause-②: no (narrowing)

What this changes

A predicate-scoped (multi: true) update or delete now matches only the rows its caller can read. This is ruling B on #21829 (record 5994120632), which carries ruling A on #21771 (5985885287) from the by-id write door to the predicate door: on the write doors, a row the caller cannot read is a row that does not exist.

A row the read door would not return to the caller is not written, not counted and not refused. So a predicate that matches only such rows answers exactly what a predicate that matches nothing answers: success, zero rows. A caller who can read a matched row but may not write it keeps the answer it had.

Where. The plugin-security write middleware, at the seam PR #21812 used. In step 3, before next(), the middleware asks the read door its question for the caller's own predicate (options.where, the predicate step 2.9 already guards). The question is a caller-context read through the engine (readableRowsScopeForPredicateWrite), so every data middleware's and every kit's read visibility applies, the parent-derived read gates included. The answer is composed onto the write's AST as an id list, beside the write scopes the step already composes. Matched set = readable ∩ writable. The engine reads its matched rows from that AST, so the narrowing binds the matched-row read, the per-row hook dispatch and the driver write alike. No engine source and no kit write hook is edited.

Binding constraints, as built:

  1. Addressed versus nested (H2). Only the predicate write the caller addressed is narrowed (addressedPredicateWrite, the twin of addressedByIdWriteId). A write that starts inside another engine operation reads as nested through fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812's engineOperationScope and keeps today's answer: a cascade, a hook's own predicate write. The referential FK clear is excluded by its server-derived marker. No second "is this addressed?" signal.
  2. By-id versus predicate comes from the engine's own dispatch predicates (resolveEngineUpdateDispatch, resolveEngineDeleteDispatch): only their multi verdict is narrowed.
  3. One classifier (H3). absentUnderCallerRead now delegates to readUnlessRefused, which the predicate read question asks too. A declared 4xx (the read grant withheld, a predicate the driver will not compile) keeps the write's previous answer. A store fault propagates as raised. No second visibility evaluator.
  4. The cap. The id list is bounded by the platform's existing row ceiling for one predicate write, MAX_BULK_PER_ROW_HOOK_ROWS (10 000). Above it the write is refused with 400 INVALID_FILTER before anything runs. That is the rule the engine's nested-relation lowering follows for the same shape. It is never a cut-off list, which would write fewer rows than the caller can reach, and never "do not narrow", which would hand the existence signal back to a padded predicate. The count is of rows the caller can read, so the refusal discloses nothing the read door does not. No error code is new.
  5. Fail closed when the engine cannot be asked: a predicate write is not run un-narrowed.

H5, security/explain. Measured: ExplainInput takes an object, an operation, a context and an optional record id. It has no predicate, so it models no predicate-scoped write. It is left alone.

Client census (step 1, before any code)

The question: does any shipped client, or any test in this repo, read a predicate-scoped update's or delete's 403 as "a hidden row exists", in a way the move to success-with-zero-rows would break?

  • packages/client: data.update and data.delete are by id. data.updateMany(records), data.deleteMany(ids), batch and batchTransaction reach REST routes that the protocol serves with per-id by-id loops. (c), unrelated.
  • packages/client-react: mutation wrappers over the same methods. (c).
  • REST: no route admits a caller-supplied predicate with multi (the delete-many ingress narrows its options to the batch-options bag). So no client door issues a predicate write.
  • packages/cli: the secret re-wrap calls the driver's updateMany directly, below the middleware. The migration plugin lists method names. (c).
  • objectui console, at the .objectui-sha pin 0abd4f9f, read-only: the data adapter's bulk update and bulk delete send id lists to the same per-id routes, with a per-id fallback. No console, app-shell or adapter source passes multi. The console issues no predicate write. (c).
  • Server-side issuers that are not clients: the automation update_record and delete_record nodes with multi: true. Any error becomes a failed step with its message, and success records the written count. Nothing reads a 403 as existence. (c).
  • Tests: 43 files carry multi: true. None asserts a 403 for a predicate that matches only rows hidden from the caller through the real security middleware. The kit suites run without the security middleware. The select-only and check-only write-scope suites already assert that unreadable rows stay untouched. The bulk widener probe uses a public-read object. The unscoped-delete gate reads the caller's raw predicate.

No class (a) dependency. No class (b) pin turned. That was confirmed empirically: every candidate suite below is green with the change.

Premises

Premise 2, the seam, holds. The engine runs the middleware chain around the predicate path's matched-row read, for update and delete alike (executeWithMiddleware wraps driver.find(object, ast, …) on both verbs). The AST is seeded from options.where before the chain runs, and step 2.9 already reads opCtx.options.where (H1).

Premise 1, the cost, holds. Measured on the showcase app (bootStack, the real SecurityPlugin, the storage and audit plugins), with a caller holding showcase_contributor. Rows were seeded at the driver, and one predicate update matching every row was timed. "Without" is the same build with the narrowing ablated (marker proved in dist/, restored and rebuilt after). All numbers are shared-box wall clock: the lock excluded other locked runs only.

object rows without with rows written (without / with) read-door id read
showcase_task (select-scoped RLS) 1 000 67.7 s 58.0 s 1 000 / 1 000 7 to 11 ms
sys_attachment (parent-derived read) 1 000 2.51 s 2.29 s 1 000 / 1 000 12 to 13 ms
showcase_task 10 000 378.8 s 472.1 s 10 000 / 10 000 27 to 45 ms
sys_attachment 10 000 18.85 s 19.87 s 10 000 / 10 000 39 to 40 ms

The end-to-end spread is box noise: the sign flips between 1 000 and 10 000. So the narrowing's own cost was measured directly at 10 000 rows, as the median of 5 runs each:

  • the read-door id read: 33 ms;
  • the matched-row read: 125 ms plain, 144 ms with the id list;
  • the driver's predicate update: 18 ms plain, 47 ms with the id list.

That is about 81 ms in all, against a write that pays 19 s (attachments) to 379 s (tasks) for the same rows. The write's own budget is its per-row hook dispatch.

How the narrowing meets the kits' MULTI_WRITE_AUTH_LIMIT (1 000). It does not meet it. A 10 000-row predicate update on sys_attachment lands on both builds. The engine's per-row dispatch binds input.id, so the kits' row resolver takes its by-id branch. Their 1 000-row bound is reached only on the whole-operation dispatch, which refuses the unscoped shape before resolving anything. The ceiling that binds both legs is the engine's per-row hook ceiling (10 000), which is also the narrowing's cap.

Pins

New plugin-security/src/predicate-write-unreadable-not-matched.test.ts uses a real ObjectQL, a real SQL driver, the real middleware and a kit-like per-row gate, on update and delete. It pins:

  • a predicate matching only hidden rows equals a predicate matching nothing;
  • hidden and visible rows: only the visible rows change, and they alone are counted;
  • a reader who may not write keeps the gate's 403;
  • control: a visible, writable match is written;
  • a read the read door refuses keeps the previous answer;
  • a store fault on the read question propagates, with nothing written;
  • a readable set over the cap is refused (INVALID_FILTER, 400), with nothing written;
  • a hook's own predicate write keeps the gate's 403, while the same caller addressing that predicate gets zero rows;
  • the by-id doors keep fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812's answers.

New qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts runs on a real stack, at the engine's predicate door, under the context the REST door resolves for the caller's own token. It covers both principal classes (outside and inside the ownership floor's org_member domain, each proven by arming probes), update and delete, and sys_attachment, sys_comment and a plain row-level-security object whose write-class policy reaches rows its read policy hides. 12 cells, each asserting:

  • hidden-only equals nothing (zero rows, nothing written);
  • the reader keeps its answer;
  • hidden and visible: count 1, only the visible row changed;
  • the by-id door still answers 404 RECORD_NOT_FOUND for the hidden row.

The reader cells pin the answer each class had. The gate's named 403 applies where the write scope reaches the row: outside the domain on both verbs, and on delete inside it. Zero rows applies where the ownership floor or the write-class policy already excludes the row.

Doubles. Two plugin-security harnesses (security-plugin.test.ts's middleware context and tenant-layer0-verdict-on-operation.test.ts's engine) gained a find, because a ql that cannot answer the read question refuses the write. Their findOne now refuses what the real engine refuses (assertEngineFindOnePredicate). Their assertions are unchanged.

Ablation

All ablations went through scripts/ablation-replace.mjs, with a trap restore and the fix committed first.

  • A, the un-narrowed matched set (unit). The narrowing call was skipped. 8 of 17 unit pins went red: both verbs' hidden-only equality, both verbs' hidden-and-visible count, both verbs' store-fault propagation, the over-cap refusal, and the addressed control. Restore was proven: blob == HEAD (38bebf13), git diff HEAD empty.
  • A, dist-mediated (dogfood). A runtime-only guard was planted, and the marker was proved present in 2 built dist/ files. 10 of 12 door cells went red, twice (once per measurement leg). The 2 cells that stay green are inside-class updates on the two kit objects, where the platform's ownership floor already kept the hidden row out of the write scope before this change. Restore was proven both times: blob == HEAD, rebuilt, ablation-dist-preflight --absent clean on the whole tree.
  • B, narrowing applied to nested writes too (unit). The hook's-own-write pin went red: zero rows instead of the gate's 403. Restore was proven: blob == HEAD.

Verification

Head 23df2c8a unless stated. The branch merges origin/main twice: at e864db56 (carrying #21873) and at 67c544cc (carrying #21881, the sibling permission-set-projection change).

  • plugin-security: the full suite, 168 files, 3632 passed, 45 skipped, 0 failed. typecheck (tsc, scripts, check:test-typecheck) is green.
  • dogfood: typecheck green. On this head, the new door file, the fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812 door file, fix(plugin-security): a data-door edit of a permission set saved into a writable runtime package updates its own row instead of forking it #21881's write-through binding file and the owner-anchor bulk-write file: 4 files, 45 passed. On 44fa3fc1 (the first merge), a batch of 10 files, with the fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812 door and parent-derived files and every census candidate (owner-anchor bulk writes, the bulk widener probe, the unscoped attachment gate, the engine where-shape refusal, flow run-as, the attachment matrix, the authored-row write scope), is 108 passed and 1 skipped. Also green: the showcase declarative endpoints, 17 tests (its predicate delete flow runs as system).
  • plugin-sharing: 38 files, 954 passed. service-automation: the write-node and bulk-intent suites, 27 passed. runtime: the stored-metadata body boundary pin, 7 passed.
  • spec: check:migration-registry reports the registry current (379 semantic entries). The migration and spec-changes surface suites: 176 passed. The ADR-0087 registration gate reads registered predicate-write-unreadable-row-not-matched (new here).
  • Derived gates: dispatch-gates --commands lists 124 commands on this head. All 124 exit 0, each run with its exit code captured before any pipe. The --ran reconciliation reads: 124 derived, 124 run, 0 NOT-MEASURED (a derived zero, from the recorded exit codes), 0 UNRUN. One finding along the way was fixed: with find beside findOne, the two harnesses became engine doubles to check:engine-double-contract. Their findOne now opens with assertEngineFindOnePredicate, and the pinned ledger (scripts/engine-double-contract.pinned.json) records the grown coverage via the gate's own --write.
  • Lint, narrowed: eslint --no-inline-config over the 7 changed TypeScript files reports 0 errors and 0 warnings (counted from --format json). The population is eslint.config.mjs's **/*.{ts,…} glob minus its never-linted list, and none of the 7 was ignored. The config enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The repo-wide run is CI's.

Docs

The sentences this made false are corrected:

ADR-0087

The changeset carries the FROM → TO a caller acts on, so registered is the honest disposition. One D3 semantic entry, 18.predicate-write-unreadable-row-not-matched, sits beside 18.by-id-write-unreadable-row-not-found. It was generated with gen:migration-registry, and @objectstack/spec is named in the changeset (patch). No Zod schema, contract docblock or export moves.

Acceptance notes

  • The read door's candidate window on the parent-derived objects. The attachment and comment read middleware pre-scans at most 2 000 candidate rows per read, and fails closed beyond that: rows past the window are omitted, with a logged warning. The narrowing asks that read door, so a predicate write that reaches more than 2 000 candidate rows on those objects writes only the rows the read door returns. Measured: 3 000 attachments on 3 000 readable parents, one predicate update. Before, 3 000 were written. After, 2 000 were written, and the result says 2 000. This follows the ruling's text ("a row the read door would not return is not matched"), and the count is honest. Whether that window should be widened, or whether a narrowed write should refuse instead, is a question for the seat, not decided here.
  • The cap is a narrowing too. A predicate write whose predicate matches more than 10 000 rows the caller can read is now refused (400 INVALID_FILTER), even where fewer of them are writable. On a composed kernel every object measured carries per-row write hooks, so a write matching more than 10 000 writable rows was already refused by the engine's ceiling. The new refusal reaches only a predicate that matches more readable than writable rows past that ceiling. The changeset declares it.
  • Inside the org_member domain, the ownership floor already kept hidden rows out of predicate updates on the two kit objects. The change there is on delete, and on the plain RLS object.
  • The kits' not-visible refusal now reaches only writes the caller did not address, and nested writes. The kits' docblocks are not edited (no kit edit, per the ruling).
  • Not edited: the sharing-rules page still names a 403 for a by-id write to a row hidden on a private object. That has been stale since fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812, and no sentence there is about predicate writes. Carrier: none.
  • Declarations: the new dogfood file is on [PM seat] domain:cli — ⏳ vacant #6024 (5994866670). The step-18 entry is the conditional spec declaration on [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017 (5994857244). The landing needs the at-tier contract review the ruling requires.

Generated by Claude Code

claude added 5 commits October 5, 2026 15:03
…ly the rows the caller can read

On the write doors a row the caller cannot read is a row that does not
exist; this carries that rule from the by-id door to the predicate door.

For the predicate (multi) update or delete the caller addressed, the
security middleware asks the read door which rows the caller's own
predicate returns (a caller-context read through the engine, so every
data middleware's read visibility applies) and composes that answer onto
the write's matched set as an id list: readable and writable. A row the
caller cannot read is not written, not counted and not refused, so a
predicate reaching only such rows answers what a predicate matching
nothing answers.

- Addressed versus nested reuses engineOperationScope; by-id versus
  predicate comes from the engine's own dispatch predicates.
- One classifier (readUnlessRefused) for the by-id and predicate read
  questions: a declared 4xx keeps the write's previous answer, a store
  fault propagates.
- The id list is bounded by the platform's row ceiling for one predicate
  write; above it the write is refused with INVALID_FILTER rather than
  narrowed by a cut-off list.
- Writes the platform issues under the caller's context, by-id writes and
  system writes keep their answers. No engine or kit source is edited.
- ADR-0087: one D3 semantic entry in step 18, beside the by-id one.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…at the engine refuses on findOne

With find beside findOne they are engine doubles to check:engine-double-contract,
so findOne opens with assertEngineFindOnePredicate and the pinned ledger records
the grown coverage.

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

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec, touching 9 documentable anchor(s).

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))

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
  • 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 — 141 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 87712ab823d8c0f8d8ce0641df69f1db6be64b4c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c28cdba3f053413901bee09662b989363d6de83 — the merge of head 23df2c8a2416b77617c8c8d47d89b2ebfb335130 into base 87712ab823d8c0f8d8ce0641df69f1db6be64b4c, 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 3c28cdba3f053413901bee09662b989363d6de83 && git checkout 3c28cdba3f053413901bee09662b989363d6de83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 87712ab823d8c0f8d8ce0641df69f1db6be64b4c 23df2c8a2416b77617c8c8d47d89b2ebfb335130 && git checkout -B drift-repro 87712ab823d8c0f8d8ce0641df69f1db6be64b4c && git merge --no-ff 23df2c8a2416b77617c8c8d47d89b2ebfb335130

node scripts/docs-audit/affected-docs.mjs --json 87712ab823d8c0f8d8ce0641df69f1db6be64b4c

⚠️ 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 87712ab823d8c0f8d8ce0641df69f1db6be64b4c → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 23df2c8a2416b77617c8c8d47d89b2ebfb335130
Local-runs: none

Inputs read: card #21829 (body and all five comments: triage, ruling B 5994120632, claim, dev report 5999084697, seat acceptance 5999225441), PR #21900 (body, 12-file list, one bot comment), the net diff origin/main...23df2c8a (1118 insertions, 33 deletions), and the head's check-runs, latest run per name. Head source was read as git objects only. Classes, positions and functions only.

① Derived judgments

  1. Position — right. The narrowing lives in packages/plugins/plugin-security/src/security-plugin.ts, in the write middleware's step 3, before next(): one more scope pushed onto the extra array that RLS, controlled-by-parent and the delegator scopes already compose, marked policy provenance and AND-ed onto opCtx.ast.where. Because the engine seeds its predicate path's matched-row read, per-row hook dispatch and driver write from that AST (verified at the head: driver.find(object, ast, preOpts) on both verbs), the narrowing binds all three. This is the seam ruling B named; no packages/objectql source and no kit write hook (attachment-access-hooks.ts, comment-access-hooks.ts) is in the file list.
  2. The read question — right. readableRowsScopeForPredicateWrite asks the engine itself (this.ql.find) under the caller's context with the operation-private keys stripped (withoutOperationPrivateKeys, exported from core), of a copy of the caller's verbatim options.where (the same predicate step 2.9 guards), projecting id with limit cap plus one. Going through the engine means every read-side middleware applies, the kits' parent-derived read filters included, with no second visibility evaluator. The composed scope is id IN (returned ids); an empty answer composes an empty list and matches nothing, pinned on a real SQL driver (unit) and a real booted stack (dogfood) by the equality of hidden-only with match-nothing. The by-id read question of fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812 passes the context un-stripped; the predicate one is the stricter form of the same read, not a divergence in verdict.
  3. Addressed versus nested — right. addressedPredicateWrite is the twin of addressedByIdWriteId: excluded by engineOperationScope (nested), by the server-derived referential-clear marker, and the by-id/multi split is read from the engine's own dispatch predicates in @objectstack/metadata-core (kind === 'multi' only). Writes the platform issues under the caller's context keep today's answer, as the ruling settled; the hook's-own-predicate-write pin and the ablation B reading cover it.
  4. The one classifier — right. absentUnderCallerRead now delegates to readUnlessRefused, which the predicate question shares: a declared 4xx keeps the write's previous answer, a store fault propagates with nothing written, an answer is composed. Named class: a principal holding a write grant and no read grant keeps today's reach, because the read door refuses rather than answers, so there is no read-door answer for the write to equal. That is the ruling's own text and the fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers #21812 classification, and it is pinned.
  5. The cap — right, with one precision. PREDICATE_WRITE_READABLE_ID_CAP is the spec constant MAX_BULK_PER_ROW_HOOK_ROWS (10 000), not a second literal; over it the write is refused with the existing INVALID_FILTER 400 before anything runs, following the engine's own relation-filter lowering (verified at the head: RELATION_FILTER_ID_CAP + 1, refuse when over). The count judged is of rows the caller can read, so the refusal discloses nothing the read door does not. Precision: the engine's existing ceiling at both predicate sites is conditional on the object having per-row hooks (hasHooksFor), so on a hook-less composition the new refusal reaches a predicate matching over 10 000 readable rows that was previously attempted. The changeset's second FROM → TO row states exactly that, generally; the acceptance note's "every object measured carries per-row write hooks" describes the measured composition, not the rule. Declared, so right.
  6. Fail closed — right. A ql without find refuses the write rather than running it un-narrowed; unreachable on a real boot, and the two plugin-security doubles were given a find and an engine-conformant findOne (assertEngineFindOnePredicate), with scripts/engine-double-contract.pinned.json grown through the gate's own --write.
  7. H5 — right. ExplainInput (explain-engine.ts) carries object, operation, context and an optional record id, no predicate; security/explain is correctly left alone.
  8. Public surface — right. No export added or removed (check:api-surface inside the green TypeScript Type Check); new symbols are module-private; no new error code; one D3 semantic entry 18.predicate-write-unreadable-row-not-matched beside its by-id sibling, with registry.ts regenerated.
  9. Docs — right. attachments-access.mdx, permissions-matrix.mdx and rls.mdx state the predicate rule and the cap; the by-id 403 to 404 correction in rls.mdx sits in the sentence this PR extends. All public text stays at classes and positions.
  10. Pins — right. 17 unit pins (hidden-only equality, mixed count, reader keeps 403, control, read refused, store fault, over-cap, hook's own write, by-id regression) and 12 dogfood door cells (two principal classes, two parent-derived objects plus one plain RLS object, update and delete, each with the by-id 404 regression). Ablation readings are the dev's; not re-run.
  11. Gate verdicts — right. All seven required contexts conclude success on the head: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, Spec property liveness, Build Docs and the three PR-claim guards are success. Console Pin Gate, Packed-tarball smoke, Auto Label and Check PR Size are skipped by design. The file list touches no governed surface; the diff is under the 5 000-line class.

② Semver level

Right. .changeset/21829-predicate-write-matches-readable-rows.md declares @objectstack/plugin-security: minor with the ! banner and @objectstack/spec: patch, and carries Clause-②: no (narrowing) — the same line the PR body carries. A (narrowing) is BREAKING; minor is the launch-window convention check-changeset-no-major.mjs enforces, and the ruling ordered exactly this shape. It is byte-for-byte the shape of the sibling .changeset/21771-write-door-unreadable-is-not-found.md on main. The body states what is refused or narrowed now, two FROM → TO rows (hidden rows not matched; the readable-over-ceiling refusal), the one-line handling for an affected caller (grant read access first; read the count as rows the caller can see; write in batches), the unchanged classes, and the ADR-0087 marker registered predicate-write-unreadable-row-not-matched whose D3 entry exists in the diff. patch on @objectstack/spec matches the data-only registry regeneration and the precedent. No skip-changeset.

③ Boundary flags

  • Dev deviation: engine-double-contract.pinned.json outside the declared surface — answered, right: giving the doubles a find made them engine doubles to the gate; the ledger grew through the gate's own --write, coverage up, no exemption.
  • Dev deviation: the cap is a narrowing beyond "not matched" — answered, right: the ruling delegated "the cap" to the dev in so many words; it is declared in the changeset's FROM → TO, the matrix doc and the acceptance notes; see ① item 5 for the hook-less reach, which the changeset already covers.
  • Dev deviation: the by-id 403 correction in rls.mdx — answered, right: same sentence, minimal.
  • Dev deviation: two merges of origin/main — answered: mechanics; the check-runs on the merged head are the verdicts.
  • Dev deviation: an additional abstract access-gap note carried in the handback message only — ESCALATED to the seat. That note is not among this review's inputs and the seat's acceptance record 5999225441 does not mention it. Before this PR lands, the seat records on the card, as an abstract class with its carrier (filed, or dismissed with the reason), what that note said. This does not fail the diff; it is a record the card owes.
  • open_questions[0]: the parent-derived read door's 2 000-row candidate window — the seat answered A on the card; judged right under the ruling's text ("a row the read door would not return is not matched"): READ_SCAN_LIMIT = 2_000 is a read-door property in both kits, so a predicate write on those two objects whose candidate set exceeds it writes what the read door returns, with an honest count and the kits' existing server-side warning, exactly as a read of the same predicate answers. Residual, escalated as a read-side question and not a defect of this diff: the caller sees no signal of the window on the write, as it sees none on the read; the seat states it is raised with the maintainer.
  • Out-of-scope finding: the stale by-id 403 sentence in sharing-rules.mdx — answered, right: docs drift with no sentence about predicate writes; an acceptance note is the carrier Prime Directive 10 prescribes for it.
  • Premises and census — reported holding (seam at step 2.9 / step 3; cost about 81 ms at 10 000 rows; no class (a) or (b) dependency in this repo or the console at the pin); not re-measured under the read-only rule, consistent with the ruling's own by-id census.

Implemented-by: claude/issue-21829-predicate-write-matches-readable-rows
Reviewed-by: session_011K3zqE8Pv1Evw5hc8tZCnN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 17:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit cab6396 Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21829-predicate-write-matches-readable-rows branch October 5, 2026 18:33
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…decision in words instead of a tracker number (stage 21) (objectstack-ai#21907)

Part of objectstack-ai#20749
Clause-②: no

Stage 21 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the second name-ordered `ui/` group: the test files
directly under `packages/spec/src/ui/` from
`component-record-blocks.test.ts` to `dashboard.test.ts`. Those files
carried 103 messages and 109 tracker-shaped ids, citing 58 records. 105
of those ids now either state what their record decided, in words (form
D), or are dropped where the title already says it. Four stay: they are
CSS colour literals in two widget fixtures, not citations (below). Text
only: no assertion, identifier, test count or code comment changes, and
no file is renamed.

## Census at the base (`1e18a0735c`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 20 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The base is `1e18a0735c`, stage 20's landing and the claim's base. Both
instruments read **764 messages / 807 ids in 159 files**, the seat's
reading and stage 20's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` (this PR: 7 of the 53 files) | 53 | 300 / 318 | 284 / 302 | 16 /
16 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **159** | **764 / 807** | **710 / 752** | **54 / 55** |

The group reads **103 messages / 109 ids in 7 files**, the seat's
figures file for file:

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `component-record-blocks.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `component-reference-rail.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `component-type-vocabulary.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `component.test.ts` | 65 / 70 | 65 / 70 | 0 |
| `dashboard-chart-structure-refusal.test.ts` | 2 / 2 | 0 | 2 / 2 |
| `dashboard-compareto.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `dashboard.test.ts` | 20 / 21 | 17 / 18 | 3 / 3 |
| **7 files** | **103 / 109** | **98 / 104** | **5 / 5** |

`component-report-items-action-members-typed.pin.test.ts` sits in the
same name range and carries no id. The five "other" strings are the four
colour literals and `dashboard.test.ts:205`.

- **Controls.** Lit: `ui/view.test.ts`, outside the group, reads 43 ids
at the base and at the head. Dark: `component.test.ts` reads 0 at the
head while 200 of its comment lines still carry a number. Planted in
scratch copies of head files: an id put into a
`component-record-blocks.test.ts` title reads 1 / 1, and an id put into
a `dashboard-compareto.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 6 of the 7 files at the base. `component.test.ts` reads one
more: `decision batch objectstack-ai#77`, a two-digit batch number the gate pattern
does not count. It leaves with the id beside it, as stages 9, 13 and 15
did with theirs.
- **At the head:** 665 messages / 702 ids in 154 files. The 7 files read
4 / 4 (the four colour literals), `ui/` reads 201 / 213, and no other
file moved.

## How the area was chosen

`ui/` has no subdirectory test file with an id, so it is taken in
name-ordered file groups near the ~100-id bound. Stage 20's re-cut named
this group at 109 ids, and this census reads 109, so no re-cut was
needed.

**Named for the next stages** (cut from the head census, 665 / 702):
- `ui/` 213 ids. One is stage 20's kept
`component-props-unknown-members.pin.test.ts:322`, four are this group's
colour literals, and 208 sit in the 45 files after `dashboard.test.ts`.
The next group nearest 100 runs from
`dataset-filter-nested-relation-list.test.ts` to
`view-inline-object-binding.test.ts`: 29 files, 96 messages / 102 ids, 7
of them "other". Cutting two files earlier gives 98. The last `ui/`
group is then the 16 files from `view-item-config-type.test.ts` to
`widget.test.ts`: 100 messages / 106 ids, `view.test.ts` alone 43.
- `api/` 201, two stages. `system/` 165, two. The files directly in
`src/`, 120, one.
- The three docblock needles plus the kept `:322`, one stage, with an
at-tier review.

## The five "other" strings: four kept, one rewritten

- **`'objectstack-ai#111'` / `'objectstack-ai#222'` at
`dashboard-chart-structure-refusal.test.ts:94` and
`dashboard.test.ts:124` are kept.** They are three-digit CSS hex
colours, not placeholders for a record: `colors: ['objectstack-ai#111', 'objectstack-ai#222']` in a
widget's `chartConfig`, and `palette: ['objectstack-ai#111', 'objectstack-ai#222']` inside the
free-form `options` bag. They cite nothing. But they are not input and
expected value at once, the case stage 12 dropped. Each is input that
the schema under test reads (`ChartConfigSchema.colors` is a
string-array or string-map union), and the assertions read other things:
the first parse must succeed, the second asserts `options.stacked`. So
by the claim's rule they stay, and are reported. They match the gate
pattern only because a short hex colour can be all digits. They are the
only four such literals in the whole census.
- **`dashboard.test.ts:205`** is the label argument of the file's
`orderPin` helper, which passes it to `it()`. So it is a test title one
call down, and no assertion reads it. It is rewritten and declared to
the text-only tool.

No string in the group is a needle (an id that is the expected value of
an assertion over a docblock or another file's text). The three known
needles are in `ai/` and `contracts/`.

## What each id became

- **21 literals (21 ids)** now state a decision in words.
- **15 literals (15 ids)** get their subject back in words, where the
number stood for a thing, such as "the objectstack-ai#11661 keys".
- **63 literals (69 ids)** drop a number the title already explains.

Every cited record was read with its comments through REST: 54 answer
200 and 4 answer 404. Four citations are cross-repo: `ui#6206` (also
spelled `ui#6206-B`), `ui#6207` and `objectui#8221` were read from
objectui and answer 200. `framework#2501` was read under the
repository's current name, as stage 18 read `framework#2536`. objectstack-ai#5042 is a
pull request, `objectstack-ai#4001` batch 14. The four 404s were read from what
landed, through the commits the stage-5 comment sweep (objectstack-ai#20576)
re-anchored them to:
- **objectstack-ai#6276:** `78f0be872`, which declares the record picker's flat `sort`
/ `limit` (maintainer ruling 2026-08-08, direction A).
- **objectstack-ai#9972:** `60e0f900a`, which records the live read point of
`page:tabs` `items[].icon` and its accept pin.
- **objectstack-ai#11507:** `88b9d749a`, which declares `sys_activity.type` an open,
author-extensible vocabulary (maintainer ruling 2026-08-24, direction
4).
- **objectstack-ai#11658:** `1a6a19c31`, which opens `RecordActivityProps.types` to
author-contributed kinds, executing that ruling.

Each of those titles already carried its decision, so the number is
dropped. `component.test.ts:3037` also gets its subject back: "the
ruling" becomes "the open-vocabulary ruling".

**Stated in words:**

| record | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#8744 | `component-record-blocks.test.ts:355` | "the
`record:discussion` / `record:chatter` pair — one shared row" |
`record:discussion` is wired to the chatter row on purpose: one
renderer, one accept face. |
| objectstack-ai#7702 | `component.test.ts:63` | "accepts a header without title — the
synthesized shape, headed from the record" | Ruling A/B: `title` becomes
optional, and an omitted title means the renderer derives the heading
from the record. |
| objectstack-ai#6776 | `component.test.ts:89`, `:262` | "PageHeaderProps recordChrome
/ showStar / showCopyId — declared because the header renderer reads
them"; "PageAccordionProps variant — declared because the accordion
renderer reads it" | Route A: declare the five author keys objectui's
renderer reads. |
| objectstack-ai#6776 | `component.test.ts:235` | "PageTabsProps tabStyle — renamed
from `type`, which collides with the component `type`" | Route A: rename
`type` to `tabStyle`, because in a flat node `type` is the component's
own dispatch key. |
| objectstack-ai#6946 | `component.test.ts:122` | "PageHeaderProps icon is retired —
no renderer reads it" | Maintainer ruling: retire three keys with zero
renderer read points, this one among them. |
| objectstack-ai#5775 | `component.test.ts:406`, `:2932` | "… items[].value /
items[].count — declared because the tabs renderer reads them";
"RecordPathProps stages[].terminal — declared because the path renderer
reads it" | Ruling A: retire the dead keys and declare the keys the
renderers honour. |
| objectstack-ai#5775 | `component.test.ts:649` | "PageContainerProps — page:section /
page:footer / page:sidebar compose through `children`" | Same ruling:
the three containers declare `children` as their one composition key,
and `body` is not declared. |
| objectstack-ai#11289 | `component.test.ts:768` | "preserves the section presentation
keys the renderer honours, verbatim" | Direction 1: declare `hideEmpty`
/ `collapsible` / `showBorder`; the renderer is unchanged. |
| objectstack-ai#11661 | `component.test.ts:889` | "still refuses `title`,
deliberately withheld as a second spelling of `label`" | Three more
renderer-honoured keys are declared; `title` stays out because `label`
already names the heading slot. |
| objectstack-ai#9220, objectstack-ai#9249 | `component.test.ts:2078`, `:2142`, `:2899`, `:2908` |
"Interactive Elements — element:filter (retired, no renderer)", the same
for `element:form`, and "… through the kept map row (retired, no
renderer)" twice | Both elements are retired at element grain: no
renderer or reader in any repository. |
| objectstack-ai#4001 | `component.test.ts:3325` | "batch A, unknown keys refused —
the prescriptions, each backed by a measured producer" | Every
authorable surface refuses unknown keys; spelled as stage 15 spelled
`objectstack-ai#4001 batch D`. |
| objectstack-ai#7751 | `component.test.ts:3419` | "object-* block props schemas —
declared, so the props gate has a schema to dispatch" | Ruling A: the
`object-*` block family enters `ComponentPropsMap`. |
| `ui#6207` | `component.test.ts:3468` | "object-grid `data` takes the
ViewDataSchema provider object — the two spec authorities converged" |
Option A: `object-grid.data` converges on `ViewDataSchema`, and the bare
array is refused. |
| objectstack-ai#19228 | `component.test.ts:4499` | "row caps on the object-bound
blocks — a bound view fills `limit` only when the authored one is not a
usable cap" | Ruling D: one row bound per view; the component face keeps
an undefaulted `limit`, which a bound view's page size fills whenever
the authored one is not a usable cap (the gate's
`!isUsableRowLimit(authored)`). |
| objectstack-ai#4614 | `dashboard.test.ts:419` | "date-range preset vocabulary — one
source, in the spec" | Ruling A: the preset names move into the spec as
their one source, and a date filter's default is checked against them. |
| objectstack-ai#16458 | `dashboard.test.ts:850` | "control — `columns` still declares
no default … (the renderer infers it from widget spans)" | Item 4 was
not landed: a `.default(12)` would retire the renderer's span inference
and switch every auto-flow dashboard to the positioned grid. |

**Subject back in words** (15 literals): "the objectstack-ai#5068 gate" becomes "the
props gate" (`component-reference-rail.test.ts:34`); "the three objectstack-ai#18305
blocks" / "object blocks" become "object-map / object-gantt /
object-tree" (`component-type-vocabulary.test.ts:88`,
`component.test.ts:4295`); the four "objectstack-ai#11661 keys" titles name
`defaultCollapsed` / `icon` / `description` (`component.test.ts:832`,
`:848`, `:860`, `:875`), because id-free sibling titles already say "the
section presentation keys"; "objectstack-ai#18639 scope fences" becomes "scope fences
of the `columns` widening"; "the twin of the objectstack-ai#14406 census pin" becomes
"the twin of the census pin that no door refuses the rule array"; "the
objectstack-ai#7750 specimen shape" becomes "the my-work specimen shape"; the four
`objectstack-ai#5011 —` prefixes in `dashboard-compareto.test.ts` become `compareTo —`
where the title needs a subject, and go where it already has one
(`:61`); "the words objectstack-ai#5042 measured" becomes "every word authors were
measured spelling … reaches …".

**Dropped where already stated** (63 literals, 69 ids). A number goes
only where the title already says its decision. Examples:
"ComponentPropsMap[\"record:alert\"] (objectstack-ai#8744)"; "user:profile is not
author-placeable (objectstack-ai#14159, ruling B)"; "ai:chat_window is retired,
refused by name (objectstack-ai#21504)"; the six "(objectstack-ai#6276)" picker titles, each already
naming what the declaration does; "the four `object-*` `sort` doors —
one sort orthography, the array (objectui#8221, decision batch objectstack-ai#77,
option B; objectstack-ai#18305)"; the `[objectstack-ai#4876]`, `[objectstack-ai#5010]`, `[objectstack-ai#17779]`, `[objectstack-ai#20958]` and
`[objectstack-ai#21293]` prefixes on `dashboard.test.ts`, each in front of the rule it
names; "drill branch (objectstack-ai#5022): …", whose sibling labels already read "…
branch: …". `批 17` stays as a batch label in stage 18's form, and
`ADR-0021` / `ADR-0049` style citations are untouched.

**No file is renamed.** None of the 7 file names carries a number.

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` (the hits are `mapfile -t`,
`docker build -t`, `type -t`, `lsof -t`, and a preflight's option
vocabulary).
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 7 files calls a snapshot matcher.
- **Projects:** all 7 files run in the `local` project;
`packages/spec/vitest.repo-tests.json` lists none of them.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (301 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 6
hits:
- a code comment in `lint/src/validate-component-props.test.ts:540`
("the objectstack-ai#7750 specimen shape");
- a QA checklist `source` entry,
`docs/qa/platform-checklist/areas/dashboards.json:406`, which anchors on
`dashboard-compareto.test.ts#dashboard` and describes it in its own
words ("objectstack-ai#5011 — compareTo converged on …"). The checklist gate looks up
the `dashboard` symbol, which this PR leaves in the file, and reads none
of the titles. `pnpm check:platform-checklist` exits 0 at the head;
- a sibling title in `service-analytics`
(`dataset-compare-dimension-resolution.test.ts:74`, "objectstack-ai#5011 — …");
- three hits on one same-id title in this card's later `ui/` stage,
`ui/view.test.ts:4236`, the visibility twin of the message-order
describe, citing objectstack-ai#6416 / objectstack-ai#6619.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares one line,
`dashboard.test.ts:205`.

- **Result:** 7 of 7 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 99 changed string leaves in 99 literals: 98 titles and 1
declared. The diff's `+` and `-` lines are exactly the 99 planned lines
as multisets, and every file keeps its line count.
`dashboard-chart-structure-refusal.test.ts` is untouched.
- **Controls (13 of 13 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; an `it.each` row given an id
VIOLATION; an undeclared `orderPin` label changed VIOLATION; a title
re-split into a `+` chain DIFF; the declared label reverted to base
SAME; the declared label given a new id VIOLATION; a kept colour literal
edited VIOLATION. The first run predicted VIOLATION for the
declared-label revert: putting `(objectstack-ai#5022)` back where it was reproduces
the base text exactly, so SAME is the right answer, and a control that
gives the label a new id was added. That run read 11 of 12.
- **Templates and tables:** no `.each` title, `%s` / `$name` placeholder
or table row changes.

**Test counts:** the 7 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 677 tests in 7 files, all passed, with the same count and
status sequence per file in 7 of 7. 469 full test names change, and each
changed name equals the base name with the planned replacements applied
(0 mismatches). One full name repeats 5 times on both sides: an
`it.each` row in `dashboard.test.ts` whose printed name is cut at the
same point for 5 rows. Only its describe prefix changed.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
6 touched files are in it, and no `*.test.ts` at all. The controls
`src/ui/component.zod.ts` and `dist/index.mjs` are in it.
- In the built `dist/`, a new phrase and an old literal each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `ec96327761`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 618 files, 18450 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 7 group
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 20, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded.
The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`. So does `check:platform-checklist`, for
the checklist entry above.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 7 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 7 configured, 0 ignored. No file sets `parserOptions.project` or
`projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 198 changed lines (+99 /
-99).
- A control-byte scan over the 6 changed files finds none.
- **Review round 1, at `69ea423302`:** two titles reworded, one line
each: the row-cap describe (`component.test.ts:4499`) now states the
gate's guard, and the offset-alias title
(`dashboard-compareto.test.ts:160`) reads straight. Text-only proof
against the base: 7 of 7 SAME, 99 changed leaves (98 title, 1 declared).
The 7 files at base and head: 677 / 677 passed, count and status
sequence identical in 7 of 7, 469 changed full names, 0 mismatches
against the plan. ESLint over the 7 files: 0 errors, 0 warnings. The
group census still reads 4 / 4. `typecheck` exit 0, and
`check:nul-bytes` OK. The derived gate set is the same 79 families; they
were not re-run for a two-literal change.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was four commits
past the base (`cab6396715`: objectstack-ai#21875, objectstack-ai#21896, objectstack-ai#21893, objectstack-ai#21900). None
touches any of the 7 files. objectstack-ai#21893 touches 8 `packages/spec` files under
`automation/` and `migrations/`, and the census over `packages/spec/src`
at `866683f96f` (after objectstack-ai#21893) still reads 764 / 807 with no file moved,
so the merged tree reads this PR's 665 / 702. objectstack-ai#21900 touches two more
`packages/spec` files, both non-test migration entries, which this
census does not count. So `main` was not merged. `git merge-tree` onto
`cab6396715` is clean.

## Acceptance notes

- **The four colour literals** stay, as above. They are the only
digit-only hex colours in the census, so the card's end state will read
them unless a later stage changes the fixture values, which would be a
fixture change rather than a text change.
- **Same-id test titles in this card's later stages** go with those
stages: 21 lines in `packages/spec/src`, for example
`system/i18n-resolver.test.ts:1982` ("(objectstack-ai#20940)"), `ui/page.test.ts:696`
("(ui#6206-B, objectstack-ai#15442)"), `ui/view-strictness-batch18.test.ts:91` ("objectstack-ai#4001
批 18 — …"), `ui/view.test.ts:4236` ("(objectstack-ai#6416 / objectstack-ai#6619)") and
`ui/view.test.ts:4793` ("(objectstack-ai#19228)").
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec/src` finds 40 lines citing ids this PR handled, in 10
packages: `lint` 25 (8 files), `service-analytics` 3 (1), `cli` 2 (1),
`platform-objects` 2 (1), `plugin-audit` 2 (2), `plugin-security` 2 (2),
and one each in `plugin-sharing`, `rest`, `service-automation` and
`spec/scripts`. Examples:
`lint/src/validate-component-props.test.ts:783` ("… are dispatched
(objectstack-ai#8744)"),
`service-analytics/src/__tests__/dataset-compare-dimension-resolution.test.ts:74`
("objectstack-ai#5011 — …"), `rest/src/meta-types-schema-titles.test.ts:131` ("objectstack-ai#16458
— …"). The two `[objectstack-ai#6206]` hits in `plugin-security` and `plugin-sharing`
cite objectstack#6206, a different record from `ui#6206`.
- **Code comments still carry ids** in these files and their sources,
for example the `objectstack-ai#9198 tombstone` comment in `component.test.ts`, the
`objectstack-ai#19228` header above `component.test.ts:4499`, and the `objectstack-ai#6416` /
`objectstack-ai#5955` docblock above `dashboard.test.ts:163`. Comments are not this
card's share, and none is touched here.

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

---------

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