Skip to content

Commit 82f5a11

Browse files
refactor(plugin-security): move the everyone anchor, the explanation, the sharing verdicts and the authored-row write verdict out of security-plugin.ts (#25223 cut 10) (#25516)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMYdF2uchcinMphEKW4sYxd)_ Part of #25223 Clause-②: no Metadata-protocol: none Ruling-ref: 6353073966 Cut 10 of the `security-plugin.ts` split covers row 8 of the series plan: the everyone anchor, the explanation, the sharing verdicts and the authored-row write verdict. The claim is 6369634056. The row has four seams, so this one PR moves the bodies of ten methods out of the class, unedited, into four new modules. Cut 9 split its row the same way, over three. To `packages/plugins/plugin-security/src/security-plugin-everyone-anchor.ts`: - `declareEveryone`: the `everyone` anchor's definition, as a started kernel derives it. It is the deployment's baseline plus every package default a `manage_metadata` holder accepted. Each withheld acceptance is reported with its reason (ADR-0090 D5/D7, ADR-0131 D3, #22801). - `declareBuiltinPositions`: the six built-in positions, declared to the engine registry from `init()`. `everyone` names the fail-closed baseline (ADR-0131 D2). It is synchronous and stays synchronous. To `packages/plugins/plugin-security/src/security-plugin-explain-access.ts`: - `explainAccessForCaller` (public): the `security/explain` answer for a caller. It covers: - who may explain whom (ADR-0090 D6, D12); - the explained user's context, built and vetted against the caller's organization; - the evaluation through the explain engine, with the CALLER as the asker of the record's read (#22792, #22851). To `packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts`: - `resolveSharingReadFilter`: the owner/share half of the read scope. It throws on a resolution failure, so its callers fail closed (#4467). - `resolveSharingCanEdit`: the sharing gate of a single-record write. It fails closed when the probe throws (#5386). - `resolveWriteScopeForSharing`: the write-depth stash that both write probes hand the service (ADR-0057 D1). - `resolveSharingWriteVerdict`: the tri-state write verdict (#5492, #6428). - `resolvePreImageFloorDrop`: the floor decision of the by-id pre-image gate. `security/explain` asks it too (#19963). - `delegatorOwnWriteFloorOptions`: the composition knobs the delegator's own direct by-id write would get (ADR-0090 D10). To `packages/plugins/plugin-security/src/security-plugin-authored-row-write.ts`: - `checkAuthoredRowWrite` (public): the `ISecurityService.checkAuthoredRowWrite` verdict, `admit` or `abstain` (#5493, #7281, #8059). The scope its probe read runs under moves with it: `AUTHORED_ROW_WRITE_PROBE_CONTEXT`, which nothing else reads. The change is move-only, and the card stays open for `start()`. The branch is `claude/project-thread-3fzya4`, the harness-pinned branch for this session. It was restarted from `origin/main` at `05cc3ee953`, which contains cut 9's merge `dd124e9466`. The branch name names no issue, so the claim comment is the identity record. **What stays in the class.** Each moved method keeps its name, signature, docblock and position, less `async`. Every caller still reaches the methods through the plugin: - the `security` service binding (`explain`, `checkAuthoredRowWrite`); - `init()` and `kernel:ready`; - the middleware; - the read scope; - the master gate. Two members of the row stay whole, `everyoneBaseline` (6 body lines) and `destroy` (3). Neither body is longer than its stub would be: `everyoneBaseline`'s stub would also be 6 lines. **Module-level declarations.** - `AUTHORED_ROW_WRITE_PROBE_CONTEXT` is read only by `checkAuthoredRowWrite`'s body. It moves with its docblock, unedited and still unexported, as cuts 1 to 4 and 9 moved declarations that only their bodies read. Its docblock's `{@link SecurityPlugin.checkAuthoredRowWrite}` resolves through the module's type import of the class. - `absentUnderCallerRead` and `unreadableUnderCallerRead` stay in `security-plugin.ts`, and each gains `export` and nothing else. The explanation reads both. `absentUnderCallerRead` is shared with the write path's step 2.7, which stays. `unreadableUnderCallerRead` is defined and documented beside it. Its docblock links to `absentUnderCallerRead`, and cut 8's `security-plugin-addressed-writes.ts` header names both as living in `security-plugin.ts`. - `requiredCapsForOperation` is already exported. The explanation imports it by value, as the object-admission module does. - Import names that only the moved bodies read leave `security-plugin.ts`. The modules import them from the same specifiers: - `explainAccessForAsker` and `buildContextForUser` (`./explain-engine.js`); - the whole `./builtin-positions.js` import (`declareEveryoneBaseline`, `registerBuiltinPositions`); - `readAcceptedEveryoneSets` (`./suggested-audience-bindings.js`); - `readDeclaredCapabilityContext` (`./declared-capability-context.js`); - `vetOrganizationClaim` (`@objectstack/core`); - the type `ExplainOperation` (`@objectstack/spec/security`). No staying docblock links to any of them. Two staying docblocks name `readAcceptedEveryoneSets` and `vetOrganizationClaim`, in backticks only. - The unexported interface `RlsFilterOptions` stays where every gate reads it. The explanation and the sharing verdicts name it through the class's own signature, as the type of `computeLayeredRlsFilter`'s fifth parameter less `undefined`, so the moved text names it exactly as before. Cut 9's modules name `ObjectSecurityMeta` the same way. - Each value import is read only inside a function body, at call time, and never while a module evaluates. `index.ts` still exports only what it did, so the published surface does not move (measured below). **Two readers outside the moved code read the moved lines by path. Both are re-pointed in this PR, as cut 9 re-pointed the same two.** - `content/docs/permissions/system-context.mdx`, row 9. `check-system-context-census` keys each elevation read by file and enclosing symbol, and `explainAccessForCaller`'s `callerContext?.isSystem` read now sits in the explain module. - A trial cut measured the page left untouched. The census reports 2 problems: the new file has no row, and the old anchor is no longer a read site. - The anchor keeps its symbol and changes only its path, from `security-plugin.ts#explainAccessForCaller` to `security-plugin-explain-access.ts#explainAccessForCaller`. No row's text changes. - `packages/plugins/plugin-security/src/delegated-organization-membership.test.ts`, the #25107 enumeration pin. The explanation resolves a delegator and asks the organization check beside it. So the expected list gains `security-plugin-explain-access.ts`, and the title says seven files instead of six. - The rule it pins is unchanged: asks equal resolutions in every file. It holds at head: the explain module counts 1 and 1, and `security-plugin.ts` counts 1 and 1 (`start()`; it counted 2 and 2 at base). **No ADR anchor entry is added.** No paragraph of the `security-plugin.ts` entry describes these bodies (see ADR anchors below). **One receiver changes, disclosed.** `resolveKernelService` is a function-valued field. The explanation and three of the sharing verdicts call it through the host, so its `this` is now the host, not the plugin. Two facts keep every call answering as it did: - the only function ever assigned to the field is the arrow function `start()` assigns, which returns `ctx.getService(name)` and answers `undefined` when that throws, and it reads no `this`; - no test or other file assigns the field. Cut 9's report said that no body it moved calls a function-valued field. This cut's bodies do, so it is stated here. ## The published `.d.ts` does not change Measured under the series bar (ruling 6348891160 item 1), on base `05cc3ee953` and on head `38392cb1b7`. Each was a full `turbo run build` in its own worktree. A package build of the same trees before the claim gave the same bytes. - Raw sha256 of `dist/index.d.ts` and `dist/index.d.mts` is the same for all four files, base and head: `7d67966ce82e00a1531807b3b0008f16c489fc2c3199f9ad67078302eb2803da`. The raw diff is 0 lines, so no union order moved either. These are the same bytes cut 9 measured, so neither cut 9's merge nor main's later commits moved this package's `.d.ts`. - Normalised sha256 is the same for all four: `b53e9025f794e20debd2245afed5ceb1a0ad48109a2a21cc19232a562ccd9cb4`. - `security-plugin.ts` gains two exports, `absentUnderCallerRead` and `unreadableUnderCallerRead`. Each takes one read callback and answers a boolean promise, so its signature names no type from another module, so the cut 6 trap does not arise. `index.ts` re-exports neither. - The normaliser, run from `packages/plugins/plugin-security`, sorts the members of each string-literal union and changes nothing else: ```sh node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum ``` - Positive controls, each a one-line edit on a copy of the head `dist/index.d.ts`: - line 557 (`private resolveSharingReadFilter;`) made public: raw `8c8fad488058d08a54e23b5410b539b7eecad71a0e749b0133a61280e146bdde`, normalised `f02f50957a57e31fd943e00fa0ef74113a2f0efdc4af8e3ee5b963c7e932a3c6`; - line 447 renamed (`private declareEveryoneRenamed;`): raw `7af56294d477cdbc07b22567c87a110bbc2c36d37724cc47ed3652dc085a8ab4`, normalised `44d50a2b10e166373f11f31ef5e046c1a05ab9f5613e9d6b762c03c7ca15ec88`; - line 745, the public `checkAuthoredRowWrite` signature, with `context?: any` changed to `context?: unknown`: raw `b75e990785a61245092adf8414acba79b6fe27f330ebd7555e9a808642802556`, normalised `6b7331a568193fda94c1e45eabe85f5704d61c6ef99639bb197b7f11510b81b6`. All three differ from the real hashes, so the comparison does see a visibility change, a rename and a change to a public signature. - The two public methods carry their full signatures and docblocks in the `.d.ts`: `explainAccessForCaller` (line 525) and `checkAuthoredRowWrite` (745). The eight private methods carry only their name and docblock: `declareEveryone` (447), `declareBuiltinPositions` (483), and the six sharing verdicts (557 to 676). Those lines are the same in both files. - Export tables: the ESM and CJS bundles each export the same 66 names at base and at head. The lists were loaded at runtime, with `require` and `import()` of the built bundles, not parsed. The sorted name list hashes to `83576430f83cf60cecbcc5c0e80214f16c626a8e66f700294fc6a44361423116` for all four, as at cuts 6 to 9. - The bundle JS bytes differ, because code moved within them: `index.js` goes from `dfa83a54` to `78d8f0eb`, and `index.mjs` from `18c4d919` to `5d290484`. Every value the new modules import comes from a file inside this package or from a specifier `security-plugin.ts` already imports. So the set of external specifiers each bundle loads is the same at base and head: 16 distinct `require` specifiers in `index.js` and 19 distinct `from` specifiers in `index.mjs`. - Bundle order, disclosed: `builtin-positions.ts`, `manifest.ts` and `objects/sys-audience-binding-suggestion.object.ts` are now emitted just ahead of the four modules, where before they sat between `security-plugin.ts`'s chunks. None of them imports `security-plugin.ts`, and their top level only declares constants. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## What moved The base line numbers are each method's signature and closing brace at `05cc3ee953`. The last column lists the members the body reads, which is exactly what its host carries. | method | base lines | module | host members | |---|---|---|---| | `declareEveryone` (private, async) | `:4912`-`:4946` | everyone-anchor | `deploymentAcceptance`, `epoch`, `everyoneBaseline`, `metadata`, `ql` | | `declareBuiltinPositions` (private) | `:4994`-`:5013` | everyone-anchor | `everyoneBaseline` | | `explainAccessForCaller` (public, async) | `:5061`-`:5373` | explain-access | `baselinePermissionSets`, `checkControlledByParentWrite`, `computeLayeredRlsFilter`, `computeReadPartialMaskRules`, `computeRlsFilter`, `delegatedAdminGate`, `foldFieldRequiredPermissions`, `getObjectSecurityMeta`, `permissionEvaluator`, `ql`, `readRowById`, `resolveKernelService`, `resolvePermissionSetsForContext`, `resolvePreImageFloorDrop`, `resolveSharingReadFilter`, `resolveWriteScopeForSharing`, `tenancyPosture` | | `resolveSharingReadFilter` (private, async) | `:5401`-`:5449` | sharing-verdicts | `getObjectSecurityMeta`, `permissionEvaluator`, `resolveKernelService`, `resolvePermissionSetsForContext` | | `resolveSharingCanEdit` (private, async) | `:5468`-`:5493` | sharing-verdicts | `logger`, `resolveKernelService`, `resolveWriteScopeForSharing` | | `resolveWriteScopeForSharing` (private, async) | `:5513`-`:5531` | sharing-verdicts | `getObjectSecurityMeta`, `permissionEvaluator`, `resolvePermissionSetsForContext` | | `resolveSharingWriteVerdict` (private, async) | `:5563`-`:5593` | sharing-verdicts | `logger`, `resolveKernelService`, `resolveWriteScopeForSharing` | | `resolvePreImageFloorDrop` (private, async) | `:5621`-`:5642` | sharing-verdicts | `collectRLSPolicies`, `resolveSharingWriteVerdict` | | `delegatorOwnWriteFloorOptions` (private, async) | `:5667`-`:5684` | sharing-verdicts | `resolvePreImageFloorDrop` | | `checkAuthoredRowWrite` (public, async) | `:5754`-`:5838` | authored-row-write | `collectRLSPolicies`, `computeLayeredRlsFilter`, `logger`, `ql`, `resolvePermissionSetsForContext` | Each async stub drops `async` and returns the moved function's promise, as cuts 1 and 3 to 9 did for their async methods. `declareBuiltinPositions` was synchronous, and its stub returns its function's `void`. Two of the ten are public, so the `.d.ts` carries their full signatures. A body move does not touch a signature, and those lines do not change (above). ## The shape (#23050 shape B, as cuts 1 to 9 applied it) - The class keeps each method with its name, signature, docblock and position, because tsup emits members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25223 cut 10]`. - `EveryoneAnchorHost`, `ExplainAccessHost`, `SharingVerdictsHost` and `AuthoredRowWriteHost` are new. Each types every member as the class member itself (`SecurityPlugin['m']`). Each moved function types its `this` as the `Pick` of its host over exactly the members its body reads, and the stub annotates its literal with the same `Pick`. There is no cast, and no member becomes public or protected. - Methods go in as late-bound closures that call `this.m(...a)` on the plugin at call time. So a spy or test double set on the plugin is still the one the body reaches: - the everyone declarations still ask the plugin's baseline and deployment acceptance; - the explanation still asks the plugin's set resolution, RLS filters, sharing read filter, write-depth stash, floor decision, controlled-by-parent check, field folds, posture reader and by-id read; - the sharing verdicts still ask the plugin's set resolution, posture reader and RLS policy collection, and they still ask each other through the plugin; - the authored-row verdict still asks the plugin's set resolution, policy collection and layered RLS filter. No test replaces a moved method on the plugin instance today. One that did would still replace it for every caller. - Fields follow the rule cut 1 set, judged on each field's own declaration: - `baselinePermissionSets` is declared `readonly`, so it goes by value. - None of the other eight fields the bodies read is declared `readonly`: `delegatedAdminGate`, `epoch`, `logger`, `metadata`, `permissionEvaluator`, `ql`, `resolveKernelService` and `tenancyPosture`. So they go as getters, and each read happens when the body makes it. `start()` assigns all of them except `permissionEvaluator` after construction, and tests replace `permissionEvaluator` on a constructed plugin. - `delegatedAdminGate`, `epoch`, `logger`, `permissionEvaluator` and `ql` are objects whose own methods the bodies call. `resolveKernelService` is the one function-valued field (disclosed above). - The hosts declare every field `readonly`, so a moved body cannot assign a member through them. The typecheck passes, so none does. - Nothing re-exports any of the modules, so the package entry does not move. ## Proof of move-only - **Bodies, by AST token.** Each moved function's body has the same TypeScript token sequence as the base method body. Each of the 549 body lines is the base line with two leading spaces removed. Parameters, return types and `async` match. - The explanation's body holds five multi-line JSDoc text tokens, the docblocks of its inner closures. Their continuation lines are re-indented one level, like every other body line. So the comparison dedents those JSDoc tokens one level, and only those. A string or template token that spans lines is compared byte for byte. The explanation's 1,731 tokens are then equal. - Four negative controls fail the check: - in `explainAccessForCaller`, `callerContext?.isSystem === true` changed to `=== false`; - in `resolveSharingCanEdit`, the fail-closed `return false;` of the probe's `catch` changed to `return true;`; - in `checkAuthoredRowWrite`, the non-write abstention `return 'abstain';` changed to `return 'admit';`; - in one of the explanation's inner JSDoc blocks, "The two contexts" changed to "The three contexts". This control shows that the JSDoc dedent does not hide a change of words. - **Class side.** Each moved method keeps its docblock and signature, less `async`. Every other class member is byte-identical to base: 116 named members plus the constructor, including `everyoneBaseline` and `destroy`. The class has 127 members at base and at head. - **Top level of `security-plugin.ts`.** Of the 106 top-level statements at base other than the class, 97 are byte-identical at head. The other nine: - five imports each lose the names listed above, and the `./builtin-positions.js` import is gone; - `AUTHORED_ROW_WRITE_PROBE_CONTEXT` is gone from the file. The authored-row module holds the same statement and docblock, byte for byte; - `absentUnderCallerRead` and `unreadableUnderCallerRead` are the base text with `export` added. The four new statements are the imports of the new modules. - **The two re-pointed readers.** The page diff is one line, the anchor's path. The pin diff is the title's count and one added list entry. - **git's own view.** This is `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 05cc3ee HEAD` over the five source files. Of the 596 deleted lines, git sees 581 as moved. The other 15 are the nine signatures that lose `async`, four of the import lines that change, and the two functions that gain `export`. Of the 1,184 added lines, 679 show as moved: 669 in the four modules, and 10 in `security-plugin.ts` whose text git also finds among the deleted lines (nine stub `};` lines and the host line `baselinePermissionSets: this.baselinePermissionSets,`). The rest are the modules' headers, imports, host interfaces, type aliases and function signatures, the ten stubs, and the four new imports with their comments. - **Size.** 1,785 changed lines (+1,187 / -598) over 7 files, under the 3,000-line manual-merge line. - `security-plugin.ts` goes from 8,852 to 8,406 lines. - `security-plugin-everyone-anchor.ts` has 140 lines, `security-plugin-explain-access.ts` 433, `security-plugin-sharing-verdicts.ts` 277 and `security-plugin-authored-row-write.ts` 184. - The other two files are the page (+1 / -1) and the pin (+2 / -1). ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs` exits 0 and gives byte-identical output at base `05cc3ee953` and at head. `dispatch-gates --commands` does not derive it for this change set, so it was run by hand. - `pnpm check:adr-anchors` exits 0 at base and at head, with 81 anchored files at both ends. - **No new entry.** No paragraph of the `security-plugin.ts` entry describes a moved body: - ADR-0055 §1 is the controlled-by-parent relation (cuts 2 to 4); - ADR-0106 D7 is the projection asymmetry (cut 9's field-readers module carries it); - ADR-0123 D2 is the write wall's no-organization refusal (cut 5); - ADR-0138 D1 is the seed-ownership claim. So no module of this cut gets an entry, as the modules of cuts 1 and 6 to 8 have none. - **The decision-letter count on the anchored surface goes from 1978 to 1969.** That gate counts `ADR-NNNN Dk` citations in each anchored file and in each entry's own invariant text. Nine citations leave the anchored surface with the moved bodies: - `explainAccessForCaller` 7: ADR-0090 D6, ADR-0090 D10, ADR-0090 D12, ADR-0105 D3 twice, ADR-0111 D3, ADR-0111 D5; - `resolveSharingReadFilter` 1: ADR-0057 D1; - `checkAuthoredRowWrite` 1: ADR-0058 D4. The other bodies, the moved constant's docblock, the four module headers and the four new import comments cite none. `resolveSharingReadFilter`'s `depth` parameter comment also cites ADR-0090 D10. The parameter list belongs to the signature, which stays in the class, so that letter stays on the anchored surface. The move copies each signature unedited, so the function's parameter list carries it too. Measured directly: `security-plugin.ts` holds 258 such citations at base and 249 at head, and the 9 that leave are exactly these. - Every moved citation is still in the tree, in the module its body moved to. Citations that resolve tree-wide go from 52223 across 6651 files at base to 52224 across 6654 at head. Three of the modules cite an ADR. The one added citation is the `depth` parameter comment, which both signatures now carry. - `docs/adr/**` is not touched, and no anchor token is planted. - The anchor map covers every ADR symbol anchor that names `security-plugin.ts`: 27 anchors over 18 symbols. - It was measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`. That is measurement, not a new gate. - It was read on `05cc3ee953` BEFORE the claim, with the whole cut applied as a local trial, and no anchor changed class. - No ADR anchors a row 8 member or the moved constant. Every anchor resolves at head in `security-plugin.ts`, with its base class. The table gives each anchor's class at base and at head, and the line its own declaration resolves on. Every anchor stays in `security-plugin.ts`. | anchor | cited at | base class · line (member) | head class · line (member) | |---|---|---|---| | `#CBP_MAX_CHAIN_DEPTH` | ADR-0055:135 | declaration · `:712` (top-level) | declaration · `:744` (top-level) | | `#SecurityPlugin` | ADR-0049:49, ADR-0138:248, ADR-0138:265 | declaration · `:1344` (class) | declaration · `:1346` (class) | | `#SecurityPlugin.getMetadataReadableFields` | ADR-0138:153 | declaration · `:6030` (method) | declaration · `:5584` (method) | | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | ADR-0138:427 | declaration · `:6624` (method) | declaration · `:6178` (method) | | `#assertControlledByParentWrite` | ADR-0055:58, ADR-0055:125 | declaration · `:8109` (method) | declaration · `:7663` (method) | | `#assertMasterRowEditable` | ADR-0055:125 | declaration · `:8235` (method) | declaration · `:7789` (method) | | `#collectRLSPolicies` | ADR-0105:93 | declaration · `:8289` (method) | declaration · `:7843` (method) | | `#composeWriteTenantWall` | ADR-0123:4 | declaration · `:7447` (method) | declaration · `:7001` (method) | | `#computeControlledByParentFilter` | ADR-0055:5, ADR-0055:123, ADR-0139:246 | declaration · `:7755` (method) | declaration · `:7309` (method) | | `#computeDelegatedReadFilter` | ADR-0139:223 | declaration · `:5949` (method) | declaration · `:5503` (method) | | `#computeRlsFilter` | ADR-0055:24 | declaration · `:7155` (method) | declaration · `:6709` (method) | | `#getReadFilter` | ADR-0096:181, ADR-0096:182, ADR-0096:183, ADR-0096:199, ADR-0139:222 | declaration · `:5895` (method) | declaration · `:5449` (method) | | `#hasPlatformAdminPosture` | ADR-0099:317 | declaration · `:7182` (method) | declaration · `:6736` (method) | | `#isPrincipalLessContext` | ADR-0138:152 | declaration · `:585` (top-level) | declaration · `:617` (top-level) | | `#manage_metadata` | ADR-0099:68 | literal · `:338` (top-level) | literal · `:370` (top-level) | | `#nav_permission_sets` | ADR-0126:406 | literal · `:1772` (`init`) | literal · `:1774` (`init`) | | `#rlsFilter` | ADR-0055:24 | declaration · `:4346` (`start`) | declaration · `:4348` (`start`) | | `#writeCheckPolicies` | ADR-0066:112 | declaration · `:1239` (top-level) | declaration · `:1241` (top-level) | ## Readers Two readers outside the moved code read the moved lines by path: the system-context page and the #25107 enumeration pin. Both are re-pointed in this PR (above). No other gate, baseline, docs anchor or test reads the moved lines by path. Each kind was read: - **The system-context census.** - `node scripts/isystem-census.mjs --json` gives the same 148 sites at base and at head, 0 differences. The comparison ignores line numbers and maps the four modules back to `security-plugin.ts`. - One head site is in the modules: `explainAccessForCaller`'s `callerContext?.isSystem` read, which the page's re-pointed anchor names. The moved bodies' `isSystem: true` keys and the probe constant's key are counted the same way at both ends. - The role counts are the same (`read` 153, `declaration` 38, `key` 438, `other` 9), and so are the 5 non-elevation reads and the 22 packages. The files holding a site go from 74 to 75, and the scanned files from 397 to 399, the two modules that name `isSystem`. - `check-system-context-census` and its `--self-test` exit 0 at base and at head. The totals are the same (148 sites, 127 symbols, 140 cited against 140 required, 163 anchors), and files go from 74 to 75. With the page left untouched, it reports 2 problems (claim). - **The tenant-audit census.** No moved body holds an engine write. The explanation and the authored-row verdict read through `ql.findOne` and `ql.find`, and the everyone declaration hands `ql` to the derivation's readers. `node scripts/tenant-audit-census.mjs --json` gives the same 238 write call sites at base and at head, compared the same way: 0 differences, none in the modules, and the same 175 non-engine calls. Only `scannedSources` moves, from 730 to 734, the four modules. `check-tenant-audit-census` exits 0 at base and at head with the same output, and `tenant-audit-census.mdx` is not touched. - **The #25107 enumeration pin** is described above. It is part of the plugin-security suite, which passes at head with the same counts as base. - **`packages/core/src/security/row-serving-door-exposure.pin.test.ts` and `operation-private-keys.pin.test.ts`.** No moved body names the read admission or the operation-private keys. Run on their own at head: 8 of 8 pass. - **The platform checklist.** `access-security.json:2032` cites `security-plugin.ts#declareEveryone` (the everyone derivation). The anchor resolves on the method, which stays with its docblock. `check-platform-checklist` (the script and `pnpm check:platform-checklist`) exits 0 at base and at head with the same output. - **Symbol anchors outside the ADRs.** Every path-qualified `plugin-security/src/security-plugin*.ts#…` anchor outside `docs/adr/` was resolved in its own file at base and at head: 32 at both, 0 unresolved. At head, 22 point into `security-plugin.ts`, 1 into the explain module (the re-pointed row 9), and 9 into the modules of cuts 3, 5 and 9, as at base. - **The non-ADR corpora.** The non-ADR corpora that cuts 2 to 9 read are spec liveness, the platform checklist, `system-context.mdx` and spec `conversions/registry.ts`. They hold 22 anchors over 13 symbols that name `security-plugin.ts`. With `symbolSegmentResolution`, each resolves at head with its base class: 0 changed. `check-scripts-symbol-anchors` and its `--self-test`, and `pnpm --filter @objectstack/spec check:liveness`, give the same output at base and at head. - **Readers by name, not by path.** These were read, and none needs a change, because each method still exists in the class under the same name and signature: - `rest-server.ts:5953`, which names `SecurityPlugin.explainAccessForCaller` in prose; - the behaviour tests of the explanation, the sharing verdicts, the authored-row verdict and the everyone anchor in this package. Each drives the plugin, and none calls or replaces a moved method directly; - the page's row text and the docblocks that stay in the class, which name these methods and stay true by method. - **Links in the moved text.** These are unedited. Where one named a class member from inside a body or a signature, the function's copy still names that member as text. Examples are `{@link computeDelegatedReadFilter}` in `resolveSharingReadFilter`'s `depth` comment, and the explanation's inner links to `resolvePreImageFloorDrop` and `checkControlledByParentWrite`. The class keeps the docblocks those links belong to. - **Line citations.** No `security-plugin.ts:N` citation in the repo points into the moved ranges (base `:760` to `:788` and `:4912` to `:5838`). ## Gates All gates ran against base `05cc3ee953` and on head `38392cb1b7`, each in its own worktree after a full `turbo run build` there (both exit 0). - `pnpm --filter @objectstack/plugin-security test` (`vitest run`): Test Files 214 passed (214); Tests 4465 passed, 45 skipped (4510). Base `05cc3ee953` gives the same counts. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 at head and at base. It runs `tsc --noEmit`, the scripts tsconfig, and `check:test-typecheck`. - eslint over the six touched source and test files: exit 0. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and the bundles measured above. - `row-serving-door-exposure.pin.test.ts` and `operation-private-keys.pin.test.ts` (`@objectstack/core`): 8 of 8 pass. - `dispatch-gates --commands`: 88 commands. The `--ran` reconciliation shows 88 derived, 88 run, 0 NOT-MEASURED and 0 UNRUN. The zero is derived: all 88 recorded an exit code, and none is 3. - Nine commands are not derived for this change set, and the reconciliation names them as recorded outside the derivation. Each was run by hand: - `node scripts/check-adr-symbol-anchors.mjs`; - `pnpm check:adr-anchors`; - `check-platform-checklist`, both the script and `pnpm check:platform-checklist`; - `check-scripts-symbol-anchors` and its `--self-test`; - the two census `--json` reads; - `pnpm --filter @objectstack/spec check:liveness`. `pnpm check:query-options-erasure` is derived for this change set this time. - Seven more were run at base and at head. Six keep a roster in a directory this change touches, so `dispatch-gates` marks their silence as no evidence: `check-published-list-mirrors`, `check:authz-resolver`, `check:console-injection`, `check:error-code-casing`, `check:filter-alias-parity` and `check:published-readme-exports`. The seventh is the package typecheck. All seven exit 0 at both. - **Base against head, all 97 commands.** All 97 (the 88 derived and the 9 hand-run) exit 0 at head and at base. 72 give identical output at both, once the worktree path and timings are masked. The other 25, each read line by line: - `check:adr-anchors`, `check-system-context-census` and the two census `--json` reads move as described above. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 42 citations across 5 files, and all resolve. - Counts that move by exactly the four modules: - `check-closing-keyword-parity` 12029 to 12033 tracked files; - `check:gitlink-declared` 12029 to 12033 index entries; - `check:nul-bytes` 12020 to 12024; - `check-comment-mask-corpus` 10005 to 10009; - `check-plugin-teardown-shape` 9397 to 9401 sources; - `check-position-name-fold-loaders` 12370 to 12374 files, with the same 8 references; - `check-undeclared-dep-imports` 3316 to 3320 files; - the spec package's `check:duration-unit-keys` 3321 to 3325; - `check:closed-ceremony-text` 4573 to 4577; - `check:doc-authoring` 1434 to 1438 parsed sources; - `check:engine-double-contract` 3625 to 3629 production files; - `check:error-status-conformance` 3413 to 3417; - `check:logger-receiver-detach` 3633 to 3637; - `check:org-identifier` 3658 to 3662; - `check:page-declaration-shape` 3666 to 3670; - `check:refd-timer-probe` 9998 to 10002; - `check:type-check-coverage` and `check:type-check-debt` 3542 to 3546 walked sources; - `check:vendor-version-stamps` 10397 to 10401. - `check-undeclared-dep-imports` also reads 2865 to 2872 `@objectstack/*` specifiers. Those are the seven the modules import, all declared dependencies. `security-plugin.ts` keeps every specifier it had. - `check:doc-authoring` also reads 104377 to 104528 strings, the strings of the new headers and stubs. - `check:skill-identifier-liveness` reads 139798 to 139805 implementation word tokens. Those are the seven words new to the tree: the four host names, the stubs' two getter locals `readEpoch` and `readResolveKernelService`, and the word "abstentions" in the authored-row module's header. No citation changes. Per command (exit code at head, then at base), the derived first and the hand-run after: - `node scripts/check-ci-filter-parity.mjs` exit 0 (base 0) - `node scripts/check-closing-keyword-parity.mjs` exit 0 (base 0) - `node scripts/check-closing-keyword-parity.mjs --self-test` exit 0 (base 0) - `node scripts/check-comment-mask-adoption.mjs` exit 0 (base 0) - `node scripts/check-comment-mask-adoption.mjs --self-test` exit 0 (base 0) - `node scripts/check-comment-mask-corpus.mjs` exit 0 (base 0) - `node scripts/check-doc-frontmatter.mjs` exit 0 (base 0) - `node scripts/check-doc-frontmatter.mjs --self-test` exit 0 (base 0) - `node scripts/check-doc-route-spelling.mjs --advisory` exit 0 (base 0) - `node scripts/check-doc-route-spelling.mjs --self-test` exit 0 (base 0) - `node scripts/check-docs-section-name.mjs` exit 0 (base 0) - `node scripts/check-docs-section-name.mjs --self-test` exit 0 (base 0) - `node scripts/check-dts-emitted.mjs --self-test` exit 0 (base 0) - `node scripts/check-issue-citations.mjs` exit 0 (base 0) - `node scripts/check-keyed-text-bounds.mjs` exit 0 (base 0) - `node scripts/check-keyed-text-bounds.mjs --self-test` exit 0 (base 0) - `node scripts/check-platform-object-tenancy-census.mjs` exit 0 (base 0) - `node scripts/check-platform-object-tenancy-census.mjs --self-test` exit 0 (base 0) - `node scripts/check-plugin-teardown-shape.mjs` exit 0 (base 0) - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 (base 0) - `node scripts/check-position-name-fold-loaders.mjs` exit 0 (base 0) - `node scripts/check-position-name-fold-loaders.mjs --self-test` exit 0 (base 0) - `node scripts/check-registry-log-declared.mjs` exit 0 (base 0) - `node scripts/check-registry-log-declared.mjs --self-test` exit 0 (base 0) - `node scripts/check-rest-log-spy-declared.mjs` exit 0 (base 0) - `node scripts/check-rest-log-spy-declared.mjs --self-test` exit 0 (base 0) - `node scripts/check-section-landing-index.mjs` exit 0 (base 0) - `node scripts/check-section-landing-index.mjs --self-test` exit 0 (base 0) - `node scripts/check-system-context-census.mjs` exit 0 (base 0) - `node scripts/check-system-context-census.mjs --self-test` exit 0 (base 0) - `node scripts/check-tenant-audit-census.mjs` exit 0 (base 0) - `node scripts/check-tenant-audit-census.mjs --self-test` exit 0 (base 0) - `node scripts/check-undeclared-dep-imports.mjs` exit 0 (base 0) - `node scripts/check-undeclared-dep-imports.mjs --self-test` exit 0 (base 0) - `node scripts/docs-audit/check-affected-docs.mjs` exit 0 (base 0) - `node scripts/docs-audit/check-drift-comment.mjs` exit 0 (base 0) - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exit 0 (base 0) - `pnpm --filter @objectstack/lint run check:doc-security-posture` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:docs` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:duration-unit-keys` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:empty-state` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:liveness` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:skill-examples` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:strictness-ledger` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:variant-docs` exit 0 (base 0) - `pnpm --filter @objectstack/spec run check:yaml-examples` exit 0 (base 0) - `pnpm check:closed-ceremony-text` exit 0 (base 0) - `pnpm check:corpus-claim-drift` exit 0 (base 0) - `pnpm check:cross-package-test-inputs` exit 0 (base 0) - `pnpm check:doc-anchors` exit 0 (base 0) - `pnpm check:doc-authoring` exit 0 (base 0) - `pnpm check:docs-audit-scope` exit 0 (base 0) - `pnpm check:docs-redirects` exit 0 (base 0) - `pnpm check:docs-single-h1` exit 0 (base 0) - `pnpm check:docs-spec-enumerations` exit 0 (base 0) - `pnpm check:docs-transcript-drift` exit 0 (base 0) - `pnpm check:driver-memory-census` exit 0 (base 0) - `pnpm check:dts-closure` exit 0 (base 0) - `pnpm check:dual-build-cjs-loads` exit 0 (base 0) - `pnpm check:engine-double-contract` exit 0 (base 0) - `pnpm check:error-status-conformance` exit 0 (base 0) - `pnpm check:gitlink-declared` exit 0 (base 0) - `pnpm check:i18n` exit 0 (base 0) - `pnpm check:i18n-stale-fill` exit 0 (base 0) - `pnpm check:issue-citations` exit 0 (base 0) - `pnpm check:lean-entry-closure` exit 0 (base 0) - `pnpm check:logger-receiver-detach` exit 0 (base 0) - `pnpm check:merge-driver` exit 0 (base 0) - `pnpm check:nul-bytes` exit 0 (base 0) - `pnpm check:objectql-double-limit` exit 0 (base 0) - `pnpm check:org-identifier` exit 0 (base 0) - `pnpm check:page-declaration-shape` exit 0 (base 0) - `pnpm check:published-files` exit 0 (base 0) - `pnpm check:published-readme-links` exit 0 (base 0) - `pnpm check:query-options-erasure` exit 0 (base 0) - `pnpm check:react-page-adapter-contract` exit 0 (base 0) - `pnpm check:refd-timer-probe` exit 0 (base 0) - `pnpm check:role-word` exit 0 (base 0) - `pnpm check:skill-identifier-liveness` exit 0 (base 0) - `pnpm check:slot-lookup` exit 0 (base 0) - `pnpm check:sourcemap-no-sources-content` exit 0 (base 0) - `pnpm check:test-source-alias` exit 0 (base 0) - `pnpm check:tier-file-adoption` exit 0 (base 0) - `pnpm check:type-check-coverage` exit 0 (base 0) - `pnpm check:type-check-debt` exit 0 (base 0) - `pnpm check:vendor-version-stamps` exit 0 (base 0) - `pnpm check:watch-hint-literal` exit 0 (base 0) - `pnpm check:where-matcher` exit 0 (base 0) - `node scripts/check-adr-symbol-anchors.mjs` exit 0 (base 0) - `pnpm check:adr-anchors` exit 0 (base 0) - `node scripts/check-platform-checklist.mjs` exit 0 (base 0) - `pnpm check:platform-checklist` exit 0 (base 0) - `node scripts/check-scripts-symbol-anchors.mjs` exit 0 (base 0) - `node scripts/check-scripts-symbol-anchors.mjs --self-test` exit 0 (base 0) - `node scripts/isystem-census.mjs --json` exit 0 (base 0) - `node scripts/tenant-audit-census.mjs --json` exit 0 (base 0) - `pnpm --filter @objectstack/spec check:liveness` exit 0 (base 0) ## Acceptance notes - Nothing outside this cut is touched: no other section of the class, no behaviour, no `docs/adr/**`, no `scripts/adr-anchors/` entry, no census ledger and no `packages/spec/**`. Outside the package, one file changes: the one anchor path on the system-context page. - Serial check, also in the claim: of the 20 open PRs at claim time, one touched `security-plugin.ts` and `system-context.mdx`. That was #25483 (#25251's first PR): one import near `:29`, a call in `start()` near `:2577`, and a new page row 1b. None of those lines is in row 8 or in an import line this cut edits. #25483 has since merged (`a285ff29a1`). Re-checked before opening this PR: of the 13 open PRs now, two more touch the package: - #25512 (#24447) adds an import near `:47` and a block in `start()` near `:4759`, ahead of row 8; - #25515 (#25233) edits `builtin-positions.ts`, whose two functions the everyone-anchor module imports, and keeps both signatures unchanged. A scratch merge of `5188e7f18f`, #25512, #25515 and this head merges cleanly. Neither PR touches a moved line or a name this cut moves. - Since the local runs, `origin/main` gained 13 commits, up to `5188e7f18f`, #25483 among them. They touch two of this cut's files, at the #25483 hunks above. - A scratch merge of this head onto `5188e7f18f` is clean. On it, these all exit 0: - `check-adr-symbol-anchors`, `check:adr-anchors`, `check-platform-checklist`, `check-system-context-census` and `check-tenant-audit-census`; - the `isystem-census` read; - `tsc --noEmit` for the package; - the #25107 pin, #25483's refusal test and the authored-row verdict test (3 files, 127 tests). - There, `check:adr-anchors` counts 82 anchored files and 1970 decision-letter citations, against 82 and 1979 on `5188e7f18f` alone: the same 9 leave. The system-context census counts 149 sites across 76 files, against 75 files on `5188e7f18f` alone: #25483's new read is the 149th site. - After this cut, row 8 leaves `everyoneBaseline` and `destroy` whole in `security-plugin.ts`, plus the ten method shells. `security-plugin.ts` has 8,406 lines at head and 8,852 at base. Next is `start()`. It is Tier H, and it goes to the seat with its scope before any PR. --- _Generated by [Claude Code](https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa2dbab commit 82f5a11

7 files changed

Lines changed: 1187 additions & 598 deletions

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ that silently does not happen.
108108
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
109109
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
110110
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` |
111-
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
111+
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` |
112112
| 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#checkControlledByParentWrite` |
113113
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` |
114114
| 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` |

‎packages/plugins/plugin-security/src/delegated-organization-membership.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t
685685
const calls = (text: string, name: string) =>
686686
(text.match(new RegExp(`\\b${name}\\(`, 'g')) ?? []).length - (text.match(new RegExp(`function ${name}\\(`, 'g')) ?? []).length;
687687

688-
it('per source file, the asks equal the resolutions — and the six files that resolve one are all counted', () => {
688+
it('per source file, the asks equal the resolutions — and the seven files that resolve one are all counted', () => {
689689
const counted: Record<string, [number, number]> = {};
690690
for (const file of readdirSync(SRC, { recursive: true }) as string[]) {
691691
if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue;
@@ -696,6 +696,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t
696696
}
697697
expect(Object.keys(counted).sort()).toEqual([
698698
'explain-engine.ts',
699+
'security-plugin-explain-access.ts',
699700
'security-plugin-field-readers.ts',
700701
'security-plugin-object-admission.ts',
701702
'security-plugin-operation-principals.ts',
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 10] `SecurityPlugin#checkAuthoredRowWrite`, moved out of
5+
* `security-plugin.ts`: the `ISecurityService.checkAuthoredRowWrite` verdict,
6+
* whether an app-authored row policy admits one row for an `update` or a
7+
* `delete` — `admit` or `abstain`, never a refusal, and `abstain` on every
8+
* failure — together with the scope its probe read runs under,
9+
* `AUTHORED_ROW_WRITE_PROBE_CONTEXT`, which nothing else reads.
10+
*
11+
* A MOVE, not a redesign. The function below is the body of the method of the
12+
* same name as it stood, re-indented one level and otherwise unedited: the
13+
* same abstentions in the same order, the same provenance pre-check, the same
14+
* layered predicate and the same id-only probe read. The scope constant and
15+
* its docblock moved with it unedited, still unexported, and the probe still
16+
* spreads it at its one call site.
17+
*
18+
* ## How the moved body reaches the class
19+
*
20+
* The shape cuts 1 to 9 used (`security-plugin-object-inputs.ts`, whose header
21+
* carries the reasoning). The class keeps the public method — the published
22+
* `dist/*.d.ts` lists its name, docblock and position — and its body is one
23+
* literal of exactly the {@link AuthoredRowWriteHost} members it reads, plus a
24+
* `.call`. A method is handed over as a closure that calls it on the plugin
25+
* at call time, so a test double or spy set on the plugin is still the one
26+
* the body reaches: the verdict still asks the plugin's set resolution, RLS
27+
* policy collection and layered RLS filter, and the `security` service
28+
* binding and every other caller still reach the method through the plugin.
29+
* The method was async and the function still is; the method now returns its
30+
* function's promise.
31+
*
32+
* Each field was classified on its own declaration, by the rule cut 1 set.
33+
* `logger` and `ql` are not declared `readonly` (`start()` assigns both after
34+
* construction), so they go as GETTERS, and each read happens when the body
35+
* makes it. Both are objects whose own methods the body calls, so no receiver
36+
* changes.
37+
*
38+
* ## What stays in `security-plugin.ts`, and how it is read
39+
*
40+
* The verdict and operation types are imported from
41+
* `@objectstack/spec/contracts`, the platform-ownership floor predicate from
42+
* `platform-ownership-policies.js`, and the clause predicate from
43+
* `rls-compiler.js`, as `security-plugin.ts` imports them.
44+
*
45+
* Nothing re-exports this module, so the published surface does not move.
46+
*/
47+
import type { AuthoredRowWriteOperation, AuthoredRowWriteVerdict } from '@objectstack/spec/contracts';
48+
import type { SecurityPlugin } from './security-plugin.js';
49+
import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js';
50+
import { policyDeclaresClause } from './rls-compiler.js';
51+
52+
/**
53+
* The `SecurityPlugin` members the function below reads — every one of them,
54+
* and no other; the function's `this` is the `Pick` of the members it reads.
55+
* Each is typed AS the class member (an indexed access on the class), so a
56+
* change to a member's signature on the class is a change here too, and naming
57+
* a member the class does not have is a compile error. See the module header
58+
* for which members are getters.
59+
*/
60+
export interface AuthoredRowWriteHost {
61+
readonly logger: SecurityPlugin['logger'];
62+
readonly ql: SecurityPlugin['ql'];
63+
collectRLSPolicies: SecurityPlugin['collectRLSPolicies'];
64+
computeLayeredRlsFilter: SecurityPlugin['computeLayeredRlsFilter'];
65+
resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext'];
66+
}
67+
68+
/**
69+
* [#7281] The scope `checkAuthoredRowWrite`'s probe read runs under.
70+
*
71+
* The method asks ONE question — "does the declared, app-authored widener admit
72+
* this row for this operation?" — and that question is about the row and the
73+
* declaration, never about what the CALLER may see. Resolving it under the
74+
* caller's own context folded a READ decision into a WRITE question: on a
75+
* `private`-OWD object `plugin-sharing`'s read filter scopes every re-read to
76+
* owner-match OR shares, so a cross-owner row was invisible to the probe and
77+
* the verdict was `abstain` for a row the declaration names by predicate. The
78+
* by-id widener surface was therefore structurally dead on `private` — the
79+
* posture #5493 built it for (maintainer ruling, 2026-08-10: reading 2).
80+
*
81+
* Elevating the READ does not widen the ANSWER, because the predicate carries
82+
* the whole of the question and travels in the `where`, not in the scope:
83+
* `{ id } AND layer0(tenant wall) AND layer1(app-authored policies)`. Both
84+
* layers are computed from the CALLER's permission sets and the CALLER's
85+
* tenant BEFORE the read (see {@link SecurityPlugin.checkAuthoredRowWrite}), so
86+
* a row in another tenant, a row no authored policy matches, and a caller
87+
* holding no authored policy at all are all still `abstain` — measured, not
88+
* asserted (`authored-row-write-verdict.test.ts`, and the real-stack pin
89+
* `packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts`).
90+
*
91+
* Principal-less on purpose: it carries no `userId`, so nothing downstream can
92+
* mistake it for the caller acting with more authority than they hold. It is
93+
* SPREAD at the single call site rather than passed by reference, so no
94+
* middleware can stamp state onto a shared object.
95+
*/
96+
const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const;
97+
98+
/** The body of `SecurityPlugin#checkAuthoredRowWrite`; the method's docblock there is its contract. */
99+
export async function checkAuthoredRowWrite(
100+
this: Pick<AuthoredRowWriteHost, 'collectRLSPolicies' | 'computeLayeredRlsFilter' | 'logger' | 'ql' | 'resolvePermissionSetsForContext'>,
101+
object: string,
102+
recordId: string,
103+
operation: AuthoredRowWriteOperation,
104+
context?: any,
105+
): Promise<AuthoredRowWriteVerdict> {
106+
try {
107+
if (!object || recordId == null || recordId === '') return 'abstain';
108+
if (operation !== 'update' && operation !== 'delete') return 'abstain';
109+
if (!this.ql) return 'abstain';
110+
// No principal, or a delegated identity this path cannot intersect —
111+
// both are "cannot measure", which is `abstain` (never `admit`).
112+
if (!context?.userId) return 'abstain';
113+
if (context?.onBehalfOf?.userId) return 'abstain';
114+
115+
const permissionSets = await this.resolvePermissionSetsForContext(context);
116+
if (permissionSets.length === 0) return 'abstain';
117+
118+
// Cheap provenance pre-check: if the caller holds NO app-authored policy
119+
// applicable to (object, operation), there is nothing that could admit by
120+
// declaration — answer without spending a database round-trip. This is
121+
// the same collection the compiler consumes, filtered by the same
122+
// provenance predicate, so the two cannot disagree about what "authored"
123+
// means.
124+
//
125+
// [#8059] The pre-check asks for an authored policy that ADMITS ROWS —
126+
// one carrying a `using` scope predicate. A `check`-only policy declares
127+
// no row scope at all (ADR-0058 D4: post-image validation), so it can
128+
// admit nothing, and the verdict for a caller holding only check-only
129+
// policies is `abstain` — which is the verdict this method already
130+
// returned before #8059 and must keep returning after it. It got there
131+
// by a route the fix removes: a check-only policy compiles to no `using`
132+
// filter, so Layer 1 was null and the `layer1 == null → abstain` rule
133+
// below fired. Site 1 now DERIVES a Layer 1 from the caller's select
134+
// narrowing in exactly that case, so without this clause the derived
135+
// readable-set scope would start answering `admit` — and `admit` is what
136+
// the by-id widener uses to WIDEN. That is precisely the masquerade the
137+
// doc comment above promises cannot happen (#5493 / #7281), so the
138+
// promise is now kept by an explicit test rather than by the accident of
139+
// a null Layer 1. Nothing narrows here: `abstain` changes nothing on its
140+
// own, and the pre-image gate still refuses on its own terms.
141+
const authored = this.collectRLSPolicies(
142+
permissionSets,
143+
object,
144+
operation,
145+
(context?.positions ?? []) as string[],
146+
context,
147+
).filter((p) => !isPlatformOwnershipFloorPolicy(p) && policyDeclaresClause(p, 'using'));
148+
if (authored.length === 0) return 'abstain';
149+
150+
const { layer0, layer1 } = await this.computeLayeredRlsFilter(
151+
permissionSets,
152+
object,
153+
operation,
154+
context,
155+
{ dropPlatformOwnershipFloor: true },
156+
);
157+
// See the doc above: a null Layer 1 means no authored predicate is
158+
// gating this write, which is an abstention and not an admission.
159+
if (layer1 == null) return 'abstain';
160+
161+
const parts = [{ id: recordId }, ...(layer0 ? [layer0] : []), layer1];
162+
// [#7281] The predicate is the question; the scope is not. Read ELEVATED
163+
// (see AUTHORED_ROW_WRITE_PROBE_CONTEXT) so the answer is decided by
164+
// `{id} AND layer0 AND layer1` alone, and projected to `id` so the probe
165+
// can only ever learn EXISTENCE — no column of a row the caller may not
166+
// read is materialised, let alone returned. The verdict remains evidence,
167+
// never authorization: the by-id write pre-image gate still resolves the
168+
// write under the caller's own context.
169+
const row = await this.ql.findOne(object, {
170+
where: { $and: parts },
171+
fields: ['id'],
172+
context: { ...AUTHORED_ROW_WRITE_PROBE_CONTEXT },
173+
});
174+
return row ? 'admit' : 'abstain';
175+
} catch (e) {
176+
this.logger.warn?.(
177+
`[security] checkAuthoredRowWrite could not resolve an authored-policy verdict for ` +
178+
`'${object}' record '${recordId}' (${operation}, user ${context?.userId ?? 'unknown'}) — ` +
179+
`abstaining (fail-closed: a verdict that cannot be resolved never lifts the sharing refusal)`,
180+
e instanceof Error ? e : new Error(String(e)),
181+
);
182+
return 'abstain';
183+
}
184+
}

0 commit comments

Comments
 (0)