Skip to content

feat(spec): ISecurityService declares checkControlledByParentWrite, an optional member answering the master-detail write check - #22492

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22464-security-master-detail-member
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22464-security-master-detail-member

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22464
Clause-②: yes (widening)

ISecurityService now declares one optional, feature-detected member that answers the existing ADR-0055 master-detail write check: checkControlledByParentWrite(object, recordId, context). It resolves with ControlledByParentWriteOutcome. This is the packages/spec half of #22455, which triage split out. #22455 serves the member from plugin-security and consumes it in the attachment and comment gates. #22455 is not addressed here. The contract-tier review is the seat's.

No runtime source changes, and no behaviour changes. The member is declared, not served.

What is declared

  • checkControlledByParentWrite?(object, recordId, context?), which resolves with a ControlledByParentWriteOutcome. The name and signature follow checkAuthoredRowWrite in the same file.
  • ControlledByParentWriteOutcome, a discriminated union on outcome:
    • allow: the check does not refuse. Every leg passes on every master up the chain, for the principal and, on an on-behalf-of context, for the delegator. A system context also answers allow, because the write path never checks it.
    • deny, with leg: ControlledByParentWriteDenialLeg: object_permission, row_level_security, record_sharing or master_chain.
    • not_applicable: the object does not declare controlled_by_parent.
    • unresolvable, with reason: ControlledByParentWriteUnresolvedReason: master_detail_relation_missing, record_not_found or master_reference_missing.
  • A store fault is a rejection, not an arm. The member rejects with the engine's own error, unchanged, so the fault keeps its declared 503.
  • TSDoc states the three things the card asks for:
    • the answer equals what a by-id update of the same record gets from this check, the ADR-0090 D10 delegator leg included;
    • absence is the absence of a master check, not a policy that admits, and the parent's own update gets the same answer there;
    • the member is optional, so every consumer handles its absence, and the unguarded call does not compile.
  • The file header's list of failure stances gains one bullet for this member.

Premises, verified on origin/main dee7692f0

  1. git grep -n "assertControlledByParentWrite" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts finds the step 2.8 calls at :3325 (principal) and :3335 (the ADR-0090 D10 delegator pass), and the private definition at :9113. Its legs are in the private assertMasterRowEditable (:9377).
  2. In registered-security-service-members.pin.test.ts, DeclaredOptionality maps keyof ISecurityService with -? (:52), DECLARED_MEMBERS ... as const satisfies DeclaredOptionality (:78), and SERVED_NOT_DECLARED is empty (:87).
  3. Read in full, ISecurityService declared 21 members, and none of them runs this check. checkAuthoredRowWrite answers authored row-level security only.

What the check decides, and the arm each outcome maps to

The outcome type carries what assertControlledByParentWrite and its legs decide for a by-id update, and nothing more.

The check, for a by-id update Arm
object not controlled_by_parent (returns early) not_applicable
no master_detail relation, MasterDetailRelationMissingError (422 INVALID_METADATA) unresolvable / master_detail_relation_missing
target row absent, DetailRecordNotFoundError (404 RECORD_NOT_FOUND) unresolvable / record_not_found
stored master FK empty, MasterReferenceMissingError (422 MISSING_REQUIRED_FIELD) unresolvable / master_reference_missing
leg 1, no object-level update on a master (403) deny / object_permission
leg 2, master row outside the write row-level security (403) deny / row_level_security
leg 3, record sharing refuses and no authored update policy admits (403) deny / record_sharing
chain walk: a cycle, the depth bound, an ancestor with no relation, an ancestor row absent, an ancestor FK empty (403) deny / master_chain
every hop passes, for the principal and then the delegator allow
a read the check lets propagate faults (#7505) rejection with the engine's error

The check tells its 403 legs apart only by the reason sentence it throws. The leg vocabulary is closed, so a consumer never parses that prose.

Shape decisions, for the contract review

  1. The store fault is a rejection, not a fifth arm. The check's own docblock says that on a fault "the engine's own error propagates … and this gate answers nothing". A rejection keeps the 503 without the consumer doing anything. A value arm would keep it only if every consumer remembered to rethrow, and a consumer that forgot would fail open. This matches the dev's option A on security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 ("a store fault thrown so it keeps its declared 503"). The card's "the outcome type covers the store fault" is met in the type's docblock, and in a compile pin that store_fault is not a reason.

  2. record_not_found is the addressed record, not its master. The card and the ruling say "a missing master row". In the check, the 404 DetailRecordNotFoundError is the target row. A missing master row is judged by the legs on the first hop, and is a master_chain refusal above it. I followed the check, and the TSDoc says so. On the first hop the legs can admit a dangling FK: resolveSharingCanEdit answers true when sharing abstains on a public master with no write row-level security. So "missing master row means refused" would have been a verdict the check does not make.

  3. Contexts around the check. The by-id update reaches step 2.8 only after several earlier steps, so the TSDoc pins what each context answers:

    • a system context answers allow, because the middleware short-circuits on it;
    • a principal whose sets resolve to none answers deny / object_permission, because checkObjectPermission('update', master, []) is false (ADR-0056 D2);
    • the three refusals of the context itself reject with the same 403 PERMISSION_DENIED: no principal at all (ADR-0096 D5), a permission-set resolution failure, and a dangling delegator (ADR-0090 D10).

    The member answers the master check alone. The record's own gates are not part of the answer.

  4. master_chain is a leg. Above the first hop, the check answers an unresolvable or dangling ancestor as an authorization refusal (assertControlledByParentWrite answers a metadata defect and a missing row with the same 403 PERMISSION_DENIED "requires edit access to its master record" #7474's envelope, ADR-0055 amendment), so it is never unresolvable.

The forced plugin-security row (claim amendment 6080901064, cross-lane declaration 6080912705)

DECLARED_MEMBERS gains checkControlledByParentWrite: 'optional', in interface order. Nothing else in plugin-security changes. An optional member that is declared and not served passes the pin's served-side check, and SERVED_NOT_DECLARED stays empty. Serving the member is #22455's.

Proof each pin bites (predicted first, run from committed 1a4557d0e)

Each mutation went through node scripts/ablation-replace.mjs in wrap mode. The anchor hit exactly once, the write was proven on disk, and the restore was proven as blob == HEAD with an empty git diff HEAD. Each leg ran the package's own check-test-typecheck.mts gate and then raw tsc -p tsconfig.test.json. The spec test program imports ./security-service by relative path, so no dist/ sits between the spec mutations and the run.

Leg Mutation Predicted Observed
C-spec none (control) green, 0 diagnostics in the security-service files gate OK, 0
A1 checkControlledByParentWrite?( made required red, 3: the unguarded-call pin plus the two exhaustiveness gates (659,7) TS2578 at the unguarded-call pin; (70,3) TS2322 (the makeService literal); (122,11) TS2322 (REQUIRED_MEMBERS)
A2 deny's leg made optional red, 1 (697,5) TS2578, the deny-without-leg pin
A6 unresolvable's reason made optional red, 1 (699,5) TS2578, the unresolvable-without-reason pin
A3 'store_fault' added to the reasons red, 1 (725,5) TS2578, the fault-is-not-a-reason pin
A4 'abstain' added to the not_applicable arm red, 2 (705,5) TS2578, the abstain pin; (740,17) TS2322 at the exhaustive switch's never
C-psec none (control) green, 0 gate OK, 0
A5 the DECLARED_MEMBERS row deleted red, 1 registered-security-service-members.pin.test.ts(79,12) TS1360, the satisfies DeclaredOptionality clause
R5 A5 restored green gate OK, 0

A5 reads the spec through dist/ (no paths rule for the spec in plugin-security). The unmutated list compiling green at 1a4557d0e is what proves that program read the rebuilt .d.ts: against a stale one without the member, the row itself would be the excess-property red. One note: my first A4 attempt used an anchor that its replacement still contained, so the tool refused it before running anything. That attempt measured nothing, and A4 above is the re-run with a corrected anchor.

The three runtime rows show the consumer's shape: feature-detect, proceed on allow and not_applicable, refuse otherwise, and propagate a rejection. They run against stubs, so mutating the contract cannot turn them red, and no ablation applies to them.

Verification

All at 1a4557d0e, under scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/spec build: exit 0. Then check:generated: exit 1, with exactly api-surface/ and export-origins/ stale. Then check:generated --fix regenerated those two: three type-only exports each, and both checks now pass. api-surface-signatures.json (it covers the defineX factories only) and the reference pages (check:docs) are unchanged.
  • pnpm --filter @objectstack/spec typecheck: exit 0. security-service.test.ts has no test-typecheck-debt.json entry, so its @ts-expect-error lines are live.
  • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: Test Files 630 passed (630), Tests 18796 passed | 1 todo.
  • pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2: Test Files 54 passed (54), Tests 915 passed (915).
  • pnpm exec turbo run build --filter='@objectstack/plugin-security^...' --concurrency=2: 17 of 17 successful.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0, and the test layer reports 0 file(s) / 0 error(s).
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/registered-security-service-members.pin.test.ts: Tests 3 passed (3). Narrowed on purpose: the package's only change is this row. The full suite is CI's.

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 1a4557d0e derived 90 commands from the merge-base change set (6 paths). I ran all 90 and recorded each exit code before any pipe. 88 exited 0 on the first pass:

  • pnpm check:i18n and pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET): only the plugin-security closure had a dist/.
  • pnpm check:dts-closure and pnpm check:sourcemap-no-sources-content exited 0, but they swept only the 17 packages built at that point.

After a full workspace build (pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2: 72 of 72 tasks, 71 replayed from the shared cache), I re-ran all four, and all four exited 0:

  • check-i18n-bundles: OK (9 package(s) — all bundles in sync …);
  • ✓ check:dual-build-cjs-loads — 107 published require entry point(s) across 66 package(s) load;
  • check-dts-closure: 72 built package(s) swept;
  • check-sourcemap-no-sources-content: 68 built package(s) swept - 544 map(s), none embed source text.

dispatch-gates --ran over the recorded codes: 90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED.

Lint, narrowed (a measurement, not a skip). pnpm exec eslint --no-inline-config --format json over the three changed .ts files reports files 3 errors 0 warnings 0. All three are in the configured population: eslint --print-config resolves a rule set for each. eslint.config.mjs enables no type-aware linting (parserOptions.project and projectService are unset for all three), so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.

Consumers. The public-surface change is one optional interface member and three type-only exports. git grep over packages/** for Required over ISecurityService, keyof ISecurityService, implements ISecurityService and satisfies ISecurityService finds only the plugin-security pin updated here. Every other reference holds the service as a Partial or calls existing members, so no other compiled verdict can move. The full workspace consumer sweep is CI's TypeScript Type Check.

Not merged with main. origin/main is two commits ahead (8b713fad7: CLI, core and dogfood tests, plus an unrelated changeset). They share no file and no package with this diff. dispatch-gates reports that none of them touched what its answer derives from. The merge queue rebuilds on current main.

Acceptance notes

Noted, not filed.


Generated by Claude Code

claude added 2 commits October 9, 2026 12:40
…n optional member answering the master-detail write check

The member exposes the existing ADR-0055 master-detail write check for an
update of (object, recordId) under the caller's context, with the ADR-0090
D10 delegator leg. It resolves with ControlledByParentWriteOutcome, a
discriminated union: allow, deny (with the refusing leg), not_applicable,
and unresolvable (with the reason no verdict was reached). A store fault
rejects with the engine's own error and keeps its declared status.

The contract test gains three rows, including the type-level pin that an
unguarded call does not compile. The plugin-security registered-member pin
gains the one DECLARED_MEMBERS row its satisfies clause forces.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
…lled-by-parent write outcome types

Regenerated by `pnpm --filter @objectstack/spec check:generated --fix`,
which proved exactly these two artifacts stale: three type-only exports
each (ControlledByParentWriteOutcome, ControlledByParentWriteDenialLeg,
ControlledByParentWriteUnresolvedReason).

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

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/authorization.mdx (via not_applicable (literal, a string literal in ControlledByParentWriteOutcome))
  • content/docs/permissions/explain.mdx (via not_applicable (literal, a string literal in ControlledByParentWriteOutcome))
  • content/docs/permissions/rls.mdx (via not_applicable (literal, a string literal in ControlledByParentWriteOutcome))

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

  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-6.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-7.mdx (via ISecurityService (symbol, a top-level interface))

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

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

Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 35ef501e1301a2e992f4f785a9ef469ce79fc086 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b88202554f10e54862a5b67da62e97491c33524 — the merge of head 1a4557d0e213965045fe240833288f32bbc7d533 into base 35ef501e1301a2e992f4f785a9ef469ce79fc086, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b88202554f10e54862a5b67da62e97491c33524 && git checkout 5b88202554f10e54862a5b67da62e97491c33524
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 35ef501e1301a2e992f4f785a9ef469ce79fc086 1a4557d0e213965045fe240833288f32bbc7d533 && git checkout -B drift-repro 35ef501e1301a2e992f4f785a9ef469ce79fc086 && git merge --no-ff 1a4557d0e213965045fe240833288f32bbc7d533

node scripts/docs-audit/affected-docs.mjs --json 35ef501e1301a2e992f4f785a9ef469ce79fc086

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 35ef501e1301a2e992f4f785a9ef469ce79fc086 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1a4557d0e213965045fe240833288f32bbc7d533
Local-runs: none

Inputs, and nothing else: card #22464 (body and every comment, the two claim records, the dev report 6082256950 and the seat review 6082295852 included); PR #22492 (body, file list, and the net diff against main from the merge base dee7692f0); the check-runs on the head; #22455's body and its comments 6079124329 and 6079158667, which the card cites as the ruling it implements; and, for the faithfulness question, packages/plugins/plugin-security/src/security-plugin.ts, errors.ts, permission-evaluator.ts and packages/spec/src/security/high-privilege.ts on main, read through git show on a private ref (main has moved seven commits past the merge base; none of them touches a PR file or security-plugin.ts, so the line numbers below hold on both). Nothing was built, run or re-run.

① Derived judgments

Accept-set changes: none. The diff adds no authorable key, changes no schema, and touches no runtime source. plugin-security does not serve the member; its only change is one row in a test file. Clause-②: yes (widening) with no accept-set movement is the right reading, as the card's acceptance states.

Public-surface change A — one optional member, ISecurityService.checkControlledByParentWrite?(object, recordId, context?), resolving with ControlledByParentWriteOutcome. Right. Optional and feature-detected, the shape of checkAuthoredRowWrite in the same file and of PR #21781's two members. The unguarded call fails to compile: pinned under @ts-expect-error in security-service.test.ts, which carries no entry in packages/spec/test-typecheck-debt.json, so the pin is live. The dev's ablation A1 (member made required) reddened that pin plus the two exhaustiveness gates, as predicted.

Public-surface change B — the outcome type, judged arm by arm against SecurityPlugin.assertControlledByParentWrite on main (security-plugin.ts:9113), its legs in assertMasterRowEditable (:9377), and the write middleware around step 2.8 (:3311 to :3342). The member is a faithful statement of what the check decides for a by-id update. No invented verdict, no missing leg:

  • not_applicable = the early return at :9120 (declaresControlledByParent false). Right. Note for the implementer: that predicate reads ql.getSchema and answers false on an unresolvable schema by design, so an object the engine cannot resolve also answers not_applicable, which is the check's own no-op there.
  • unresolvable / master_detail_relation_missing = MasterDetailRelationMissingError at :9150 (errors.ts:124, 422 INVALID_METADATA). Right.
  • unresolvable / record_not_found = DetailRecordNotFoundError at :9164 (errors.ts:154, 404 RECORD_NOT_FOUND), thrown when readRowById(object, targetId) answers null, that is, the ADDRESSED record. Right. See ③ for the naming deviation.
  • unresolvable / master_reference_missing = MasterReferenceMissingError at :9277 (errors.ts:211, 422 MISSING_REQUIRED_FIELD). On an update the stored-row shape always throws; the A missing required master-detail parent still answers 422 MISSING_REQUIRED_FIELD with no fields[] and a [Security] message — while the same field, present-but-unresolvable, answers 400 VALIDATION_FAILED with fields[] (#7474 residual, 17.0.0 GA) #8688 stand-down applies to insert only, which the member never asks. Right.
  • deny / object_permission = leg 1 at :9388. Right.
  • deny / row_level_security = leg 2 at :9479, including its own probe's catch (null, so deny). Right, and the outcome TSDoc says that leg fails closed on its probe.
  • deny / record_sharing = leg 3 at :9515 to :9523: resolveSharingCanEdit false (its catch at :5629 ff. answers false) and checkAuthoredRowWrite not admit. Right, and the TSDoc names the authored-policy lift.
  • deny / master_chain = the five masterEditDenied throws of the walk: cycle :9320, depth bound :9327, no relation :9335, row not present :9345, empty master reference :9353. All 403 PERMISSION_DENIED, never a non-verdict code, exactly as the TSDoc says. Right as an arm and as a vocabulary. One prose defect, named in ③.
  • allow = both passes return without throwing. Right.
  • A store fault is a rejection, not an arm: readRowById (:7894, :7909) propagates the engine's error by the readRowById swallows engine failures into null, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505 ruling, and so does the walk's read of each master row. The check's own docblock says the gate then "answers nothing". Right. The TSDoc also says, correctly, that legs 2 and 3 fail closed on their OWN probes and that their deny is then the answer as it stands.
  • The legs run on every hop, so object_permission can name an ancestor: right (:9311 runs assertMasterRowEditable per hop).

Edge contexts the member TSDoc pins, each against the middleware:

  • A system context answers allow: :2453 returns next() before every gate. Right.
  • A principal whose sets resolve to none answers deny / object_permission: checkObjectPermission('update', master, []) iterates zero sets and answers false (permission-evaluator.ts:205 ff.), the ADR-0056 D2 deny baseline the step-2 CRUD gate applies unguarded (:2968). Right, with one reading the implementer must hold: on the write path, step 2.8 itself is guarded on a non-empty set list (:3321), so a by-id update by that principal is refused one gate earlier and never runs the master check. The contract therefore pins what the check's first leg answers over the empty list, in the same direction the write path refuses. The served member (security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455) must run the legs over the empty list rather than mirror that half of the guard. And the pin is read as "when the legs are reached": an object with no relation answers master_detail_relation_missing first, as the check does.
  • The three context refusals reject with 403 PERMISSION_DENIED: no principal at all (isPrincipalLessContext at :2657, principalLessDenial is a PermissionDeniedError), a set-resolution failure (:2669 to :2688), a dangling delegator (:2720). Right.
  • The ADR-0090 D10 delegator leg: the second pass at :3335 runs only when delegatorSets resolved, over delegatorContext; the principal's pass throws first. "The first refusal is the answer" is right.
  • "Absence is the absence of a master check, not a policy that admits", and "the parent's own update gets the same answer there": a kernel with no plugin-security composes no step 2.8, so the statement is true by construction, and the test pins that a gate reports absence as its own state and never relabels it allow. Right, and it is ruling 6079158667's shape detail 2.

The header bullet ("Gate outcomes refuse, and a fault rejects") is a fourth stance beside the three the file already lists, and it says what the type does. Right.

Generated artifacts. api-surface/contracts.json and export-origins/contracts.json each gain the three type-only exports in sorted position, origin src/contracts/security-service.ts. Consistent with the source. The reference pages and api-surface-signatures.json are unchanged, which is right for an interface member (the signatures file covers the defineX factories).

The forced plugin-security row (claim amendment 6080901064). DeclaredOptionality maps keyof ISecurityService with -? (registered-security-service-members.pin.test.ts:52) and DECLARED_MEMBERS is as const satisfies DeclaredOptionality (:78), so a new interface member with no row fails the package's test typecheck on a missing property, as the dev's A5 ablation showed (TS1360). The diff adds exactly one line, checkControlledByParentWrite: 'optional', in interface order; SERVED_NOT_DECLARED stays empty; the served side is unchanged, and the pin's own header says declared-but-not-served is legal for an optional member. The row is bookkeeping of the declaration, not serving, exactly as the amendment declares. Right.

Contract tests. The three new rows pin: absence typed and reported as its own state; each arm's required field (leg on deny, reason on unresolvable), abstain refused, both vocabularies closed, store_fault refused as a reason, an exhaustive switch with a never floor; and a store fault rejecting through a gate with its own status while a non-verdict refuses. The dev's ablations A2, A3, A4 and A6 each reddened the predicted line. The three runtime rows run against stubs and prove the consumer's shape, not the contract, which the report says plainly.

② Semver level

.changeset/22464-security-service-controlled-by-parent-write.md: '@objectstack/spec': minor. What the diff publishes is one optional interface member and three type-only exports from @objectstack/spec: additive, so minor is both the floor Clause-②: yes takes and the right level; nothing is removed or narrowed, so no ADR-0087 disposition is owed and none is written. @objectstack/plugin-security publishes nothing from this diff (a test file only), so no second changeset is owed and skip-changeset does not apply. The changeset body carries the migration-free summary and the Clause-②: yes (widening) line; the PR body's second line is Clause-②: yes (widening); the card's acceptance says Clause-②: yes and the dispatch claim says the same with the widening arm. All three agree, and the arm is the only one a pure addition can take. Consumers: the dev's structural sweep found no Required, keyof, implements or satisfies over ISecurityService outside the pin updated here, and the workspace typecheck on the head is the proof of record (named below).

③ Boundary flags

Every deviation in the dev report 6082256950, each answered:

  1. The DECLARED_MEMBERS row lands here, not in security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455. Answered above: compiler-forced, declared on the card in amendment 6080901064, exactly one line, no serving. Accepted.
  2. The store fault is a rejection, not a fifth arm. Right. The ruling's shape detail 1 asks that the fault "keeps its declared 503"; a rejection carrying the engine's own error keeps it by construction, where a value arm would keep it only while every consumer remembered to rethrow, and one that forgot would admit. The card's "the outcome type covers the store fault" is met by the type's docblock, the compile pin that store_fault is not a reason, and the runtime pin that a gate propagates the rejection. This is also option A's own wording in 6079124329 ("a store fault thrown so it keeps its declared 503"). Accepted.
  3. "A missing master row" is named record_not_found, the addressed record. Right. The check's own docblock lists its three non-verdict outcomes as a broken declaration, "the target row does not exist", and a null master FK (:9091 to :9096); the ruling's phrase is a paraphrase of the second. A missing first-hop master is judged by the three legs as any master row is (and leg 3 can admit it where sharing abstains on a master with no write row-level security), and a missing master the walk reads is a master_chain refusal at :9345. Naming it a refusal would have been a verdict the check does not make, and the TSDoc records the distinction. Accepted.
  4. Not merged with main. main is seven commits past the merge base; none touches a PR file. The merge queue rebuilds on current main. Accepted.
  5. plugin-security's runtime suite narrowed to the pin file. The package's only change is that file; Test Core on the head is the suite of record (below). Accepted.
  6. Lock hold and the worktree re-add. Informational; no edit happened on the re-added tree. Accepted.

open_questions: none declared, and none found.

Out-of-scope findings, both carried to #22455 by the dev, each answered:

One finding of this review, not raised by the dev: in the ControlledByParentWriteDenialLeg docblock, the master_chain sentence says the three resolution refusals name "a master ABOVE the record's own master". On the code, the first iteration of the walk resolves and reads the record's OWN master when that master is itself controlled_by_parent (masterObject = hopRel.master at the top of the loop, then :9335, :9345, :9353), and only later iterations name a master above it. The arm is right, the five conditions are enumerated right, and every consumer branches on deny regardless of the master named, so this is a prose under-description by one level and not a wrong verdict. Carried to the spec seat as a one-sentence TSDoc correction ("the record's own master, when it is itself controlled_by_parent, or any master above it"); a docs-only follow-up, or this branch before enqueue (which moves the head and asks for a fresh record). The changeset text does not repeat the claim.

Check-runs on the head, read at 2026-10-09T14:13Z, after waiting for every one to complete (35 check-runs, all completed). The seven required contexts, each success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Every other check success: the six Test Core shards, the three Dogfood Regression Gate shards, Type Check · source gates, Type Check · workspace, Type Check · consumer gates, Type Check · debt ledger, Check Changeset, Check PR Size, Check Documentation Links, Spec property liveness, Dogfood Verify CLI, Flag docs affected by code changes, Auto Label, filter, and the three card-claim checks (The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card). Skipped by path filter: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). Red checks: none. No governed surface is in the file list.

Implemented-by: claude/issue-22464-security-master-detail-member
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants