Skip to content

Commit b3a3634

Browse files
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 (#22535)
Fixes #22497 Clause-②: no Four docblock sentences. No type change, no code change, no test change. `@objectstack/spec` and `@objectstack/plugin-sharing` `patch`: the built `.d.ts` of both carries the text (checked below, with a positive control). ## What changed 1. **`ControlledByParentWriteDenialLeg`, the `master_chain` bullet** (`packages/spec/src/contracts/security-service.ts`). - Before: "or a master ABOVE the record's own master has no relation to derive from, is not present, or has an empty master reference". - After: "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". - The next sentence's frame changes from "Above the first hop" to "Past the record itself" (see Deviations). 2. **`ISharingService.canEdit`** (`packages/spec/src/contracts/sharing-service.ts`). The write-depth paragraph said the `sys_comment` and `sys_attachment` parent gates "reach this service through this method"; it now says through `{@link checkEdit}`. The two-state-projection paragraph's caller list drops the `sys_attachment` parent gate. 3. **`SharingService.canEdit`** (`packages/plugins/plugin-sharing/src/sharing-service.ts`). The same caller list drops the `sys_attachment` parent gate. 4. **`ControlledByParentWriteUnresolvedReason`, the `record_not_found` bullet** (`packages/spec/src/contracts/security-service.ts`). Added by the `domain:spec` seat's order on the card, after the missing-row probe below. - Before: "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." - After: "A 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." The changeset's first bullet names this fourth sentence. The `allow`-arm clause is not in this PR: it belongs to #22455's serving decision, per the triage comment on the card. ## Measured before editing **The first hop.** At this PR's base `ee8751d41e`, `SecurityPlugin.assertControlledByParentWrite` runs `assertMasterRowEditable` for the record's own master on the first loop iteration (`security-plugin.ts:9473`). It then sets `masterObject = hopRel.master` (`:9477`), which is that same master. When `declaresControlledByParent(masterObject)` holds (`:9478`) the walk resolves that master's relation (`:9496`-`:9503`), reads its row (`:9507`-`:9515`) and its master reference (`:9516`-`:9523`). Every one of those refusals names `masterObject`. The card's `:9320`-`:9353` anchors have moved; the walk is now `:9468`-`:9526`. Observed through the real engine middleware and the registered `checkControlledByParentWrite` member, on the fixture of `controlled-by-parent-write-member.test.ts` plus two rows. The probe was a throwaway copy of that test file; it was deleted and is not in this PR. ``` cbpm_line/line_above_dangling -> {"outcome":"deny","leg":"master_chain"} :: ... (master 'cbpm_quote' has no 'account' master reference to derive edit access from) cbpm_line/line_missing_master -> {"outcome":"deny","leg":"master_chain"} :: ... (master 'cbpm_quote' record 'q_missing' is not present, so its own master access cannot be derived) cbpm_orph_child/oc1 -> {"outcome":"deny","leg":"master_chain"} :: ... (master 'cbpm_orphan' declares controlled_by_parent with no relation to derive edit access from) ``` In all three the named master is the record's own master, not one above it. The suite's existing `master_chain` case is the first row of this probe. The second row is the missing-row case behind the fourth sentence: the record's own master is itself `controlled_by_parent`, its row is absent, and the answer is `master_chain` on the first hop. **The canEdit callers.** #22513 is merged (`ce3d0ad419`) and is in this base. The attachment gate calls `checkEdit` (`attachment-access-hooks.ts:402`) and `checkControlledByParentWrite` (`:428`). The comment gate does the same (`comment-access-hooks.ts:422`, `:448`). Neither calls `canEdit`, and both type their sharing dependency as a pick of `checkEdit` alone. They still hand the service a context with the operation-private keys stripped, so the write-depth paragraph's behaviour (ownership matched at `own`) is unchanged. Both lists were stale. The "Write DEPTH is an input the CALLER supplies" section of the same file says the gates "ask this service" without naming `canEdit`, so it was correct and is untouched. ## Verification Head `9e21e91be7`, base `ee8751d41e`. The commits up to `99f06a1073` carry the other three docblocks; the last commit adds the fourth sentence and one changeset clause. At `9e21e91be7`: - `pnpm --filter @objectstack/spec build`, then `check:generated`: all 15 generated artifacts up to date, `--fix` not needed. The built `dist/contracts/index.d.ts` carries the new `record_not_found` sentence. No generated artifact renders these docblocks: `check:generated` finds nothing stale. - `dispatch-gates --commands` with no paths re-derived the same 83 families over the same four paths (48 changed lines, +32/-16). 82 run, all exit 0, one NOT MEASURED. The changeset gates are among the 82. `pnpm check:i18n` and the `lint` doc-formula gate passed after their prerequisite builds. Carried over from `99f06a1073`, because the delta since is one comment sentence and one changeset clause on an unchanged path set: - `@objectstack/spec` contracts tests: 45 files, 441 tests passed. Not re-run at the new head. - `@objectstack/plugin-sharing` build and `typecheck`: green. Publish check with a positive control: its built `dist/index.d.ts` carries the edited `SharingService.canEdit` sentence and the unedited `Tri-state UPDATE verdict` docblock beside it; the old list text has 0 hits; `files[]` is `dist`, `README.md`, `CHANGELOG.md`. - The artifact-roster block: 48 of 51 run, 47 exit 0. The 3 PR-context guards, wired to this PR, exit 0. NOT MEASURED: `pnpm check:dual-build-cjs-loads` (no whole-workspace build, per the dispatch). `pnpm check:published-readme-exports` exits 3, `PREREQUISITE NOT MET` for six unbuilt packages; the diff changes no README and no export. CI owns both. Not run, declared to CI: full suites, `pnpm lint`, the declared wide-population gates, the 5 path-scheduled CI jobs. ## Deviations - **One word-frame change in a second sentence.** The card asks for one sentence. The sentence after it said "Above the first hop the check answers these as refusals". Once the first sentence says the record's own master can be named, that frame contradicts it, so it now reads "Past the record itself". It is a four-word change and reverts alone. The seat accepted it on the card. - **The surface widened by one bullet** by the seat's order on the card: the `record_not_found` bullet above. The changeset headline reads "four contract docblocks" for that reason. - **`origin/main` moved 6 commits** after the base. None touches the four paths of this PR (checked at `faf6348508`), so no merge commit was added. ## Acceptance notes (noted, not edited) - **A test title repeats the old wording.** `controlled-by-parent-write-member.test.ts:306` is titled "a master above the record's own master with no master reference"; its fixture is the first-hop case. The assertion (`deny`, `master_chain`) is right, so the title is the only thing out of step. - **`security/explain` is also a `canEdit` caller** (`security-plugin.ts:5526`, through `canEditRecord`) and neither list names it. That omission predates #22513. - **Three more comments still list the `sys_attachment` parent gate as a `canEdit` consumer:** `permission-evaluator.ts:369`-`:370` and `explain-engine.ts:1950`-`:1951`, as does a dogfood fixture comment, `packages/qa/dogfood/test/fixtures/attachments-fixture.ts:15`. All three are comments outside the docblocks this card names, with no runtime reader. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebbb8f9 commit b3a3634

4 files changed

Lines changed: 32 additions & 16 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
docs: four contract docblocks now describe what the code decides. No type, schema or runtime change.
7+
8+
Clause-②: no
9+
10+
- **`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.
11+
- **`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`.
12+
13+
`ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes.

‎packages/plugins/plugin-sharing/src/sharing-service.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -816,10 +816,10 @@ export class SharingService implements ISharingService {
816816
* verdict that is not `deny`. The truth table is byte-for-byte the historical
817817
* one — `abstain` is where the old `true` for public / owner-less / bypassed
818818
* objects went — so every existing caller (the sharing middleware, the
819-
* `sys_attachment` parent gate, the ADR-0055 master check) keeps its exact
820-
* semantics. A caller that would let this answer OVERRIDE another authority
821-
* must read {@link checkEdit} instead, because only there is "I permit this"
822-
* distinguishable from "I do not enforce here".
819+
* ADR-0055 master check) keeps its exact semantics. A caller that would let
820+
* this answer OVERRIDE another authority must read {@link checkEdit} instead,
821+
* because only there is "I permit this" distinguishable from "I do not
822+
* enforce here".
823823
*/
824824
async canEdit(
825825
object: string,

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,10 @@ export interface OwnershipFloorAlternate {
312312
* ({@link ISecurityService.checkAuthoredRowWrite}).
313313
* - `master_chain`: the chain itself cannot be walked to a master that governs
314314
* its own rows. It re-enters an object it has already visited, it exceeds
315-
* the depth bound, or a master ABOVE the record's own master has no relation
316-
* to derive from, is not present, or has an empty master reference. Above the
317-
* first hop the check answers these as refusals, not as defects of the
315+
* the depth bound, or a master that is itself `controlled_by_parent` (the
316+
* record's own master, or any master above it) has no relation to derive its
317+
* own master from, is not present, or has an empty master reference. Past the
318+
* record itself the check answers these as refusals, not as defects of the
318319
* request, so they are a leg here and never `unresolvable`.
319320
*
320321
* The first three legs run on every hop, so `object_permission` can name the
@@ -339,9 +340,11 @@ export type ControlledByParentWriteDenialLeg =
339340
* defect (`422 INVALID_METADATA`).
340341
* - `record_not_found`: the record `(object, recordId)` does not exist
341342
* (`404 RECORD_NOT_FOUND`). This is the addressed record, not its master. A
342-
* missing master row is not one of these outcomes: on the first hop the legs
343-
* judge it as they judge any master row, and above the first hop it is the
344-
* `master_chain` leg.
343+
* missing master row is not one of these outcomes: where the master governs
344+
* its own rows the legs judge it as they judge any master row, and where it
345+
* is itself `controlled_by_parent` the walk reads that row to derive the next
346+
* master, so an absent row after the legs is the `master_chain` leg, on the
347+
* first hop or above it.
345348
* - `master_reference_missing`: the record's stored master reference is empty
346349
* (`422 MISSING_REQUIRED_FIELD`).
347350
*/

‎packages/spec/src/contracts/sharing-service.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -291,18 +291,18 @@ export interface ISharingService {
291291
* [#7144] The write DEPTH in that first sentence is an INPUT this service is
292292
* handed, never one it resolves — so a caller outside the CRUD middleware
293293
* (the `sys_comment` and `sys_attachment` PARENT-record gates, which both
294-
* reach this service through this method) matches ownership at `own`. That is
295-
* the ruled shape, and the fail-open that blocks widening it is written up
296-
* once in "Write DEPTH is an input the CALLER supplies" on this interface.
294+
* reach this service through {@link checkEdit}) matches ownership at `own`.
295+
* That is the ruled shape, and the fail-open that blocks widening it is
296+
* written up once in "Write DEPTH is an input the CALLER supplies" on this
297+
* interface.
297298
*
298299
* [#6428] The two-state PROJECTION of {@link checkEdit}: `true` for every
299300
* verdict that is not `deny`, i.e. `allow` and `abstain` alike. That
300301
* collapse is the historical semantics, kept byte-for-byte so existing
301302
* callers do not drift — it is correct for a caller that only ADDS this gate
302303
* to whatever else already guards the row (the sharing middleware, the
303-
* `sys_attachment` parent gate, the ADR-0055 master check), and WRONG for a
304-
* caller that would let this answer override another authority's floor. The
305-
* latter must read {@link checkEdit}.
304+
* ADR-0055 master check), and WRONG for a caller that would let this answer
305+
* override another authority's floor. The latter must read {@link checkEdit}.
306306
*/
307307
canEdit(
308308
object: string,

0 commit comments

Comments
 (0)