Skip to content

Commit dfab597

Browse files
feat(spec): declare declaredRuleNamed on ISharingRuleService; document sharing-rule regime C (#25250)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25187 Clause-②: yes (widening) Metadata-protocol: none This PR handles the three regime C follow-ups the card lists: the contract member, the docs paragraph and the ledger docblock. It changes no behaviour. ## What changed 1. **Contract** (`packages/spec/src/contracts/sharing-service.ts`). `ISharingRuleService` gains an optional `declaredRuleNamed?(name: string)` member. It resolves to `{ name: string; packageId?: string }` or `null`. It is optional, has the shape the activation door already duck-typed, and its docblock covers the probe-and-refuse duty and the 503. 2. **The door reads the contract's type** (`packages/runtime/src/domains/catalog-activation.ts`). The local `interface DeclaredSharingRuleReader` is now a type alias: `Required` applied to a `Pick` of `ISharingRuleService`'s `declaredRuleNamed`. The change is type-only: the `typeof … === 'function'` probe, the 404 and the 503 `SERVICE_UNAVAILABLE` stay as they were. A runtime build shows the symbol in no emitted file (see Evidence). 3. **`SharingRuleService` satisfies the member as declared.** It already `implements ISharingRuleService`, and its `declaredRuleNamed(name)`, which resolves to a `DeclaredSharingRule` or `null`, type-checks against the new member. Nothing in `plugin-sharing` changed. 4. **Docs** (`content/docs/permissions/sharing-rules.mdx`). This adds a new section, "Declared rules are read-only: clone one, then switch it off". It covers read-only declared rules, clone and switch off through `POST {basePath}/security/_activation/sharing_rule/:name`, the 409 `RESOURCE_CONFLICT` refusal of a new or renamed row under a declared name, the `manage_metadata` gate plus the platform-operator wall, and the reconcile-failure window. Each claim cites the function and file it was read from at this head. The `/meta` half links to `concepts/metadata-lifecycle`, which this PR leaves unchanged and which stays consistent with the new section. 5. **Ledger docblock** (`packages/platform-objects/src/system/sys-metadata-activation.object.ts`). The `metadata_type` description now lists every type the ledger holds at this head: `flow` (service-automation's flow store), `action` (objectql's action store), and `position`, `permission`, `sharing_rule` and `webhook` (the catalog activation door). 6. **Changeset** `.changeset/25187-declared-rule-reader-contract.md`: `@objectstack/spec` and `@objectstack/platform-objects` at `patch`, following `Metadata-protocol: none`. `@objectstack/runtime` is not listed because its change is type-only and nothing in its published `dist` moves (see Evidence). ## How the docs claims were checked against the code - **Read-only.** `SharingRuleService.deleteRule` refuses a declared name with `PERMISSION_DENIED`, and `sharing-routes.ts` `handleError` answers 403. `PUT /meta/sharing_rule/:name` is refused because the `sharing_rule` registry row in `metadata-plugin.zod.ts` sets `allowRuntimeCreate: false`. - **409.** `DeclaredSharingRuleNameConflictError` sets `code = 'RESOURCE_CONFLICT'` and `status = 409`. It is thrown by `defineRule` (create) and by `bindDeclaredRuleNameGuard`'s `beforeInsert`/`beforeUpdate` hooks (insert, and a rename onto a declared name). System writes are exempt. - **Operator wall.** In `activation-gate.ts`, `refuseUngrantedActivationAuthoring` requires `manage_metadata`. `refuseUngrantedActivationWrite` is inert when no posture is set, or under `single`; under a wall it requires `posture === 'PLATFORM_ADMIN'`. `SHARING_RULE_ACTIVATION_SUBJECT.remedy` names the platform operator. - **Reconcile window.** `bindSharingRuleActivationReconcile` registers a plain `registerHook` handler with no `meta`, so `ObjectQL.opensAfterAbortUnit` opens no unit. `ObjectStoreMetadataActivationStore.setActive` opens no transaction. So the ledger row stays written when the reconcile throws, and the door answers the error through `errorFromThrown(err, 503)`. At boot, `kernel:bootstrapped` in `sharing-plugin.ts` runs the `listRules` backfill, which includes served declared projections, and then `sweepOrphanedRuleGrants`, which revokes the grants of a declared rule that is no longer served. A repeated switch re-runs the reconcile because `setActive` always issues an `update`, and `afterUpdate` fires on it. ## Evidence All readings were taken on head `672daf099`, the last commit, unless noted otherwise. - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: VERDICT command-exit 0, "All 14 generated artifacts are up to date". No regeneration was needed. - `pnpm --filter @objectstack/runtime --filter @objectstack/plugin-sharing --filter @objectstack/platform-objects --filter @objectstack/spec run typecheck`: VERDICT command-exit 0, all four packages done. - **Reverse verification** (cross-package type change). The member's return was mutated to `{ name: number; … }` through `scripts/ablation-replace.mjs`, and spec was rebuilt. `tsc --noEmit` then went red in both consumers: - `runtime src/domains/catalog-activation.ts(159,36): error TS2322` - `plugin-sharing src/sharing-rule-service.ts(1050,9): error TS2416 Property 'declaredRuleNamed' in type 'SharingRuleService' is not assignable to the same property in base type 'ISharingRuleService'` The restore was checked three ways: the blob equals HEAD with an empty `git diff HEAD`; after a spec rebuild, 0 dist files carry the mutated type; and both consumers' `tsc` passed again. - **Tests:** - `vitest run src/domains/catalog-activation-door.test.ts` (runtime): 64/64. - `vitest run src/sharing-rule.test.ts` (plugin-sharing): 140/140. - `pnpm --filter @objectstack/platform-objects test`: 87 files, 1278/1278. - **Runtime publish measure.** After `pnpm --filter @objectstack/runtime build`, a grep of `dist/` (maps excluded) for `DeclaredSharingRuleReader`, `ISharingRuleService` and the new comment text finds 0 files each. The positive control `declaredSharingRuleReaderOf` is found in 2 files. - **Gates.** The local subset comes from `node scripts/pm/dispatch-gates.mjs --commands` (106 commands). Results are under "Local gates" below. ## Local gates All gates were run on head `672daf099`. - **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 106 commands, and all 106 were run. The first pass ended with 103 at exit 0 and 3 at exit 3 (`PREREQUISITE NOT MET`, no build output): `spec check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`. Their prerequisite builds were run (turbo, `--concurrency=2`), and all three then exited 0. `check:i18n` reported "all bundles in sync". `check:skill-examples` reported "262 prose examples type-check". - **Reconciliation.** `dispatch-gates --ran` reports "106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. - **Local-only gates.** `pnpm check:dev-prereqs` exited 0, and so did `pnpm check:skill-frame-freshness`. - **Left to CI.** The repo-wide farm (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, `Build Docs`) is not repeated locally. ## Acceptance notes - `docs/adr/0126-packaged-metadata-customization-model.md:205` still lists `metadata_type` as (`'flow'`, `'permission'`, …). It is a governed surface and outside this card, so it is left as is. The docblock this PR changes is the field's runtime description. - Not covered here, and recorded only as an observation: the `/data` door's 409 for the name guard is asserted by the error class's declared `code`/`status` and by the plugin-sharing tests. This PR adds no end-to-end HTTP pin for it. - **Deviation from the claim's file surface.** The claim listed three files plus a changeset. The dispatch asked for the runtime door to read the contract type in the same PR when it can, so `packages/runtime/src/domains/catalog-activation.ts` is touched too. The change is type-only. --- _Generated by [Claude Code](https://claude.ai/code/session_01B2EeooonSXrANpkpgUSfFZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bc8cc6 commit dfab597

5 files changed

Lines changed: 119 additions & 4 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/platform-objects': patch
4+
---
5+
6+
feat(spec): `ISharingRuleService` declares the optional `declaredRuleNamed` reader that the catalog activation door uses for declared sharing rules
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **The contract member.** `ISharingRuleService` (`@objectstack/spec/contracts`) gains an optional `declaredRuleNamed?(name)` member. It returns `Promise<{ name: string; packageId?: string } | null>`: the rule the application declares under that name, or `null` when nothing servable is declared under it. The answer covers every organization, and it does not depend on whether the activation ledger has the rule switched off. `POST /api/v1/security/_activation/sharing_rule/:name` reads it to confirm the name it is asked to switch. Before this change the door read the method by duck type, and the contract did not mention it.
12+
- **Optional, so no implementer breaks.** An implementation that serves no declared rules can leave it out. A caller probes for it and refuses fail-closed when it is absent. The activation door still answers `503 SERVICE_UNAVAILABLE` in that case, so the door's behaviour does not change. `@objectstack/plugin-sharing`'s `SharingRuleService` already implements it with a compatible signature.
13+
- **The ledger's field description.** On `sys_metadata_activation`, the `metadata_type` description now lists every type the ledger holds: `flow`, `action`, `position`, `permission`, `sharing_rule` and `webhook`. It used to stop at `flow`, `permission`, ….

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,80 @@ upgraded from a build that leaked these rows — converges on the next start.
242242
forces a reconcile on demand.
243243
</Callout>
244244

245+
### Declared rules are read-only: clone one, then switch it off
246+
247+
A sharing rule comes from one of two sources. The application **declares** it
248+
(a stack's `sharingRules`), or an organization **authors** its own as a
249+
`sys_sharing_rule` row. A declared rule is code. It is served from the
250+
registry and has no row, so it is read-only from every API (ADR-0131 D6,
251+
regime C):
252+
253+
- `DELETE {basePath}/sharing/rules/:idOrName` on a declared name answers
254+
`403 PERMISSION_DENIED`, switched off or not
255+
(`SharingRuleService.deleteRule`, `plugin-sharing/src/sharing-rule-service.ts`).
256+
- `PUT {basePath}/meta/sharing_rule/:name` is refused too. The `/meta` side
257+
of this rule is described in
258+
[Metadata lifecycle](/docs/concepts/metadata-lifecycle).
259+
260+
**To customise a declared rule, clone it and switch the original off.**
261+
Create your own rule under a **new name**, with the criteria, recipient and
262+
access level you want. Then switch the declared rule off through the
263+
activation door:
264+
265+
```http
266+
POST {basePath}/security/_activation/sharing_rule/:name
267+
{ "enabled": false }
268+
```
269+
270+
The door writes one `sys_metadata_activation` row (`metadata_type:
271+
'sharing_rule'`, no organization column). The same request then revokes
272+
every grant that carries the declared rule's name, and `{ "enabled": true }`
273+
re-derives them (`handleCatalogActivationWrite`,
274+
`runtime/src/domains/catalog-activation.ts`;
275+
`bindSharingRuleActivationReconcile` and
276+
`SharingRuleService.reconcileDeclaredRuleActivation`). The door confirms the
277+
name through the sharing-rule service's `declaredRuleNamed`, and an unknown
278+
name answers `404`. A deployment whose sharing-rule service does not provide
279+
that reader answers `503 SERVICE_UNAVAILABLE`: the door does not write a row
280+
for a name it cannot confirm is declared.
281+
282+
**A declared rule's name is taken in every organization.** A new row under a
283+
declared name is refused with `409 RESOURCE_CONFLICT`, and so is an update
284+
that renames a row onto one. The refusal message names the clone path. This
285+
applies to `POST {basePath}/sharing/rules`, to Setup's **New** and edit forms,
286+
and to scripts and imports through the data API
287+
(`DeclaredSharingRuleNameConflictError` and `bindDeclaredRuleNameGuard`,
288+
`plugin-sharing/src/sharing-rule-service.ts`). An update that keeps the
289+
row's name, such as switching your own clone's `active` field, is not
290+
affected.
291+
292+
**Who may flip the switch.** The ledger row is deployment-level, so the door
293+
is gated by the activation authority, not by `manage_sharing`:
294+
295+
- the caller must hold `manage_metadata` (`403 PERMISSION_DENIED` otherwise);
296+
- under a walled tenancy posture (`group` / `isolated`), the caller must also
297+
be the **platform operator**, because one row switches the rule off in
298+
every organization. An organization administrator is refused with
299+
`403 PERMISSION_DENIED`, and the message says to ask the platform operator;
300+
- under `single`, the `manage_metadata` holder is enough.
301+
302+
These checks are `refuseUngrantedActivationAuthoring` and
303+
`refuseUngrantedActivationWrite` (`runtime/src/domains/activation-gate.ts`,
304+
ADR-0126 §5). Rules an organization authors under its own names stay that
305+
organization's to create, and each is switched by its own `active` field.
306+
307+
**If the reconcile fails.** The grant reconcile runs in an `afterInsert` /
308+
`afterUpdate` hook on `sys_metadata_activation`, after the ledger row is
309+
written, and no transaction wraps the two. A reconcile that throws reaches
310+
the door, which answers the error instead of `200`. The ledger row has
311+
already been written, though, and it keeps its new value. Some of the rule's
312+
grants can then disagree with the switch until the next boot. At boot,
313+
`sweepOrphanedRuleGrants` revokes the grants of a rule that is switched off,
314+
and the rule backfill re-derives the grants of one that is switched on
315+
(`SharingServicePlugin`'s `kernel:bootstrapped` handler,
316+
`plugin-sharing/src/sharing-plugin.ts`). Sending the same switch again re-runs
317+
the reconcile without waiting for a restart.
318+
245319
### There is no "share every record" rule
246320

247321
The predicate is **mandatory on every authoring path**, whether you declare

‎packages/platform-objects/src/system/sys-metadata-activation.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export const SysMetadataActivation = ObjectSchema.create({
122122
required: true,
123123
searchable: true,
124124
maxLength: 100,
125-
description: "The artifact's registry type ('flow', 'permission', …).",
125+
description: "The artifact's registry type ('flow', 'action', 'position', 'permission', 'sharing_rule', 'webhook').",
126126
group: 'Identity',
127127
}),
128128

‎packages/runtime/src/domains/catalog-activation.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
*/
5454

5555
import { AUDIENCE_ANCHOR_POSITIONS, GUEST_POSITION } from '@objectstack/spec/identity';
56+
import type { ISharingRuleService } from '@objectstack/spec/contracts';
5657
import {
5758
METADATA_ACTIVATION_TABLE,
5859
ObjectStoreMetadataActivationStore,
@@ -111,10 +112,12 @@ const NOUN: Readonly<Record<CatalogActivationType, string>> = {
111112
* the code, whether or not the ledger has it switched off. A rule an
112113
* organization authored is a `sys_sharing_rule` row with a switch of its own
113114
* (`active`) and is never addressed here.
115+
*
116+
* The reader is the contract's own optional member,
117+
* `ISharingRuleService.declaredRuleNamed` (`@objectstack/spec/contracts`): this
118+
* door probes for its presence and answers 503 when it is absent.
114119
*/
115-
interface DeclaredSharingRuleReader {
116-
declaredRuleNamed(name: string): Promise<{ name: string; packageId?: string } | null>;
117-
}
120+
type DeclaredSharingRuleReader = Required<Pick<ISharingRuleService, 'declaredRuleNamed'>>;
118121

119122
function declaredSharingRuleReaderOf(service: unknown): DeclaredSharingRuleReader | undefined {
120123
const candidate = service as Partial<DeclaredSharingRuleReader> | null | undefined;

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,31 @@ export interface ISharingRuleService {
614614
* upserts/reconciles only that record's share rows.
615615
*/
616616
evaluateAllForRecord(object: string, recordId: string, context: ExecutionContext): Promise<SharingRuleEvaluationResult[]>;
617+
618+
/**
619+
* [ADR-0131 D6 regime C] The rule the application DECLARES under `name` (a
620+
* stack's `sharingRules`), or `null` when it declares no rule the service
621+
* serves under that name. It answers for the declaration itself: in every
622+
* organization, and whether or not the activation ledger
623+
* (`sys_metadata_activation`, `metadata_type: 'sharing_rule'`) has it
624+
* switched off. A rule an organization authored is a `sys_sharing_rule` row
625+
* and is never answered here.
626+
*
627+
* The catalog activation door (`POST /security/_activation/sharing_rule/:name`)
628+
* reads it to confirm that the name it is asked to switch is a declaration,
629+
* and records `packageId` on the ledger row it writes.
630+
*
631+
* **Optional, deliberately.** An implementation that serves no declared
632+
* rules need not carry it. A caller MUST probe for presence and refuse
633+
* fail-closed when it is absent: the activation door answers
634+
* `503 SERVICE_UNAVAILABLE` rather than write a ledger row for a name
635+
* nothing can confirm is declared.
636+
*
637+
* @param name - The declared rule's machine name
638+
* @returns the declaration's `name`, plus `packageId` when a package ships
639+
* it; `null` when nothing servable is declared under `name`
640+
*/
641+
declaredRuleNamed?(name: string): Promise<{ name: string; packageId?: string } | null>;
617642
}
618643

619644
// ─────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)