Skip to content

Commit ce3d0ad

Browse files
fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master (#22513)
Fixes #22455 Clause-②: no (narrowing) The `sys_attachment` parent gate (`@objectstack/service-storage`) and the `sys_comment` moderation gate (`@objectstack/plugin-audit`) now judge a `controlled_by_parent` parent through its master: the answer the parent's own by-id update gets. `@objectstack/plugin-security` serves the master-detail write check that card #22464 declared (`ISecurityService.checkControlledByParentWrite`) from the write path's own composition, and both gates ask it when the sharing service abstains. This executes seat 1's ruling A (`6079158667`) as carried by triage (`6079448762`) and the claim `6083853580`. ## Reproduction (before) Real stack: `bootStack`, org-bound, with `StorageServicePlugin` and `AuditPlugin`. The fixture is `packages/qa/dogfood/test/fixtures/cbp-parent-gates-fixture.ts`. A member holds `org_member`, the fixture's read/create/edit baseline, and a set granting `sys_attachment` and `sys_comment` create and delete. The master `cpg_account` is `public_read`, so the member reads it, and it is owned by the admin. `cpg_contract` is a `controlled_by_parent` child of it. `cpg_vault_item` is a `controlled_by_parent` child of a private admin-owned master. Measured with the new dogfood file on the unmodified base `e148ca984` (test commit `bca810323`): | Request by the member | Before | |:--|:--| | `PATCH /data/cpg_contract/ID` (precondition) | 403 `PERMISSION_DENIED` | | `POST /data/sys_attachment` on that `cpg_contract` | **201** (file attached) | | `POST /data/sys_attachment` on `cpg_vault_item`, which answers the member 404 | **201** | | `DELETE /data/sys_attachment/ID` of the admin's file on the `cpg_contract` | **200** (row gone) | | `DELETE /data/sys_comment/ID` of the admin's comment on the `cpg_contract` | **200** (row gone) | | `security.checkControlledByParentWrite` | not served (`undefined`) | Controls, green before and after: the master owner attaches (201); the member attaches under a master they own (201); a `public_read_write` parent admits the member (201); an uploader deletes their own file (200); an author deletes their own comment, and the master owner moderates a member's comment (200). ## What changed - **`plugin-security`**: serves `checkControlledByParentWrite(object, recordId, context)` on the typed service literal, so the compiler holds it to the contract. - It runs `assertControlledByParentWrite` for the principal's sets, then for the delegator's on an on-behalf-of context (step 2.8's two calls, in order). The first refusal is the answer. - Before that it runs the engine middleware's context prologue: principal-less refusal, permission-set resolution failing closed, the missing-delegator refusal and delegator resolution. To keep one composition, that prologue moved out of the middleware into `resolveOperationPrincipals`, which the middleware and the member both call. Its refusals, their order and their wording are unchanged. - Outcomes are read off what the check throws. Each master-edit refusal is tagged with its leg in a `WeakMap` beside the error, never on it, so the write path's envelope is byte for byte the same. The three non-verdict errors map to the `unresolvable` reasons. Anything else (a store fault, a refusal of the context) rejects unchanged. - There is no second copy of the master-detail check: the member is the check. - **`service-storage`**: one composition, `mayEditParent`, answers parent EDIT for all four limbs: attach, the row rule on update and delete, and the re-point's attach rule. It reads the tri-state `checkEdit`, not `canEdit`. The installer takes an optional trailing security resolver, and `StorageServicePlugin` wires it. - **`plugin-audit`**: `canEditParent` gets the same composition. It is the one function behind the moderation limb, so it covers comment delete (which reproduced) and comment update. Insert and re-point still ask READ. - **`AttachmentSharingLike` / `CommentSharingLike`** now `Pick` `checkEdit`. New port types `AttachmentSecurityLike` and `CommentSecurityLike` are `Pick`s of `ISecurityService`. - **`content/docs/permissions/system-context.mdx`**: one census row (9b), anchored at `security-plugin.ts#checkControlledByParentWrite`, plus the census gate's own `--fix` counts (121 to 122) and nothing else on the page. The reason: the served member answers `allow` for a system context (the contract's first bullet), which is a new `ExecutionContext.isSystem` read site, and `check:system-context-census` requires every such site to carry a row. The seat authorized this one row by amending the claim (`6083853580`), declared to `domain:devx`. ## Gate truth table (both gates, every limb) | `checkEdit` | `checkControlledByParentWrite` | Gate | |:--|:--|:--| | `allow` | not asked | admit | | `deny` | not asked | refuse, today's envelope | | `abstain` | `allow` | admit | | `abstain` | `not_applicable` | admit (the parent derives nothing from a master: the `public_read_write` control) | | `abstain` | `deny` (any leg) | refuse, today's envelope | | `abstain` | `unresolvable` (broken declaration, missing record, null master reference) | refuse, today's envelope (fails closed) | | `abstain` | rejects (datasource fault, refused context) | propagates unchanged; a fault keeps its `503` | | `abstain` | member absent (no security service, or one that does not serve it) | admit: today's answer, and the parent's own update meets no master check either | | no sharing service | n/a | degraded mode: caller-scoped parent read, unchanged | Envelopes: attach and re-point answer 403 `ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers 403 `ATTACHMENT_DELETE_DENIED`, and an update of it answers 403 `RECORD_NOT_ACCESSIBLE`. Comment delete or update answers 403 `RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible 403 `PERMISSION_DENIED`. On `not_applicable`: the gates ask the member on every abstention. The member is the one place that decides whether a parent is `controlled_by_parent`, so the gates carry no second copy of that predicate. `not_applicable` is therefore exactly the "any other abstain admits" arm of the ruling, and it matches the contract's consumer rule (proceed on `allow` and `not_applicable`). The claim's line first read `not_applicable` as fail-closed. That reading only arises if the gate decides "this parent is controlled_by_parent" itself, which this design never does. The seat accepted this reading and corrected the claim line. ## Pins and ablations Every ablation went through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and on restore the blob equals HEAD with `git diff HEAD` empty. | Pin | Where | Ablation | Reading | |:--|:--|:--|:--| | Truth table (9 rows), member asked with the gate's envelope, 503 propagation, kernel without the member, delete / not-visible / update / re-point limbs | `service-storage` `attachment-access-hooks.test.ts` | `if (verdict === 'allow') return true;` becomes `if (verdict !== 'deny') return true;` (the `canEdit` fold) | 12 failed / 63 passed; restored to blob `c078a57acb10` = HEAD | | The same for the moderation limb | `plugin-audit` `comment-access-hooks.test.ts` | same mutation | 11 failed / 56 passed; restored to blob `7718798ea3e2` = HEAD | | Real stack: both attaches, the delete of another's file and the comment delete refused; the controls admit; PATCH parity | `dogfood` `cbp-parent-attachment-comment-gates.dogfood.test.ts` | both gates stop consulting the master check, so an abstention admits; rebuilt; `ablation-dist-preflight` found the marker in 2 built files of each package | 4 failed / 6 passed (`expected 201 to be 403` twice, `expected 200 to be 403` twice). Restore leg: sources equal HEAD, rebuilt, `--absent` passes with a clean tree, rerun 10 / 10 | | Member parity table, served pin | `plugin-security` `controlled-by-parent-write-member.test.ts`, `registered-security-service-members.pin.test.ts` | A: member not served. B: mirror step 2.8's `userId` guard. C: refusals carry no leg | A 11 failed / 3 passed. B 2 failed (the guest pin and the principal-less pin) / 12 passed. C 4 failed / 10 passed. Each restored to blob `8b1ac155b333` = HEAD | Three earlier dogfood ablation attempts were not measurements, and none of them reached a test. Two mutations failed the declaration build (a narrowing error, then an unused parameter), and one was refused by `ablation-replace` because the replacement contained the anchor. Each attempt restored the sources to HEAD. ## PATCH parity - **Plugin level** (`controlled-by-parent-write-member.test.ts`): the real engine middleware's by-id update and the served member run on one store for one caller. - `allow` for exactly the records the update admits: an owned master, a master shared at `edit`, a master admitted by authored write RLS, and a two-hop chain. - Each `deny` leg (`object_permission`, `row_level_security`, `record_sharing`) matches the reason step 2.8's refusal states. The delegator leg is covered, and so is `master_chain` (an empty master reference above the first hop). - The three `unresolvable` reasons each meet a refused update. A system context answers `allow`, and a non-`controlled_by_parent` object answers `not_applicable`. - A principal-less context and a missing delegator reject with the write path's own refusal message. - **Real stack**: the member's `PATCH` of the child answers 403, and its message says `requires edit access to its master record (master 'cpg_account' not editable by this user (row-level security))`. The member answers `{ outcome: 'deny', leg: 'row_level_security' }`. - The leg is the master's write RLS, not record sharing: the platform's `created_by` ownership floor binds `org_member` on the master, and record sharing gives the member no basis to lift it. My first expectation was `record_sharing`, and the measurement corrected it. - The owner gets `allow` and a 200 `PATCH`. The member under their own master gets `allow` and a 200 `PATCH`. The `public_read_write` board gets `not_applicable`. ## The two serving notes (landing record `6083231669`) 1. **Step 2.8's `context.userId` guard is NOT mirrored.** - Mirroring it would answer `allow` for a principal whose master nothing measured. The contract's `allow` covers only a system context or every leg passing, and this lane does not edit the spec TSDoc. - Not mirroring it can only refuse: every non-`allow` outcome refuses. The record's own update already refuses such a principal at the object-level gate, because guest bindings refuse `allowEdit`, so the overall parity holds. - Pinned (a positions-only context answers `deny` on `object_permission`) and ablated (mirroring the guard turns that pin red). The spec's `allow` TSDoc needs no second clause. 2. **The registration log line** is now read off the registered object (`Object.keys(registeredSecurityService)`, sorted), so it cannot fall behind a served member again. The hand list named 16 of the 21 members served before this PR. It omitted `describeDelegableScope`, `describeDelegationNarrowing`, `getEffectiveObjectPermissions`, `hasWriteBypass` and `resolveWriteScope`. It now prints all 22. ## Local verification (HEAD `15d05d2b6`) - **Census:** `node scripts/check-system-context-census.mjs` at `15d05d2b6` reads `check-system-context-census: OK — 122 elevation read sites in 20 packages across 57 files, living in 104 symbol(s); the page cites 117 symbol(s) against 117 required`. The gate's own `--fix` is a no-op on the committed page (blob unchanged before and after). - **Gates:** `dispatch-gates --commands` derived 105 families at `15d05d2b6`. That is the 79 from the previous head plus 26 that the docs path adds (doc frontmatter, route spelling, section names, landing index, doc anchors, docs redirects, single H1, spec `check:docs`, and others). All 105 were run with recorded exit codes, and every one exited 0. `--ran` reads: `105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED`. - **Workspace:** rebuilt in the recreated worktree (`turbo run build`, 73/73 tasks) before the gates ran. - **Tests:** the code is unchanged since `bdce0149d`, so these readings carry over. At `bdce0149d`, after rebuilding: - the six cbp suites in `plugin-security`: 106 passed; - the attachment and comment gate suites: 75 and 67 passed; - the new dogfood file: 10 passed. - **Full package suites** at `faea6141f` (main has moved since, through merges that touch none of these packages): - `plugin-security`: 191 files, 3969 passed / 45 skipped; - `service-storage`: 47 files, 800 passed; - `plugin-audit`: 42 files, 672 passed; - `typecheck` for all three, test layers included: exit 0. - **ESLint** over the 14 changed `.ts` files: 14 results, 0 errors, 0 warnings. No type-aware linting is enabled, so untouched files' verdicts cannot move. - **Main moved:** after the census commit, `main` moved by one commit, `4e9fe9ff6` (the protocol version bump). It touches neither the census page, these packages nor any `isSystem` read in code, so it was not merged this round. ## Acceptance notes - **Measured, other lane (class a):** `POST /api/v1/security/explain` for the member, `{ object: 'cpg_contract', operation: 'update', recordId }`, answers `allowed: true` with the OWD layer reading "controlled_by_parent: rows are org-shared at this baseline". The same member's `PATCH` of that record answers 403. Measured at `faea6141f` with an untracked scratch probe on this PR's fixture, deleted after the reading. The explain record write gate asks `sharing.canEdit` (`canEditRecord`), whose abstention reads as permission. The member this PR serves is the parity path. Not fixed here. - Spec TSDoc drift: the `ISharingService.canEdit` TSDoc still names the `sys_attachment` parent gate (and, under the write-depth paragraph, both parent-record gates) as `canEdit` callers. Both now read `checkEdit`. No runtime consumer; carrier: the spec lane's TSDoc follow-up, card #22497. - The same caller list sits in `plugin-sharing`'s `SharingService.canEdit` docblock. Doc only; carrier: none. - The comment UPDATE limb moved with the delete limb (one function). For org members the platform's edit floor answers that limb first, so the visible change is on delete. - Two changesets, not one. The served member is a non-breaking `minor` for `plugin-security`, and the BREAKING banner rides only on the two packages that narrow. The ADR-0087 disposition is `not-required (no-migration-prescription)`. The remedy ("grant edit on the master") is written as prose, because the gate refuses that category when a FROM-TO block is detected and no other category applies to a runtime accept-set narrowing. - `scripts/engine-double-contract.pinned.json` gains one row, written by `--write` for the new suite's pinned `findOne` double. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 215e662 commit ce3d0ad

18 files changed

Lines changed: 1683 additions & 181 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime accept set: the attachment parent gate (attach, the update and delete row rule, the re-point) and the comment moderation gate now refuse a write on a controlled_by_parent parent whose master the caller may not edit. The remedy is an access decision the app owner makes on the master record, not a rewrite. The two exported port types (AttachmentSharingLike, CommentSharingLike) now pick the sharing service's tri-state member instead of its two-state projection; the registered sharing service serves both, and a hand-written double meets the compiler at its own call site. The other categories are closed on facts: both bumped packages publish (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered / already-registered); and the port types were concretely typed before, not erased (not type-surface-only). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055.
13+
14+
**What stops being accepted.**
15+
16+
- A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read.
17+
- Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`.
18+
- The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`.
19+
20+
**What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets.
21+
22+
**What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
The registered `security` service serves `checkControlledByParentWrite(object, recordId, context)`, the ADR-0055 master-detail write check that `ISecurityService` declares as optional.
6+
7+
- **What it answers.** It answers what a by-id update of the record gets from the master-detail write check. It runs the write path's own composition and never re-derives it: the engine middleware's context prologue (a principal-less context, permission sets that cannot be resolved and a missing on-behalf-of delegator are refused with the same `403 PERMISSION_DENIED`), and then the check for the principal's permission sets and, on a delegated context, for the delegator's (ADR-0090 D10). The first refusal is the answer. A refusal resolves `deny` with the leg it came from, the three non-verdicts resolve `unresolvable` with their reason, and an object that is not `controlled_by_parent` resolves `not_applicable`. A system context resolves `allow`. A store fault rejects with the engine's own error, so a datasource outage keeps its `503`.
8+
- **Where it differs from the write path's guard.** A principal whose permission sets resolve to none is judged over that empty list, so it answers `deny` on `object_permission` as the contract declares. A principal with positions or sets and no user id is also judged, where the write path skips the check. It answers `deny` when it holds no `update` grant on the master, and its own update is refused by the object-level gate anyway.
9+
- **Who asks it.** The `sys_attachment` parent gate in `@objectstack/service-storage` and the `sys_comment` moderation gate in `@objectstack/plugin-audit` ask it, so they judge a `controlled_by_parent` parent through its master.
10+
- The boot line that announces the `security` service now lists the members the registered service actually serves, read from the service object. The hand-kept list it replaced had fallen behind by five members.
11+
12+
Nothing an author writes changes, and no by-id write is judged differently. The middleware's context prologue moved into one method that the middleware and the member both call. Its refusals, their order and their wording are unchanged.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **121
13+
because the flag is not one concept: it is a single boolean read at **122
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -106,6 +106,7 @@ that silently does not happen.
106106
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
107107
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
108108
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
109+
| 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` |
109110
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` |
110111
| 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` |
111112
| 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the seeders and the package door write rows for definitions that already have their home, untouched. Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` |
@@ -142,7 +143,7 @@ that silently does not happen.
142143

143144
### 3. Sharing (`plugin-sharing`)
144145

145-
The largest single consumer — **17 of the 121 sites**.
146+
The largest single consumer — **17 of the 122 sites**.
146147

147148
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
148149
|:--|:---|:---|:---|
@@ -285,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
285286
independent decisions, and a seed loader plausibly wants the first two but not
286287
the third. The concept is nevertheless **staying as one boolean**:
287288

288-
- **Shipped semantics.** `isSystem` is a published contract with 121 read sites
289+
- **Shipped semantics.** `isSystem` is a published contract with 122 read sites
289290
in 20 packages. Splitting it is a breaking contract change across all of them.
290291
(The ruling was taken when the census read 80 sites in 18 packages; the count
291292
has grown, which strengthens rather than weakens the argument.)
@@ -359,16 +360,16 @@ still holds equal to the census on every pull request:
359360
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
360361
| — parsed as a declaration | 28 | ✅ |
361362
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
362-
| — parsed as a property **read** | 127 | ✅ |
363+
| — parsed as a property **read** | 128 | ✅ |
363364
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
364365
| — the remainder: text inside comments and string literals | 358 | — |
365366
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
366-
| Of those reads: reads of `ExecutionContext.isSystem` | **121** | ✅ |
367-
| — behaviour-bearing (rows 1–61 above) | 118 | ✅ |
367+
| Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ |
368+
| — behaviour-bearing (rows 1–61 above) | 119 | ✅ |
368369
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
369370
| Packages containing at least one elevation read | **20** | ✅ |
370371
| Files containing at least one elevation read | 57 | ✅ |
371-
| — the distinct symbols those reads live in — what this page anchors | 103 | ✅ |
372+
| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ |
372373
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
373374

374375
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import { resolveLocalizationContext } from '@objectstack/core';
5-
import type { IDataEngine, II18nService, ISharingService } from '@objectstack/spec/contracts';
5+
import type { IDataEngine, II18nService, ISecurityService, ISharingService } from '@objectstack/spec/contracts';
66
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
77
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
88
// It has landed, so the contribution moved to @objectstack/service-messaging —
@@ -355,7 +355,7 @@ export class AuditPlugin implements Plugin {
355355
() => {
356356
try {
357357
// Typed with the slot's contract (#4251): the gate consults
358-
// `canEdit` only, but it consults the REAL interface.
358+
// `checkEdit` only, but it consults the REAL interface.
359359
return ctx.getService<ISharingService>('sharing');
360360
} catch {
361361
return null;
@@ -372,6 +372,17 @@ export class AuditPlugin implements Plugin {
372372
if (typeof t !== 'function') return undefined;
373373
return (key: string, loc: string, params?: Record<string, unknown>) => t.call(i18n, key, loc, params);
374374
},
375+
// [ADR-0055] The security service, for its master-detail write check:
376+
// a `controlled_by_parent` parent is judged through its master, as its
377+
// own update is. Resolved per check (the security plugin may start
378+
// after this one); a kernel without it composes no master check.
379+
() => {
380+
try {
381+
return ctx.getService<ISecurityService>('security');
382+
} catch {
383+
return null;
384+
}
385+
},
375386
);
376387
if (typeof (engine as any).registerMiddleware === 'function') {
377388
installCommentReadVisibility(engine as any, ctx.logger);

0 commit comments

Comments
 (0)