From 0a93e8096a71bdfb7b6890919de39b7bfd57062e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:40:53 +0000 Subject: [PATCH 01/14] wip(spec): sharing_rule becomes a code-only metadata kind Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .../src/packaged-base-regime.ts | 16 ++++++++-- packages/metadata-protocol/src/protocol.ts | 21 ++++++++++--- .../src/kernel/metadata-create-seeds.test.ts | 4 +++ .../spec/src/kernel/metadata-plugin.zod.ts | 30 +++++++++++++++++++ .../spec/src/kernel/metadata-type-schemas.ts | 28 +++++++++-------- .../meta-spelling/metadata-url-spelling.ts | 19 ++++++------ 6 files changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 77839a96c12..2d46fd8ec6f 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -55,9 +55,13 @@ * to `POST /data/sys_permission_set` under a new name. Spelled as * `plugin-security`'s own lock refusal spells that same path * (`packaged-permission-set-lock.ts`), so one condition reads one way at - * every door. + * every door; + * - `sharing_rule` — both (ADR-0131 D6 charts it under regime C): the clone is + * an organization rule under a new name, `POST /sharing/rules`, and the + * switch is the activation ledger's row, `POST /security/_activation/sharing_rule/:name`, + * body `{ enabled }`. * - * Both switch routes are gated by the ONE §5 write-authority gate + * The switch routes are gated by the ONE §5 write-authority gate * (`packages/runtime/src/domains/activation-gate.ts`), so both carry the same * operator-only clause. Still absent: ADR-0126 §3's pre-charted `tool` / * `skill` / `position` — no sanctioned path is built for them, and a row with no @@ -198,6 +202,14 @@ export const PACKAGED_BASE_REGIME: Readonly> = { + private static readonly CODE_ONLY_TYPE_REMEDY: Readonly> = { field: ' Fields are not standalone artifacts: author it inside its object and write the whole ' + 'object instead (PUT /api/v1/meta/object/:object with the new field in `fields`), or declare ' + 'it in the object source (**/*.object.ts) and redeploy.', + sharing_rule: ' A sharing rule is not runtime metadata: author an organization rule instead ' + + '(POST /api/v1/sharing/rules, or Setup > Sharing Rules), or clone the declared rule under a new ' + + 'name as an organization rule and switch the declared one off (POST ' + + '/api/v1/security/_activation/sharing_rule/:name, body {enabled: false}; operator-only where one ' + + 'install serves several organizations). A package declares its rules in the stack\'s ' + + '`sharingRules` and redeploys.', }; /** @@ -17641,12 +17654,12 @@ export class ObjectStackProtocolImplementation implements * is supposed to declare this item instead. Read from the type's own * registry entry (`filePatterns`), so a newly-flagged type carries an * accurate hint the day it is flagged — nothing here to keep in sync, - * except the fragment types {@link NESTED_TYPE_REMEDY} names explicitly. + * except the types {@link CODE_ONLY_TYPE_REMEDY} names explicitly. */ private static codeOnlySourceHint(type: string): string { const singular = PLURAL_TO_SINGULAR[type] ?? type; - const nested = ObjectStackProtocolImplementation.NESTED_TYPE_REMEDY[singular]; - if (nested) return nested; + const named = ObjectStackProtocolImplementation.CODE_ONLY_TYPE_REMEDY[singular]; + if (named) return named; const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === singular); const pattern = entry?.filePatterns?.[0]; return pattern ? ` Declare it in source (${pattern}) and redeploy.` : ''; diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index 015ea546d8d..cbccc0e0174 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -81,6 +81,10 @@ describe('metadata create seeds validate against their spec schemas', () => { // `picklist` is package-owned (`allowRuntimeCreate: false`): there is no // runtime create surface to seed. Same category as `capability`. 'picklist', + // [ADR-0131 D6] `sharing_rule` is code-only (regime C): an organization + // authors its own rules through the sharing door, not `/meta`. Same + // category as `capability`. + 'sharing_rule', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 28732ed60ba..818bb75b12f 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -179,6 +179,15 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // deliberately NOT admitted here: they have no `PLURAL_TO_SINGULAR` mapping, // no declaration schema and no read-back seam — see #5961's ruling. 'capability', // Package-declared authorization capabilities (CapabilityDeclarationSchema, ADR-0066 D1) + // `sharing_rule` was a URL-map-only kind with no registry entry, so the two + // `/meta` doors read its absence as "runtime-creatable": `/meta/types` + // advertised `allowRuntimeCreate: true` and `PUT /meta/sharing_rule/:name` + // stored an environment-wide item the sharing service then enforced — a third + // authoring door beside the organization's `sys_sharing_rule` rows and the + // package's declaration, past both the same-name guard and the organization + // wall. Regime C (ADR-0131 D6) has two sources and only two; the registry + // entry declares the type code-only so both doors read one sealed answer. + 'sharing_rule', // Package-declared sharing rules (SharingRuleSchema, ADR-0131 D6 regime C) // AI Protocol 'agent', // AI agent definitions (AgentSchema) @@ -1222,6 +1231,27 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // DEFINITION side of ADR-0066 D1: grants live on permission sets, // requirements on resources. { type: 'capability', label: 'Capability', description: 'A permission a package defines; you grant it through permission sets', filePatterns: ['**/*.capability.ts', '**/*.capability.yml'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 12, domain: 'security' }, + // [ADR-0131 D6, regime C] Declared sharing rules. + // + // ⛔ CODE-ONLY, the `capability` pair, for a regime-C reason: a sharing rule + // has exactly two sources — a package declares it (the stack's `sharingRules`, + // served from the registry, switched off through the activation ledger) and an + // organization authors its own (`sys_sharing_rule`, `POST /api/v1/sharing/rules`, + // where the same-name guard and the organization wall hold). A runtime + // `sys_metadata` item is neither, so `saveMetaItem` refuses + // `PUT /api/v1/meta/sharing_rule/:name` with 403 `NOT_CREATABLE` (a new name) + // or `NOT_OVERRIDABLE` (a declared one) on every kernel, and `/meta/types` + // advertises `allowRuntimeCreate: false`. The refusal names the two sanctioned + // paths rather than `filePatterns[0]` (`CODE_ONLY_TYPE_REMEDY` in + // metadata-protocol). + // + // The declaration channel is untouched: artifact ingest maps the stack's + // `sharingRules` onto items of this type through `registerApp`, never through + // `saveMetaItem`. `filePatterns` follows the `*.TYPE.ts` convention so the + // CLI's file-name derivation has a spelling; nothing this repo boots reaches + // the glob. `supportsOverlay: false` — a declared rule is customized by a + // clone, never by an overlay of it. + { type: 'sharing_rule', label: 'Sharing Rule', description: 'A rule a package declares to share records; organizations author their own in Setup', filePatterns: ['**/*.sharing-rule.ts', '**/*.sharing-rule.yml'], supportsOverlay: false, allowEnvironmentOverlay: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 16, domain: 'security' }, // AI Protocol // `agent`: executionPinned — long-running conversations must stick to the diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 27c7b268bf2..e3b53667b1e 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -184,6 +184,14 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // `capability` like every other kind, and the registry entry next to it // (`allowRuntimeCreate: false`) closes the runtime write door outright. capability: CapabilityDeclarationSchema, + // [ADR-0131 D6] `sharing_rule` moved here from `UNREGISTERED_KIND_SCHEMAS` + // when regime C made it a code-only kind (its registry entry carries + // `allowRuntimeCreate: false` + `allowEnvironmentOverlay: false`). The binding is + // the one its stack collection is validated against (`stack.zod.ts`: + // `sharingRules: z.array(SharingRuleSchema)`), `.strict()` and declaring the + // ADR-0010 envelope. It is an AUTHOR-shape check applied where author shapes + // are submitted, never a filter on rows already in `sys_sharing_rule`. + sharing_rule: SharingRuleSchema, // AI Protocol agent: AgentSchema, @@ -204,6 +212,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = * `PUT /api/v1/meta/webhook/:name` accepted ANY JSON and returned * `success: true`. Enforced but undeclared — the mirror of declared-but- * unenforced, and the same hole, three more doors. + * (`sharing_rule` has since left this map: ADR-0131 D6 made it a code-only kind.) * * [commit 2306a765c, the same shape again] `theme` and `analytics_cube` sat in the very * position this docblock describes — declared, authorable stack collections @@ -255,15 +264,9 @@ const UNREGISTERED_KIND_SCHEMAS: Record = { // so catalog descriptors are unaffected. connector: DeclarativeConnectorEntrySchema as unknown as z.ZodType, - // `stack.zod.ts`: `sharingRules: z.array(SharingRuleSchema)`. - // - // This shape is `.strict()` with no stored/stamped envelope, so it is an - // AUTHOR-shape check and is applied only where author shapes are submitted: - // the write door. It is not a filter on rows already in `sys_sharing_rule`. - // Nothing re-parses stored rows through this registry — the read path only - // DECORATES a document with `_diagnostics` (`computeMetadataDiagnostics`) and - // never refuses one. Same treatment #5271/#5312 gave their strict surfaces. - sharing_rule: SharingRuleSchema, + // `sharing_rule` was bound here (#6245) until ADR-0131 D6 charted it under + // regime C and it became a code-only KIND — see its entry in + // `BUILTIN_METADATA_TYPE_SCHEMAS` above. // [commit 2306a765c → 35ad101bc] `theme: ThemeSchema` was bound here when `themes` was a // stack collection; commit 35ad101bc retired that carrier and the schema whole @@ -328,7 +331,7 @@ export function getMetadataTypeSchema(type: string): z.ZodType | undefined { * a kind enters the live set as a side effect of registering an ITEM of that * kind (`SchemaRegistry.registerItem` during app/manifest registration, or * `MetadataManager.register` at runtime), which is what #6245's schema-only - * bindings for `webhook` / `connector` / `sharing_rule` rely on. + * bindings for `webhook` / `connector` rely on. */ export function registerMetadataTypeSchema(type: string, schema: z.ZodType): void { EXTRA_METADATA_TYPE_SCHEMAS.set(type, schema); @@ -353,8 +356,9 @@ export function listMetadataTypeSchemaTypes(): string[] { /** * Snapshot of the non-KIND stack collections bound in - * `UNREGISTERED_KIND_SCHEMAS` — today `webhook` / `connector` / `sharing_rule` - * / `theme` / `analytics_cube` (#6245 the first three, commit 2306a765c the last two). + * `UNREGISTERED_KIND_SCHEMAS` — today `webhook` / `connector` / `theme` / + * `analytics_cube` (#6245 the first two, commit 2306a765c the last two; #6245's + * third, `sharing_rule`, became a kind under ADR-0131 D6). * * [#6931] This exists so a check can ENUMERATE that map, and for nothing else. * The exclusion documented directly above is about {@link diff --git a/packages/spec/src/meta-spelling/metadata-url-spelling.ts b/packages/spec/src/meta-spelling/metadata-url-spelling.ts index a8f8e676680..b47e7631b08 100644 --- a/packages/spec/src/meta-spelling/metadata-url-spelling.ts +++ b/packages/spec/src/meta-spelling/metadata-url-spelling.ts @@ -257,15 +257,16 @@ export function metaUrlSpellingRefusal( * {@link META_URL_TO_SINGULAR} rather than its keys. * * Strictly larger than `DECLARED_META_TYPES`, and that difference is the whole - * reason this set exists: limb 1 carries five kinds that NO registry derivation - * could produce — `webhook`, `connector`, `sharing_rule`, `analytics_cube`, - * `rag_pipeline` — which are legal, addressable metadata kinds with no static - * registry entry. A refusal quantified over the registry alone would refuse - * all five, i.e. break `PUT /meta/webhook/stripe`, which is the exact - * operation the plugin path exists to serve. (`theme` was the sixth until - * commit 35ad101bc retired its carrier; dropping the `PLURAL_TO_SINGULAR` row is what - * moved `/meta/theme` from this set to `unrecognisedMetaTypeRefusal`'s - * verdict.) + * reason this set exists: limb 1 carries four kinds that NO registry derivation + * could produce — `webhook`, `connector`, `analytics_cube`, `rag_pipeline` — + * which are legal, addressable metadata kinds with no static registry entry. A + * refusal quantified over the registry alone would refuse all four, i.e. break + * `PUT /meta/webhook/stripe`, which is the exact operation the plugin path + * exists to serve. (`theme` was the sixth until commit 35ad101bc retired its + * carrier; dropping the `PLURAL_TO_SINGULAR` row is what moved `/meta/theme` + * from this set to `unrecognisedMetaTypeRefusal`'s verdict. `sharing_rule` was + * the fifth until ADR-0131 D6 gave it a code-only registry entry: it is a + * declared type now, and its write is refused by that declaration.) * * Module-internal (#8424), for the same reason `DECLARED_META_TYPES` is: it * LOOKS like a live registry of registered types and is not one. From 9905c15059bb5a309e383a4d36360ca48b73d76a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:47:56 +0000 Subject: [PATCH 02/14] wip(spec): regenerate artifacts for the sharing_rule kind Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- content/docs/references/api/metadata.mdx | 5 +++-- .../references/kernel/metadata-plugin.mdx | 6 ++++-- .../meta-spelling/meta-url-data.generated.ts | 2 ++ .../src/shared/metadata-url-spelling.test.ts | 19 +++++++++++++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index c1440099e69..bc19544ab5b 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -648,7 +648,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -715,7 +715,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +13 more>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +14 more>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | @@ -747,6 +747,7 @@ Metadata query with filtering, sorting, and pagination * `permission` * `position` * `capability` +* `sharing_rule` * `agent` * `tool` * `skill` diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 631e9603a4e..05d7ac99680 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'sharing_rule' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -250,6 +250,7 @@ const result = MetadataBulkResultSchema.parse(data); * `permission` * `position` * `capability` +* `sharing_rule` * `agent` * `tool` * `skill` @@ -263,7 +264,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +13 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +14 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -304,6 +305,7 @@ const result = MetadataBulkResultSchema.parse(data); * `permission` * `position` * `capability` +* `sharing_rule` * `agent` * `tool` * `skill` diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts index a40a2e45d03..f499614aea0 100644 --- a/packages/spec/src/meta-spelling/meta-url-data.generated.ts +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -52,6 +52,7 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "externalCatalogs": "external_catalog", "translations": "translation", "email_templates": "email_template", + "sharing_rules": "sharing_rule", }); /** @@ -84,6 +85,7 @@ export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze "permission", "position", "capability", + "sharing_rule", "agent", "tool", "skill", diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 1cf158f85a6..82c90749f71 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -224,8 +224,8 @@ describe('the second verdict: not a metadata type AT ALL', () => { it('accepts every manifest spelling AND the singular each one folds to', () => { // The direction that matters most, and the one a registry-quantified - // refusal would get wrong: five of these singulars — `webhook`, - // `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline` — are + // refusal would get wrong: four of these singulars — `webhook`, + // `connector`, `analytics_cube`, `rag_pipeline` — are // PLUGIN kinds with no static registry entry at all. Refusing them would // break `PUT /meta/webhook/stripe`, the exact operation the plugin path // exists to serve, which is the failure #8421's measurement disqualified @@ -241,16 +241,27 @@ describe('the second verdict: not a metadata type AT ALL', () => { } }); - it('carries the five plugin kinds by NAME, since quantification hides them', () => { + it('carries the four plugin kinds by NAME, since quantification hides them', () => { const declared = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); for (const kind of [ - 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'webhook', + 'analytics_cube', 'connector', 'rag_pipeline', 'webhook', ]) { expect(declared.has(kind), `${kind} must NOT be in the static registry`).toBe(false); expect(unrecognisedMetaTypeRefusal(kind), `${kind} must stay accepted anyway`).toBeNull(); } }); + it('`sharing_rule` left the plugin kinds for the registry — still a spelling the contract accepts', () => { + // [ADR-0131 D6] Regime C gave it a code-only registry entry, so the WRITE + // is now refused by that declaration (`NOT_CREATABLE`), never by this + // verdict: the spelling stays part of the contract. + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'sharing_rule'); + expect(entry, 'sharing_rule is a declared type').toBeDefined(); + expect({ create: entry?.allowRuntimeCreate, overlay: entry?.allowEnvironmentOverlay }) + .toEqual({ create: false, overlay: false }); + expect(unrecognisedMetaTypeRefusal('sharing_rule')).toBeNull(); + }); + it('`theme` left the contract with its carrier — refused by name', () => { // The retired kind's whole exit path: the `themes: 'theme'` fold left // `PLURAL_TO_SINGULAR`, so both spellings now earn the #8421 verdict From cc73318118c9a07c16db35267f09627ae4125b3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:59:34 +0000 Subject: [PATCH 03/14] wip: Setup nav entry for declared sharing rules; re-judge the plugin-kind pins Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- ...col.meta-types-mint-door-agreement.test.ts | 20 ++++++++++++++++--- packages/metadata-protocol/src/protocol.ts | 17 +++++++++------- .../src/apps/translations/en.ts | 1 + .../apps/translations/es-ES.source-hashes.ts | 1 + .../src/apps/translations/es-ES.ts | 1 + .../apps/translations/ja-JP.source-hashes.ts | 1 + .../src/apps/translations/ja-JP.ts | 1 + .../apps/translations/zh-CN.source-hashes.ts | 1 + .../src/apps/translations/zh-CN.ts | 1 + .../plugin-sharing/src/sharing-plugin.ts | 8 ++++++++ 10 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts index bf319d23b0a..74562647c4e 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts @@ -38,7 +38,7 @@ * 1. **statically declared** (`view`, `hook`, `agent`) — the flag comes off * the registry entry, as it always did, in both the `true` and the `false` * direction; - * 2. **URL-map-only plugin kinds** (`webhook`, and its four siblings) — no + * 2. **URL-map-only plugin kinds** (`webhook`, and its three siblings) — no * registry entry, IN the static spelling contract, still advertised and * still mintable. This is the discriminating control: without it the change * cannot show its narrowing is narrow; @@ -156,6 +156,20 @@ const SAMPLE: Array<{ creatable: false, item: { name: 'probe_agent', label: 'Probe' }, }, + { + // [ADR-0131 D6] `sharing_rule` left class 2 for class 1 when regime C gave + // it a code-only registry entry: an organization authors its rules through + // the sharing door, so the `/meta` door neither advertises nor honours a + // create. Spec-valid body, so the refusal read is the declaration's. + type: 'sharing_rule', + klass: 'declared', + creatable: false, + item: { + type: 'criteria', name: 'probe_meta_rule', label: 'Probe', object: 'task', + condition: "record.status == 'new'", accessLevel: 'read', + sharedWith: { type: 'position', value: 'probe_position' }, active: true, + }, + }, { type: 'webhook', klass: 'url-map-only', @@ -247,7 +261,7 @@ describe('#8421 — the read door and the mint door agree, across all three clas } }); - it('the five URL-map-only plugin kinds are ALL still advertised as creatable', async () => { + it('the four URL-map-only plugin kinds are ALL still advertised as creatable', async () => { // The blanket-flip guard, quantified rather than sampled. `webhook` // above is the one driven end-to-end through a write; the others have // no hand-written spec-valid body here, so they are pinned on the door @@ -257,7 +271,7 @@ describe('#8421 — the read door and the mint door agree, across all three clas const { protocol } = makeProtocol(); const listing = await protocol.getMetaTypes(); for (const kind of [ - 'analytics_cube', 'connector', 'rag_pipeline', 'sharing_rule', 'webhook', + 'analytics_cube', 'connector', 'rag_pipeline', 'webhook', ]) { const entry = listing.entries.find((e: any) => e.type === kind); expect(entry, `${kind} must be listed`).toBeDefined(); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a0097b70608..6cc4b77b52e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8837,8 +8837,8 @@ export class ObjectStackProtocolImplementation implements * the one that actually carries the plugin-registered family: manifests * register through the `manifest` service during kernel Phase 1 * (`ql.registerApp`), so by Phase 2 it holds `theme`, `connector`, - * `webhook`, `sharing_rule`, `analytics_cube`, … — none of which have a - * registry entry. + * `webhook`, `analytics_cube`, … — none of which have a registry entry + * (and `sharing_rule`, which has had one since ADR-0131 D6). * - the `metadata` service's `getRegisteredTypes()` — types the * MetadataManager knows. Its `typeRegistry` is seeded with * `DEFAULT_METADATA_TYPE_REGISTRY` in the manager's constructor, so @@ -8956,11 +8956,14 @@ export class ObjectStackProtocolImplementation implements // ({@link unrecognisedMetaTypeRefusal}) instead of two // independently-maintained rules that drifted apart. // - // What this keeps, and it is the whole point: the six legitimate - // plugin kinds with no static registry entry — `theme`, `webhook`, - // `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline` — - // are IN the static spelling contract (limb 1 of the URL map), so - // the predicate accepts them and they stay advertised as creatable. + // What this keeps, and it is the whole point: the legitimate + // plugin kinds with no static registry entry — `webhook`, + // `connector`, `analytics_cube`, `rag_pipeline` — are IN the static + // spelling contract (limb 1 of the URL map), so the predicate + // accepts them and they stay advertised as creatable. (`theme` and + // `sharing_rule` were two more: `theme` left the contract with its + // carrier, and ADR-0131 D6 gave `sharing_rule` a code-only registry + // entry, so it takes the registry arm above.) // `PUT /meta/theme/dark` is the operation the plugin path exists to // serve and this change must not touch it. // diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 521638376de..7f2626fce4f 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -92,6 +92,7 @@ export const en: TranslationData = { nav_capabilities: { label: 'Capabilities' }, nav_permission_sets: { label: 'Permission Sets' }, nav_sharing_rules: { label: 'Sharing Rules' }, + nav_declared_sharing_rules: { label: 'Declared Sharing Rules' }, nav_record_shares: { label: 'Record Shares' }, // `nav_sso_providers` is contributed by `@objectstack/plugin-auth` only // when the external-IdP RP is wired (`OS_SSO_ENABLED`, or the cloud diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 73fad1650b8..20c00a60ecb 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -94,6 +94,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', + 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 1f7843928b0..91760e4668d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -64,6 +64,7 @@ export const esES: TranslationData = { nav_capabilities: { label: 'Capacidades' }, nav_permission_sets: { label: 'Conjuntos de Permisos' }, nav_sharing_rules: { label: 'Reglas de Compartición' }, + nav_declared_sharing_rules: { label: 'Reglas de Compartición Declaradas' }, nav_record_shares: { label: 'Registros Compartidos' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index e088b62d82c..734adc15e22 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -94,6 +94,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', + 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 14cd00bd630..92f0c29b9c9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -64,6 +64,7 @@ export const jaJP: TranslationData = { nav_capabilities: { label: 'ケイパビリティ' }, nav_permission_sets: { label: '権限セット' }, nav_sharing_rules: { label: '共有ルール' }, + nav_declared_sharing_rules: { label: '宣言済みの共有ルール' }, nav_record_shares: { label: 'レコード共有' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 6f979543020..f8e0905a5aa 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -94,6 +94,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', + 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 7b3fa4b042f..f15ce3d9348 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -67,6 +67,7 @@ export const zhCN: TranslationData = { nav_capabilities: { label: '能力' }, nav_permission_sets: { label: '权限集' }, nav_sharing_rules: { label: '共享规则' }, + nav_declared_sharing_rules: { label: '已声明的共享规则' }, nav_record_shares: { label: '记录共享' }, // Conditionally contributed by `@objectstack/plugin-auth` (only when an // external IdP is wired), so `check:app-nav-i18n` cannot see it — see diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 80a2d758590..0a8472e5479 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -612,6 +612,13 @@ export class SharingServicePlugin implements Plugin { // ADR-0029 D7 — contribute the sharing entries into the Setup app's // `group_access_control` slot (priority 200 so they sit after plugin- // security's Roles / Permission Sets). This plugin owns these objects (K2). + // + // [ADR-0131 D6, regime C] A sharing rule has two sources, and Setup lists + // each where it lives: `nav_sharing_rules` opens the organization's own + // rules (`sys_sharing_rule` rows), `nav_declared_sharing_rules` the rules + // packages declare — registry items with no object behind them, so the + // entry is a `url` to Setup's list of the registry type in its environment + // scope, as plugin-security's `nav_permission_sets` is. navigationContributions: [ { app: 'setup', @@ -619,6 +626,7 @@ export class SharingServicePlugin implements Plugin { priority: 200, items: [ { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_declared_sharing_rules', type: 'url', label: 'Declared Sharing Rules', url: '/apps/setup/metadata/sharing_rule?scope=environment', icon: 'package', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', viewName: 'all_shares', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, ], }, From 3f37c591a296df179c0f004d9a402ec3c684aa03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:07:19 +0000 Subject: [PATCH 04/14] wip(runtime): pin the /meta door's sharing_rule refusal Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .../src/meta-sharing-rule-door.test.ts | 270 ++++++++++++++++++ .../src/kernel/metadata-type-schemas.test.ts | 22 +- .../src/shared/metadata-url-spelling.test.ts | 7 +- 3 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/meta-sharing-rule-door.test.ts diff --git a/packages/runtime/src/meta-sharing-rule-door.test.ts b/packages/runtime/src/meta-sharing-rule-door.test.ts new file mode 100644 index 00000000000..13aa8667ee0 --- /dev/null +++ b/packages/runtime/src/meta-sharing-rule-door.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6, regime C] The `/meta` door is not a third way to author a + * sharing rule — pinned at the RUNTIME DISPATCHER's `/meta` door + * (`HttpDispatcher.handleMetadata`, the transport the `@objectstack/hono` + * catch-all serves). + * + * A sharing rule has two sources: a package declares it (served from the + * registry, switched off through the activation ledger) and an organization + * authors its own (`sys_sharing_rule`, through `POST /api/v1/sharing/rules`, + * behind the same-name guard and the organization wall). `sharing_rule` had no + * metadata-type registry entry, so `GET /meta/types` advertised it + * `allowRuntimeCreate: true` and `PUT /meta/sharing_rule/:name` answered 200 + * with an environment-wide item the sharing service then enforced — past both + * guards. The registry now declares the type code-only. + * + * What is pinned, on both kernel shapes: + * - a create of a new name — `403` `NOT_CREATABLE`, in publish and draft mode, + * no row, nothing registered, and the refusal names the organization door; + * - a write over a declared rule's name — `403` `NOT_OVERRIDABLE`, no row; + * - the listing — `allowRuntimeCreate: false`, `allowEnvironmentOverlay: false`; + * - the control: a new position is still created (`200`, a row), so the + * refusal is the type's, not the door's. + * + * Every case drives the REAL `HttpDispatcher`, the REAL + * `ObjectStackProtocolImplementation` and the REAL `SysMetadataRepository` + * over a `sys_metadata`-shaped store, and reads the stored rows back. The store + * and registry doubles follow `meta-managed-content-seal.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpDispatcherResult } from './http-dispatcher.js'; + +const PKG = 'com.example.crm'; + +const DECLARED_RULE = { + type: 'criteria', + name: 'crm_share_open_leads', + label: 'Open Leads → Sales', + object: 'crm_lead', + condition: "record.status == 'open'", + accessLevel: 'read', + sharedWith: { type: 'position', value: 'sales_rep' }, + active: true, + _packageId: PKG, +}; + +const strip = (item: Record) => + Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + +/** A spec-valid rule under a name nothing declares — the probe the gap was measured with. */ +const PROBE_RULE = { ...strip(DECLARED_RULE), name: 'probe_meta_rule', label: 'Probe' }; + +interface Row { id: string; [k: string]: unknown } + +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (cond === undefined) continue; + if (key === '$or') { + if (!(cond as Array>).some((b) => matches(row, b))) return false; + continue; + } + const value = row[key]; + if (cond !== null && typeof cond === 'object') { + const op = cond as Record; + if ('$null' in op) { + if ((value === null || value === undefined) !== (op.$null === true)) return false; + continue; + } + if ('$in' in op) { + if (!(op.$in as unknown[]).includes(value)) return false; + continue; + } + continue; + } + if (cond === null) { + if (value !== null && value !== undefined) return false; + continue; + } + if (value !== cond) return false; + } + return true; +} + +function makeEngine() { + const tables = new Map(); + let nextId = 0; + const tableOf = (name: string) => { + let t = tables.get(name); + if (!t) { t = []; tables.set(name, t); } + return t; + }; + const artifacts = new Map>([ + ['sharing_rule', new Map([[DECLARED_RULE.name, DECLARED_RULE]])], + ]); + const runtimeItems = new Map>(); + const engine: any = { + registry: { + listItems: (type: string) => [ + ...Array.from(artifacts.get(type)?.values() ?? []), + ...Array.from(runtimeItems.get(type)?.values() ?? []), + ], + getItem: (type: string, name: string) => runtimeItems.get(type)?.get(name) ?? artifacts.get(type)?.get(name), + getArtifactItem: (type: string, name: string) => artifacts.get(type)?.get(name), + getObject: () => undefined, + // The type listing (`GET /meta/types`) reads the live types from here. + getRegisteredTypes: () => ['sharing_rule', 'position'], + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + // The producer's arity: `(type, item, keyStrategy, ownerId?)`. + registerItem: (type: string, item: any, keyStrategy?: string) => { + const key = keyStrategy === 'object' ? (item?.object as string) : (item?.name as string); + if (!key) return; + let byName = runtimeItems.get(type); + if (!byName) { byName = new Map(); runtimeItems.set(type, byName); } + byName.set(key, item); + }, + registerObject: () => {}, + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + const rows = tableOf(table).filter((r) => matches(r, opts?.where)); + // The caller's bound, applied after the filter (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(table: string, opts?: { where?: Record }) { + assertEngineFindOnePredicate(table, opts); + return tableOf(table).find((r) => matches(r, opts?.where)) ?? null; + }, + async insert(table: string, data: Record) { + nextId += 1; + const row: Row = { id: (data.id as string) ?? `r_${nextId}`, ...data }; + tableOf(table).push(row); + return row; + }, + // [#5619] Both write verbs open with the PRODUCER's own dispatch predicate + // (`check:engine-double-contract`). + async update(table: string, data: Record, opts?: { where?: Record }) { + const dispatch = assertEngineUpdateDispatch(data as any, opts as any); + const rows = tableOf(table); + const target = dispatch.kind === 'by-id' + ? rows.find((r) => r.id === dispatch.id) + : rows.find((r) => matches(r, opts?.where)); + if (target) Object.assign(target, data); + return target ?? null; + }, + async delete(table: string, opts?: { where?: Record }) { + const dispatch = assertEngineDeleteDispatch(opts as any); + const rows = tableOf(table); + const keep = dispatch.kind === 'by-id' + ? rows.filter((r) => r.id !== dispatch.id) + : rows.filter((r) => !matches(r, opts?.where)); + tables.set(table, keep); + return { deleted: rows.length - keep.length }; + }, + async count(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)).length; + }, + async aggregate() { return []; }, + async execute() { return undefined; }, + metaRows: () => tableOf('sys_metadata'), + runtimeItem: (type: string, name: string) => runtimeItems.get(type)?.get(name), + }; + return engine; +} + +function makeStack(environmentId?: string) { + const engine = makeEngine(); + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); + const services: Record = { + protocol, + objectql: { registry: engine.registry }, + auth: { api: { getSession: async () => ({ session: {} }) } }, + }; + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return { engine, dispatcher: new HttpDispatcher(kernel) }; +} + +/** An authorized caller: `manage_metadata` held, so a 403 below is the type's, never the capability gate. */ +const ctx = (): any => ({ + request: { headers: {} }, + environmentId: 'env_1', + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, +}); + +function responseOf(result: HttpDispatcherResult): NonNullable { + if (!result.response) throw new Error('the dispatcher handled the route but returned no response'); + return result.response; +} + +const rowsOf = (engine: any, type: string, name: string) => + engine.metaRows().filter((r: any) => r.type === type && r.name === name); + +function resetHatch() { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +} + +beforeEach(() => { + resetHatch(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + resetHatch(); + vi.restoreAllMocks(); +}); + +describe('[ADR-0131 D6] the dispatcher /meta door authors no sharing rule', () => { + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + + for (const query of [undefined, { mode: 'draft' }]) { + const mode = query ? 'draft' : 'publish'; + + it(`PUT /meta/sharing_rule/ (${mode}) — 403 NOT_CREATABLE naming the organization door; no row, nothing registered (${kernel})`, async () => { + const { engine, dispatcher } = makeStack(environmentId); + const res = responseOf(await dispatcher.handleMetadata( + `/sharing_rule/${PROBE_RULE.name}`, ctx(), 'PUT', PROBE_RULE, query, + )); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body?.error?.code).toBe('NOT_CREATABLE'); + // The prescription names the sanctioned door, not a source glob. + expect(String(res.body?.error?.message)).toContain('POST /api/v1/sharing/rules'); + expect(rowsOf(engine, 'sharing_rule', PROBE_RULE.name)).toEqual([]); + expect(engine.runtimeItem('sharing_rule', PROBE_RULE.name)).toBeUndefined(); + }); + } + + it(`PUT /meta/sharing_rule/ — 403 NOT_OVERRIDABLE; no row (${kernel})`, async () => { + const { engine, dispatcher } = makeStack(environmentId); + const res = responseOf(await dispatcher.handleMetadata( + `/sharing_rule/${DECLARED_RULE.name}`, ctx(), 'PUT', { ...strip(DECLARED_RULE), accessLevel: 'edit' }, + )); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(rowsOf(engine, 'sharing_rule', DECLARED_RULE.name)).toEqual([]); + }); + + it(`GET /meta/types advertises no runtime create and no overlay for sharing_rule (${kernel})`, async () => { + const { dispatcher } = makeStack(environmentId); + const listing = responseOf(await dispatcher.handleMetadata('/types', ctx(), 'GET', undefined)); + expect(listing.status).toBe(200); + const entries = ((listing.body?.data ?? listing.body) as { entries?: any[] })?.entries ?? []; + const entry = entries.find((e) => e.type === 'sharing_rule'); + expect(entry, 'sharing_rule is listed').toBeDefined(); + expect({ allowRuntimeCreate: entry.allowRuntimeCreate, allowEnvironmentOverlay: entry.allowEnvironmentOverlay }) + .toEqual({ allowRuntimeCreate: false, allowEnvironmentOverlay: false }); + }); + + it(`control: a new position is still created through the same door (${kernel})`, async () => { + const { engine, dispatcher } = makeStack(environmentId); + const res = responseOf(await dispatcher.handleMetadata( + '/position/crm_new_position', ctx(), 'PUT', { name: 'crm_new_position', label: 'New Position' }, + )); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(rowsOf(engine, 'position', 'crm_new_position')).toHaveLength(1); + }); + } +}); diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 30cdf2f2cbf..c137414fedf 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -387,7 +387,8 @@ describe('the envelope invariant also covers UNREGISTERED_KIND_SCHEMAS', () => { const STACK_COLLECTION_OF: Record = { webhook: 'webhooks', connector: 'connectors', - sharing_rule: 'sharingRules', + // sharing_rule: 'sharingRules' — a registered kind since ADR-0131 D6; its + // stack-parity pin is the case after the mapping-table one below. // theme: 'themes' — retired by commit 35ad101bc (ADR-0049). analytics_cube: 'analyticsCubes', }; @@ -424,6 +425,17 @@ describe('each bound entry IS its stack collection\'s schema', () => { expect(Object.keys(STACK_COLLECTION_OF).sort()).toEqual(listUnregisteredKindSchemaTypes()); }); + it('`sharing_rule`, promoted to a kind, still binds the schema its stack collection validates', () => { + // [ADR-0131 D6] It left this map for BUILTIN_METADATA_TYPE_SCHEMAS; the + // parity this block pins for the map's members must not leave with it. + const element = objectShapes(ObjectStackSchema) + .map((shape) => shape.sharingRules) + .map((collection) => (collection === undefined ? undefined : arrayElementOf(collection))) + .find((v) => v !== undefined); + expect(element, 'stack.zod.ts declares a `sharingRules` collection').toBeDefined(); + expect(getMetadataTypeSchema('sharing_rule')).toBe(element); + }); + it.each(Object.entries(STACK_COLLECTION_OF))( '`%s` binds the same schema `stack.zod.ts` validates `%s` with', (kind, collection) => { @@ -637,7 +649,11 @@ describe('registered-type closure is derived, not tallied', () => { // // 26 → 27 on 2026-09-30: `picklist` JOINED the registry, closed (a // `strictObject` authoring surface), so the closed count moves with it. - expect(closed.length).toBe(26); - expect(types.length).toBe(27); + // + // 27 → 28 on 2026-10-19: `sharing_rule` JOINED the registry (ADR-0131 D6, + // regime C), closed — `SharingRuleSchema` is `.strict()` and an authoring + // surface only — so the closed count moves with it. + expect(closed.length).toBe(27); + expect(types.length).toBe(28); }); }); diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 82c90749f71..cb4b5ba521a 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -63,7 +63,7 @@ describe('INVARIANT 1 — no spelling that worked before may stop working', () = } }); - it('carries the five plugin-kind spellings that no registry derivation could produce', () => { + it('carries the four plugin-kind spellings that no registry derivation could produce', () => { // Named explicitly because losing them is the specific regression the // union-vs-pure-derivation choice was made to avoid, and a reader needs to // see the population rather than infer it. @@ -71,9 +71,10 @@ describe('INVARIANT 1 — no spelling that worked before may stop working', () = const pluginOnly = Object.entries(PLURAL_TO_SINGULAR).filter(([, s]) => !registryTypes.has(s)); // `themes` left this list at commit 35ad101bc (ADR-0049 — the carrier retired, so // `/meta/theme` now earns `unrecognisedMetaTypeRefusal`'s verdict; pinned - // in the #8421 block at the bottom of this file). + // in the #8421 block at the bottom of this file). `sharingRules` left it + // when ADR-0131 D6 gave `sharing_rule` a code-only registry entry. expect(pluginOnly.map(([p]) => p).sort()).toEqual( - ['analyticsCubes', 'connectors', 'ragPipelines', 'sharingRules', 'webhooks'], + ['analyticsCubes', 'connectors', 'ragPipelines', 'webhooks'], ); for (const [plural, singular] of pluginOnly) { expect(canonicalMetaUrlType(plural)).toBe(singular); From 869b957508c5d9f3e5fc48833df074186e94947d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:12:31 +0000 Subject: [PATCH 05/14] wip: changeset and ADR-0087 semantic entry for the sharing_rule door Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .../24550-sharing-rule-meta-door-closed.md | 28 ++++++++++++ ...8.sharing-rule-runtime-create-withdrawn.ts | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 .changeset/24550-sharing-rule-meta-door-closed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.sharing-rule-runtime-create-withdrawn.ts diff --git a/.changeset/24550-sharing-rule-meta-door-closed.md b/.changeset/24550-sharing-rule-meta-door-closed.md new file mode 100644 index 00000000000..209ad6c276d --- /dev/null +++ b/.changeset/24550-sharing-rule-meta-door-closed.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/plugin-sharing': minor +'@objectstack/platform-objects': patch +--- + +feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). + +A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: + +- `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. +- `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. + +**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, set `OS_METADATA_WRITABLE=sharing_rule` for that removal only. + +Also in this change: + +- **`@objectstack/spec`.** `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling. +- **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). +- **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. +- **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. diff --git a/packages/spec/src/migrations/entries/semantic/18.sharing-rule-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/18.sharing-rule-runtime-create-withdrawn.ts new file mode 100644 index 00000000000..e8e96ac009f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sharing-rule-runtime-create-withdrawn.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'sharing-rule-runtime-create-withdrawn', + surface: + 'PUT /api/v1/meta/sharing_rule/{name} (a runtime-authored `sharing_rule` metadata item, draft and ' + + 'active alike) and the `sharing_rule` entry of GET /api/v1/meta/types. FROM `200` with an ' + + 'environment-wide item stored in `sys_metadata` and served by `GET /api/v1/sharing/rules`, and ' + + '`allowRuntimeCreate: true` advertised, TO `403 NOT_CREATABLE` for a name nothing declares and ' + + '`403 NOT_OVERRIDABLE` for a declared rule\'s name, nothing stored, and `allowRuntimeCreate: false` ' + + 'advertised', + replacement: + 'author an organization rule (POST /api/v1/sharing/rules, or Setup > Sharing Rules), or clone the ' + + 'declared one: copy it to an organization rule under a new name and switch the declared rule off ' + + '(POST /api/v1/security/_activation/sharing_rule/{name}, body {enabled: false}). A package declares ' + + 'its rules in the stack\'s `sharingRules`', + reason: + 'ADR-0131 D6 charts `sharing_rule` under regime C: a sharing rule is either a sealed declaration, ' + + 'switched through the activation ledger, or a rule an organization authors in `sys_sharing_rule`. ' + + 'The type had no metadata-type registry entry, so both `/meta` doors read its absence as ' + + '"runtime-creatable": the type listing advertised `allowRuntimeCreate: true`, and the save door ' + + 'stored the item and wrote it through to the registry, where the sharing service serves and ' + + 'enforces declared rules. That is a third source, and it bypasses what the organization door ' + + 'holds: the refusal of a row taking a declared rule\'s name, and the organization wall (the item ' + + 'belongs to the whole deployment). The type now has a code-only registry entry ' + + '(`allowRuntimeCreate: false`, `allowEnvironmentOverlay: false`), the `capability` pair, so the ' + + 'listing and the save door read one answer. No authored source spells anything that moves: a ' + + 'stack\'s `sharingRules` reaches the registry through artifact ingest, never through ' + + '`saveMetaItem`, and is valid before and after byte for byte, so there is no D2 conversion — ' + + 'the change is a runtime HTTP verdict, one semantic TODO for Studio and API callers. ADR-0131 D6, ' + + 'ADR-0126 §2, ADR-0049.', + acceptanceCriteria: + 'No caller creates or updates a `sharing_rule` item through the runtime metadata API: ' + + '`PUT /api/v1/meta/sharing_rule/{name}` answers 403 `NOT_CREATABLE` (in `?mode=draft` too) naming ' + + 'POST /api/v1/sharing/rules, or 403 `NOT_OVERRIDABLE` over a declared rule, and stores nothing; ' + + '`GET /api/v1/meta/types` lists `sharing_rule` with `allowRuntimeCreate: false`. Verify the two ' + + 'sanctioned sources are UNAFFECTED: a stack declaring `sharingRules` still loads and its rules ' + + 'still grant, and `POST /api/v1/sharing/rules` still creates an organization rule. An item stored ' + + 'through the old door is still served until it is removed: delete it with ' + + '`DELETE /api/v1/meta/sharing_rule/{name}` (on an environment kernel with ' + + '`OS_METADATA_WRITABLE=sharing_rule` set for that one operation, which is also the operator ' + + 'escape hatch that re-opens the create), then author it again as an organization rule.', +}; From f0f6b8c69d5e4ed25206cb19f4913559959b031c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:34:46 +0000 Subject: [PATCH 06/14] fix(metadata-protocol): a Regime C code-only refusal names its sanctioned paths whole, under the REST door's 500-character bound Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .../src/packaged-base-regime.ts | 6 ++--- packages/metadata-protocol/src/protocol.ts | 27 ++++++++++++------- .../src/meta-sharing-rule-door.test.ts | 18 ++++++++++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 2d46fd8ec6f..fcc0c9c19c0 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -205,7 +205,7 @@ export const PACKAGED_BASE_REGIME: Readonly Sharing Rules), or clone the declared rule under a new ' - + 'name as an organization rule and switch the declared one off (POST ' - + '/api/v1/security/_activation/sharing_rule/:name, body {enabled: false}; operator-only where one ' - + 'install serves several organizations). A package declares its rules in the stack\'s ' - + '`sharingRules` and redeploys.', + sharing_rule: ' Author an organization rule instead (POST /api/v1/sharing/rules), or clone a declared rule ' + + 'there under a new name and switch the declared one off ' + + '(POST /api/v1/security/_activation/sharing_rule/:name, body {enabled: false}).', }; /** @@ -17684,8 +17681,16 @@ export class ObjectStackProtocolImplementation implements + `allowRuntimeCreate=false and allowEnvironmentOverlay=false, so it cannot be created through the ` + `runtime metadata API (PUT /api/v1/meta/${type}/:name) on any kernel.` + ObjectStackProtocolImplementation.codeOnlySourceHint(type) - + ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` + // [ADR-0131 D6] A Regime C type's refusal names its sanctioned paths and + // never the hatch, as its regime sentence does (`packaged-base-regime.ts`): + // the hatch would re-open the very source the regime closes. It still + // opens the type for an operator — the residue repair the delete door + // keeps — it is only not prescribed. Under the REST door's 500-character + // bound either way (`sharing_rule`: 473). + + (packagedBaseRegimeRow(type)?.regime === 'C' + ? '' + : ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` + + `See docs/adr/0005-metadata-customization-overlay.md.`) ); (err as any).code = 'NOT_CREATABLE'; (err as any).status = 403; @@ -17704,7 +17709,11 @@ export class ObjectStackProtocolImplementation implements * opens. The source remedy stays: it is where a code-only item is declared. */ private static codeOnlyOverrideError(type: string, name: string): Error { + // [ADR-0131 D6, ADR-0126 §2] A Regime C type's declared item reads its + // regime sentence — the one the package door and the repository speak for + // the same item — so the refusal names the clone and the switch. const err = new Error( + (packagedBaseRegimeRow(type)?.regime === 'C' ? packagedBaseRegimeSentence(type, name, 'save') : undefined) ?? `Metadata item '${type}/${name}' is provided by a managed package and its type is ` + `code-only (allowRuntimeCreate=false, allowEnvironmentOverlay=false), so it is sealed against ` + `runtime edits on any kernel, and OS_METADATA_WRITABLE does not open a managed item.` diff --git a/packages/runtime/src/meta-sharing-rule-door.test.ts b/packages/runtime/src/meta-sharing-rule-door.test.ts index 13aa8667ee0..ad195dc9ad7 100644 --- a/packages/runtime/src/meta-sharing-rule-door.test.ts +++ b/packages/runtime/src/meta-sharing-rule-door.test.ts @@ -17,8 +17,10 @@ * * What is pinned, on both kernel shapes: * - a create of a new name — `403` `NOT_CREATABLE`, in publish and draft mode, - * no row, nothing registered, and the refusal names the organization door; + * no row, nothing registered; * - a write over a declared rule's name — `403` `NOT_OVERRIDABLE`, no row; + * - both refusals name the organization door and the activation ledger's + * switch, under the REST door's 500-character message bound; * - the listing — `allowRuntimeCreate: false`, `allowEnvironmentOverlay: false`; * - the control: a new position is still created (`200`, a row), so the * refusal is the type's, not the door's. @@ -52,6 +54,9 @@ const DECLARED_RULE = { const strip = (item: Record) => Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); +/** The two routes a refusal names: the organization door, and the activation ledger's switch. */ +const SANCTIONED_ROUTES = ['POST /api/v1/sharing/rules', 'POST /api/v1/security/_activation/sharing_rule/:name']; + /** A spec-valid rule under a name nothing declares — the probe the gap was measured with. */ const PROBE_RULE = { ...strip(DECLARED_RULE), name: 'probe_meta_rule', label: 'Probe' }; @@ -230,8 +235,12 @@ describe('[ADR-0131 D6] the dispatcher /meta door authors no sharing rule', () = )); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body?.error?.code).toBe('NOT_CREATABLE'); - // The prescription names the sanctioned door, not a source glob. - expect(String(res.body?.error?.message)).toContain('POST /api/v1/sharing/rules'); + // The prescription names both sanctioned paths, not a source glob, and + // arrives whole through the REST door, which truncates a message of + // 500 characters or more (`truncateClientMessage`). + const message = String(res.body?.error?.message); + for (const route of SANCTIONED_ROUTES) expect(message).toContain(route); + expect(message.length).toBeLessThan(500); expect(rowsOf(engine, 'sharing_rule', PROBE_RULE.name)).toEqual([]); expect(engine.runtimeItem('sharing_rule', PROBE_RULE.name)).toBeUndefined(); }); @@ -244,6 +253,9 @@ describe('[ADR-0131 D6] the dispatcher /meta door authors no sharing rule', () = )); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body?.error?.code).toBe('NOT_OVERRIDABLE'); + const message = String(res.body?.error?.message); + for (const route of SANCTIONED_ROUTES) expect(message).toContain(route); + expect(message.length).toBeLessThan(500); expect(rowsOf(engine, 'sharing_rule', DECLARED_RULE.name)).toEqual([]); }); From a76fe2940632358a44ab31405aae408c63acc1e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:54:32 +0000 Subject: [PATCH 07/14] chore: register the sharing_rule kind's derived artifacts (i18n metadata forms, engine-double ledger, overlay table) Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- content/docs/concepts/metadata-lifecycle.mdx | 3 ++- .../translations/en.metadata-forms.generated.ts | 4 ++++ .../es-ES.metadata-forms.generated.ts | 4 ++++ .../ja-JP.metadata-forms.generated.ts | 4 ++++ .../zh-CN.metadata-forms.generated.ts | 4 ++++ scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 6 files changed, 33 insertions(+), 1 deletion(-) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index ef58eb34490..0763eda2ddd 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -118,8 +118,9 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m | `field` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#7893). A field is not a standalone artifact — fields are authored inside their object (`ObjectSchema.fields`), so a `field` write minted a *separate* `sys_metadata` row keyed `('field','.')` that nothing ever composed into the parent: `PUT /meta/field/showcase_task.zz_probe` answered 200 `state=active`, the row read back `_diagnostics.valid: true`, and `GET /meta/object/showcase_task` never listed the field. The door is closed rather than bridged: add a field by writing its **object** (`PUT /meta/object/:name`, or `**/*.object.ts` and redeploy), which both persists and composes. Existing rows are untouched — they were inert before the change too — and remain deletable. | | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (`allowRuntimeCreate: true` — the datasource wizard persists `origin: 'runtime'` rows.) | | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | +| `sharing_rule` | ❌ | **Also `allowRuntimeCreate: false` since protocol 18** (ADR-0131 D6, regime C). A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own through `POST /api/v1/sharing/rules`, behind the same-name guard and the organization wall. A runtime `sys_metadata` item was a third source that bypassed both, so `PUT /meta/sharing_rule/:name` now answers `403 NOT_CREATABLE` (`NOT_OVERRIDABLE` over a declared rule) and names the two sanctioned paths: author an organization rule, or clone the declared one and switch it off. Items stored before the change are still served until deleted. | -Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 28 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. +Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 29 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). Nor is there a standalone `validation` type any more — it was retired in protocol 17 under [ADR-0088](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0088-metadata-kind-admission-and-retirement.md) because `ValidationRuleSchema` carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own `validations[]` instead. The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 9883700cd95..c159d7b286a 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2395,6 +2395,10 @@ export const enMetadataForms: NonNullable = { label: "Capability", description: "A permission a package defines; you grant it through permission sets" }, + sharing_rule: { + label: "Sharing Rule", + description: "A rule a package declares to share records; organizations author their own in Setup" + }, agent: { label: "AI Agent", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index ef1f0390bfa..ccb9bc93677 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2395,6 +2395,10 @@ export const esESMetadataForms: NonNullable = label: "Capacidad", description: "Permiso que define un paquete; se concede mediante conjuntos de permisos" }, + sharing_rule: { + label: "Regla de Compartición", + description: "Una regla que un paquete declara para compartir registros; las organizaciones crean las suyas en Configuración" + }, agent: { label: "Agente de IA", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 3ed361a909d..4f0f16329fb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2395,6 +2395,10 @@ export const jaJPMetadataForms: NonNullable = label: "ケイパビリティ", description: "パッケージが定義する権限。権限セットを通じて付与します" }, + sharing_rule: { + label: "共有ルール", + description: "パッケージが宣言するレコード共有ルール。組織は自身のルールを設定で作成します" + }, agent: { label: "AI エージェント", sections: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index f196125ca1f..466dfce126a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2395,6 +2395,10 @@ export const zhCNMetadataForms: NonNullable = label: "能力", description: "软件包定义的一项权限;通过权限集授予" }, + sharing_rule: { + label: "共享规则", + description: "软件包声明的记录共享规则;组织在设置中编写自己的规则" + }, agent: { label: "AI 代理", sections: { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index aebfa7dea7e..6c24c6e946c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4171,6 +4171,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/meta-sharing-rule-door.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/runtime/src/meta-sharing-rule-door.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/runtime/src/meta-sharing-rule-door.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/meta-write-org-scope.test.ts", "verb": "delete", From 03ec6356174b26437685821eb48536abfe08c546 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:07:18 +0000 Subject: [PATCH 08/14] test: enrol sharing_rule in the code-only batteries and re-judge the open-door pins Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .../src/protocol.code-only-types.test.ts | 19 +++++- ...tocol.recovery-doors-emit-mutation.test.ts | 2 +- packages/objectql/src/protocol-meta.test.ts | 67 ++++++++++++------- .../bare-type-display-echo-decisions.test.ts | 25 +++---- ...ect-lifecycle-panel-echo-decisions.test.ts | 4 +- 5 files changed, 76 insertions(+), 41 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index adf325d1608..43324805ad4 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -169,6 +169,23 @@ const PROBES: Record }> = options: [{ label: 'One', value: 'one' }], }, }, + // [ADR-0131 D6] `sharing_rule`, regime C: a sharing rule is a package's + // declaration or an organization's `sys_sharing_rule` row, and a runtime + // `sys_metadata` item was a third source past the same-name guard and the + // organization wall. Schema-valid for the same reason as the rest. + sharing_rule: { + name: 'rc3_sharing_rule_probe', + item: { + type: 'criteria', + name: 'rc3_sharing_rule_probe', + label: 'Probe', + object: 'task', + condition: "record.status == 'open'", + accessLevel: 'read', + sharedWith: { type: 'position', value: 'probe_position' }, + active: true, + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -285,7 +302,7 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // auto-enrolment is the point of deriving the set instead of listing it // (Prime Directive #8). expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist']); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist', 'sharing_rule']); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts index 09c6dd01672..1a359f0f739 100644 --- a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts +++ b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts @@ -542,7 +542,7 @@ describe('[#14179] reachability: the legacy exit is confined to bootstrap mode', // Non-empty: the branch is not dead code. expect(codeOnly.length).toBeGreaterThan(0); expect(codeOnly.map((e) => e.type).sort()) - .toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist']); + .toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist', 'sharing_rule']); // …and the #6960 carve-out reaches none of them, which is what makes // the 403 below total rather than incidental. expect(codeOnly.filter((e) => e.supportsOverlay)).toEqual([]); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 0b5535de7c8..8e9f3d5743e 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; import { SchemaRegistry } from './registry.js'; /** @@ -1752,31 +1752,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); - it('accepts a spec-valid `sharing_rule` item (write door unchanged by the schema binding)', async () => { + // [ADR-0131 D6] `sharing_rule` is code-only (regime C): an organization + // authors its rules through the sharing door, so this door refuses a + // spec-valid body outright. The schema binding #6245 added still judges + // the body, behind the operator hatch. + it('refuses a spec-valid `sharing_rule` item with 403 NOT_CREATABLE (code-only, regime C)', async () => { mockEngine.findOne.mockResolvedValue(null); - const result = await scoped.saveMetaItem({ - type: 'sharing_rule', - name: 'sales_records', - item: { - name: 'sales_records', - type: 'criteria', - object: 'case', - condition: "record.department == 'Sales'", - sharedWith: { type: 'team', value: 'sales' }, - }, - }); - - expect(result.success).toBe(true); - }); - - it('refuses a spec-INVALID `sharing_rule` item with 422 instead of storing it unvalidated', async () => { - mockEngine.findOne.mockResolvedValue(null); - - // No `condition` and no `object`: a criteria rule that names - // neither what it shares nor which records. Stored unvalidated this - // is the #3896 shape — a rule that binds to nothing, or worse - // compiles to the empty filter. await expect( scoped.saveMetaItem({ type: 'sharing_rule', @@ -1784,15 +1766,48 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'sales_records', type: 'criteria', + object: 'case', + condition: "record.department == 'Sales'", sharedWith: { type: 'team', value: 'sales' }, }, }), ).rejects.toMatchObject({ - code: 'INVALID_METADATA', - status: 422, + code: 'NOT_CREATABLE', + status: 403, }); }); + it('behind OS_METADATA_WRITABLE=sharing_rule, refuses a spec-INVALID item with 422 instead of storing it unvalidated', async () => { + mockEngine.findOne.mockResolvedValue(null); + process.env.OS_METADATA_WRITABLE = 'sharing_rule'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + try { + // No `condition` and no `object`: a criteria rule that names + // neither what it shares nor which records. Stored unvalidated this + // is the #3896 shape — a rule that binds to nothing, or worse + // compiles to the empty filter. + await expect( + scoped.saveMetaItem({ + type: 'sharing_rule', + name: 'sales_records', + item: { + name: 'sales_records', + type: 'criteria', + sharedWith: { type: 'team', value: 'sales' }, + }, + }), + ).rejects.toMatchObject({ + code: 'INVALID_METADATA', + status: 422, + }); + } finally { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + } + }); + // ─────────────────────────────────────────────────────────────── // [commit 2306a765c] `theme` / `analytics_cube` — the two doors #6245 left // open, closed the same way and pinned the same way: the write door diff --git a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts index ea453d84b68..fa9609a802e 100644 --- a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts @@ -791,29 +791,30 @@ describe('#19403 round 7 — the provenance table agrees these leaves are now au }); describe('#19403 round 7 — the population, DERIVED from the registry and a shape', () => { - it('⭐ the registry and the catalog describe the same 28 metadata types', () => { + it('⭐ the registry and the catalog describe the same 29 metadata types', () => { // The ratchet's anchor. The catalog is GENERATED from this registry, so a // new metadata type arrives in both at once — which is what makes the bare // class below pick up a future unauthored display pair without anybody // adding it to a list. // 27 → 28: `picklist` joined the registry, a bare type (no form) with a description. - expect(REGISTRY_TYPES.length).toBe(28); + // 28 → 29: `sharing_rule` joined it (ADR-0131 D6), bare with a description too. + expect(REGISTRY_TYPES.length).toBe(29); expect([...REGISTRY_TYPES].sort()).toEqual(Object.keys(enMetadataForms as Record).sort()); }); it('the BARE predicate splits that population, and is read off the SHAPE not a name list', () => { // Lit. - expect(BARE_TYPES.length).toBe(11); + expect(BARE_TYPES.length).toBe(12); expect(PANEL_TYPES.length).toBe(17); expect(BARE_TYPES.length + PANEL_TYPES.length).toBe(REGISTRY_TYPES.length); - for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist']) { + for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist', 'sharing_rule']) { expect(BARE_TYPES, `${type} is bare`).toContain(type); } for (const type of AUTHORED_BARE_TYPES) expect(BARE_TYPES, `${type} is bare`).toContain(type); - // 18 leaves: eleven labels, and a description on the seven that carry one. - expect(BARE_LEAVES.length).toBe(18); - expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(11); - expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(7); + // 20 leaves: twelve labels, and a description on the eight that carry one. + expect(BARE_LEAVES.length).toBe(20); + expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(12); + expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(8); expect(BARE_LEAVES.every((l) => ['label', 'description'].includes(l.prop))).toBe(true); }); @@ -832,8 +833,8 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(registryEntry('dataset')?.description, 'dataset carries a registry description like the six').toBeTruthy(); expect(isBare('dataset')).toBe(false); const described = REGISTRY_TYPES.filter((t) => typeof registryEntry(t)?.description === 'string'); - expect([...described].sort()).toEqual(['api', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'seed']); - expect(described.filter(isBare).sort()).toEqual(['api', 'book', 'capability', 'doc', 'mapping', 'picklist', 'seed']); + expect([...described].sort()).toEqual(['api', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'seed', 'sharing_rule']); + expect(described.filter(isBare).sort()).toEqual(['api', 'book', 'capability', 'doc', 'mapping', 'picklist', 'seed', 'sharing_rule']); // And no panel type's own display pair echoes — the exclusion removes only // leaves that are already decided elsewhere or already authored. const panelEchoes: string[] = []; @@ -894,13 +895,13 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(flagged.length).toBe(BARE_LEAVES.length); }); - it('⭐ this class is now DONE — zero of its 18 leaves echoes in all three locales', () => { + it('⭐ this class is now DONE — zero of its 20 leaves echoes in all three locales', () => { const echoing = BARE_LEAVES.filter((l) => TRANSLATED_LOCALES.every(([, forms]) => forms[l.type]?.[l.prop] === l.en), ); expect(echoing.map((l) => `${l.type}.${l.prop}`)).toEqual([]); // Lit — and it really walked the class, which a zero alone would not show. - expect(BARE_LEAVES.length).toBe(18); + expect(BARE_LEAVES.length).toBe(20); }); }); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 1ecc33d042b..460a165d90a 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1202,7 +1202,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 673 since #23891: the permission-set form's `managedBy` select left // with its key (retired — nothing read the authored provenance), taking // its label — authored in all three locales — out of the catalog. - expect(translated.length, `${locale} positive control`).toBe(673); + // 674 since ADR-0131 D6: `sharing_rule` joined the registry, so the catalog + // gains its type-level display pair — authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(674); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From 90f91a884d9b833505a1aa0722fdbcbe29cc7dd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:12:53 +0000 Subject: [PATCH 09/14] =?UTF-8?q?docs(changeset):=20Clause-=E2=91=A1=20as?= =?UTF-8?q?=20measured=20=E2=80=94=20the=20diff=20widens=20a=20spelling=20?= =?UTF-8?q?and=20the=20kind=20union=20beside=20the=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .changeset/24550-sharing-rule-meta-door-closed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24550-sharing-rule-meta-door-closed.md b/.changeset/24550-sharing-rule-meta-door-closed.md index 209ad6c276d..a7f804c291b 100644 --- a/.changeset/24550-sharing-rule-meta-door-closed.md +++ b/.changeset/24550-sharing-rule-meta-door-closed.md @@ -7,7 +7,7 @@ feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules -Clause-②: no (narrowing) +Clause-②: yes (narrowing) @@ -22,7 +22,7 @@ A sharing rule has two sources: a package declares it (the stack's `sharingRules Also in this change: -- **`@objectstack/spec`.** `MetadataTypeSchema` and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling. +- **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. - **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). - **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. - **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. From 47f5080549d908b875a144ea683bdffa88683442 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:16:57 +0000 Subject: [PATCH 10/14] test(cli): classify sharing_rule as a gated kind in the os validate schema gate Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- packages/cli/test/metadata-type-schema-gate.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 0ee7d48349c..9f575b05b0a 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -95,6 +95,10 @@ const GATED_AT: Readonly> = { // shape (`stack.zod.ts`: `z.array(CapabilityDeclarationSchema)`), and that // shape is `.strict()`, so both gates reject an undeclared key identically. capability: 'capabilities', + // [ADR-0131 D6] `sharing_rule` joined the registry as a code-only, CLOSED + // shape: the stack authors it at `sharingRules:` as a flat array of the + // registry's own `.strict()` `SharingRuleSchema`, so it is gated from the start. + sharing_rule: 'sharingRules', agent: 'agents', tool: 'tools', skill: 'skills', From 90e39904e2a0659e67f915d6f67d8b8d360faa88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:49:34 +0000 Subject: [PATCH 11/14] test: enrol sharing_rule in the showcase kind coverage and the security-catalog package-door pin Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- examples/app-showcase/src/coverage.ts | 7 +++++++ .../src/engine-security-catalog-package-door.test.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 6a8a21f8156..74fdefe7d8f 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -167,6 +167,13 @@ export const KIND_COVERAGE: Record = { notes: 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), served from the security catalog under the package (ADR-0131 D3). Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', }, + // [ADR-0131 D6] `sharing_rule` joined the registry as a code-only kind (regime C). + sharing_rule: { + status: 'demonstrated', + files: ['src/security/sharing-rules.ts'], + notes: + 'Regime C (ADR-0131 D6): package-declared rules (`sharingRules` in objectstack.config.ts), served from the registry and switched off through the activation ledger (POST /api/v1/security/_activation/sharing_rule/:name). Organizations author their own through POST /api/v1/sharing/rules. Code-only kind: PUT /meta/sharing_rule/:name answers 403 NOT_CREATABLE (NOT_OVERRIDABLE over a declared rule), which runtime meta-sharing-rule-door.test.ts pins.', + }, // ── ai ── agent: { diff --git a/packages/objectql/src/engine-security-catalog-package-door.test.ts b/packages/objectql/src/engine-security-catalog-package-door.test.ts index 8a43dffbe11..7baa851c5dd 100644 --- a/packages/objectql/src/engine-security-catalog-package-door.test.ts +++ b/packages/objectql/src/engine-security-catalog-package-door.test.ts @@ -72,6 +72,12 @@ const BODIES: Record> = { permission: { name: NAME, label: 'Probe', objects: {} }, position: { name: NAME, label: 'Probe' }, capability: { name: NAME, label: 'Probe' }, + // [ADR-0131 D6] `sharing_rule` joined as a code-only `security` kind. + sharing_rule: { + type: 'criteria', name: NAME, label: 'Probe', object: 'probe_object', + condition: "record.status == 'open'", accessLevel: 'read', + sharedWith: { type: 'position', value: 'probe_position' }, + }, }; /** The overlayable control: `allowEnvironmentOverlay: true`, declared through a stack collection. */ From 3cd2fbfa3f3fa1f67e08fc45344105a20f656a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:12:51 +0000 Subject: [PATCH 12/14] chore(scripts): drop the stale sharingRules waiver now that KIND_COVERAGE covers the sharing_rule kind Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- scripts/check-stack-collection-maps.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index 5a14fb99cd7..83f88d907cf 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -778,7 +778,8 @@ const SITES = [ waivers: [ { direction: 'missing', - keys: ['datasourceMapping', 'translations', 'data', 'sharingRules', 'webhooks'], + // `sharingRules` left with ADR-0131 D6: `sharing_rule` is a registry kind, covered by KIND_COVERAGE. + keys: ['datasourceMapping', 'translations', 'data', 'webhooks'], reason: 'NOT DEMONSTRATED, and visible for the first time. This manifest tracked 3 of the non-kind ' + 'collections and — unlike its ratcheted sibling `KIND_COVERAGE` — was answerable to nothing ' From 9bf8837075b5b9f81e93a2538377e25fd6868ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:51:24 +0000 Subject: [PATCH 13/14] docs(changeset): the OS_METADATA_WRITABLE hatch is an explicit operator act, not an authoring route Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .changeset/24550-sharing-rule-meta-door-closed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24550-sharing-rule-meta-door-closed.md b/.changeset/24550-sharing-rule-meta-door-closed.md index a7f804c291b..323fc5cf232 100644 --- a/.changeset/24550-sharing-rule-meta-door-closed.md +++ b/.changeset/24550-sharing-rule-meta-door-closed.md @@ -18,7 +18,7 @@ A sharing rule has two sources: a package declares it (the stack's `sharingRules - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. -**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, set `OS_METADATA_WRITABLE=sharing_rule` for that removal only. +**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, set `OS_METADATA_WRITABLE=sharing_rule` for that removal only. That hatch is an explicit operator act, not an authoring route: it opens `sharing_rule` the way it opens every code-only type, and no refusal names it. Also in this change: From b8e0baa72a41607e5aa7bc27f1d3dfff96f44d68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:53:00 +0000 Subject: [PATCH 14/14] docs(changeset): the hatch reopens every sharing_rule write, creates included; unset it after the removal Claude-Session: https://claude.ai/code/session_013BWJEDZYcJKmZYj6ApCCa7 Co-authored-by: Claude --- .changeset/24550-sharing-rule-meta-door-closed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24550-sharing-rule-meta-door-closed.md b/.changeset/24550-sharing-rule-meta-door-closed.md index 323fc5cf232..38bfe1009a6 100644 --- a/.changeset/24550-sharing-rule-meta-door-closed.md +++ b/.changeset/24550-sharing-rule-meta-door-closed.md @@ -18,7 +18,7 @@ A sharing rule has two sources: a package declares it (the stack's `sharingRules - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. -**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, set `OS_METADATA_WRITABLE=sharing_rule` for that removal only. That hatch is an explicit operator act, not an authoring route: it opens `sharing_rule` the way it opens every code-only type, and no refusal names it. +**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. Also in this change: