Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21861-write-through-update-keeps-package
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21861-write-through-update-keeps-package

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #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 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) 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


Generated by Claude Code

claude added 3 commits October 5, 2026 12:49
…ermission set updates its own row

The unit double now keys a stored row by its package as the repository
does, and serves the row's binding on the single-item read. The door pin
counts the active sys_metadata rows before and after each edit.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…set saves into its own row's package

The write-through's update leg saved the merged body with no package, and a
sys_metadata row is keyed by its package: for a set whose only row is bound
to a writable runtime package, the save minted a second, package-less row.
The leg now reads the edited row's binding from the metadata door's
single-item read (the row's package_id, stated as _packageId) and passes it
as packageId. A set with no stored row, or a package-less one, saves as
before; a code-shipped set is still refused by the lock first.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
The row count is the contract; the save argument is the mechanism, so an
ablation's first red names the second row.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 2 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via createPermissionSetWriteThrough (symbol, a top-level function))
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 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e

⚠️ 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 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e → 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 14:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 14:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 07c842d Oct 5, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21861-write-through-update-keeps-package branch October 5, 2026 15:08
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/m tests tooling

Projects

None yet

2 participants