Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/24550-sharing-rule-meta-door-closed.md
Original file line number Diff line number Diff line change
@@ -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-②: yes (narrowing)

<!-- adr-0087: registered sharing-rule-runtime-create-withdrawn -->

**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, 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:

- **`@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`.
3 changes: 2 additions & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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','<object>.<name>')` 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()`.

Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down Expand Up @@ -747,6 +747,7 @@ Metadata query with filtering, sorting, and pagination
* `permission`
* `position`
* `capability`
* `sharing_rule`
* `agent`
* `tool`
* `skill`
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -250,6 +250,7 @@ const result = MetadataBulkResultSchema.parse(data);
* `permission`
* `position`
* `capability`
* `sharing_rule`
* `agent`
* `tool`
* `skill`
Expand All @@ -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 |
Expand Down Expand Up @@ -304,6 +305,7 @@ const result = MetadataBulkResultSchema.parse(data);
* `permission`
* `position`
* `capability`
* `sharing_rule`
* `agent`
* `tool`
* `skill`
Expand Down
7 changes: 7 additions & 0 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
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: {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/test/metadata-type-schema-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ const GATED_AT: Readonly<Record<string, string>> = {
// 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',
Expand Down
20 changes: 16 additions & 4 deletions packages/metadata-protocol/src/packaged-base-regime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -198,6 +202,14 @@ export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow
+ 'or POST /api/v1/security/permission-sets/:name/clone with a new name',
},
},
sharing_rule: {
regime: 'C',
routes: {
clone: 'POST /api/v1/sharing/rules',
switchOff: 'POST /api/v1/security/_activation/sharing_rule/:name, body {enabled: false}; '
+ 'operator-only where one install serves several organizations',
},
},
datasource: {
regime: 'origin-gated',
noun: 'Datasource',
Expand Down Expand Up @@ -308,8 +320,8 @@ export function isOriginGatedType(type: string): boolean {
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
* the tail is truncated. Characters outside the item's name, save / removal:
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294,
* `datasource` 192 / 193 — so a name of up to 88 characters arrives whole for
* every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
* `datasource` 192 / 193, `sharing_rule` 390 / 383 — so a name of up to 88
* characters arrives whole for every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
* with its own remedy (`default`: under 300 characters whole).
*/
export function packagedBaseRegimeSentence(
Expand Down
19 changes: 18 additions & 1 deletion packages/metadata-protocol/src/protocol.code-only-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ const PROBES: Record<string, { name: string; item: Record<string, unknown> }> =
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 }> = []) {
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading
Loading