Skip to content

fix(plugin-security): Discard Overlay refuses every permission set no code package ships, so a runtime-package set's only stored row is no longer deleted - #21873

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21860-discard-overlay-eligibility
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21860-discard-overlay-eligibility

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21860
Clause-②: no

What this changes

The Discard Overlay action (POST /api/v1/security/permission-sets/:id/discard-overlay) is declared to refuse any permission set that is not package-declared, so that it can never destroy a set the environment authored (its docblock, and the permission-sets docs page). It decided "package-declared" by asking whether any engine-registry item of the set's name carried a package id (_packageId ?? packageId). The registry holds stored rows as well as artifacts, and the metadata list read (GET /api/v1/meta/permission, issued by every Studio page load) stamps a stored row's package_id onto its body as _packageId. So a set saved into a writable runtime package passed the check after the first list read: the action answered 200 and deleted the set's only sys_metadata row. The drift diagnostics (computePermissionSetDriftDiagnostics) read the package id the same way, and their overlay_shadow detail names Discard Overlay as the remedy.

Two edits, both in packages/plugins/plugin-security/src:

  1. permission-set-overlay-discard.ts: eligibility is classifyPackagedPermissionSet's verdict for the set's name. That is the classifier the lock's two write doors and the overlay detection reading already ask, over the same engine registry. Only packaged proceeds. org and unknown are both refused with the action's existing refusal (PermissionDeniedError, 403 PERMISSION_DENIED), because the safe direction for a destructive action is the reading's: a set this environment cannot prove a code package ships is never deleted. The refusal's sentence now says the set is not shipped by any installed code package (for unknown: that this could not be determined, with the classifier's reason).
  2. permission-set-drift.ts: the declared population is gated on the same verdict, per name.

No second evaluator. In both files the per-item test that remains below the verdict (_packageId ?? packageId) no longer decides anything: it only picks, among the registry items of a name the classifier has already called code-shipped, which body is used (the degraded-kernel resync body in discard, the compared bodies in drift), by the same selector as before. So for every code-shipped set the accepted population, the bodies and the audited or reported package are what they were.

This builds on PR #21857 (landed as c9be1f1, merged into this branch): there the classifier learned to skip a tenant-authored registry item (_provenance: 'org'), so a runtime-package set's stored row reads org. It already excluded the 'sys_metadata' runtime-shadow sentinel.

Not touched: packaged-permission-set-lock.ts, permission-set-projection.ts, packages/spec, any error code, route, export or exported signature.

Docs: the permission-sets page's Discard Overlay sentence now names the sets the action refuses (a set created in the environment, a clone, a set saved into a writable runtime package). "Package-declared" on its own reads as covering a runtime package's set.

Measurements, at the door (showcase, booted through @objectstack/verify, two boots on one database file)

shape old reading (ablation A below, and this branch before #21857 landed) this PR (62143b0)
set saved into a writable runtime package, after the list read 200, overlaysDiscarded: 1: its only sys_metadata row deleted 403 PERMISSION_DENIED, every row intact
org-owned set (data door) 403 PERMISSION_DENIED (no package id on its item) 403 PERMISSION_DENIED, every row intact
clone ("Clone to customize") 403 PERMISSION_DENIED (no package id on its item) 403 PERMISSION_DENIED, every row intact
control: showcase_contributor (shipped by com.example.showcase) with a legacy overlay 200, overlay discarded, record healed to the artifact unchanged

The runtime-package set's registry row after the list read is { _packageId: 'com.dogfood.discard21860', _provenance: 'org' } (asserted as a precondition before any refusal is read). The control's precondition: after the cold boot, its record enforces the legacy overlay's grants and the boot's drift pass wrote drift_status: 'overlay_shadow'.

Mechanism hypotheses, measured

  • H1 holds. Reproduced at both levels before the fix took effect: the unit pin read "promise resolved instead of rejecting", and the door answered 200 with overlaysDiscarded: 1 (the set's only stored definition deleted).
  • H2 holds, with one refinement. On main at dispatch (5b2d189e) the classifier alone did NOT exclude the runtime-package set: it read any non-sentinel package id. That is exactly what PR fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857 changed in the lock (isTenantAuthored), and why this PR waited for it and is built on it. Measured on this branch before that merge: the three runtime-package legs (two unit, one door) stayed red with the classifier swap alone, and everything else was green. Only the packaged verdict may discard. unknown is refused, mirroring the overlay detection reading's direction rather than the write door's.
  • H3 holds. The drift filter had the same reading. It is gated on the same verdict, and a pin checks that the report and the action agree, shape by shape: reported exactly when eligible.
  • H4 holds. Every existing pin in both test files is unedited and green. The door control still discards a shipped set's overlay and heals its record.

Pins

  • permission-set-overlay-discard.test.ts, new block: each environment-authored registry body is refused with code PERMISSION_DENIED and status 403, and both tables are counted and compared whole before and after. The bodies are the runtime-package row (_packageId plus _provenance: 'org'), the org-owned set, the clone, and the lock's documented 'sys_metadata' runtime shadow. A code-shipped set sits beside them in the registry. Further cases: a registry read that throws (unknown) is refused with the rows intact, and a control discards a code-shipped set's overlay beside a hydrated overlay item that wears the artifact's envelope.
  • permission-set-drift.test.ts, new block: a drifted environment-authored set with a stored definition is never diagnosed (for each of the four shapes). The agreement pin runs the drift report and Discard Overlay over one registry: the code-shipped set is reported and eligible (409 INVALID_STATE, nothing to discard), each other shape is unreported and refused (403 PERMISSION_DENIED), and no stored definition is deleted.
  • packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts (new): the three shapes made through their real doors (POST /packages then PUT /meta/permission/NAME?package=PKG; POST /data/sys_permission_set; the shipped clone_permission_set action's own payload). Then a legacy overlay row for the shipped control, a cold boot, and the list read. Then a precondition for the stamp, then each shape's refusal with its record ids and stored rows compared before and after. The drift report run over the booted registry judges the shipped set and none of the three shapes. The control's discard returns 200, its overlay row is gone, its record is kept, and its grants equal the shipped artifact's again.

Ablations

The fix and its pins were committed first. Each ablation went through node scripts/ablation-replace.mjs (anchor 1 to 0, blob changed), then pnpm --filter @objectstack/plugin-security build and node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER (exit 0, marker in dist/index.js and dist/index.mjs). The dogfood suite resolves plugin-security from dist/. Each restore was proven: blob equal to HEAD, git diff HEAD empty, a rebuild, --absent preflight exit 0, and an empty git status --porcelain.

They ran before #21857 landed, on a local, never-pushed composition: this branch at a4d82d3 plus #21857's two source files, blob-identical to 9e3e32e. The plugin-security source in that composition is byte-identical to this PR's head. Only #21857's two test files differ, and they were not in the composition.

ablation what was put back unit result dogfood result
A discard's eligibility reads "an item of this name carries a package id" again (the verdict rebuilt as: some readDeclared item has the row's name and a truthy _packageId ?? packageId) 3 failed / 29 passed: runtime-package and sentinel refusals, and the agreement pin 1 failed / 6 passed: runtime-package set, 200 with overlaysDiscarded: 1
B drift's population reads the package id again (the classifier gate made always-true) 3 failed / 29 passed: runtime-package and sentinel population pins, and the agreement pin 1 failed / 6 passed: the drift-population leg

The org-owned, clone and control legs stayed green under both, as they must: the old reading already refused the first two and accepted the third. A first attempt at ablation A used a typed replacement that narrowed the verdict's type, so the DTS step failed. The JS bundle still carried the marker and the same three unit pins and one door pin went red. It was redone with a cast that keeps the declared type, and the numbers above are from the clean run.

Tests and gates (head 62143b0 unless noted)

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 167 files, 3611 passed, 45 skipped (on 50f93c1, the merge of origin/main at c9be1f1; the only later commit is a comment in the dogfood file).
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0, including check:test-typecheck (0 errors). pnpm --filter @objectstack/dogfood typecheck: exit 0 (its tsconfig.json includes test/**).
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/permission-set-discard-overlay-eligibility.dogfood.test.ts: 7 passed on 62143b0. Together with fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857's permission-set-lock-row-provenance.dogfood.test.ts: 21 passed on 50f93c1.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 97 commands on 62143b0; all 97 ran, every exit 0. --ran: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. The first battery, on 50f93c1, found one real red: check:cross-package-test-inputs read a docs path spelled in the dogfood file's header comment as a test input. The comment now names the page in words (62143b0). It also hit two PREREQUISITE NOT MET (exit 3: check:skill-examples, check:dual-build-cjs-loads), cleared by building the eight packages they named before the second battery. The derivation printed a stale-tree note: origin/main moved two commits (fix(plugin-auth): phone send-otp with no deliverable SMS service answers 400 SMS_SERVICE_REQUIRED instead of a bare 500 #21858, fix(cloud-connection): reseed and purge refuse a protocol-incompatible install-local entry before any side effect #21862) after the merge, and one of them edits scripts/engine-double-contract.pinned.json. Those commits touch neither this diff's packages nor its files, and git merge-tree against them is clean.
  • Lint, narrowed and proven: pnpm exec eslint --no-inline-config --format json over the five touched TypeScript files gives 5 files in the JSON output, 0 errors, 0 warnings, on 62143b0. The population is the files this diff touches. The .md and .mdx files are outside the lint globs in eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed rules, stated in its own comment), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Route. The suggested route was to replace both _packageId ?? packageId readings with the verdict. Measured, the eligibility half is replaced. The per-item selector is kept below the verdict, because the two alternatives both change something for code-shipped sets. Keying it on the verdict's package id changes which bodies are compared when two code packages ship one name (ADR-0048 §3.4 lets them coexist under distinct registry keys). Dropping it would let a projection echo with no package id into the drift comparison.
  • The 'sys_metadata' sentinel shape is pinned at the unit level only. It is the runtime shadow the lock module's docs describe; no door producing it was measured here. The old reading accepted it, and the classifier already refused it on main.
  • Drift's reach. The boot's drift pass runs before any list read, and the boot's hydration does not stamp the package id (measured by fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857). So on the showcase the drift misjudgment shows only in a later run of the diagnostics after a list read. The door pin runs computePermissionSetDriftDiagnostics over the booted stack's registry after the list read.
  • Observation, not filed (carrier: none). In the degraded-kernel branch (no metadata protocol), discard's resync body is the first registry item of the name that carries any package id. If a hydrated overlay of a code-shipped name preceded its artifact in registry order, that body would be the overlay's. Nothing measured shows that order. It is unchanged here, by H4.
  • Changeset. .changeset/21860-discard-overlay-eligibility.md, patch for @objectstack/plugin-security, with Clause-②: no copied from the claim. The action's declared population (sets a code package ships) is restored, and no accepted input widens.

Generated by Claude Code

claude added 6 commits October 5, 2026 09:49
…ibility by the lock's classifier, not by a package id

A set saved into a writable runtime package carries its package id on its
registry body after the first metadata list read, and the Discard Overlay
action read that as package-declared: it deleted the set's only stored
definition. Eligibility is now classifyPackagedPermissionSet's verdict for the
name, and the drift report gates its declared population on the same verdict.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
… shapes with rows intact, and still discards a shipped set's overlay

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…ships; changeset

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…ut scan reads as a test input

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 5, 2026
@github-actions github-actions Bot added 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 1 package(s): @objectstack/plugin-security, touching 3 documentable anchor(s).

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

  • content/docs/permissions/permission-sets.mdx (via /security/permission-sets/:id/discard-overlay (route, bridged from symbol discardPermissionSetOverlay — its route source's handler names it))
What this run could not see
  • 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 — 16 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 aead296874176b1bcf2f2b8a916223b11cf30c4b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json aead296874176b1bcf2f2b8a916223b11cf30c4b

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 13:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 13:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 5e0b489 Oct 5, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21860-discard-overlay-eligibility branch October 5, 2026 13:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… a writable runtime package updates its own row instead of forking it (objectstack-ai#21881)

Fixes objectstack-ai#21861
Clause-②: no

## What this changes

Setup saves a permission set through the data door (`PATCH
/api/v1/data/sys_permission_set/:id`). The security plugin's
write-through (`createPermissionSetWriteThrough`,
`packages/plugins/plugin-security/src/permission-set-projection.ts`)
redirects that edit into the metadata store. Its update leg merged the
patch into the stored body and called `saveMetaItem` with no
`packageId`.

A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a
save that names no package targets the package-less row
(`SysMetadataRepository.put`: an omitted `packageId` is the unbound
row). So for a set saved into a writable runtime package (`PUT
/api/v1/meta/permission/:name?package=PKG`), whose only stored row is
bound to that package, the edit answered `200` and minted a second,
package-less active row carrying the edit. The package-bound row stayed
as it was.

The update leg now reads the package binding of the row it edits and
passes it as `packageId`:

- The row is the `overlay` layer of the layered envelope the leg already
holds. `effectiveBodyForRow` takes that layer as the base the patch
merges into.
- Its binding is read from the metadata door's own single-item read,
`protocol.getMetaItem({ type: 'permission', name })`. That read serves
the row through the same served-row resolution as the layered read
(`findServedOverlayRow`, no package), and it states the row's
`package_id` on the item as `_packageId` ("surface the persisted
software-package binding", `protocol.ts`). The binding is not taken from
the patch, from the projected record, or from a registry item.
- No `overlay` layer means no stored row. Then there is nothing to fork,
and the call is unchanged.
- A package-less row carries no `_packageId`, so its save still names no
package.
- A code-shipped set is refused by the lock before this read
(`assertPermissionSetNotPackageDeclared` runs in the pre-pass). The lock
and its refusal do not move.
- A failed binding read is not caught. Guessing the binding would choose
which row the save lands in.

One file of production code, one new non-exported helper. No
`packages/spec` or `packages/metadata-protocol` edit, no new error code,
no new exported symbol, and no second classifier: every target reaching
the save has already been classified `org` by
`classifyPackagedPermissionSet`, so the binding read only tells a
package-bound `org` set from a package-less one.

## The dispatch hypotheses, measured

- **H1 (the update leg saves package-less and forks): confirmed.** On
`origin/main` `88a39c09` (this branch's base, which carries PR objectstack-ai#21857),
through the new door pin's own steps, the runtime-package set's first
data-door edit answered `200`. Its active rows went from one, bound to
the package, to two: `{ package_id: null, description: "Edited at the
data door" }` and `{ package_id: "com.dogfood.bind21861", description:
undefined }`. The update leg is the `saveMetaItem` call at `:1374` on
this base. The `:1297` named in the dispatch is the insert leg's call.
- **H2 (where the binding can be read): measured before any edit.** The
projected `sys_permission_set` record reads `managed_by: "admin"`,
`package_id: null`, so the projection does not carry the binding. The
layered read's `overlay` layer has no `_packageId`, and its envelope
`packageId` is null: the code layer is the projection echo.
`getMetaItem`'s `item._packageId` is `com.dogfood.bind21861`, both
through `GET /api/v1/meta/permission/:name` and through
`protocol.getMetaItem` in-process. That existing reader is the one used
here.
- **H3 (only the runtime-package set needs the binding): held.** It is
pinned at the door: the package-less set saves package-less, and the
code-shipped `showcase_contributor` is still refused with `403
NOT_OVERRIDABLE` with its row count unchanged.
- **H4 (other legs): none forks the same way, so none is changed.**
Details are under Acceptance notes.

## Pins

- **Door pin** (new file
`packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts`,
showcase). Every case counts the active `sys_metadata` rows for the name
under both type spellings and in every scope, before and after the edit:
- a runtime-package set: `200`, exactly one row before and after, still
bound to the package and carrying the edit. Edited twice, before and
after the list read (`GET /meta/permission`) that every Studio page load
issues;
- a package-less set: `200`, one package-less row before and after,
carrying the edit;
- a code-shipped set: `403` with `code: NOT_OVERRIDABLE`, and the stored
rows are unchanged.
- **Unit** (`permission-set-projection.test.ts`). The suite's existing
protocol double now keys a stored row by its package, as the repository
does, and serves the row's binding on `getMetaItem`. No new engine
double, so the engine-double ledger does not move. Four cases:
package-bound, package-less, a filtered edit spanning one of each (each
saved into its own row), and the code-shipped refusal (`code` and
`status` asserted, no save, the binding never read). Each case asserts
the row count before and after.

## Ablation (committed fix first, then mutated, then restored)

At `32048afa30`, `scripts/ablation-replace.mjs` replaced the anchor
`item: body, ...packageArg, ...actorArg });` (1 hit, then 0) with `item:
body, ...actorArg });` (0 hits, then 1). This puts the package-less save
back. Blob `e77bd871` became `4186f983`. The run was wrapped in a `trap`
restore on EXIT, INT and TERM.

- Rebuild: `pnpm turbo run build --filter=@objectstack/plugin-security`
exited 1, at DTS only (`TS6133: 'packageArg' is declared but its value
is never read`). The JS was already emitted, and `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security "item:
body, ...actorArg }"` found the marker in 2 built files
(`dist/index.js`, `dist/index.mjs`). The ablation was live in the
artifact the door pin consumes.
- Unit: 2 failed, 72 passed. The first red is the row count: `expected [
…(2) ]`, the package-bound row plus `{ package_id: null, description:
"edited at the data door" }`.
- Door pin: 2 failed, 2 passed. The runtime-package set's two cases went
red with two active rows, `{ package_id: "com.dogfood.bind21861",
description: undefined }` and `{ package_id: null, description: "Edited
at the data door" }`. The package-less and code-shipped cases stayed
green, as they should.
- Restore: blob equal to HEAD (`e77bd871`), `git diff HEAD` empty, `git
status --porcelain` empty across the whole tree. After the rebuild,
`--absent` found the marker in 0 of 6 built files. Unit 74 of 74 passed,
door pin 4 of 4 passed.

## Tests (all at `32048afa30`)

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files, 3604 passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security run typecheck`, which
includes `check:test-typecheck` over `tsconfig.test.json`: pass. `pnpm
--filter @objectstack/dogfood run typecheck`: pass.
- After a fresh closure rebuild (`turbo run build
--filter=@objectstack/dogfood^...`, 63 tasks): dist preflight shows the
fix's spelling present and the ablation marker absent.
`permission-set-projection.test.ts` 74 of 74 passed. The door pins
`permission-set-write-through-package-binding` and
`permission-set-lock-row-provenance` (PR objectstack-ai#21857's) passed 18 of 18.
- Narrowed lint: `eslint --no-inline-config --format json` over the
three changed `.ts` files reported 3 files, 0 errors, 0 warnings. The
population is the config's `**/*.{ts,…}` globs, and the fourth path is
the `.md` changeset. The config never enables type-aware linting (no
`parserOptions.project`), so the diff cannot move a verdict on an
untouched file. The repo-wide `pnpm lint` is left to CI.
- `integration`-tier tests are not relevant (no `packages/cli` path).

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 70 commands at `32048afa30`. All 70
were run, and each exit code was recorded. 69 exited 0 on the first
pass. `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: 8
packages outside the dogfood closure had no `dist/`). Those packages
were built (all cache hits), and the gate exited 0 on the rerun. `--ran`
reports: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. The derivation
names 8 commands the dispatch-time list did not have, all
changeset-related because this PR adds a changeset:
`check-adr-0087-registration` (both), `check-empty-changeset` (both),
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:objectui-changeset` and
`check:pm-changeset-deadline-census`.

## Acceptance notes

- **The write-through's other legs (H4), read rather than changed.**
- **Insert:** the duplicate-name check refuses whenever the name has a
record. A stored row with no record is only transient, because the
projector is awaited on every save and boot reconciliation creates a
record for every overlay. No door was measured to fork through it.
- **Restore:** it re-authors package-less after a delete. A
runtime-package set's delete removes its bound row, because
`deleteMetaItem` with no package matches the row in any package. So a
restore would leave one package-less row: a lost binding, not two rows.
That needs a different fix, since nothing holds the binding once the row
is gone. The leg is also unreachable on a real engine, because no trash
state exists (the leg's own comment). Carrier: none.
- **Reconcile backfill:** only names with no metadata presence, so there
is nothing to fork.
  - **Delete:** matches any package, so no fork.
- **Degraded path, unchanged.** When the layered pre-read fails and the
lock answers from the engine registry alone, the leg has no envelope. It
reads no binding and saves package-less, as before. That needs a
transient read failure followed by a successful save. Carrier: none.
- **A stored row bound to a read-only package (a booted code package, or
a `system`/`cloud`-scoped one) with no artifact.** For such a row, the
save would now meet the protocol's own read-only-base refusal, which
`PUT /meta?package=` meets too, instead of forking silently. No door
mints such a row: `saveMetaItem`'s D1 check and
`SysMetadataRepository.assertAllowed` refuse that binding at write time.
Hence `Clause-②: no`. The accepted set does not move, and the edit still
answers `200`.
- **Base.** `origin/main` has moved past `88a39c09`. The incoming
commits include objectstack-ai#21871, a `metadata-protocol` change to the
package-scoped list merge. It does not touch `getMetaItem`'s single-item
stamp or `findServedOverlayRow`, which this fix reads. objectstack-ai#21860 (PR
objectstack-ai#21873) has not landed, so main was not merged, per the dispatch. CI's
merge ref covers the joint state.
- **Docs.** No `content/docs` sentence describes a data-door edit
forking a package-bound set, so none is made false. The older
`permission-sets.mdx` drift about overlays of packaged sets is the one
PR objectstack-ai#21857's review already noted. It is untouched.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 19) (objectstack-ai#21882)

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

Stage 19 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 last `data/` group: the name-ordered test files
directly under `packages/spec/src/data/` from `query-transport.test.ts`
to `validation.test.ts`, plus `data/driver/`. Those 18 files carried 82
messages and 86 tracker ids, citing 39 records. Every one of those ids
now either states what its record decided, in words (form D), or is
dropped where the title already says it. Text only: no assertion,
identifier, test count or code comment changes. After this PR `data/`
carries no tracker id in a test string.

## Census at the base (`124533b388`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 18 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 `124533b388`, the claim's base and stage 18's landing. Both
instruments read **942 messages / 994 ids in 208 files**, the seat's
reading and stage 18's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` | 84 | 396 / 419 | 378 / 401 | 18 / 18 |
| `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 |
| `data/` (this PR) | 18 | 82 / 86 | 81 / 85 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **208** | **942 / 994** | **885 / 936** | **57 / 58** |

The group reads **82 messages / 86 ids in 18 files**, the seat's
figures, file for file: 11 files directly under `data/` (34 ids) and 7
in `data/driver/` (52 ids).

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `driver/config-registry.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `driver/driver-credential-refusal.test.ts` | 27 / 30 | 27 / 30 | 0 |
| `driver/driver-placeholder-refusal.test.ts` | 9 / 10 | 9 / 10 | 0 |
| `driver/memory.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `driver/pg-url-grammar.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `driver/postgres.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `driver/turso.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `query-transport.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `query.test.ts` | 11 / 11 | 10 / 10 | 1 / 1 |
| `record-surface.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `search-fields.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `secret-mask.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `seed.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `tree-reference-self-only.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `unique-scope-message.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `unique-scope.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `validation-boundary-description.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `validation.test.ts` | 1 / 1 | 1 / 1 | 0 |
| **18 files** | **82 / 86** | **81 / 85** | **1 / 1** |

The one "other" string is the `expect` message at `query.test.ts:202`,
declared to the text-only tool. Three more test files sit in the same
name range and carry no id: `seed-loader`, `type-compat` and
`driver/mongo`. They are not touched.

- **Controls.** Lit: `ui/dashboard.test.ts`, outside the group, reads 21
ids at the head as at the base. Dark: `search-fields.test.ts` and
`driver/driver-credential-refusal.test.ts` read 0 at the head while 16
and 29 of their comment lines still carry a number. Planted in scratch
copies of head files: an id put into a `seed.test.ts` title reads 1 / 1,
and an id put into a `secret-mask.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals as the
gate pattern in all 18 files at the base, and 0 in all 18 at the head.
- **At the head:** 860 messages / 908 ids in 190 files. The 18 files
read 0 / 0, `data/` reads 0 / 0, and no other file moved.

## How the area was chosen

`data/` has been taken in name-ordered file groups near the ~100-id
bound. Stage 18's re-cut named this group at 86 ids, the whole remainder
of `data/`, and this census reads 86, so the rule needed no re-cut.

**Named for the next stages** (re-cut from the head census, 860 / 908):
- `ui/` 419 ids in 84 files, about four stages. It has no subdirectory,
so the same name-ordered rule applies. The first group near 100 runs
from `action-confirm-params-guard.test.ts` to
`component-record-block-field-security.test.ts`: 32 files, 97 messages /
102 ids. It holds three "other" strings: two `expect` messages in
`action-requires-confirmation-docblock.pin.test.ts` (`:168`, `:175`) and
one in `component-props-unknown-members.pin.test.ts` (`:322`). That
stage should check whether any of them is a needle.
- `api/` 201, two stages. `system/` 165, two. The files directly in
`src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

- **16 literals (17 ids)** now state a decision in words.
- **12 literals (15 ids)** get their subject back in words, where the
number stood for a thing, such as "the objectstack-ai#9041 arm".
- **54 literals (54 ids)** drop a number the title already explains.

Every cited record was read with its comments through REST. 33 answer
200. Six answer 404, and each decision was read from what landed, the
landing commit and its CHANGELOG entry:
- objectstack-ai#6345 (`e2798fab76`, one driver vocabulary: `mongo` → `mongodb`, turso
gets a config contract);
- objectstack-ai#8495 (`4bfe1a539d`, `${…}` refused in memory `persistence.path` /
`persistence.key` at publish);
- objectstack-ai#8696 (`90a12fb18d`, a bound secret injected on the mongodb DSN
branch);
- objectstack-ai#8876 (`d634e665b0`, `urlUserinfoUsername`, the username half of the
userinfo grammar);
- objectstack-ai#9040 (`24206416a7`, a credential in the mongo `options` passthrough
refused at publish);
- objectstack-ai#9041 (`d491625c17`, bound `credentialsRef` with a user-less mongo
`config.url` refused at publish).

**Stated in words:**

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#4410 | `driver/config-registry.test.ts:71` | "DatasourceSchema ×
driver config — parsed against the contract its driver ships". Ruled
enforce: `data/driver/` became the one contract, and `DatasourceSchema`
parses `config` against it. |
| objectstack-ai#6969 | `driver/config-registry.test.ts:178` | "… — what a boot flag
may offer, derived from the one driver table". The CLI's hand-written
`--database-driver` lists were replaced by a set derived from the shared
table. |
| objectstack-ai#8155 | `driver/driver-credential-refusal.test.ts:851` | "accepts the
blessed shape byte-identically: bare-username URL + bound secret, what
the stored-credential remedy prescribes". Ruled shape (b): the operator
moves the credential to the secret store; a URL-embedded one is told to
keep a bare `user@host` and bind the secret. |
| objectstack-ai#8336, objectstack-ai#8495 | `driver/driver-placeholder-refusal.test.ts:234` |
"memory `initialData` stays UNJUDGED — the deliberate seed-data
exclusion holds". The placeholder refusal excluded seed data; objectstack-ai#8495
extended it to `persistence.path` / `persistence.key` only. |
| objectstack-ai#9091 | `driver/pg-url-grammar.test.ts:39` | "pg-url-grammar server
twin — still asks the parser `pg` itself runs". A postgres `config.url`
that `pg` cannot open is refused at publish. |
| objectstack-ai#11072 | `driver/pg-url-grammar.test.ts:55` | "pg-url-grammar browser
twin — degrades to the shape-only checks, with no `pg` parse". Ruled
option A: a `browser` export condition whose bundle drops the `pg`
parse. |
| objectstack-ai#16066 | `query-transport.test.ts:38` | "§1 what the transport
declares — a flattened spelling of the AST, never a second semantics".
Ruled: the wire dialect is declared in spec as a 1:1 alias table;
`QuerySchema` itself does not grow. |
| objectstack-ai#4721 | `query.test.ts:202` (`expect` message) | "this parsed to
`order: asc` before the sort node was closed". Ruled (a)+(b):
`SortNodeSchema` strict, with `direction` → `order` as a prescription. |
| objectstack-ai#2604 | `record-surface.test.ts:59` | "deriveRecordFlowSurface —
viewing a record may route, a create or edit task is always an overlay".
Decision D1: view is a state and may route; create and edit are tasks
and never route. |
| objectstack-ai#2578 | `record-surface.test.ts:64` | "view keeps the field-count
detail surface verbatim: …". The detail surface is derived from the
field count: heavy → page, light → drawer. |
| objectstack-ai#4254 | `search-fields.test.ts:120` | "the ingress gate that refuses
an unsearchable `$searchFields` entry no longer admits
`$searchFields=id`". Ruled: an unknown or unsearchable `searchFields`
entry answers 400, never a wider scan. |
| objectstack-ai#6674 | `search-fields.test.ts:257` | "a virtual field declared in
searchableFields — not admitted, since no driver stores it". Promoted on
the objectstack-ai#4254 precedent: a declared entry that can never match is not
admitted. |
| objectstack-ai#7572 | `secret-mask.test.ts:40` | "SECRET_MASK — the credential read
mask (ADR-0100), declared once for every masked read". Option A: one
declaration in spec, re-exported by objectql and aliased by
service-settings. |
| objectstack-ai#8323 | `unique-scope-message.test.ts:137` | "… the accept/reject line
does not move, and bare `true` keeps its meaning". Ruled: no unannounced
reinterpretation of bare `unique: true`. |
| objectstack-ai#3696 | `unique-scope.test.ts:19` | "UniqueScope (ADR-0120) — bare
`true` on a field is unique per organization; global uniqueness must be
said". Field-level `true` became a composite per-organization index, and
`'global'` the explicit opt-in. |
| objectstack-ai#3184 | `validation.test.ts:1120` | "ValidationRule - events property
— insert and update only; a delete guard is a beforeDelete hook".
Decision: trim `delete` from the enum, not enforce it. |

**Subject back in words** (12 literals, 15 ids). In
`driver/driver-credential-refusal.test.ts` and
`driver/driver-placeholder-refusal.test.ts`, numbers that named a
sibling refusal now name it: "objectstack-ai#9041" becomes "the user-less URL arm" or
"the user-less URL refusal", "objectstack-ai#9147's arm" becomes "the no-username
arm", "objectstack-ai#9040" becomes "the options-passthrough credential refusal" or
"the passthrough read path", "objectstack-ai#8082" becomes "the URL userinfo refusal"
or "check", "the inverted objectstack-ai#8078 pin" becomes "the pin that once recorded
them as accepted, inverted", "objectstack-ai#8336" in "the deep judgement" becomes
"the deep placeholder judgement", and "the honest objectstack-ai#4410 boundary"
becomes "the honest boundary of config validation".

**Dropped where already stated** (54 literals, 54 ids). A number goes
only where the title already says its decision. Examples:
"QueryAST.joins — REMOVED (objectstack-ai#4286)" and its three siblings; "FieldNode —
the nested-select object form is REMOVED (objectstack-ai#4196)";
"AggregationNode.distinct — REMOVED (objectstack-ai#6815, ADR-0049)", which keeps
`(ADR-0049)`; the four `objectstack-ai#13879 —` describe prefixes, each of which
states its semantics; "`$driver` — inline credential refusal (objectstack-ai#7990)"
and the other refusal-family describes; "TursoConfigSchema refuses what
the turso driver refuses (objectstack-ai#19977)". `(ADR-0049)`, `(ADR-0100)` and
`(ADR-0120)` stay: they cite decision records by number, not tracker
ids.

## Readers

- **Test-name filters:** none. No tracked script, workflow or config
passes `-t` / `--testNamePattern`.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 18 files calls a snapshot matcher.
- **Projects:** none of the 18 files is listed in
`packages/spec/vitest.repo-tests.json`; all 18 run in the `local`
project.
- **By substring:** every old literal, plus a window around each id (245
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 11 hits are:
- 7 code comments citing "the objectstack-ai#4254 ingress gate" (`lint`
`validate-searchable-fields.ts` / `.test.ts`,
`validate-react-page-props.test.ts`, `metadata-protocol`
`protocol.search-title-namefield.test.ts`, `spec`
`data/search-fields.ts:198`);
  - two release-owned CHANGELOG lines;
- a sibling title in this card's `ui/` stage
(`ui/dashboard.test.ts:717`);
- a sibling title in `metadata-protocol`
(`protocol.query-transport-dialect.test.ts:176`).
  None reads a spec test title.
- **The files by name:** outside CHANGELOGs, `data/query.test.ts` is
named by `test-typecheck-debt.json`, which keys on the file and on error
signatures, not on a title, and by two code comments;
`data/secret-mask.test.ts` by one code comment.

## 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,
`query.test.ts:202`.

- **Result:** 18 of 18 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 82 changed string leaves in 82 literals: 81 titles and 1
declared. The diff's `+` and `-` lines are exactly the 82 planned lines,
and every file keeps its line count.
- **Controls (10 of 10 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 `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.
- **`.each` titles:** five `describe.each` titles lose only their
trailing id. No `$driver` / `$name` placeholder, row or table value
changes.

**Test counts:** the 18 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 737 tests, all passed, with the same count and status
sequence per file in 18 of 18. 476 full test names change, and each
equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
18 touched files are in it, and no `*.test.ts` at all. The controls
`src/data/query.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 `8788de7a4a`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 616 files, 18426 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 18 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
  - `check:generated`: all 15 generated artifacts up to date.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 to 18, 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`.
- The reconciliation printed `STALE TREE`:
`scripts/engine-double-contract.pinned.json` moved on `main` after the
base. This diff adds and changes no engine double,
`check:engine-double-contract` exits 0 on this tree, and the queue
re-runs it on the merged generation.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 18 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 18 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 164 changed lines.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was two commits
past the base (`5e0b489bca`: objectstack-ai#21871, objectstack-ai#21873). Neither touches any of the
18 files, and neither touches `packages/spec`. So `main` was not merged.
`git merge-tree` onto `5e0b489bca` is clean. No open PR touches the 18
files.

## Acceptance notes

- **No needle in this group.** The one "other" string is an `expect`
message, not the expected value of an assertion over a source docblock.
The three known needles are untouched.
- **Same-id test titles in this card's later stages** go with those
stages: `api/protocol.test.ts:677` (objectstack-ai#4286), `ui/dashboard.test.ts:717`
(objectstack-ai#15680) and `ui/view-authoring-wire-split.test.ts:192` (objectstack-ai#4721).
- **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 49 lines citing ids this PR handled, in 12
packages: `service-datasource` 18 (8 files), `metadata-protocol` 7 (4),
`objectql` 6 (4), `cli` 5 (3), `lint` 4 (3), `driver-sql` 3 (3), and one
each in `client`, `driver-turso`, `platform-objects`, `plugin-security`,
`rest` and `service-settings`. Examples:
`cli/src/commands/database-driver-flag-derivation.test.ts:63` ("objectstack-ai#6969 —
…"), `driver-sql/src/sql-driver-unique-tenancy.test.ts:48` ("(objectstack-ai#3696)"),
`lint/src/validate-searchable-fields.test.ts:809` ("[objectstack-ai#6674] …").
- **Code comments still carry ids** in these files and their sources,
for example the `[objectstack-ai#6674]` and `[objectstack-ai#4483]` blocks in
`search-fields.test.ts`, the `[objectstack-ai#16066]` header of
`query-transport.test.ts` and `data/search-fields.ts:198`. 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ly the rows the caller can read (objectstack-ai#21900)

Fixes objectstack-ai#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 objectstack-ai#21829 (record
`5994120632`), which carries ruling A on objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#21873) and at `67c544cc` (carrying objectstack-ai#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
objectstack-ai#21812 door file, objectstack-ai#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 objectstack-ai#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:
- the multi-delete rule in the attachments access page, and its update
twin;
- the performance note in the permissions matrix;
- the write-widener floor paragraph in the RLS page. That paragraph also
still named a `403` for a hidden by-id target, which objectstack-ai#21812 turned into
the not-found; it is corrected in the same sentence.

## 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
objectstack-ai#21812, and no sentence there is about predicate writes. Carrier: none.
- **Declarations:** the new dogfood file is on objectstack-ai#6024 (`5994866670`). The
step-18 entry is the conditional spec declaration on objectstack-ai#6017
(`5994857244`). The landing needs the at-tier contract review the ruling
requires.

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

---------

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/l tests tooling

Projects

None yet

2 participants