Skip to content

Commit 21a25dd

Browse files
refactor(plugin-security): move SecurityPlugin's controlled-by-parent master relation into security-plugin-cbp-relation.ts (#25223 cut 2) (#25310)
<!-- 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 2 of the `security-plugin.ts` split, as ruled in 6353073966 (letter A, narrowed). The bodies of `resolveCbpRelation` and its two loud reports, `reportCbpMasterUnresolved` and `reportRejectedReferenceAlias`, move unedited to the new module `packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts`. Together they are the ADR-0055 §1 derivation of the one master a `controlled_by_parent` object takes its access from. The one module-level declaration only those bodies read, `masterNotWritableRefusal`, moves with them, and so do the two imports only it used. It is move-only, and the card stays open for the later cuts. The amended claim is 6353132374. It supersedes the five-body claim 6352049267, whose head made `check-adr-symbol-anchors` exit 1. Branch: `claude/project-thread-3fzya4`, the harness-pinned branch for this session. It names no issue, so the claim comment is the identity record. The branch carries the withdrawn five-body commit and then the narrowing commit, and the PR's diff against its merge base `5801a4f2c` is the narrowed cut alone. Two bodies stay in `security-plugin.ts` byte for byte, by the ruling's item 1: the read leaf `computeControlledByParentFilter` and its chain refusal `controlledByParentChainRefusal`. So do `CBP_LEAF_READ_VERBS`, `CBP_MAX_CHAIN_DEPTH`, `CbpRelation` and every import they read. ADR-0055:26 `#sharingModel` and ADR-0055:58 `#controlled_by_parent` resolve only on tokens inside those two bodies. They move only after a separate Tier H docs PR repoints those anchors (ruling items 3 and 4). ## The published `.d.ts` does not change Under the series bar (ruling 6348891160 item 1), measured on base `5801a4f2` and on the narrowed head (the tree of the commit this PR adds): - Raw sha256 of `dist/index.d.ts` and `dist/index.d.mts`, base and head, all four the same: `803f5bfb7da0b00f8cd8d8f25172463d8dfb07d93fbdd8a9c6f4d4b7435e429a`. The raw diff is 0 lines, so no union order moved either. - Normalised sha256, all four the same: `b6b5c1b7fa6fa188f6e2f7e4cca3816a246b570f20d708643d116012aa86fa77`. - 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 1870 (`private resolveCbpRelation;`): - Made public (`resolveCbpRelation;`): raw `1ff696eaab36c50ffffb459dc4a323a7b32fea15a9bd06d8cac43967ec92ac24`, normalised `41486774e365f562d279585cb6b64e282b783b7ae822bb19ef41d434a89d7a12`. - Renamed (`private resolveCbpRelationRenamed;`): raw `307edbdd465438d1ab2c5bcbf12d10854c3f8a4179ab90b49d065282ba6ec2ec`, normalised `5cb31db9affddd093d0068889eaef49ed684a9ab499ce213a3a77d9ebcd159ce`. - Both differ from the real hashes, so the comparison does see a visibility change and a rename. - Export tables: the ESM and CJS bundles each export the same 63 names at base and head. The lists were loaded at runtime, not parsed. The sorted name list hashes `f607fd765d6b277e08b3b46ccb16d1e2d133ec55658ffe40155aef25e1bf54f0` on all four. - The bundle JS bytes differ (`index.js` `68f4e3a8` to `6d2a398f`, `index.mjs` `1f24e6d5` to `953c5d9a`), because code moved within them. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## What moved Base line numbers are each method's signature at `5801a4f2`. The last column lists the members the body reads, which is exactly what its host carries. | method | base lines | moved function async | host members | |---|---|---|---| | `resolveCbpRelation` | `:8884`-`:8985` | no | `cbpRelCache`, `ql`, `reportCbpMasterUnresolved`, `reportRejectedReferenceAlias` | | `reportCbpMasterUnresolved` | `:9007`-`:9042` | no | `logger` | | `reportRejectedReferenceAlias` | `:9094`-`:9114` | no | `logger` | `masterNotWritableRefusal` (base `:727`-`:737`, with its docblock) moves too; `resolveCbpRelation` is its only reader. So do the two imports only it reads, `controlledByParentMasterRefusal` (with its two-line comment) and `OWD_DELEGATING_ROW_WRITES`. ## The shape (#23050 shape B, as cut 1 applied it) - The class keeps each private method with its name, signature, docblock and position, because tsup emits private members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25223 cut 2]`. - `CbpRelationHost` 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 each stub annotates its literal with the same `Pick`. There is no cast, and no member becomes public or protected. - The module imports `SecurityPlugin` type-only, so there is no runtime import cycle, and `security-plugin.ts` gains no export. `CbpRelation` is named in the module through the class's own signature (an alias over the non-null return type of `SecurityPlugin['resolveCbpRelation']`), so the interface keeps its one declaration beside `cbpRelCache` and the consumers that read it. - The two reports 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 derivation reaches. No moved body calls a function-valued field, so no receiver changes. - Fields: `ql`, `logger` and `cbpRelCache` go as getters, because none is declared `readonly`. `start()` assigns `ql` and `logger` after construction, and the metadata watch clears `cbpRelCache` in place. The host declares every field `readonly`, so a moved body cannot assign a member through it; the typecheck passes, so none does. - All three methods are synchronous, so no stub changes `async`. Nothing re-exports the module, so the package entry does not move. ## Proof of move-only - **Bodies, by AST token**: for all 3 methods, the moved function's body has the same TypeScript token sequence as the base method body, and each of its 144 lines is the base line with two leading spaces removed. Parameters, return types and `async` match. A negative control, `candidates.length === 1` changed to `=== 2` in `resolveCbpRelation`, fails the check. - **Class side**: each method keeps its docblock and signature. `masterNotWritableRefusal` is verbatim (11 lines) in the module and absent from `security-plugin.ts`. `computeControlledByParentFilter`, `controlledByParentChainRefusal` (docblock to closing brace), `CBP_LEAF_READ_VERBS` and `CBP_MAX_CHAIN_DEPTH` (docblock included) are byte-identical to base. - **git's own view** (`git diff --color-moved=plain --color-moved-ws=allow-indentation-change 5801a4f HEAD` over the two source files): 158 of 159 deleted lines and 178 of 283 added lines are detected as moved. - Deleted, not moved (1): `OWD_DELEGATING_ROW_WRITES,`, taken out of a multi-name import. It is re-imported in the module beside `masterGovernsRowWrites`. - Added, not moved: the stub bodies and the import of the new module with its comment. In the module: the license header, the module docblock, the imports, the `CbpRelation` alias, `CbpRelationHost`, one pointer docblock per function and the rewritten signature heads. The rest are lines git does not pair with a moved block, such as blanks and closing braces. - **Size**: 449 changed lines (+290 / -159, 3 files), under the 3000-line human-merge threshold. `security-plugin.ts` goes from 10,952 to 10,823 lines. ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs`: exit 0 at base `5801a4f2` and exit 0 at head, both with `2383 anchors across 142 records resolve — 467 symbol (440 declaration, 27 literal), 1888 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, so it is run by hand here. - `pnpm check:adr-anchors`: exit 0, 76 anchored files (75 at base, plus the new entry). The new entry is `scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-cbp-relation.ts.json`, carrying ADR-0055 and the §1 invariant paragraph of the `security-plugin.ts` entry, verbatim. That paragraph describes the master derivation, which now lives in the new module. The `security-plugin.ts` entry is unchanged, because the methods, their docblocks and both consumers of the derivation remain in that file. `docs/adr/**` is not touched. - The anchor map covers every ADR symbol anchor naming `security-plugin.ts`: 28 anchors, 20 symbols, 10 ADRs. It was measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`, which is measurement, not a new gate. Base lines are at `5801a4f2`. Every anchor resolves at head in `security-plugin.ts` with its base class, and none moves to the new module. The table gives each anchor's class at base and head, the code lines it resolves on, and the member each line sits in. The member is shown after the line number. Every anchor stays in `security-plugin.ts`. | anchor | cited at | base class · lines (member) | head class · lines (member) | |---|---|---|---| | `#CBP_MAX_CHAIN_DEPTH` | ADR-0055:135 | declaration · `:630` (top-level) | declaration · `:635` (top-level) | | `#SecurityPlugin` | ADR-0049:49, ADR-0138:248, ADR-0138:265 | declaration · `:1384` (class) | declaration · `:1377` (class) | | `#SecurityPlugin.getMetadataReadableFields` | ADR-0138:153 | declaration · `:6126` (method) | declaration · `:6119` (method) | | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | ADR-0138:427 | declaration · `:7037` (method) | declaration · `:7030` (method) | | `#assertControlledByParentWrite` | ADR-0055:125 | declaration · `:9654` (method) | declaration · `:9525` (method) | | `#assertMasterRowEditable` | ADR-0055:125 | declaration · `:10132` (method) | declaration · `:10003` (method) | | `#collectRLSPolicies` | ADR-0105:93 | declaration · `:10389` (method) | declaration · `:10260` (method) | | `#computeControlledByParentFilter` | ADR-0055:5, ADR-0055:123, ADR-0139:246 | declaration · `:9200` (method) | declaration · `:9071` (method) | | `#computeDelegatedReadFilter` | ADR-0139:223 | declaration · `:6004` (method) | declaration · `:5997` (method) | | `#computeRlsFilter` | ADR-0055:24 | declaration · `:5256` (`explainAccessForCaller`), `:7659` (method) | declaration · `:5249`, `:7652` | | `#controlled_by_parent` | ADR-0055:58 | literal · `:9035` (`reportCbpMasterUnresolved`), `:9209` (`computeControlledByParentFilter`), `:9275` (`controlledByParentChainRefusal`) | literal · `:9080` (`computeControlledByParentFilter`), `:9146` (`controlledByParentChainRefusal`) | | `#getReadFilter` | ADR-0096:181, :182, :183, :199, ADR-0139:222 | declaration · `:2296` (`start`), `:5882` (method) | declaration · `:2289`, `:5875` | | `#hasPlatformAdminPosture` | ADR-0099:317 | declaration · `:7686` (method) | declaration · `:7679` (method) | | `#isPrincipalLessContext` | ADR-0138:152 | declaration · `:484` (top-level) | declaration · `:489` (top-level) | | `#manage_metadata` | ADR-0099:68 | literal · `:233` (`PLATFORM_ADMIN_ONLY_CAPABILITIES`) | literal · `:238` | | `#nav_permission_sets` | ADR-0126:363 | literal · `:1789` (`init`) | literal · `:1782` | | `#refuseNoActiveOrganization` | ADR-0123:4 | declaration · `:8499` (local in `composeWriteTenantWall`) | declaration · `:8492` | | `#rlsFilter` | ADR-0055:24 | declaration · `:4359` (local in `start`) | declaration · `:4352` | | `#sharingModel` | ADR-0055:26 | declaration · `:9208` (local in `computeControlledByParentFilter`) | declaration · `:9079` | | `#writeCheckPolicies` | ADR-0066:112 | declaration · `:1279` (top-level) | declaration · `:1272` (top-level) | Only one resolving site moves: `#controlled_by_parent` at base `:9035`. It is the quoted token inside `reportCbpMasterUnresolved`'s message template, and it goes to the new module with that body, where it is still a literal. The anchor names `security-plugin.ts`, and there it keeps its other two sites. That is ruling 6353073966 premise 6, read on this cut's base. ## Readers No test, script, gate, baseline or docs anchor reads the moved lines by path, so nothing else is re-pointed. - The other symbol-anchor gates all exit 0: `check-scripts-symbol-anchors`, `check-spec-docblock-symbol-anchors`, `pnpm --filter @objectstack/spec check:liveness`, and `check-platform-checklist --changed-from origin/main`. The last of these judged 6 anchors and lost 0. - Line citations: no `security-plugin.ts:N` citation in the repo points into a moved range. The moved ranges are base `:727`-`:738`, `:8884`-`:8985`, `:9007`-`:9042` and `:9094`-`:9114`. - Head line numbers shift by -3 to +6 for base lines `:16`-`:726` (the import block changed) and by -7 for base `:739`-`:8883`. - The citations `:132`, `:303`, `:309`, `:1614`, `:1690`, `:1736`, `:3215`, `:3281`, `:3519` and `:4344` all land in those shifted ranges. They are in `CHANGELOG.md`, `packages/objectql/CHANGELOG.md`, `docs/launch-readiness.md`, `docs/design/predicate-compilation-convergence.md`, `packages/objectql/src/engine.ts` and `packages/spec/liveness/object.json`. - At base, every one of them already points at an unrelated line. For example, `:1614` is a middleware docblock, not the system bypass, and `:309` is `interface WriteWallRefusal`. So this cut does not make any of them wrong. - Census gates, run on base and head and compared: - Identical output: `check-platform-object-tenancy-census`, `check-position-name-fold-loaders`, `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 62 unswept sites in 16 files at both base and head, and `security-plugin.ts` is not a baseline key. - Only the scanned counts move, by the one new file: - `check-undeclared-dep-imports`: 3286 to 3287 files, 2824 specifiers at both. - `check:doc-authoring`: 1410 to 1411 sources. - `check:org-identifier`: 3628 to 3629 files. - `check:logger-receiver-detach`: 3602 to 3604 files. The second extra file is `apps/docs/.next/types/validator.ts`, a gitignored local build output of the docs app. It is not in the tree CI checks out. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 12 citations across 2 files, and all resolve. ## Acceptance notes - Nothing outside this cut is touched: no other section of the class, no behaviour, no other lane's file, and no `docs/adr/**`. - Out of scope, noted for the seat per ruling 6353073966 items 3 and 7, and filed by the seat, not here: - The three ADR anchors that resolve only on tokens inside a body: ADR-0055:26 `#sharingModel`, ADR-0055:58 `#controlled_by_parent` and ADR-0123:4 `#refuseNoActiveOrganization`. They need a docs-only Tier H card. - A tooling finding for `domain:cli`: `dispatch-gates --commands` does not derive `check-adr-symbol-anchors` for this change set. ## Gates All gates ran at the narrowed head, the tree of `4c92fe8165`, on base `5801a4f2`. Since that base, `origin/main` moved to `09e07c259a`, but nothing under `packages/plugins/plugin-security/`, `docs/adr/` or `scripts/adr-anchors/` changed. - `pnpm --filter @objectstack/plugin-security test` (`vitest run --maxWorkers=3`, under the verify lock): Test Files 210 passed (210), Tests 4403 passed, 45 skipped (4448). Base `5801a4f2` 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`, whose debt ledger holds 0 files / 0 errors. - `eslint --no-inline-config` over the two touched source files: exit 0. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and bundles measured above. - `node scripts/check-adr-symbol-anchors.mjs` exit 0, `pnpm check:query-options-erasure` exit 0, plus the three symbol-anchor gates under Readers. None of these is derived for this change set; each was run by hand. - `dispatch-gates --commands`: 62 commands, 449 changed lines, under the threshold. The `--ran` reconciliation shows 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived: all 62 recorded an exit code and none is 3. Per command (exit code): - `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-scripts-symbol-anchors.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.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:adr-anchors` exit 0 - `pnpm check:agent-test-spelling` exit 0 - `pnpm check:bash32-floor` exit 0 - `pnpm check:cli-command-ids` 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:entry-guard` 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:parse-guard` exit 0 - `pnpm check:pnpm-filter-targets` exit 0 - `pnpm check:pnpm-flag-spellings` 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 --- _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 db1c7cc commit 21a25dd

3 files changed

Lines changed: 290 additions & 159 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 2] The controlled-by-parent master relation of `SecurityPlugin`,
5+
* moved out of `security-plugin.ts` whole: the ADR-0055 §1 derivation of the
6+
* one master a `controlled_by_parent` object takes its access from, with its
7+
* two loud reports (the unresolved master and the tolerated reference alias).
8+
*
9+
* A MOVE, not a redesign. Each function below is the body of the
10+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
11+
* and otherwise unedited: the same derivation, the same cache, the same reports,
12+
* failing closed exactly where they did. The one module-level declaration only
13+
* these bodies read moved with them, also unedited: `masterNotWritableRefusal`,
14+
* with the two imports below that `security-plugin.ts` no longer needs. Both
15+
* consumers of the relation stay in `security-plugin.ts` and read it through
16+
* the class's `resolveCbpRelation`, as before: the read leaf
17+
* (`computeControlledByParentFilter`, ADR-0139) with its chain refusal, and the
18+
* master-write gate.
19+
*
20+
* ## How the moved bodies reach the class
21+
*
22+
* The shape cut 1 used (`security-plugin-object-inputs.ts`, whose header
23+
* carries the reasoning). The class keeps every method — the published
24+
* `dist/*.d.ts` lists each one's name, docblock and position — and each body is
25+
* one literal of exactly the {@link CbpRelationHost} members that method reads,
26+
* plus a `.call`. A method is handed over as a closure that calls it on the
27+
* plugin at call time, so a test double or spy set on the plugin is still the
28+
* one the body reaches, and the derivation reaches its two reports through the
29+
* plugin, as before. Every function here is synchronous.
30+
*
31+
* Every field the bodies read goes as a GETTER, by the rule cut 1 set: none of
32+
* the three is declared `readonly`. `start()` assigns `ql` and `logger` after
33+
* construction, and `cbpRelCache` is a plain field the metadata watch clears in
34+
* place.
35+
*
36+
* `CbpRelation` keeps its one declaration in `security-plugin.ts`, beside the
37+
* cache that holds it and the consumers that read it; this module names it
38+
* through the class's own signature (below). The import of `SecurityPlugin` is
39+
* type-only, so there is no runtime import cycle, and nothing re-exports this
40+
* module, so the published surface does not move.
41+
*/
42+
43+
import type { SecurityPlugin } from './security-plugin.js';
44+
// [ADR-0055 §1] The parse's own one-master judgement, asked of an object that
45+
// reached the registry without the parse (see `resolveCbpRelation`).
46+
import { controlledByParentMasterRefusal } from '@objectstack/spec/data';
47+
import { masterGovernsRowWrites, OWD_DELEGATING_ROW_WRITES } from './platform-ownership-policies.js';
48+
49+
/**
50+
* [ADR-0055 §1] {@link CbpRelation.masterNotWritable} for a resolved relation:
51+
* the parse's refusal of its one master reference, judged on that one def,
52+
* else `null`. `controlledByParentMasterRefusal` reads `readonly === true` and
53+
* `system === true` on it, so the flags and the sentence are spelled once, in
54+
* the parse, and never re-derived here.
55+
*/
56+
function masterNotWritableRefusal(object: string, fk: string, def: unknown): string | null {
57+
const refusal = controlledByParentMasterRefusal(object, OWD_DELEGATING_ROW_WRITES, { [fk]: def });
58+
return refusal?.shape === 'master-not-writable' ? refusal.message : null;
59+
}
60+
61+
/**
62+
* A `controlled_by_parent` object's resolved master relation, as the class
63+
* declares it: the `CbpRelation` interface stays in `security-plugin.ts`
64+
* beside `cbpRelCache` and the consumers that read it, and the moved bodies
65+
* name it exactly as they did.
66+
*/
67+
type CbpRelation = NonNullable<ReturnType<SecurityPlugin['resolveCbpRelation']>>;
68+
69+
/**
70+
* The `SecurityPlugin` members the functions below read — every one of them,
71+
* and no other; each function's `this` is the `Pick` of the members IT reads.
72+
* Each is typed AS the class member (an indexed access on the class), so a
73+
* change to a member's signature on the class is a change here too, and naming
74+
* a member the class does not have is a compile error. Every field here is a
75+
* getter on the host; see the module header.
76+
*/
77+
export interface CbpRelationHost {
78+
readonly cbpRelCache: SecurityPlugin['cbpRelCache'];
79+
readonly logger: SecurityPlugin['logger'];
80+
readonly ql: SecurityPlugin['ql'];
81+
reportCbpMasterUnresolved: SecurityPlugin['reportCbpMasterUnresolved'];
82+
reportRejectedReferenceAlias: SecurityPlugin['reportRejectedReferenceAlias'];
83+
}
84+
85+
/** The body of `SecurityPlugin#resolveCbpRelation`; the method's docblock there is its contract. */
86+
export function resolveCbpRelation(
87+
this: Pick<CbpRelationHost, 'cbpRelCache' | 'ql' | 'reportCbpMasterUnresolved' | 'reportRejectedReferenceAlias'>,
88+
object: string,
89+
): CbpRelation | null {
90+
if (this.cbpRelCache.has(object)) return this.cbpRelCache.get(object) ?? null;
91+
let rel: CbpRelation | null = null;
92+
const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null;
93+
const fields = schema?.fields;
94+
const entries: Array<[string, any]> = Array.isArray(fields)
95+
? fields.map((f: any) => [f?.name, f] as [string, any])
96+
: fields && typeof fields === 'object'
97+
? (Object.entries(fields) as Array<[string, any]>)
98+
: [];
99+
// ── The reference spelling: TOLERANT HERE, and deliberately so ───────────
100+
//
101+
// `@objectstack/spec` declares `reference` as the ONLY relationship
102+
// spelling, and `FieldSchema` REJECTS `reference_to` / `referenceTo` with
103+
// *"Did you mean `reference_to` → `reference`?"* (#11567, "one key, one
104+
// answer"). Two sibling readers were narrowed to canonical-only on that
105+
// ruling — `refOf` in `packages/lint/src/validate-security-posture.ts`
106+
// (which records its reasoning in full) and `refOf` in
107+
// `packages/lint/src/data-model-rules.ts`. This reader is the DELIBERATE
108+
// INVERSE of both, and the asymmetry is the answer rather than an
109+
// oversight (maintainer ruling, 2026-08-30):
110+
//
111+
// • Those two read a stack that WENT THROUGH the parse, so the alias
112+
// cannot legitimately reach them — narrowing there costs nothing and
113+
// the schema names the real defect (the alias key) itself.
114+
// • This one reads `ql.getSchema()`, i.e. the `SchemaRegistry`, and a
115+
// raw `registerObject` SKIPS Zod by design (#3896 — the registry's own
116+
// header names those doors). So here the alias is genuinely REACHABLE.
117+
// • And here a miss is not a quiet wrong answer, it is a DENIAL.
118+
// `resolveCbpRelation` returning null is FAIL-CLOSED: the read leg
119+
// answers `RLS_DENY_FILTER` (zero rows for every non-admin
120+
// caller) and the write leg throws `MasterDetailRelationMissingError`.
121+
// Narrowing this reader would take a raw-registered, alias-spelled
122+
// `controlled_by_parent` object from "access derived from its master"
123+
// to "everything denied, and writes throw" — an availability outage on
124+
// a population that provably exists, not a spelling correction.
125+
//
126+
// So the tolerance STAYS, and is made LOUD instead: the alias still
127+
// resolves — no deployment loses access — and the author hears about it,
128+
// once per object, through `reportRejectedReferenceAlias` below. ⛔ Do not
129+
// narrow this in place. Narrowing is only honest behind a migration that
130+
// sweeps stored / raw-registered metadata first, and that is a separate
131+
// card rather than a rider on the one that made this loud.
132+
//
133+
// `refKey` exists so the diagnostic and the resolution cannot disagree
134+
// about WHICH key was read: `ref` is derived from it rather than spelling
135+
// the `??` chain a second time. The mirror is exact for every shape either
136+
// call site can observe — `??` falls through on null/undefined only, which
137+
// is what `!= null` tests. The one difference is unobservable: with EVERY
138+
// spelling present-but-null the old chain evaluated to `null` and this
139+
// returns `undefined`, and both call sites below take `ref` as a
140+
// truthiness test or read it only after one (`pick` requires `ref(f)`, and
141+
// `String(ref(def))` runs only on what `pick` returned). Pinned in
142+
// `controlled-by-parent-reference-alias.test.ts`.
143+
const REFERENCE_SPELLINGS = ['reference', 'reference_to', 'referenceTo'] as const;
144+
const refKey = (f: any) => REFERENCE_SPELLINGS.find((k) => f?.[k] != null);
145+
const ref = (f: any) => {
146+
const k = refKey(f);
147+
return k === undefined ? undefined : f[k];
148+
};
149+
// [ADR-0055 §1 · #23100 ruling B] Exactly one required `master_detail`
150+
// naming a reference — see the docblock. `required === true` is the spec's
151+
// own type for the key and the lint's reading of it.
152+
const candidates = entries.filter(([, f]) => f?.type === 'master_detail' && f?.required === true && ref(f));
153+
const found = candidates.length === 1 ? candidates[0] : undefined;
154+
if (!found && masterGovernsRowWrites(schema)) {
155+
this.reportCbpMasterUnresolved(object, entries, candidates.map(([k]) => String(k)));
156+
}
157+
if (found) {
158+
const def = found[1];
159+
// Resolved from the SAME field def the relation itself came from, and
160+
// from the same `refKey`, so the report can never name a different field
161+
// or a different key than the one that actually answered.
162+
const spelling = refKey(def);
163+
if (spelling !== undefined && spelling !== 'reference') {
164+
this.reportRejectedReferenceAlias(object, String(found[0]), spelling, String(ref(def)));
165+
}
166+
rel = {
167+
fk: String(found[0]),
168+
master: String(ref(def)),
169+
// [#8688] A MIRROR of `validateRecord`'s insert-mode gate in
170+
// `@objectstack/objectql` (`record-validator.ts`): it walks every
171+
// declared field but skips provenance-flagged ones outright
172+
// (`if (def.system || def.readonly) continue;`) before the required
173+
// check is reached, and the check itself is `def.required && isMissing`.
174+
// So this predicate must track that gate literally, not approximate it
175+
// — it is the licence for the guard to stand down, and every shape it
176+
// reports `true` for is a shape validation provably refuses.
177+
//
178+
// `master_detail` + `required` are the resolver's own predicate above
179+
// (since #23100 nothing else resolves), so they always hold here; they
180+
// stay spelled out so this licence reads as the validator's test it
181+
// mirrors and cannot widen if the resolver ever changes.
182+
omissionRefusedByValidation:
183+
def?.type === 'master_detail' && !!def?.required && !def?.readonly && !def?.system,
184+
// [#25095] From the same def, asked of the parse's own predicate.
185+
masterNotWritable: masterNotWritableRefusal(object, String(found[0]), def),
186+
};
187+
}
188+
this.cbpRelCache.set(object, rel);
189+
return rel;
190+
}
191+
192+
/** The body of `SecurityPlugin#reportCbpMasterUnresolved`; the method's docblock there is its contract. */
193+
export function reportCbpMasterUnresolved(
194+
this: Pick<CbpRelationHost, 'logger'>,
195+
object: string,
196+
entries: ReadonlyArray<readonly [string, any]>,
197+
requiredMasters: readonly string[],
198+
): void {
199+
const optionalMasters = entries
200+
.filter(([, f]) => f?.type === 'master_detail' && f?.required !== true)
201+
.map(([k]) => String(k));
202+
const requiredLookups = entries
203+
.filter(([, f]) => f?.type === 'lookup' && f?.required === true)
204+
.map(([k]) => String(k));
205+
const why =
206+
requiredMasters.length > 1
207+
? `it declares ${requiredMasters.length} required master_detail fields (${requiredMasters.join(', ')}) ` +
208+
'and derives its master from exactly one'
209+
: [
210+
'it declares no required master_detail field',
211+
...(optionalMasters.length > 0
212+
? [`master_detail without required: true: ${optionalMasters.join(', ')} (add required: true)`]
213+
: []),
214+
...(requiredLookups.length > 0
215+
? [
216+
`required lookup: ${requiredLookups.join(', ')} (a lookup derives no access — declare the ` +
217+
"master as type: 'master_detail', or give the object its own sharingModel)",
218+
]
219+
: []),
220+
].join('; ');
221+
this.logger.warn?.(
222+
`[security/controlled-by-parent] object "${object}" declares sharingModel 'controlled_by_parent' but ` +
223+
`resolves no master relation — ${why}. Every read of it is DENIED and every write refused ` +
224+
'(422 INVALID_METADATA): ADR-0055 §1 derives the master from exactly one master_detail field declared ' +
225+
'required: true, and from nothing else. A non-required master_detail and a required lookup no longer ' +
226+
'derive access.',
227+
{ object, requiredMasters, optionalMasters, requiredLookups },
228+
);
229+
}
230+
231+
/** The body of `SecurityPlugin#reportRejectedReferenceAlias`; the method's docblock there is its contract. */
232+
export function reportRejectedReferenceAlias(
233+
this: Pick<CbpRelationHost, 'logger'>,
234+
object: string,
235+
field: string,
236+
alias: string,
237+
master: string,
238+
): void {
239+
// The tag is a resolvable NAME, not a tracker id: this string reaches
240+
// operators and generated surfaces, and `#NNNN` means nothing to any of
241+
// them (`check:doc-authoring`). The anchor for a reader who CAN resolve it
242+
// is this comment — the ruling is #11567, and git history keeps it.
243+
this.logger.warn?.(
244+
`[security/reference-spelling] object "${object}": its controlled_by_parent master relation resolved ` +
245+
`only from the REJECTED alias \`${alias}\` on field "${field}" — \`reference\` is the one ` +
246+
'relationship spelling @objectstack/spec declares, and this object reached the registry ' +
247+
'without being parsed (a raw registerObject skips Zod by design). Access is UNAFFECTED: ' +
248+
'this reader keeps accepting the alias precisely so no deployment loses access, and the ' +
249+
'relation still derives from the master. Rename the key to `reference` — every parsed ' +
250+
'authoring path already refuses this shape, so the two answers disagree until you do.',
251+
{ object, field, alias, master },
252+
);
253+
}

0 commit comments

Comments
 (0)