Skip to content
17 changes: 17 additions & 0 deletions .changeset/15204-catalog-activation-environment-authored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/runtime": minor
"@objectstack/core": minor
"@objectstack/platform-objects": minor
"@objectstack/service-automation": minor
---

The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means

Clause-②: yes (widening)

- **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`).
- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`.
- **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate.
- **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead.
- **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes.
- **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it.
9 changes: 8 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,14 @@ The ledger row is written through
grant off, `true` back on). The caller needs `manage_metadata`; under a `group`
or `isolated` tenancy posture the caller must also be the platform operator,
because the row reaches every organization (ADR-0126 §5). The name must resolve
in the security catalog, or the route answers `404`. It is the same authority
in the security catalog, or the route answers `404`. That covers a position or
permission set the environment authored through the metadata door as well as
one a package ships: the ledger row records the package when there is one and
leaves `package_id` empty when there is none, and the resolver honours both
alike, because it keys the row by type and name. A write the ledger refuses
answers with the status its code means: a record validation failure is `400`
`VALIDATION_FAILED`, a hook's refusal keeps its own pair, and a store failure
is `503` `SERVICE_UNAVAILABLE`. It is the same authority
as the flow and action activation switches. The two audience anchors,
`everyone` and `guest`, are refused with `400` in either direction: one row
would switch the authenticated or anonymous baseline off for every principal.
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,62 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics,
});
});

// [#22621 → A] The security catalog's activation door switches a position or
// permission set the environment authored, and no package ships those. The row
// says so with ONE spelling — no package value — and the store refuses the
// other spelling a caller reaches for (`?? ''`), so a reader can never take an
// environment item for a package named ''.
describe('ObjectStoreMetadataActivationStore — an item no package ships', () => {
it('INSERTS a row with no package when packageId is absent', async () => {
const { engine, calls } = makeStoreEngine([]);

await new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
name: 'env_position', active: false,
});

expect(calls.find((c) => c.op === 'insert')?.data).toEqual({
metadata_type: 'position', name: 'env_position', package_id: null, active: false,
});
});

it('UPDATES its row with no package on the way back on', async () => {
const { engine, calls } = makeStoreEngine([
{ id: 'r1', metadata_type: 'permission', name: 'env_set', package_id: null, active: false },
]);

await new ObjectStoreMetadataActivationStore(engine, 'permission').setActive({
name: 'env_set', active: true,
});

expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0);
expect(calls.find((c) => c.op === 'update')?.data).toEqual({ id: 'r1', active: true, package_id: null });
});

it('REFUSES an empty-string package before reading or writing anything', async () => {
const { engine, calls } = makeStoreEngine([]);

await expect(new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
name: 'env_position', packageId: '', active: false,
})).rejects.toThrow(TypeError);
expect(calls).toEqual([]);
});

it('reads a package-less row back with packageId ABSENT, beside a packaged one that keeps its package', async () => {
const { engine } = makeStoreEngine([
{ id: 'r1', metadata_type: 'position', name: 'env_position', package_id: null, active: false },
{ id: 'r2', metadata_type: 'position', name: 'sales_rep', package_id: 'crm', active: false },
]);

const rows = await new ObjectStoreMetadataActivationStore(engine, 'position').list();

expect(rows).toEqual([
{ name: 'env_position', active: false },
{ name: 'sales_rep', packageId: 'crm', active: false },
]);
expect('packageId' in rows[0]).toBe(false);
});
});

describe('InMemoryMetadataActivationStore', () => {
it('round-trips a row and reflects the latest flip', async () => {
const store = new InMemoryMetadataActivationStore();
Expand Down
39 changes: 31 additions & 8 deletions packages/core/src/utils/metadata-activation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,25 @@ export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation';
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;

/**
* [ADR-0126 §4] One packaged artifact's install-level activation row.
* [ADR-0126 §4] One artifact's install-level activation row.
*
* The ledger's own columns are `metadata_type` / `name` / `package_id` /
* `active`; `metadata_type` is fixed by the store, so it never reaches a
* consumer's projection.
*/
export interface MetadataActivationRow {
/** The packaged artifact's declarative machine name (ADR-0126 §4). */
/** The artifact's declarative machine name (ADR-0126 §4). */
name: string;
/** The package that ships the base artifact. */
packageId: string;
/** Is the packaged artifact armed for this installation. */
/**
* The package that ships the artifact — ABSENT when no package does: a
* position or permission set the environment authored, which the security
* catalog's activation door switches as well as a packaged one. Absent is
* the only package-less spelling: the store writes no value for it and
* refuses `''` ({@link ObjectStoreMetadataActivationStore.setActive}), so
* no reader can take an environment item for a package named `''`.
*/
packageId?: string;
/** Is the artifact armed for this installation. */
active: boolean;
}

Expand Down Expand Up @@ -190,7 +197,9 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
if (typeof r.name !== 'string' || !r.name) continue;
out.push({
name: r.name,
packageId: typeof r.package_id === 'string' ? r.package_id : '',
// A row with no package — an environment-authored item — reads
// back with `packageId` absent, the spelling it was written in.
...(typeof r.package_id === 'string' && r.package_id !== '' ? { packageId: r.package_id } : {}),
// The column defaults to `true`; only an explicit `false`
// disarms. A driver that round-trips booleans as 0/1
// (SQLite/libsql) is read through the same `=== false || === 0`
Expand All @@ -216,8 +225,22 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
* out of the result, back when the table carried a reserved tenant column;
* with no such column the set it was choosing from can no longer hold more
* than one member.
*
* `packageId` absent writes NO package (`null`), the ledger's spelling for
* an item no package ships. An empty string is refused before anything is
* read or written: it is the placeholder a caller reaches for with
* `?? ''`, and a stored `''` would be one more spelling of "no package"
* beside the absent one — exactly the ambiguity the field's contract
* rules out.
*/
async setActive(row: MetadataActivationRow): Promise<void> {
if (row.packageId === '') {
throw new TypeError(
`Activation row for ${this.metadataType} '${row.name}' names the empty string as its package. ` +
`Omit packageId for an item no package ships; an empty package id is not a package.`,
);
}
const packageId = row.packageId ?? null;
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
where: { metadata_type: this.metadataType, name: row.name },
context: SYSTEM_CTX,
Expand All @@ -227,7 +250,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
if (current && (current as { id?: unknown }).id != null) {
await this.engine.update(
METADATA_ACTIVATION_TABLE,
{ id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId },
{ id: (current as { id: unknown }).id, active: row.active, package_id: packageId },
{ context: SYSTEM_CTX },
);
return;
Expand All @@ -238,7 +261,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
{
metadata_type: this.metadataType,
name: row.name,
package_id: row.packageId,
package_id: packageId,
active: row.active,
},
{ context: SYSTEM_CTX },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
* Writers: the enable/disable actions (ADR-0126 L2/L3). Readers: each runtime's
* own consult point.
*
* Packaged and environment-authored: flows and actions reach this ledger only
* when a package ships them, but the security catalog's activation door
* (`POST /security/_activation/:type/:name`) switches every position and
* permission set the catalog holds, the environment's own included — so
* `package_id` is empty for those (see the field).
*
* @namespace sys
*/
export const SysMetadataActivation = ObjectSchema.create({
Expand All @@ -65,7 +71,7 @@ export const SysMetadataActivation = ObjectSchema.create({
isSystem: true,
managedBy: 'engine-owned',
description:
'Activation ledger for packaged metadata artifacts (ADR-0126 §4): one row per packaged artifact whose armed state has been changed from the packaged default. No row means the packaged default — active.',
'Activation ledger for metadata artifacts (ADR-0126 §4): one row per artifact whose armed state has been changed from its default. No row means the default — active. Packaged artifacts and positions or permission sets authored in this environment are both recorded here.',
displayNameField: 'name',
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
highlightFields: ['metadata_type', 'name', 'package_id', 'active'],
Expand Down Expand Up @@ -124,22 +130,31 @@ export const SysMetadataActivation = ObjectSchema.create({
required: true,
searchable: true,
maxLength: 255,
description: "The packaged artifact's machine name.",
description: "The artifact's machine name.",
group: 'Identity',
}),

// Not part of the row identity (the index below is `(metadata_type,
// name)`), and not read by any consumer to decide anything: every reader
// keys a row by type and name. It records provenance, and so it is empty
// exactly when there is no package to record — a position or permission
// set the environment authored through the metadata door, which the
// security catalog's activation door switches as well (#22621 → A). The
// writer (`ObjectStoreMetadataActivationStore`) stores NO value for such
// an item and refuses an empty string, so a package-less row has one
// spelling and can never be read as a package named ''.
package_id: Field.text({
label: 'Package',
required: true,
maxLength: 255,
description: 'The package that ships the base artifact.',
description:
'The package that ships the artifact. Empty when no package ships it: an item authored in this environment.',
group: 'Identity',
}),

active: Field.boolean({
label: 'Active',
defaultValue: true,
description: 'Is the packaged artifact armed for this scope.',
description: 'Is the artifact armed for this scope.',
group: 'State',
}),
},
Expand Down
Loading
Loading