Skip to content

fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers - #21812

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21771-write-door-unreadable-is-not-found
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21771-write-door-unreadable-is-not-found

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21771
Clause-②: no (narrowing)

What this changes

On the write doors, a row the caller cannot read now answers exactly what a nonexistent id answers, for every principal class. This is ruling A on #21771, record 5985885287.

The by-id write pre-image check (security-plugin.ts, middleware step 2.7) now asks a question before "may you write this row?": can you read it? It asks this for the by-id update or delete the caller addressed. The question is the read door's own: a caller-context by-id read through the engine, so every data middleware's read visibility counts. That includes the parent-derived read gates of the attachment and comment kits. A row that read does not return is answered by the read door's not-found producer (recordNotFoundError from @objectstack/core): 404 RECORD_NOT_FOUND, the same body a nonexistent id gets. A caller who can read the row but may not write it keeps its 403.

Before this change, the read question was only implicit inside the write-class re-read. A principal that no write-class row filter binds was never asked it. Such a principal got a later gate's 403 for a hidden row and a 404 for a missing one.

Decisions, from the seat verdict 5986522256:

  • Q1 → A. A by-id write to a row the caller cannot read answers not-found even when that caller's write gates would admit it. This is the narrowing declared above. The claim line "no change to who may write" is withdrawn.
  • Q2 → A. Only the write the caller addressed is asked the new question. By-id writes the platform issues under the caller's context keep their answer exactly: the engine's cascade delete of a dependent row, a hook's ctx.api write, and the referential clear of a lookup. The tree had no signal that marks the addressed write, so this PR adds one owned by plugin-security: an async scope its middleware opens around the rest of the chain (engineOperationScope). A by-id write that starts inside another engine operation reads as nested. The FK clear is excluded by its existing server-derived marker. No engine source is edited. The engine's not-found gate order was measured and is not part of the split.
  • Q3 → A. One PR. security/explain models the same answer. The explain parity suites stay equal.

Binding constraints, as built:

  1. Only absence is "cannot read". A store fault propagates as raised. A read-time policy refusal (a declared 4xx) keeps the answer the write had before. This is one shared classifier, absentUnderCallerRead, used by the write path and by explain.
  2. By-id versus predicate comes from the engine's own dispatch predicates, resolveEngineUpdateDispatch and resolveEngineDeleteDispatch, in addressedByIdWriteId. It is never a second id extractor.
  3. The server-derived FK-clear write is excluded.
  4. The answer comes from the read door's producer. There is no new error code and no hand-built body.

Explain. For an update or delete of a record the explained principal cannot read, the record-grained verdict is now the missing-record shape: visible: false, with no decider. It is reached past the capability and object-CRUD gates, as enforcement reaches it. The question goes through a new optional dependency, recordAbsentToCaller, which runs the same caller-context read and the same classifier.

ADR-0087. The changeset's FROM → TO is a prescription a consumer acts on. With it in the body, the registration gate left registered as the only honest disposition. So this PR adds one D3 semantic entry in step 18, 18.by-id-write-unreadable-row-not-found, generated with gen:migration-registry. check:generated is clean. This is the conditional spec declaration on #6017. No Zod schema, contract docblock or export moves.

Client census (before any code, as the ruling ordered)

The question: does any shipped client, or any test in this repo, read a by-id write's 403 as "the row exists but you have no access" in a way the move would break?

  • packages/client (client.data.update / client.data.delete envelope cases; the shares envelope table; the environments-delete doc): (c), unrelated. These pass a mocked envelope through, or belong to other routes.
  • packages/client-react: no status or code branch at all. (c).
  • packages/cli (package install response handling) and packages/verify (probeAsPersona): (c). The RLS proof judges a by-id write by ground truth (whether the row changed), and it only interpolates the status into a detail sentence.
  • In-repo source that branches on PERMISSION_DENIED: (c). This covers the plugin-auth guard markers, canWriteObject, the explain route arm, and generic error classification.
  • Tests: class (b), pins this ruling turns. They are listed below.
  • The objectui console is NOT MEASURED. Its source is not readable from this seat. It is not reported as clean.

No class (a) dependency was found.

Turned pins

Every turned pin keeps its expects, flipped to the ruled answer. Each one was measured to turn.

  • plugin-security:
    • security-plugin.test.ts: the pre-image deny case (a readable, unwritable row keeps PermissionDeniedError, with two reads now); the two "no write-class re-read" cases (now one plain read); and two owner-anchor cases whose double now holds the row.
    • explain-enforce-parity.test.ts: the same-class control's r2 update, and the own writer on a private object. Both now expect 404 RECORD_NOT_FOUND.
    • explain-cross-class-refusal.test.ts: the control's r2 update and its explain record.
    • explain-json-column-refusal.test.ts: the controls' update record for a hidden row.
    • position-catalog-refusal.test.ts: a foreign-organization row and a missing id are both 404 now, and their message is equal apart from the caller-supplied id.
    • store-fault-fail-closed.test.ts: an absent pre-image is now the not-found. An addressed by-id write now reaches the store, so a fault there propagates.
    • controlled-by-parent-sharing.test.ts: a missing detail id now gets the read door's producer.
    • get-writable-fields.test.ts and authz-matrix-gate.test.ts: the doubles answer the new read. The pinned verdicts are unchanged.
  • plugin-auth: sys-user-self-service-route.test.ts PIN 2. The read question now appears in the recorded reads, and the 403 is unchanged.
  • qa/dogfood, the two files the qa-lane declaration admits:
    • parent-derived-write-refusal-not-visible.dogfood.test.ts: the reference answer is now the not-found, and it is equal across principal classes apart from the id.
    • authored-row-write-scope.dogfood.test.ts: [E2E private], [no-leak 5] and [no-leak 6] on the private object.
  • qa/dogfood, measured to turn beyond the first qa-lane declaration, admitted by the seat as surface revision 2 (review 5988217853; qa-lane amendment 5988222916 on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024): api-key-owner-revoke.dogfood.test.ts, attachments-permission-matrix.dogfood.test.ts, showcase-invoice-seed-isolation.dogfood.test.ts and flow-runas.dogfood.test.ts.
  • Measured and not turning: the candidate tests in plugin-sharing, plugin-audit, service-storage, service-automation, runtime, rest and platform-objects.

New pins

  • plugin-security/src/by-id-write-unreadable-not-found.test.ts runs on a real ObjectQL, a real SQL driver and the real middleware. It pins:
    • on update and delete, a hidden row's answer equals a missing id's;
    • a reader who may not write keeps 403 PERMISSION_DENIED with the record-level sentence;
    • a writable row is still written;
    • the cascade delete of a hidden dependent, and a hook's by-id write, keep the record-level 403 and name nothing;
    • the same caller addressing that row itself gets the not-found.
  • security-plugin.test.ts: a hidden row answers the not-found, never PermissionDeniedError.
  • NEW qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts runs at the REST data door. 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 and sys_comment. It pins:
    • the hidden row's whole answer (status and body) equals the missing id's;
    • a reader who may not write gets 403, from whichever gate answers it today;
    • the caller's own row is written and then deleted.

Ablation

Both ablations went through scripts/ablation-replace.mjs, on the mutation anchor in addressedByIdWriteId.

  • A: put the skipped read question back. The read question was never asked. The marker reached dist/ (ablation-dist-preflight: present in 2 built files).
    • Unit: 4 pins red — both verbs' hidden-row equality, the addressed control, and the unit not-found case.
    • Dogfood: 8 of 8 hidden-row cells red, across both classes, both objects and both verbs. With the question gone, the inside class's delete answered 403 for a missing id too, which is the card's measured split.
  • B: ask the question of nested writes too. The cascade and hook pins went red.
  • Restore: proven each time. The blob equals HEAD (59831b78), git diff HEAD is empty, and dist/ was rebuilt and preflighted --absent with a clean tree.

Verification

All of this is on head 547a6726 unless stated.

  • plugin-security: the full suite (166 files, 3634 tests, 0 failed) and typecheck (tsc plus scripts plus check:test-typecheck) both green.
  • dogfood: typecheck green. The turned files and the new file are green. The full dogfood suite (182 files) was run in batches against plugin-security built from the fix commit, and every red was a turned pin listed above. After the fix commit, plugin-security source changed only in explain, where a redundant guard was dropped.
  • plugin-auth: typecheck and the turned file are green.
  • spec: the migration tests, and the two pins that read semantic entries, are green.
  • Derived gates: dispatch-gates --commands lists 118 commands. All 118 exit 0 on 547a6726. The --ran reconciliation reads: 118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config over the 24 changed TypeScript files, 0 errors and 0 warnings. eslint.config.mjs 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: content/docs sentences this made false are corrected: the attachments access table, the sharing-refusal note in the error-handling page, and the pre-image sentence in the permissions matrix.

Acceptance notes

  • Flow runs. A by-id write issued by a flow run started at the automation door is not nested inside another engine operation, so it is treated as an addressed write and answers the not-found. That is the flow-runas turn. A flow started inside another write (a record-change trigger) is nested and keeps its previous answer.
  • The parent-derived kits. Their not-visible PERMISSION_DENIED stays the gate's own answer, for a write the gate answers first. Their docblocks now say so (comment-only edits in service-storage and plugin-audit). Their unit suites are unchanged and green.
  • A unit double in controlled-by-parent-sharing.test.ts returns rows regardless of the caller's read visibility. So its "hidden detail" case still asserts 403 at that seam, while the real stack answers that row with the not-found. It was not measured to turn, so it is not edited.
  • Explain's reach. Explain's READ record verdict does not model a data middleware's read visibility, and it did not before this change either. Its new write-side question runs the real caller-context read, so the update and delete verdicts do see that visibility.
  • Declarations:
    • Four existing dogfood files and one plugin-auth test turned beyond the first declared surface, as listed under "Turned pins". The seat admitted them in review 5988217853, and the qa-lane declaration was amended (5988222916).
    • The one step-18 ledger entry is the conditional spec declaration on [PM seat] domain:spec — 🟢 os-litant · session_01LAi5BVvQNiYzepSAcsoFLK #6017.
    • The landing needs the at-tier contract review the ruling requires.

Patch round 1 (head 2ce9723)

Appended by the seat (domain:services#1, session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report 5989030715, after seat review 5988217853. The two declaration lines above were annotated in the same act.

  • The one defect, fixed. The changeset frontmatter now names @objectstack/spec (patch). The step-18 ledger entry this PR adds ships under that package's ./migrations export. No other changeset line moved. Commit 2ce9723d.
  • Merged origin/main (75ddcd1b) through scripts/pm/os-regen-merge.sh, as merge commit 921da525. The merge brought one plugin-security pin file, and text changes to other step-18 registry entries. Re-running gen:migration-registry produced no diff: the registry is current, with 377 semantic entries.
  • Checks on 2ce9723d, under the verify lock: all three exit 0.
    • check-changeset-no-major --base origin/main
    • check-adr-0087-registration --base origin/main: registered by-id-write-unreadable-row-not-found, new here
    • check:migration-registry
  • Derived gates re-run, because the merge touched plugin-security and the registry. dispatch-gates --commands lists the same 118 commands, and all 118 exit 0 on 2ce9723d. The --ran reconciliation reads: 118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN.
  • The at-tier contract review runs on this head once its CI settles. Landing waits for its PASS.

Generated by Claude Code

claude added 4 commits October 5, 2026 02:47
…ad answers what a nonexistent id answers

The by-id write pre-image check (security-plugin.ts, step 2.7) now asks
every principal class whether it can read the row its by-id update or
delete addressed, before it asks whether it may write it. The question is
the read door's own: a caller-context by-id read, so every data
middleware's read visibility counts. A row that read does not return is
answered by the read door's not-found producer (recordNotFoundError), the
answer a nonexistent id gets. A caller who can read the row but may not
write it keeps its 403.

- By-id versus predicate comes from the engine's own dispatch predicates
  (resolveEngineUpdateDispatch / resolveEngineDeleteDispatch).
- Only absence is "cannot read": a store fault propagates as raised, and a
  read-time policy refusal keeps the answer the write had before.
- Writes the platform issues under the caller's context keep their
  previous answer: the engine's cascade delete, hook writes, and the
  referential clear of a lookup. An async scope owned by the plugin marks
  them; the engine is not edited.
- security/explain models the same answer: an update or delete of a record
  the principal cannot read reports the missing-record shape.

Turned pins keep every expect, flipped to the ruled answer. New pins: a
real-engine unit file and a door-level dogfood file covering both principal
classes, both verbs and both parent-derived objects.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…tep 18)

The changeset's FROM -> TO is a prescription a consumer acts on, so the
disposition is registered: one D3 semantic entry, generated with
gen:migration-registry; check:generated is clean.

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

A system principal reads every row, so the caller-context read cannot
change its write verdict; the redundant guard is dropped.

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

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/service-storage, @objectstack/spec, touching 12 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/comment-access-hooks.ts, packages/services/service-storage/src/attachment-access-hooks.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/permissions/authorization.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))
  • content/docs/permissions/explain.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/rls.mdx (via object_crud (literal, a string literal in explainAccess))
  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v13.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

Coarse fallback — 145 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 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2ce9723d42edde850931e0656e1d6f9e9493b0b5
Local-runs: none

① Derived judgments

Inputs read: card #21771 body and all eight comments (triage routing, ruling A, the claim, the stop report, the seat verdict, the done report, the seat's patch-round review, the patch-round report), PR #21812 body, file list (28 files, 1056/78), the three-dot diff against origin/main at the merge-base, and the check-runs on the head. Source read in git objects at the head only.

  1. Accept-set narrowing — right, and the one the ruling owns. security-plugin.ts step 2.7 now asks callerCannotReadAddressedRow for every principal class. The new else if arm covers the principal no write-class row filter binds: a by-id update or delete it addressed on a row the read door hides is refused with recordNotFoundError before any later gate (sharing, the parent-derived kits) can admit or refuse it. This refuses writes that used to land (the kits' uploader/author arm with a hidden parent). Ruling A 5985885287 and the seat's Q1 → A 5986522256 order exactly this; the claim line "no change to who may write" was withdrawn there. Pinned on a real engine in by-id-write-unreadable-not-found.test.ts and at the REST door in write-door-unreadable-is-not-found.dogfood.test.ts: both principal classes (inside and outside the ownership floor's org_member domain, each proven armed), both verbs, both measured objects, whole answer equal to the missing id's apart from the caller-supplied id, row left in place.

  2. Answer change 403 → 404 for the bound principal — right. In the writeParts.length > 0 arm, a write-class re-read that returns nothing now asks the read question; absent → the read door's not-found, present → the record-level PERMISSION_DENIED as before. The write-class re-read runs under the caller's context through the engine, so a row it returns is readable, and the "reader who may not write keeps 403" leg holds in both arms (bound: the record-level 403; unbound: no throw at 2.7, the later gate answers, and the new dogfood file pins the answering gate's code per class and verb). A re-read that THREW skips the read question (reReadThrew) and keeps the denial. Right.

  3. Absence classification — right against binding constraint 1. absentUnderCallerRead answers true only for an empty read, false for a declared 4xx (declaredHttpStatus, both spellings), and rethrows anything else. store-fault-fail-closed.test.ts now pins that an addressed by-id write reaches the store and propagates an outage rather than relabelling it. One shared classifier serves the write path and explain. The cost the seat accepted is real: every addressed by-id write now performs one caller-context read even where no write filter binds (the former "no extra read" design pin turned); the getCallerPreImage memo shares that row with steps 3.5 and 3.6, so it stays one read per operation.

  4. Nested-write exclusion — right, no engine edit. engineOperationScope (an AsyncLocalStorage owned by the plugin) is read at middleware entry before the scoped next opens; every exit of the middleware calls the wrapper, so hooks, the cascade and the driver call all run inside it, and a by-id write started there reads as nested and keeps today's answer. The FK clear stays excluded by its server-derived marker. Pinned with the real engine: the cascade of a hidden dependent and a hook's ctx.api write both keep the record-level 403 naming nothing, while the same caller addressing that row gets the not-found. Consequence judged and accepted: a flow run started at the automation door is not nested, so its by-id write to a hidden row answers the not-found (the flow-runas turn), while the same flow fired from a record-change trigger inside another write keeps the 403. The answer depends on where the flow started; it is the Q2 → A line applied honestly, and a runAs: user flow can only name ids from caller input or rows the caller reads, so no existence signal crosses.

  5. By-id versus predicate — right. addressedByIdWriteId reads the engine's own resolveEngineUpdateDispatch / resolveEngineDeleteDispatch, update and delete only. extractSingleId still picks the re-read's target; the two agree on every by-id dispatch (payload id outranks where.id in both), and the dispatch predicate is the stricter, so the question is never asked of a predicate write. Lifecycle verbs keep the old pre-image check; none of them is a live by-id door today.

  6. Explain parity — right. ExplainEngineDeps.recordAbsentToCaller is wired in security-plugin.ts through the same classifier over readRowById under the target context; the override applies on update and delete past the capability and object-CRUD deciders and leaves the already-missing shape alone, so the record verdict becomes visible: false with no decider exactly where enforcement answers the not-found. The parity, cross-class and JSON-column suites turned to that shape with every expect kept. Public surface: ExplainEngineDeps is an exported type of plugin-security and gains one optional member — additive, inside minor.

  7. Producer — right. The answer is recordNotFoundError from @objectstack/core, the read door's own; no new code or body. controlled-by-parent-sharing.test.ts dropped its statusCode assertion because the core producer spells status once; both transports resolve either spelling, so the wire is unchanged.

  8. Spec surface — right. One D3 semantic entry in step 18 (18.by-id-write-unreadable-row-not-found.ts plus the regenerated registry.ts row), no Zod schema, docblock or export moved; check:migration-registry, check:upgrade-guide and check:api-surface are green on the head through the required jobs.

  9. Docs — accurate. The attachments access table now separates the not-found for a by-id write of an unreadable attachment from the gate's own PERMISSION_DENIED where the gate answers first (a nested or predicate write); the permissions-matrix callout and the error-handling note say the same thing. The comment-only docblocks in service-storage and plugin-audit change nothing those packages publish.

  10. Gate verdicts on the head (latest run per check name): the seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — all success; Console Pin Gate and Packed-tarball smoke (opt-in) skipped by design; the later Check PR Size and Auto Label runs skipped on the edit event after earlier successes, advisory. No governed-surface path in the file list; 1134 changed lines; the PR is draft, assigned, and closes the card.

② Semver level

.changeset/21771-write-door-unreadable-is-not-found.md: @objectstack/plugin-security: minor with the ! banner, @objectstack/spec: patch. The PR body's line 2 and the changeset body both carry Clause-②: no (narrowing), and that matches the diff: no accepted input widens, and item 1 above is the narrowing. BREAKING per the Post-Task Checklist, so the body carries what a breaking changeset owes — the FROM → TO, the one-line handling for an affected caller, and the ADR-0087 marker registered by-id-write-unreadable-row-not-found, which the gate reads; Check Changeset is green. minor is the launch-window convention the check-changeset-no-major header records for an accept-set narrowing. patch on spec is right: the registry row ships under its ./migrations export, nothing in spec's schema or export surface moves, and the previous landing of this same shape (a non-spec breaking change carrying one D3 entry) took "@objectstack/spec": patch. The double-quoted frontmatter line is valid YAML; cosmetic.

③ Boundary flags

Open questions: the done and patch-round reports carry none; the three in the stop report 5986486910 were answered in 5986522256 (Q1 A, Q2 A, Q3 A) and built as ruled — verified in items 1, 4 and 6.

Dev flags from 5988175661:

  • Turned pins beyond the declared surface (four dogfood files, one plugin-auth test) — answered: admitted by the seat in 5988217853, qa-lane declaration amended (5988222916 on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024); every expect kept and flipped to the ruled answer, checked in the diff.
  • Comment-only docblocks in the two parent-derived kits — answered: accepted; prose this change made false; no code line.
  • ADR-0087 registered with one step-18 entry under the conditional [PM seat] domain:spec — 🟢 os-litant · session_01LAi5BVvQNiYzepSAcsoFLK #6017 declaration — answered: the FROM → TO is a prescription, so registered is the honest disposition; the entry is generated and the registry is current (patch-round report).
  • Flow runs started at the door count as addressed — answered: accepted, with the consequence stated in item 4.
  • One typecheck read outside the verify lock — process only; the required type-check job is green on the head.
  • Dogfood full suite measured against a dist built from the fix commit — superseded by Dogfood Regression Gate 1/3–3/3 success on this head.
  • Ablations on pre-squash commits with an identical security-plugin.ts blob — accepted; blob equality named.

Dev flags from 5989030715:

  • Double-quoted changeset line — cosmetic; gates green.
  • Merge commit 921da525 carries git's default message without the session trailer pair — the pre-push hook passed it and the squash landing collapses it; no action.

Escalated, not blocking:

  • The objectui console leg stays NOT MEASURED. The ruling's census asked it; this seat cannot read that repository and Console Pin Gate skips on a PR that does not opt in, so nothing on this head measured whether the console branches on a by-id write's 403 as "exists but no access". The seat has named it to the maintainer; it stays named here as an open census leg, never reported clean.
  • One residual principal class, abstract. A principal holding an object's write grant without its read grant, bound by no write-class row filter. The permission bits are independent (only the wildcard super-user fold pulls read true from modify), so the class is declarable. For it the caller-context read is REFUSED with a declared 4xx, which binding constraint 1 classifies as not-absence, so the write keeps today's path: a missing id reaches the engine's not-found while a hidden row reaches a later gate's refusal or lands. The split the card measured therefore persists for that class. The ruling's text ("this is the read door's answer") does not settle it — the read door refuses that principal for every id — and the seat chose the classification deliberately. Not a defect of this PR against its ruling; a doctrine question for its own card.
  • No ADR holds the governing text. "Hidden and gone are one answer on the write doors" lives in the ruling comment, the changeset, three docs pages and code comments; triage noted no ADR recorded the prior doctrine either, and ADR-0120 S10 is extended by the ruling's words without an amendment. Tier H, the maintainer's act; noted for the round report, not owed by this PR.

Implemented-by: claude/issue-21771-write-door-unreadable-is-not-found
Reviewed-by: session_011K3zqE8Pv1Evw5hc8tZCnN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 06:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 06:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 53021e3 Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21771-write-door-unreadable-is-not-found branch October 5, 2026 07:03
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…nce, so org-owned sets, clones and runtime-package sets edit again (objectstack-ai#21857)

Fixes objectstack-ai#21789
Clause-②: no

## What this changes

The packaged-permission-set lock in `plugin-security` answers one
question for both write doors: is this set shipped by a code (artifact)
package? It answered it as "does any engine-registry item of this name
carry a package id?". The registry holds stored rows as well as
artifacts, and the metadata list read (`GET /api/v1/meta/permission`,
which every Studio page load issues) stamps a stored row's `package_id`
column onto its body as `_packageId`. So a set saved into a writable
runtime package looked code-shipped after the first list read.

The read the console renders from had the matching defect. The security
plugin keeps a marked copy of every overlay-backed definition in the
metadata manager for the evaluator (the "projection echo"), and the
protocol's layered read serves that copy as the item's `code` layer. The
echo carried no provenance, so an org's own set, a clone and a
runtime-package set all reported a `code` layer with no `provenance`.
objectui's permission-matrix editor reads exactly that as "a code
package ships this" and rendered them locked, while every write door
accepted the save.

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

1. `packaged-permission-set-lock.ts`, `declaredPackageIdOf`: a
tenant-authored item (ADR-0010 `_provenance: 'org'`, the stamp the
hydrator writes on every stored row) is never a shipped artifact. It is
read through `isTenantAuthored` from `@objectstack/metadata-core`, the
exclusion `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem`
already apply. No second provenance evaluator. A stored row of a name a
code package ships is hydrated wearing the artifact's envelope
(`_provenance: 'package'`), and the artifact itself is in the same list,
so a code-shipped set stays locked.
2. `permission-set-projection.ts`: the projection echo carries
`_provenance: 'org'` exactly when `classifyPackagedPermissionSet` (the
classifier both write doors ask, fed the same layered probe) answers
`org` for the name. A `packaged` or `unknown` verdict leaves the echo
unstamped, as before. The reported state and the enforced state are one
judgment.

Not touched: `metadata-protocol` (H4 was not needed), `packages/spec`,
any error code, any export, any parameter of an exported function (the
new parameter is on the module-private `syncEvaluatorRegistry`). The
lock-resolution semantics from objectstack-ai#21801 are unchanged.

## Measurements, before and after

Driven through the real showcase over HTTP, base `088428fb` (before) and
this branch (after):

| shape | before: door (`PUT /meta` after the list read, `PATCH /data`)
| before: layered read | after: door | after: layered read |
|---|---|---|---|---|
| set in a writable runtime package | 403 `NOT_OVERRIDABLE` / 403
`NOT_OVERRIDABLE` | `code` = echo, no `provenance`, `editable: true` |
200 / 200 | `provenance: 'org'`, `editable: true` |
| org-owned set (data door) | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| clone ("Clone to customize") | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| control: `showcase_contributor` (shipped by `com.example.showcase`) |
403 `NOT_OVERRIDABLE` / 403 `NOT_OVERRIDABLE` | `code._packageId` = the
package, `provenance: 'package'`, `editable: false` | unchanged |
unchanged |

The runtime-package set's registry row after the list read was `{
_packageId: 'com.dogfood.lock21789', _provenance: 'org' }`: the
provenance that tells it apart was on the body all along.

## Mechanism hypotheses, measured

- **H1, holds.** The lock read any non-sentinel `_packageId` as
code-shipped. The three shapes carry, in the registry: runtime-package
set `{ _packageId: PKG, _provenance: 'org' }` (the package id appears
only after a list read; neither the write-through nor the boot hydration
stamps it), org-owned set and clone `{ _provenance: 'org' }`, no package
id. The clone's record has `created_by` and `organization_id` null, but
so do the org-owned set's and the runtime-package set's records: it is
not specific to the clone.
- **H2, holds.** The platform's one answer is `isCodeArtifactBody` /
`isTenantAuthored` in `@objectstack/metadata-core` (already a dependency
of `plugin-security`). The lock reuses `isTenantAuthored`; it keeps its
two documented extensions (the echo-marker skip and the spec `packageId`
fallback).
- **H3, holds, with a refinement.** The org-owned set and the clone were
never refused by the server (both doors 200 before the fix); their
"lock" was report-only. The runtime-package set was refused by both
doors while the server's own `editable` said `true`. So the reported
state and the enforced state were split in both directions, and the fix
pins both.
- **H4, not needed.** No `metadata-protocol` edit: the layered read
already reads `provenance` off the `code` layer, and the echo now states
it.
- **H5, the lock's judgment (the smaller one).** Stamping the clone's
`created_by` / organization would not change anything the lock or the
console reads: the server lock already answered `org` for the clone, and
the console's lock came from the echo's missing provenance.
- **H6, holds.** The code-shipped set is still refused at both doors
with `403 NOT_OVERRIDABLE` (the data door's refusal is the lock's own
sentence naming the clone path), and its layered read still reports
`provenance: 'package'`, its package id and `editable: false`, before
and after a cold boot.

## Pins

- `packaged-permission-set-lock.test.ts`, block `[objectstack-ai#21789]`: the
classifier over the bodies the hydrator registers (runtime-package row,
org-owned row, clone; a shipped artifact, alone and beside a legacy
overlay wearing its envelope, in both orders), the layered probe with no
registry, the data door (hatch-open double, so only the lock can
refuse), and the metadata-door gate, with the refusal asserted on `code`
and `status`.
- `permission-set-projection.test.ts`: the echo of a set no code package
ships carries `_provenance: 'org'`; the control shows the echo of a
legacy overlay of a shipped set does not.
-
`packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts`
(new): the showcase, 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), the list read, a precondition that the list read stamped
the package id, then both doors and the layered read for each shape, the
code-shipped control at both doors and on the read, and a cold boot on
the same file that reads the three shapes again (the echo minted by the
boot's reconciliation) and re-checks the control.

## Ablations (each committed first, mutated through
`scripts/ablation-replace.mjs`, rebuilt, dist proven, restored to
`HEAD`)

Both ablations were run at `e9dff47f` (the fix and its pins committed,
pre-merge), each through `node scripts/ablation-replace.mjs` (anchor
hits went from 1 to 0, blob changed), then `pnpm turbo run build
--filter=@objectstack/plugin-security`, then `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER
--absent` (exit 0: marker absent from every built file), because the
dogfood suite resolves `plugin-security` from `dist/`. Each restore was
proven by blob equality with `HEAD` and an empty `git diff HEAD`, then a
rebuild and the preflight without `--absent` (exit 0, marker back in
`dist/index.js`, tree clean).

| ablation | what was put back | unit result | dogfood result |
|---|---|---|---|
| 1 | the lock reads "has a package id" again: `if
(isTenantAuthored(item)) return null;` replaced by a no-op | 5 failed /
87 passed: the four classifier/door pins for the runtime-package shape,
and the echo pin | 2 failed / 12 passed: runtime-package set, metadata
door and data door |
| 2 | the echo states no provenance again: the `_provenance: 'org'`
spread replaced by an empty one | 1 failed / 91 passed: the echo pin | 4
failed / 10 passed: the layered-read pin for each of the three shapes,
and the cold-boot read |

The controls (a code-shipped set refused, and its read reporting
`provenance: 'package'`) stayed green in both directions, as they must.
A first attempt at ablation 1 used a replacement that left the
`isTenantAuthored` import unused, so the DTS step of the build failed
(the JS bundle still carried the ablation and the same pins went red);
it was redone with the import kept in use, and the numbers above are
from the clean run.

## Tests and gates

All on `9e3e32ed` (this branch after merging `origin/main` `8832655a`,
which carries objectstack-ai#21812 and touches `plugin-security`), after rebuilding
the dogfood dependency closure:

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files passed, 3600 tests passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(including `check:test-typecheck`: 0 errors).
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/permission-set-lock-row-provenance.dogfood.test.ts`: 14 passed.
`pnpm --filter @objectstack/dogfood typecheck`: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 71 gate commands; all 71 run, every
exit 0, `--ran` verdict: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.
`check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT
MET: eight packages outside the dogfood closure had no `dist/`); those
eight were built and the gate re-run, exit 0.
- Lint, narrowed and proven: `pnpm exec eslint --no-inline-config
--format json` over the five touched TypeScript files (the changeset is
not linted): 5 files in the JSON output, 0 errors, 0 warnings. The
population is the files this diff touches; `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, no typed rules,
stated in its own header), so this diff cannot move the verdict on any
untouched file. The repo-wide `pnpm lint` is CI's.

## Acceptance notes

- **Not in this PR: `metadata-protocol`.** Measured, the layered read
did not need to change for the read and the lock to agree: it reads
`provenance` off its `code` layer, which is the plugin's projection
echo, and all three shapes read `provenance: 'org'` with `protocol.ts`
byte-identical to `main`. No shape is left unfixed without a protocol
edit. This PR's file list is disjoint from objectstack-ai#21844's (`item-lock.ts`,
`protocol.ts`, two protocol/objectql tests, the engine-double ledger,
its changeset).
- **Observation, not filed (carrier: the `domain:engine` seat, objectstack-ai#21844
holds the region).** For a set no artifact ships, the layered read's
`code` layer is still a non-null body (the echo, read through
`readItemFromMetadataService` in `getMetaItemLayered`), while
`GetMetaItemLayeredResponseSchema.code` says `null` when no artifact
ships the item. The registry fallback right below it already drops a
tenant-authored item (`runtimeOnly`, `isTenantAuthored`); the
MetadataService read does not. After this PR the echo is tenant-stamped,
so a provenance-only filter there would answer `code: null` for these
sets; no client reads a wrong answer today, which is why it is noted
here rather than built.
- **Finding, reported for the seat to file (same family: a package id
read as "shipped by code").** The Discard Overlay action's eligibility
(`permission-set-overlay-discard.ts`, `discardPermissionSetOverlay`)
reads `_packageId ?? packageId` on the registry item, as the lock did.
Measured on `088428fb` and again on this branch: after a list read,
`POST /api/v1/security/permission-sets/ID/discard-overlay` on a set
saved into a writable runtime package answered 200 and deleted the set's
only `sys_metadata` row. The action declares, and
`content/docs/permissions/permission-sets.mdx` repeats, that it refuses
any set that is not currently package-declared.
`permission-set-drift.ts`'s declared filter carries the same reading.
Not fixed here: outside the claimed file surface.
- **Finding, reported for the seat to file.** A data-door edit (`PATCH
/api/v1/data/sys_permission_set/ID`) of a set saved into a writable
runtime package writes a second, package-less active `sys_metadata` row
carrying the edit and leaves the package-bound row unchanged (the
write-through's update leg calls `saveMetaItem` without the row's
package). Measured on this branch: two active rows after one PATCH; the
projected record reads `managed_by: 'admin'`, `package_id: null`. It is
reachable on `main` before any list read, and through a package-less
`PUT /meta`; this PR lets the data door accept the edit after a list
read too.
- **H5.** The clone's record has `created_by` and `organization_id`
null, and so do the other two shapes' records: the projection writes
them in system context. It is not what locked the clone, and is not
changed here.
- **Docs.** No `content/docs` sentence is made false by this change;
`content/docs/concepts/metadata-lifecycle.mdx` (runtime-created sets,
package-bound rows included, keep working) becomes true.
`content/docs/permissions/permission-sets.mdx` still says an edit of a
packaged set through Setup becomes an environment overlay, which the
lock has refused since the clone-to-customize ruling; that is older
drift, not touched here.
- **Report state.** `Clause-②: no` is copied from the claim: the fix
restores the lock's declared population (code-shipped sets) and widens
no accepted input; a code-shipped set is refused exactly as before.

---
_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
…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/xl tests tooling

Projects

None yet

2 participants