Repository navigation
fix(plugin-security)!: on the write doors, a row the caller cannot read answers what a nonexistent id answers - #21812
Conversation
…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>
Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…ite-door-unreadable-is-not-found
…ps the step-18 entry Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsInputs 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
② Semver level
③ Boundary flagsOpen questions: the done and patch-round reports carry none; the three in the stop report Dev flags from
Dev flags from
Escalated, not blocking:
Implemented-by: VERDICT: PASS |
…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>
…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>
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 (recordNotFoundErrorfrom@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 its403.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
403for a hidden row and a404for a missing one.Decisions, from the seat verdict
5986522256:ctx.apiwrite, and the referential clear of a lookup. The tree had no signal that marks the addressed write, so this PR adds one owned byplugin-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.security/explainmodels the same answer. The explain parity suites stay equal.Binding constraints, as built:
absentUnderCallerRead, used by the write path and by explain.resolveEngineUpdateDispatchandresolveEngineDeleteDispatch, inaddressedByIdWriteId. It is never a second id extractor.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
registeredas the only honest disposition. So this PR adds one D3 semantic entry in step 18,18.by-id-write-unreadable-row-not-found, generated withgen:migration-registry.check:generatedis 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
403as "the row exists but you have no access" in a way the move would break?packages/client(client.data.update/client.data.deleteenvelope 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) andpackages/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.PERMISSION_DENIED: (c). This covers the plugin-auth guard markers,canWriteObject, the explain route arm, and generic error classification.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 keepsPermissionDeniedError, 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 theownwriter on a private object. Both now expect404 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 both404now, 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.tsandauthz-matrix-gate.test.ts: the doubles answer the new read. The pinned verdicts are unchanged.plugin-auth:sys-user-self-service-route.test.tsPIN 2. The read question now appears in the recorded reads, and the403is 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 (review5988217853; qa-lane amendment5988222916on [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.tsandflow-runas.dogfood.test.ts.plugin-sharing,plugin-audit,service-storage,service-automation,runtime,restandplatform-objects.New pins
plugin-security/src/by-id-write-unreadable-not-found.test.tsruns on a realObjectQL, a real SQL driver and the real middleware. It pins:403 PERMISSION_DENIEDwith the record-level sentence;403and name nothing;security-plugin.test.ts: a hidden row answers the not-found, neverPermissionDeniedError.qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.tsruns at the REST data door. It covers both principal classes (outside and inside the ownership floor'sorg_memberdomain, each proven by arming probes), update and delete, andsys_attachmentandsys_comment. It pins:403, from whichever gate answers it today;Ablation
Both ablations went through
scripts/ablation-replace.mjs, on the mutation anchor inaddressedByIdWriteId.dist/(ablation-dist-preflight: present in 2 built files).403for a missing id too, which is the card's measured split.59831b78),git diff HEADis empty, anddist/was rebuilt and preflighted--absentwith a clean tree.Verification
All of this is on head
547a6726unless stated.plugin-security: the full suite (166 files, 3634 tests, 0 failed) andtypecheck(tscplus scripts pluscheck:test-typecheck) both green.dogfood:typecheckgreen. The turned files and the new file are green. The full dogfood suite (182 files) was run in batches againstplugin-securitybuilt from the fix commit, and every red was a turned pin listed above. After the fix commit,plugin-securitysource changed only in explain, where a redundant guard was dropped.plugin-auth:typecheckand the turned file are green.spec: the migration tests, and the two pins that read semantic entries, are green.dispatch-gates --commandslists 118 commands. All 118 exit 0 on547a6726. The--ranreconciliation reads: 118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN.eslint --no-inline-configover the 24 changed TypeScript files, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The repo-wide run is CI's.content/docssentences 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-runasturn. A flow started inside another write (a record-change trigger) is nested and keeps its previous answer.PERMISSION_DENIEDstays the gate's own answer, for a write the gate answers first. Their docblocks now say so (comment-only edits inservice-storageandplugin-audit). Their unit suites are unchanged and green.controlled-by-parent-sharing.test.tsreturns rows regardless of the caller's read visibility. So its "hidden detail" case still asserts403at that seam, while the real stack answers that row with the not-found. It was not measured to turn, so it is not edited.plugin-authtest turned beyond the first declared surface, as listed under "Turned pins". The seat admitted them in review5988217853, and the qa-lane declaration was amended (5988222916).Patch round 1 (head 2ce9723)
Appended by the seat (
domain:services#1,session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report5989030715, after seat review5988217853. The two declaration lines above were annotated in the same act.@objectstack/spec(patch). The step-18 ledger entry this PR adds ships under that package's./migrationsexport. No other changeset line moved. Commit2ce9723d.origin/main(75ddcd1b) throughscripts/pm/os-regen-merge.sh, as merge commit921da525. The merge brought oneplugin-securitypin file, and text changes to other step-18 registry entries. Re-runninggen:migration-registryproduced no diff: the registry is current, with 377 semantic entries.2ce9723d, under the verify lock: all three exit 0.check-changeset-no-major --base origin/maincheck-adr-0087-registration --base origin/main:registered by-id-write-unreadable-row-not-found, new herecheck:migration-registryplugin-securityand the registry.dispatch-gates --commandslists the same 118 commands, and all 118 exit 0 on2ce9723d. The--ranreconciliation reads: 118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN.Generated by Claude Code