Skip to content

Commit 25f88e8

Browse files
refactor(plugin-security): move the permission-set resolution and addressed-write readers out of security-plugin.ts (#25223 cut 8) (#25406)
<!-- 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 8 of the `security-plugin.ts` split: row 6 of the series plan, the permission-set resolution and the addressed-write readers. The claim is 6363282332. The row has two seams, so the bodies of nine private methods move unedited out of the class to two new modules in this one PR, as cut 6 split its bodies over two modules. To `packages/plugins/plugin-security/src/security-plugin-permission-sets.ts`: - `resolvePermissionSetsForContextUnmemoized`, the resolution itself. It folds the positions into the request (never the guest's), applies the additive human baseline or the agent's restricted ceiling, and runs the post-resolution fallback for an authenticated caller whose positions resolved nothing. - `dropDeactivatedFoldedSets`, the activation ledger's say over a set that reached the resolution only through a position of the same name. - `reportNameFoldCollisions`, the #13419 / #24757 warning for a grant that exists only because a position and a permission set share a name. - `permissionSetMemoKey`, the fields of the context the per-context memo is keyed on (#10757). To `packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts`: - `addressedByIdWriteId` and `addressedPredicateWrite`, which say whether an update or delete is the by-id or the predicate write the caller addressed, read from the engine's own dispatch predicates (#21771, #21829). - `readableRowsScopeForPredicateWrite`, the read door's answer for the caller's own predicate, as the id scope a predicate write is narrowed to (#21829). - `extractSingleId`, the single scalar id an update or delete names. - `readRowById`, the by-id row read the provenance and pre-image gates share, whose `null` means absent and only absent (#7505). It is move-only, and the card stays open for the later cuts. Branch: `claude/project-thread-3fzya4`, the harness-pinned branch for this session, restarted from `origin/main` at `2dfeb1a772`, cut 7's merge. It 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 calls the methods. Seven members of the row stay whole: - `getEffectiveObjectPermissions`, `resolveFallbackPermissionSets`, `declaresControlledByParent`, `callerCannotReadAddressedRow` and `getCallerPreImage`: each body is no longer than its stub would be. - `resolvePermissionSetsForContext`, the memo. Its body retires a rejected entry with `this.permissionSetMemo.delete(context)`. The tenant-audit census places that call by its receiver's declared type, a `WeakMap` the class constructs, and subtracts it as a non-engine call. Read through a host, the receiver has no type the census can read. A trial cut measured it: the census exits 1 (`[untyped-receiver]`) and asks for an `UNTYPED_RECEIVERS` row, in a ledger its own docblock declares shrink-only. So the memo stays. - `describePositionGrants`. Its body calls `this.canReadObject(…)`, and `packages/core/src/security/row-serving-door-exposure.pin.test.ts` classifies every non-test source file that calls the read admission. Moved, the body would make the new module a caller the pin has no row for, and writing that row is a classification, not a move. `extractSingleId` has no docblock of its own. Its docblock is the first of the two that sit above `declaresControlledByParent`'s, and it stays there; the moved function's pointer comment says so. **Module-level declarations.** - Two stay and gain `export`. The modules import both by value, as cuts 3, 5, 6 and 7 import theirs. - `readUnlessRefused` is shared: `absentUnderCallerRead` and `unreadableUnderCallerRead` ask it too. - `POSITION_NAME_FOLD_EVENT`: the `reportNameFoldCollisions` docblock, which stays, names it in place with a `{@link}`. Its own docblock says it is deliberately not exported from the package, and that stays true: `index.ts` exports only `SecurityPlugin`, and the published `.d.ts` does not change (below). - The modules also import four values that are already exported: `isGuestEnvelope` (cut 6), and `PREDICATE_WRITE_READABLE_ID_CAP`, `predicateWriteReadableCapError` and `copyOfCallerPredicate` (cut 3, which `security-plugin-cbp-write-gate.ts` imports from `security-plugin.ts`). Those stay where they are, so that module's import does not move. - The cut 6 `.d.ts` trap was checked before the claim. At cut 6, an export whose signature names another file's type moved a declaration in the published `.d.ts`. A local trial build with the whole cut in place ran first. With both new exports in place, the head `dist/index.d.ts` is byte-identical to base (below). - Import names only the moved bodies read leave `security-plugin.ts`, and the modules import them from the same specifiers: - `MCP_AGENT_PERMISSION_SET_RESTRICTED` from `@objectstack/spec/ai` (that whole import statement goes); - `readDisabledCatalogNames` and `withoutOperationPrivateKeys` from `@objectstack/core`; - `resolveEngineDeleteDispatch` and the type `EngineDeleteDispatchInput` from `@objectstack/metadata-core`. No staying docblock links to any of them. - Each value import is read only inside a function body, at call time, never while any of the three modules evaluates. `index.ts` still exports only `SecurityPlugin`, so the published surface does not move (measured below). **Readers.** No reader outside the moved code reads the moved lines by path, so this PR changes only the three source files (see Readers below). ## The published `.d.ts` does not change Under the series bar (ruling 6348891160 item 1), measured on base `2dfeb1a772` and on the head (the tree of this PR's commit): - Raw sha256 of `dist/index.d.ts` and `dist/index.d.mts`, base and head, all four the same: `af5dee1bebafa0376a876d1e7b247c878ea42ac35c7336a6d3c12a7953143156`. The raw diff is 0 lines, so no union order moved either. - Normalised sha256, all four the same: `431e7f5097ac1c220bc840188aef02105bc0b16e845e976dd562d02cc79be8bd`. - These are the same hashes as cuts 6 and 7 reported. Base and head here are both built against `2dfeb1a772`'s dependencies, rebuilt on that commit first. - `readUnlessRefused` and `POSITION_NAME_FOLD_EVENT` gain `export` in `security-plugin.ts`. A local trial build measured this before the claim, because at cut 6 an export whose signature names another file's type moved a declaration. Here the `.d.ts` did not change by a byte. - The normaliser, run from `packages/plugins/plugin-security` (it 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 on a copy of the head `dist/index.d.ts`, line 1307 (`private resolvePermissionSetsForContextUnmemoized;`): - Made public (`resolvePermissionSetsForContextUnmemoized;`): raw `19c84435e20b905ee3f3d43803961fa4f0475b04f5747e4662731d5c50432b45`, normalised `cb6bcc7126d1a176815c99a2b9ad5a674d7b9f5f7b1c0dd43b47e39c5980f8a6`. - Renamed (`private resolvePermissionSetsForContextUnmemoizedRenamed;`): raw `bbfe2a4f35bc7a2f415852a2bb4ba3f35bd78391557b484e8638dee38b1b4039`, normalised `677731d933ae7a9ddf2e4c65135853065b58506a08d33b62dab1ed2d1a9fb974`. - Both differ from the real hashes, so the comparison does see a visibility change and a rename. - The nine moved methods are private, so the `.d.ts` carries each one's name and docblock only (lines 1261, 1307, 1326, 1448, 1542, 1574, 1600, 1601 and 1643 of both). - Export tables: the ESM and CJS bundles each export the same 66 names at base and head. The lists were loaded at runtime (`require` and `import()` of the built bundles), not parsed. The sorted name list hashes `83576430f83cf60cecbcc5c0e80214f16c626a8e66f700294fc6a44361423116` on all four, as at cuts 6 and 7. The new exports of `security-plugin.ts` do not reach the entry. - The bundle JS bytes differ (`index.js` `cf7a65c1` to `3a109ef0`, `index.mjs` `d4178511` to `1c38ee91`), because code moved within them. One consequence of the removed `@objectstack/spec/ai` import: that import now sits in the new module's segment (`index.mjs` line 9232), so the bundle's first import of `@objectstack/spec/ai` is one it already had, at line 1046 (1047 at base), rather than line 664. `@objectstack/spec` declares `"sideEffects": false`, so no evaluation order a caller can observe moves. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## What moved The base line numbers are each method's signature and closing brace at `2dfeb1a772`. The last column lists the members the body reads, which is exactly what its host carries. | method | base lines | module | host members | |---|---|---|---| | `permissionSetMemoKey` (private) | `:6906`-`:6915` | permission-sets | none: called directly | | `resolvePermissionSetsForContextUnmemoized` (private, async) | `:7003`-`:7085` | permission-sets | `baselinePermissionSets`, `bootstrapPermissionSets`, `dbLoaderForContext`, `dropDeactivatedFoldedSets`, `logger`, `metadata`, `permissionEvaluator`, `reportNameFoldCollisions` | | `dropDeactivatedFoldedSets` (private, async) | `:7105`-`:7122` | permission-sets | `ql` | | `reportNameFoldCollisions` (private) | `:7279`-`:7324` | permission-sets | `logger`, `nameFoldWarned` | | `addressedByIdWriteId` (private) | `:7437`-`:7450` | addressed-writes | none: called directly | | `addressedPredicateWrite` (private) | `:7486`-`:7497` | addressed-writes | none: called directly | | `readableRowsScopeForPredicateWrite` (private, async) | `:7524`-`:7556` | addressed-writes | `ql` | | `extractSingleId` (private) | `:7558`-`:7570` | addressed-writes | none: called directly | | `readRowById` (private, async) | `:7613`-`:7630` | addressed-writes | `ql` | Each async stub drops `async` and returns the moved function's promise, as cuts 1, 3, 5, 6 and 7 did for their async methods. A body that reads no member is called directly, with no host, as cut 1's `extractTargetField` is. All nine methods are private, so the `.d.ts` carries each one's name and docblock only. ## The shape (#23050 shape B, as cuts 1 to 7 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, …)`, or one direct call, marked `[#25223 cut 8]`. - `PermissionSetsHost` and `AddressedWritesHost` are new. Each types every member as the class member itself (`SecurityPlugin['m']`). Each moved function types its `this` as the `Pick` of the 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. A spy or test double set on the plugin is still the one the body reaches: - the resolution still asks the plugin's catalog loader, its folded-set drop and its fold report; - the memo, `describePositionGrants` and every other caller still reach the moved methods through the plugin. No test replaces a moved method on the instance today; one that did would still replace it for every caller. No moved body calls a function-valued field, so no receiver changes: `permissionEvaluator`, `ql`, `metadata` and `logger` are objects whose own methods the bodies call. - Fields follow the rule cut 1 set, on each field's own declaration. - `baselinePermissionSets`, `bootstrapPermissionSets` and `nameFoldWarned` are declared `readonly` and go by value, so the bodies read the plugin's own lists and its own warned set. - `logger`, `metadata`, `permissionEvaluator` and `ql` are not declared `readonly`, so they go as getters, and each read happens when the body makes it. `start()` assigns `logger`, `metadata` and `ql` after construction. - 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 either module, 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, and each of its 217 lines is the base line with two leading spaces removed. Parameters, return types and `async` match. Three negative controls fail the check: - in `resolvePermissionSetsForContextUnmemoized`, `if (!isAgent && context?.userId)` changed to `if (isAgent && context?.userId)`; - in `readableRowsScopeForPredicateWrite`, `PREDICATE_WRITE_READABLE_ID_CAP + 1` changed to `+ 2`; - in `reportNameFoldCollisions`, `removedIn: 'next_major'` changed to `'next_minor'`. - **Class side**: each moved method keeps its docblock and signature, less `async`. Every other class member, 117 named members plus the constructor, is byte-identical to base. That includes the seven staying members above. The class has 127 members at base and at head. - **Top level of `security-plugin.ts`**: 97 of the 103 base top-level statements other than the class are byte-identical to base. The six that differ: - two are the base text with `export ` before the declaration: `readUnlessRefused`, `POSITION_NAME_FOLD_EVENT`; - three imports lose the names listed above: `@objectstack/core` (twice) and `@objectstack/metadata-core`; - the `@objectstack/spec/ai` import is gone. The two new statements are the imports of the new modules. - **git's own view** (`git diff --color-moved=plain --color-moved-ws=allow-indentation-change 2dfeb1a HEAD` over the three source files): of the 227 deleted lines, git sees 218 as moved. The other 9 are the four signatures that lose `async`, the two declarations that gain `export`, and the three import lines that lose names (`@objectstack/core` twice, `@objectstack/metadata-core`). The deleted `@objectstack/spec/ai` import line shows as moved, because the permission-sets module imports it with the same text. Of the 578 added lines, 229 show as moved. The other 349 are the two modules' headers, imports, host interfaces and pointer comments, the nine stubs, the two new imports with their comments, and the edited lines above. - **Size**: 805 changed lines (+578 / -227) over 3 files, under the 3,000-line manual-merge line. `security-plugin.ts` goes from 9,394 to 9,248 lines; `security-plugin-permission-sets.ts` has 281 and `security-plugin-addressed-writes.ts` 216. ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs`: exit 0 at base `2dfeb1a772` and exit 0 at head, both with `2384 anchors across 142 records resolve — 466 symbol (440 declaration, 26 literal), 1890 file-level, 28 cross-repo, 6 exempt, 18 continuation`. CI runs this gate (`lint.yml`), but `dispatch-gates --commands` does not derive it for this change set (#21697), so it is run by hand here. - `pnpm check:adr-anchors`: exit 0 at base and at head, both with 80 anchored files. The decision-letter count on the anchored surface goes from 2005 to 2002. That gate counts `ADR-NNNN Dk` citations in anchored files only, and neither new module has an entry: - 3 citations leave `security-plugin.ts` with the moved bodies, all in `resolvePermissionSetsForContextUnmemoized`: ADR-0138 D3, ADR-0090 D10 and ADR-0090 D5; - none arrives: the two new import comments, the module headers and the pointer comments cite no decision letter. - Each moved citation is still in the tree, in `security-plugin-permission-sets.ts`. - `scripts/adr-anchors/` is unchanged. No paragraph of the `security-plugin.ts` entry (ADR-0055 §1, ADR-0106 D7, ADR-0123 D2, ADR-0138 D1) describes these bodies, so neither new module gets an entry, as the modules of cuts 1, 6 and 7 have none, and none is invented to hold the count. `docs/adr/**` is not touched. - The anchor map covers every ADR symbol anchor naming `security-plugin.ts`: 27 anchors, 18 symbols. It was measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`, which is measurement, not a new gate. It was read on `2dfeb1a772` BEFORE the claim, with the whole cut applied as a local trial: no anchor changed class. Every anchor resolves at head in `security-plugin.ts` with its base class, and neither new module resolves any of them. The table gives each anchor's class at base and 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 · `:665` (top-level) | declaration · `:688` (top-level) | | `#SecurityPlugin` | ADR-0049:49, ADR-0138:248, ADR-0138:265 | declaration · `:1297` (class) | declaration · `:1320` (class) | | `#SecurityPlugin.getMetadataReadableFields` | ADR-0138:153 | declaration · `:6092` (method) | declaration · `:6115` (method) | | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | ADR-0138:427 | declaration · `:7003` (method) | declaration · `:7020` (method) | | `#assertControlledByParentWrite` | ADR-0055:58, ADR-0055:125 | declaration · `:8651` (method) | declaration · `:8505` (method) | | `#assertMasterRowEditable` | ADR-0055:125 | declaration · `:8777` (method) | declaration · `:8631` (method) | | `#collectRLSPolicies` | ADR-0105:93 | declaration · `:8831` (method) | declaration · `:8685` (method) | | `#composeWriteTenantWall` | ADR-0123:4 | declaration · `:7989` (method) | declaration · `:7843` (method) | | `#computeControlledByParentFilter` | ADR-0055:5, ADR-0055:123, ADR-0139:246 | declaration · `:8297` (method) | declaration · `:8151` (method) | | `#computeDelegatedReadFilter` | ADR-0139:223 | declaration · `:5970` (method) | declaration · `:5993` (method) | | `#computeRlsFilter` | ADR-0055:24 | declaration · `:7697` (method) | declaration · `:7551` (method) | | `#getReadFilter` | ADR-0096:181, ADR-0096:182, ADR-0096:183, ADR-0096:199, ADR-0139:222 | declaration · `:5848` (method) | declaration · `:5871` (method) | | `#hasPlatformAdminPosture` | ADR-0099:317 | declaration · `:7724` (method) | declaration · `:7578` (method) | | `#isPrincipalLessContext` | ADR-0138:152 | declaration · `:538` (top-level) | declaration · `:561` (top-level) | | `#manage_metadata` | ADR-0099:68 | literal · `:287` (top-level) | literal · `:310` (top-level) | | `#nav_permission_sets` | ADR-0126:406 | literal · `:1725` (`init`) | literal · `:1748` (`init`) | | `#rlsFilter` | ADR-0055:24 | declaration · `:4299` (`start`) | declaration · `:4322` (`start`) | | `#writeCheckPolicies` | ADR-0066:112 | declaration · `:1192` (top-level) | declaration · `:1215` (top-level) | No anchor gains or loses a resolving site; each line only shifts. ADR-0138:427 `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` resolves on the method, which stays with its docblock; its body moved. The two `export`s this cut adds (`readUnlessRefused`, `POSITION_NAME_FOLD_EVENT`) name no anchored symbol. ## Readers No reader outside the moved code reads the moved lines by path, so no gate, baseline, docs anchor or test moves with this cut. Each kind was read: - **The tenant-audit census.** The moved bodies hold no engine write: `readRowById`'s `ql.findOne` and the predicate write's `ql.find` are reads. The one write-verb call in the row, the memo's `permissionSetMemo.delete(…)`, stays in the class (above). `node scripts/tenant-audit-census.mjs --json` gives the same 238 write call sites at base and head, compared without line numbers and with the new modules mapped back to `security-plugin.ts`: 0 differences, the same totals, the same 174 non-engine subtractions with the same reasons, 0 unresolved, 0 unledgered. Only `scannedSources` moves, 721 to 723: the two modules. `check-tenant-audit-census` and its `--self-test` exit 0 at head. - **The system-context census.** The moved bodies hold no elevation read. `node scripts/isystem-census.mjs --json` gives the same 148 sites at base and head, compared the same way: 0 differences, and no site in either new module. The role counts are the same (`read` 153, `declaration` 38, `key` 438, `other` 9), and so are the 5 non-elevation reads. The scanned file count is 393 at both, because neither module mentions the flag. `check-system-context-census` and its `--self-test` exit 0 at base and at head with the same output. - **`packages/core/src/security/row-serving-door-exposure.pin.test.ts`** classifies every non-test caller of the read admission `canReadObject`. No moved body calls it: `describePositionGrants`, the one row 6 member that does, stays (above). **`operation-private-keys.pin.test.ts`** in the same directory finds every declaration of `withoutOperationPrivateKeys` and the key prefix; the moved body only calls the helper. Run on their own at head: 8 of 8 pass. Open PR #25389 changes those two pins' scan scope (an extension-scoped prefilter), not what either discriminates on, and touches none of this cut's files. - **The #25107 enumeration pin** (`delegated-organization-membership.test.ts`) counts `resolveDelegatorContext(` and `delegatedOrganizationRefusal(` per source file. The moved bodies call neither, so its list of three files is unchanged, and it passes at head. - **Other readers that name the file in code.** Besides the two pins above, the code that names `security-plugin.ts` by path is: the `check-system-context-census.mjs` row for the file (keyed on `start`, which this cut does not touch), the spec liveness ledger's file-level evidence in `field.json`, the file's `scripts/adr-anchors/` entry, and the citation string in `measure-position-name-fold-census.mjs`. None reads text inside the moved bodies. The tree-wide scanners that read every source file are the gates and censuses below, each run at base and head. - **The position-name fold's prose and its two scripts.** Four texts name `security-plugin.ts` as where the fold happens: the `lint.yml` comment on the #13419 gate, the message text of `check-position-name-fold-loaders.mjs`, the comment in `measure-position-name-fold-census.mjs`, which quotes the request line, and the `delegated-admin-gate.ts` docblock, which quotes the fold expression. Each stays true by method: `SecurityPlugin#resolvePermissionSetsForContextUnmemoized` is still in that file, and its body is one call away. None of them is edited. - `check-position-name-fold-loaders.mjs` scans the tree only for references to the HotCRM artifact, which no moved body names. It and its `--self-test` exit 0 at head. - `measure-position-name-fold-census.mjs` scans plugin-security's sources for `export const` string constants, so it now also sees `POSITION_NAME_FOLD_EVENT`, whose value names no position or set. Run at base and at head, its output is byte-identical: it already refuses on its own failing self-test at base. CI does not run it. - **The platform checklist.** `check-platform-checklist` exits 0 at base and head with the same output. No checklist citation names a moved symbol or a local inside a moved body. - **Symbol anchors outside the ADRs.** Every `security-plugin.ts#…` anchor anywhere in the repo was resolved at base and at head: 30 symbols, among them the 27 spec liveness, platform checklist, `system-context.mdx` and spec `conversions/registry.ts` anchors cuts 2 to 7 read. With `symbolSegmentResolution`, each resolves at head with its base class: 0 changed. - **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: - the behaviour tests of the resolution, the fold warning, the activation drop, the addressed-write read question and the store-fault probe (`resolve-permission-sets-for-context.pin.test.ts`, `position-name-fold-warning.test.ts`, `orgless-position-name-fold.test.ts`, `permission-set-active.test.ts`, `explain-read-absent-every-write-verb.test.ts`, `vama-write-path-convergence.test.ts`, `store-fault-fail-closed.test.ts` and the rest). Each drives the plugin; none calls or replaces a moved method directly; - comments and prose naming these methods: `explain-engine.ts` (`addressedByIdWriteId` in `security-plugin.ts`), `security-plugin-cbp-write-gate.ts` (whose header says `readableRowsScopeForPredicateWrite` in `security-plugin.ts` shares its three values; it still does, through this module), and the tests above. - **Line citations**: no `security-plugin.ts:N` citation in the repo points into the moved ranges (base `:6906`-`:7630`). The citations found are the list cuts 2 to 7 read (`docs/launch-readiness.md` `:309`, `packages/objectql/src/engine.ts` `:1614`, `:1690`, `:1736`, spec liveness `:3281` and the rest, and the `L949` of `engine-summary-recompute-context.test.ts`). Each already pointed at an unrelated line before cut 2, so this cut does not make any of them wrong. - **Docs Drift Check**, re-derived with `node scripts/docs-audit/affected-docs.mjs --json 2dfeb1a` (advisory): 0 pages. The derivation reads 29 anchors (22 symbol, 9 literal) across the one changed package, and no page names any of them. This PR edits no page. - **Census gates, run on base `2dfeb1a772` and head and compared:** - Identical output: `check-adr-symbol-anchors`, `check-platform-checklist`, `check-platform-object-tenancy-census`, `check-registry-log-declared`, `check-rest-log-spy-declared`, `check-system-context-census`, `check-tenant-audit-census`, `check:cross-package-test-inputs`, `check:driver-memory-census` and `check:query-options-erasure`. The last reports the same counts at both, and `security-plugin.ts` is not a baseline key. - `check:adr-anchors`: the same 80 anchored files; the decision-letter count goes from 2005 to 2002 (above), and the files carrying citations from 6631 to 6632, the permission-sets module. - Only scanned counts move, each by the two new files: - `check-undeclared-dep-imports`: 3307 to 3309 files, and 2853 to 2857 specifiers. The modules import `@objectstack/spec/security`, `@objectstack/spec/ai`, `@objectstack/core` and `@objectstack/metadata-core`, all declared dependencies. - `check:org-identifier` 3649 to 3651, `check:page-declaration-shape` 3656 to 3659, `check:logger-receiver-detach` 3623 to 3626, `check:refd-timer-probe` 9976 to 9978, `check-plugin-teardown-shape` 9377 to 9379, `check-comment-mask-corpus` 9982 to 9985, `check:closed-ceremony-text` 4548 to 4550, `check:nul-bytes` 11979 to 11981, `check:gitlink-declared` and `check-closing-keyword-parity` 11988 to 11990, `check:error-status-conformance` 3404 to 3406, and the spec package's `check:duration-unit-keys` 3312 to 3314. Where a count moves by 3, the third file is a local build output or generated file that exists only in the head worktree. - `check:doc-authoring`: 1426 to 1428 parsed sources. Its clean-file count goes from 424 to 427 because the head worktree holds three gitignored generated pages that the base worktree does not, as at cuts 6 and 7. CI's checkout does not have them. - `check-position-name-fold-loaders`: 12343 to 12348 files, with the same 8 references (6 test, 2 declared instrument, 0 loader). It walks the working tree. - `check:dts-closure` and `check:sourcemap-no-sources-content` sweep built packages, and the head worktree has more of them built (76 and 72) than the base worktree (19). Both pass at both. `check:dual-build-cjs-loads` and `check:i18n` answer exit 3 at base, a missing-build prerequisite in the base worktree rather than a verdict, and exit 0 at head. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 26 citations across 3 files, and all resolve. ## 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 file outside the package. - Serial check, also in the claim: of the 13 open PRs at claim time, none touched `security-plugin.ts`, the new module names, `explain-engine.ts`, `delegated-admin-gate.ts`, the census scripts, the permissions docs pages or the plugin-security anchor entry. - Since the local runs, `origin/main` gained 1 commit (`830a63a680`, #25402, analytics). It touches none of this cut's files. On a scratch merge of this head onto that main, `check-adr-symbol-anchors`, `check:adr-anchors`, `check-platform-checklist`, `check-system-context-census` and `check-tenant-audit-census` all exit 0; `check:adr-anchors` there counts 80 anchored files and 2002 decision-letter citations. - What is left of the plan's row 6 in `security-plugin.ts` after this cut: the seven members that stay whole (above) and the nine method shells. `security-plugin.ts` has 9,248 lines at head, 9,394 at base. ## Gates All gates ran at the head, the tree of `23b0616bae`, on base `2dfeb1a772`. - `pnpm --filter @objectstack/plugin-security test` (`vitest run --maxWorkers=3`, under the verify lock): Test Files 214 passed (214), Tests 4465 passed, 45 skipped (4510). Base `2dfeb1a772` gives the same counts. VERDICT command-exit 0 on both. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. It runs `tsc --noEmit`, the scripts tsconfig, and `check:test-typecheck`. - `eslint --no-inline-config` over the three touched source files: exit 0. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and bundles measured above. The package's dependencies were rebuilt on `2dfeb1a772` first. - `row-serving-door-exposure.pin.test.ts` and `operation-private-keys.pin.test.ts` (`@objectstack/core`): 8 of 8 pass. - `node scripts/check-adr-symbol-anchors.mjs`, `pnpm check:adr-anchors`, `pnpm check:platform-checklist` (and its script), `check-scripts-symbol-anchors` (and `--self-test`), `pnpm check:query-options-erasure`, and the two census `--json` reads. None of these is derived for this change set; each was run by hand, and each exit is listed below. - `dispatch-gates --commands`: 52 commands. The `--ran` reconciliation shows 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived: all 52 recorded an exit code and none is 3. No docs family is derived, because this cut touches no page. Per command (exit code), the derived first and the hand-run after: - `node scripts/check-ci-filter-parity.mjs` exit 0 - `node scripts/check-closing-keyword-parity.mjs` exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` exit 0 - `node scripts/check-comment-mask-adoption.mjs` exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` exit 0 - `node scripts/check-comment-mask-corpus.mjs` exit 0 - `node scripts/check-dts-emitted.mjs --self-test` exit 0 - `node scripts/check-issue-citations.mjs` exit 0 - `node scripts/check-keyed-text-bounds.mjs` exit 0 - `node scripts/check-keyed-text-bounds.mjs --self-test` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs --self-test` exit 0 - `node scripts/check-plugin-teardown-shape.mjs` exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 - `node scripts/check-position-name-fold-loaders.mjs` exit 0 - `node scripts/check-position-name-fold-loaders.mjs --self-test` exit 0 - `node scripts/check-registry-log-declared.mjs` exit 0 - `node scripts/check-registry-log-declared.mjs --self-test` exit 0 - `node scripts/check-rest-log-spy-declared.mjs` exit 0 - `node scripts/check-rest-log-spy-declared.mjs --self-test` exit 0 - `node scripts/check-system-context-census.mjs` exit 0 - `node scripts/check-system-context-census.mjs --self-test` exit 0 - `node scripts/check-tenant-audit-census.mjs` exit 0 - `node scripts/check-tenant-audit-census.mjs --self-test` exit 0 - `node scripts/check-undeclared-dep-imports.mjs` exit 0 - `node scripts/check-undeclared-dep-imports.mjs --self-test` exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` exit 0 - `pnpm check:closed-ceremony-text` exit 0 - `pnpm check:cross-package-test-inputs` exit 0 - `pnpm check:doc-authoring` exit 0 - `pnpm check:driver-memory-census` exit 0 - `pnpm check:dts-closure` exit 0 - `pnpm check:dual-build-cjs-loads` exit 0 - `pnpm check:error-status-conformance` exit 0 - `pnpm check:gitlink-declared` exit 0 - `pnpm check:i18n` exit 0 - `pnpm check:i18n-stale-fill` exit 0 - `pnpm check:issue-citations` exit 0 - `pnpm check:lean-entry-closure` exit 0 - `pnpm check:logger-receiver-detach` exit 0 - `pnpm check:nul-bytes` exit 0 - `pnpm check:org-identifier` exit 0 - `pnpm check:page-declaration-shape` exit 0 - `pnpm check:published-files` exit 0 - `pnpm check:refd-timer-probe` exit 0 - `pnpm check:slot-lookup` exit 0 - `pnpm check:sourcemap-no-sources-content` exit 0 - `pnpm check:test-source-alias` exit 0 - `pnpm check:tier-file-adoption` exit 0 - `pnpm check:watch-hint-literal` exit 0 - `node scripts/check-adr-symbol-anchors.mjs` exit 0 - `pnpm check:adr-anchors` exit 0 - `node scripts/check-platform-checklist.mjs` exit 0 - `pnpm check:platform-checklist` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` exit 0 - `pnpm check:query-options-erasure` exit 0 - `node scripts/isystem-census.mjs --json` exit 0 - `node scripts/tenant-audit-census.mjs --json` exit 0 --- _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 925fd07 commit 25f88e8

3 files changed

Lines changed: 578 additions & 227 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 8] The addressed-write readers of `SecurityPlugin`, moved out of
5+
* `security-plugin.ts`:
6+
*
7+
* - `addressedByIdWriteId` and `addressedPredicateWrite`, which say whether an
8+
* UPDATE or DELETE is the by-id or the predicate write the caller addressed,
9+
* read from the engine's own dispatch predicates (#21771, #21829).
10+
* - `readableRowsScopeForPredicateWrite`, the read door's answer for the
11+
* caller's own predicate, as the id scope a predicate write's matched set is
12+
* narrowed to (#21829).
13+
* - `extractSingleId`, the single scalar id an update or delete names.
14+
* - `readRowById`, the by-id row read every provenance and pre-image gate
15+
* shares, whose `null` means absent and only absent (#7505).
16+
*
17+
* A MOVE, not a redesign. Each function below is the body of the
18+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
19+
* and otherwise unedited: the same exclusions, the same engine dispatch
20+
* verdicts, the same read under the caller's context with the same cap and
21+
* the same refusal above it, the same fail-closed errors when no engine is
22+
* wired, and a store fault still propagates as raised.
23+
*
24+
* ## How the moved bodies reach the class
25+
*
26+
* The shape cuts 1 to 7 used (`security-plugin-object-inputs.ts`, whose header
27+
* carries the reasoning). The class keeps all five private methods — the
28+
* published `dist/*.d.ts` lists each one's name, docblock and position. Three
29+
* of the bodies read no member (`addressedByIdWriteId`,
30+
* `addressedPredicateWrite`, `extractSingleId`), so their methods call their
31+
* functions directly, as cut 1's `extractTargetField` does. The other two read
32+
* `ql` alone, and each method's body is a literal of that one
33+
* {@link AddressedWritesHost} member plus a `.call`. Every caller still reaches
34+
* these methods through the plugin, so a test double or spy set on the plugin
35+
* is still the one the gates reach. Two of the methods were async and their
36+
* functions still are; each of those methods now returns its function's
37+
* promise.
38+
*
39+
* `ql` is not declared `readonly` (`start()` assigns it after construction),
40+
* so it goes as a GETTER, by the rule cut 1 set, and each read happens when the
41+
* body makes it.
42+
*
43+
* ## What stays in `security-plugin.ts`, and how it is read
44+
*
45+
* Three members of the same family stay in the class whole, because each body
46+
* is no longer than its stub would be: `declaresControlledByParent`,
47+
* `callerCannotReadAddressedRow` and `getCallerPreImage`. `extractSingleId`
48+
* has no docblock of its own: its docblock is the first of the two that sit
49+
* above `declaresControlledByParent`'s, and it stays there.
50+
*
51+
* Four module-level values the predicate write's read question reads stay
52+
* there too, and are imported here by value. `readUnlessRefused`, the
53+
* three-way classification of a caller-context read, is exported for this
54+
* module: `absentUnderCallerRead` and `unreadableUnderCallerRead` there ask it
55+
* too. `PREDICATE_WRITE_READABLE_ID_CAP`, `predicateWriteReadableCapError` and
56+
* `copyOfCallerPredicate` were exported by cut 3, for the predicate-write
57+
* master set in `security-plugin-cbp-write-gate.ts`, which imports them from
58+
* there. Each is read only inside a function body, at call time, never while
59+
* either module evaluates, so the two-way import is safe under ESM, and the
60+
* published bundle holds one module.
61+
*
62+
* The imports only these bodies read moved here: the engine's delete dispatch
63+
* predicate and its input type from `@objectstack/metadata-core`, and the
64+
* operation-private key strip from `@objectstack/core`. The update dispatch
65+
* predicate and its data type are imported from `@objectstack/metadata-core`,
66+
* as `security-plugin.ts` imports them.
67+
*
68+
* Nothing re-exports this module, so the published surface does not move.
69+
*/
70+
71+
import type { SecurityPlugin } from './security-plugin.js';
72+
import {
73+
copyOfCallerPredicate,
74+
PREDICATE_WRITE_READABLE_ID_CAP,
75+
predicateWriteReadableCapError,
76+
readUnlessRefused,
77+
} from './security-plugin.js';
78+
import { withoutOperationPrivateKeys } from '@objectstack/core';
79+
import {
80+
resolveEngineDeleteDispatch,
81+
resolveEngineUpdateDispatch,
82+
type EngineDeleteDispatchInput,
83+
type EngineUpdateDispatchData,
84+
} from '@objectstack/metadata-core';
85+
86+
/**
87+
* The `SecurityPlugin` member the functions below read — the one, and no
88+
* other; each function's `this` is the `Pick` of the members IT reads. It is
89+
* typed AS the class member (an indexed access on the class), so a change to
90+
* its signature on the class is a change here too. See the module header for
91+
* why it is a getter.
92+
*/
93+
export interface AddressedWritesHost {
94+
readonly ql: SecurityPlugin['ql'];
95+
}
96+
97+
/** The body of `SecurityPlugin#addressedByIdWriteId`; the method's docblock there is its contract. */
98+
export function addressedByIdWriteId(
99+
opCtx: any,
100+
nested: boolean,
101+
referentialFieldClear: boolean,
102+
): unknown {
103+
if (nested || referentialFieldClear) return undefined;
104+
if (opCtx.operation === 'update') {
105+
const data = opCtx.data;
106+
if (data == null || typeof data !== 'object') return undefined;
107+
const route = resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options);
108+
return route.kind === 'by-id' ? route.id : undefined;
109+
}
110+
if (opCtx.operation === 'delete') {
111+
const route = resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined);
112+
return route.kind === 'by-id' ? route.id : undefined;
113+
}
114+
return undefined;
115+
}
116+
117+
/** The body of `SecurityPlugin#addressedPredicateWrite`; the method's docblock there is its contract. */
118+
export function addressedPredicateWrite(
119+
opCtx: any,
120+
nested: boolean,
121+
referentialFieldClear: boolean,
122+
): boolean {
123+
if (nested || referentialFieldClear) return false;
124+
if (opCtx.operation === 'update') {
125+
const data = opCtx.data;
126+
if (data == null || typeof data !== 'object') return false;
127+
return resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options).kind === 'multi';
128+
}
129+
if (opCtx.operation === 'delete') {
130+
return resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined).kind === 'multi';
131+
}
132+
return false;
133+
}
134+
135+
/** The body of `SecurityPlugin#readableRowsScopeForPredicateWrite`; the method's docblock there is its contract. */
136+
export async function readableRowsScopeForPredicateWrite(
137+
this: Pick<AddressedWritesHost, 'ql'>,
138+
opCtx: any,
139+
): Promise<Record<string, unknown> | null> {
140+
if (typeof this.ql?.find !== 'function') {
141+
// The read question cannot be asked, and a write that cannot be narrowed
142+
// must not run un-narrowed. Unreachable in a real deployment — `start()`
143+
// registers no security middleware at all without a query engine — but a
144+
// non-conforming engine double fails closed rather than writing wide.
145+
throw new Error(
146+
`[Security] Cannot narrow the predicate ${opCtx.operation} on '${opCtx.object}' to the rows the ` +
147+
`caller can read: no query engine is available to ask.`,
148+
);
149+
}
150+
const callerWhere = (opCtx.options as { where?: unknown } | undefined)?.where;
151+
const answer = await readUnlessRefused(() =>
152+
this.ql.find(opCtx.object, {
153+
...(callerWhere != null ? { where: copyOfCallerPredicate(callerWhere) } : {}),
154+
fields: ['id'],
155+
limit: PREDICATE_WRITE_READABLE_ID_CAP + 1,
156+
// [#7145] The caller's envelope minus the operation-private keys: the
157+
// write's own stamps (`__writeScope`, …) are not inputs to a read, and
158+
// a copy keeps the read's stamps off the write's context.
159+
context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record<string, unknown>),
160+
}),
161+
);
162+
if (answer.refused) return null;
163+
const rows: unknown[] = Array.isArray(answer.value) ? answer.value : [];
164+
if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) {
165+
throw predicateWriteReadableCapError(opCtx.object, opCtx.operation);
166+
}
167+
const ids = rows
168+
.map((r) => (r && typeof r === 'object' ? (r as { id?: unknown }).id : undefined))
169+
.filter((id) => id !== undefined && id !== null);
170+
return { id: { $in: ids } };
171+
}
172+
173+
/**
174+
* The body of `SecurityPlugin#extractSingleId`. Its contract is the docblock
175+
* there that sits above `declaresControlledByParent`'s.
176+
*/
177+
export function extractSingleId(
178+
opCtx: any,
179+
): string | number | bigint | null {
180+
const isScalar = (v: unknown): v is string | number | bigint =>
181+
v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint');
182+
const data = opCtx?.data;
183+
if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) {
184+
return data.id;
185+
}
186+
const where = opCtx?.options?.where;
187+
if (where && typeof where === 'object' && 'id' in where && isScalar((where as any).id)) {
188+
return (where as any).id;
189+
}
190+
return null;
191+
}
192+
193+
/** The body of `SecurityPlugin#readRowById`; the method's docblock there is its contract. */
194+
export async function readRowById(
195+
this: Pick<AddressedWritesHost, 'ql'>,
196+
object: string,
197+
id: unknown,
198+
context: any,
199+
): Promise<Record<string, unknown> | null> {
200+
// Not a read at all: there is no id to look one up by. Every call site
201+
// already screens this; the guard is defence, and `null` is honest here
202+
// because nothing was ever asked of the store.
203+
if (id == null) return null;
204+
if (typeof this.ql?.findOne !== 'function') {
205+
// The third collapsed fact, and it is not absence either: the probe
206+
// could not run. Unreachable in a real deployment — `start()` registers
207+
// no security middleware at all without a query engine, so no gate can
208+
// reach this line — but a non-conforming engine double must fail closed
209+
// rather than manufacture a 404 for a row nobody looked for.
210+
throw new Error(
211+
`[Security] Cannot verify record '${String(id)}' on '${object}': no query engine is available to read it.`,
212+
);
213+
}
214+
const row = await this.ql.findOne(object, { where: { id }, context });
215+
return row && typeof row === 'object' ? (row as Record<string, unknown>) : null;
216+
}

0 commit comments

Comments
 (0)