From 91e9edaadf663976eaae26e438270608a19f30d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:51:08 +0000 Subject: [PATCH 1/3] docs(spec,plugin-sharing): the master_chain refusals can name the record's own master; canEdit no longer lists the attachment and comment parent gates The ControlledByParentWriteDenialLeg docblock said the master_chain arm's three resolution refusals name a master above the record's own master. The walk resolves the record's own master first, so when that master is itself controlled_by_parent the refusals can name it too. The two canEdit docblocks (ISharingService and SharingService) listed the sys_attachment and sys_comment parent gates among canEdit's callers; those gates read checkEdit and the master-detail write check now. Docblock sentences only: no type change, no code change. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../plugins/plugin-sharing/src/sharing-service.ts | 8 ++++---- packages/spec/src/contracts/security-service.ts | 7 ++++--- packages/spec/src/contracts/sharing-service.ts | 12 ++++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index b2e809eb036..7d173f74220 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -816,10 +816,10 @@ export class SharingService implements ISharingService { * verdict that is not `deny`. The truth table is byte-for-byte the historical * one — `abstain` is where the old `true` for public / owner-less / bypassed * objects went — so every existing caller (the sharing middleware, the - * `sys_attachment` parent gate, the ADR-0055 master check) keeps its exact - * semantics. A caller that would let this answer OVERRIDE another authority - * must read {@link checkEdit} instead, because only there is "I permit this" - * distinguishable from "I do not enforce here". + * ADR-0055 master check) keeps its exact semantics. A caller that would let + * this answer OVERRIDE another authority must read {@link checkEdit} instead, + * because only there is "I permit this" distinguishable from "I do not + * enforce here". */ async canEdit( object: string, diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 90c88efd110..d406c154f1d 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -312,9 +312,10 @@ export interface OwnershipFloorAlternate { * ({@link ISecurityService.checkAuthoredRowWrite}). * - `master_chain`: the chain itself cannot be walked to a master that governs * its own rows. It re-enters an object it has already visited, it exceeds - * the depth bound, or a master ABOVE the record's own master has no relation - * to derive from, is not present, or has an empty master reference. Above the - * first hop the check answers these as refusals, not as defects of the + * the depth bound, or a master that is itself `controlled_by_parent` (the + * record's own master, or any master above it) has no relation to derive its + * own master from, is not present, or has an empty master reference. Past the + * record itself the check answers these as refusals, not as defects of the * request, so they are a leg here and never `unresolvable`. * * The first three legs run on every hop, so `object_permission` can name the diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index c7ed21f8473..d76cec0bc7e 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -291,18 +291,18 @@ export interface ISharingService { * [#7144] The write DEPTH in that first sentence is an INPUT this service is * handed, never one it resolves — so a caller outside the CRUD middleware * (the `sys_comment` and `sys_attachment` PARENT-record gates, which both - * reach this service through this method) matches ownership at `own`. That is - * the ruled shape, and the fail-open that blocks widening it is written up - * once in "Write DEPTH is an input the CALLER supplies" on this interface. + * reach this service through {@link checkEdit}) matches ownership at `own`. + * That is the ruled shape, and the fail-open that blocks widening it is + * written up once in "Write DEPTH is an input the CALLER supplies" on this + * interface. * * [#6428] The two-state PROJECTION of {@link checkEdit}: `true` for every * verdict that is not `deny`, i.e. `allow` and `abstain` alike. That * collapse is the historical semantics, kept byte-for-byte so existing * callers do not drift — it is correct for a caller that only ADDS this gate * to whatever else already guards the row (the sharing middleware, the - * `sys_attachment` parent gate, the ADR-0055 master check), and WRONG for a - * caller that would let this answer override another authority's floor. The - * latter must read {@link checkEdit}. + * ADR-0055 master check), and WRONG for a caller that would let this answer + * override another authority's floor. The latter must read {@link checkEdit}. */ canEdit( object: string, From 99f06a10738f0721be8f1ca0f5520b407364160d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:08:36 +0000 Subject: [PATCH 2/3] chore(changeset): patch for the master_chain and canEdit docblock sentences Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22497-master-chain-docblock.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22497-master-chain-docblock.md diff --git a/.changeset/22497-master-chain-docblock.md b/.changeset/22497-master-chain-docblock.md new file mode 100644 index 00000000000..841f7687bc5 --- /dev/null +++ b/.changeset/22497-master-chain-docblock.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +'@objectstack/plugin-sharing': patch +--- + +docs: three contract docblocks now describe what the code decides. No type, schema or runtime change. + +Clause-②: no + +- **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. +- **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. + +`ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. From 9e21e91be753b7e2a3f899cc247cbd156bb1492e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:51:48 +0000 Subject: [PATCH 3/3] docs(spec): a missing master row of a controlled_by_parent master is the master_chain leg, on the first hop or above it The record_not_found bullet of ControlledByParentWriteUnresolvedReason said the legs judge a missing master row on the first hop and only above it is it the master_chain leg. The walk reads a controlled_by_parent master's row after the legs on every iteration, the first included, so an absent row there answers master_chain. The changeset names the sentence. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22497-master-chain-docblock.md | 4 ++-- packages/spec/src/contracts/security-service.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.changeset/22497-master-chain-docblock.md b/.changeset/22497-master-chain-docblock.md index 841f7687bc5..f065f6929ed 100644 --- a/.changeset/22497-master-chain-docblock.md +++ b/.changeset/22497-master-chain-docblock.md @@ -3,11 +3,11 @@ '@objectstack/plugin-sharing': patch --- -docs: three contract docblocks now describe what the code decides. No type, schema or runtime change. +docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. Clause-②: no -- **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. +- **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. - **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. `ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index d406c154f1d..72020ac0657 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -340,9 +340,11 @@ export type ControlledByParentWriteDenialLeg = * defect (`422 INVALID_METADATA`). * - `record_not_found`: the record `(object, recordId)` does not exist * (`404 RECORD_NOT_FOUND`). This is the addressed record, not its master. A - * missing master row is not one of these outcomes: on the first hop the legs - * judge it as they judge any master row, and above the first hop it is the - * `master_chain` leg. + * missing master row is not one of these outcomes: where the master governs + * its own rows the legs judge it as they judge any master row, and where it + * is itself `controlled_by_parent` the walk reads that row to derive the next + * master, so an absent row after the legs is the `master_chain` leg, on the + * first hop or above it. * - `master_reference_missing`: the record's stored master reference is empty * (`422 MISSING_REQUIRED_FIELD`). */