Skip to content

Commit 67b669e

Browse files
fix(runtime,core,platform-objects): the catalog activation door switches an environment-authored position or permission set, and keeps ADR-0112's code and status together (#22811)
Part of #15204 Clause-②: yes (widening) Stage 2d of #15204 (claim amendment 6106681412): 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 (ADR-0112). This PR does not close the card: the other stages stay open. ## The defect (F5 of objectui#12089 round 2), reproduced first On `origin/main` `9f5eca5`, the dogfood case added here failed before any fix: ``` AssertionError: {"success":false,"error":{"code":"VALIDATION_FAILED","message":"Package is required","httpStatus":503}}: expected 503 to be 200 ``` - `handleCatalogActivationWrite` wrote `packageId: entry.packageId ?? ''`. An item saved through `PUT /meta/position/:name` or `PUT /meta/permission/:name` has no `_packageId`, so the row carried `''`. - `sys_metadata_activation.package_id` was `Field.text({ required: true })`, so the engine refused the row: `VALIDATION_FAILED`, with no status of its own. - The door's `catch` kept the thrown `code` but defaulted the status to `503`. The code and the status disagreed. ## Who writes and reads `package_id` (measured on `9f5eca5`) | Seam | Uses `package_id` for | |:--|:--| | catalog door (`runtime/src/domains/catalog-activation.ts`) | writes `entry.packageId ?? ''` (the producer of F5) | | action door (`runtime/src/domains/actions.ts:223`) | writes the action's `_packageId`, else `''` | | flow toggle (`service-automation/src/engine.ts`) | writes the loader's package; a flow no package ships is refused before the write | | `ObjectStoreMetadataActivationStore.list()` (core) | maps it onto `packageId`; the flow and action hydrators (`hydrateFlowActivations`, `ActionActivationProjection.hydrate`) read only `name` and `active` | | `readDisabledCatalogNames` (`core/src/security/resolve-authz-context.ts`) | not read: type, name, active | | `catalog-set-in-effect.ts`, `last-admin-guard.ts` (plugin-auth), `sharing-rule-service.ts` (plugin-sharing) | not read | | objectui console at `main` `a194b4e` | `git grep package_id` over the ledger readers: no hit | The row identity is `(metadata_type, name)` (the `'global'` unique index). No reader decides anything from `package_id`. ## The fix, and why option (i) **(i) `package_id` becomes optional, and "no package" has exactly one spelling.** - `sys_metadata_activation.package_id`: `required` is dropped. The field's description now says it is empty when no package ships the item. The column was already nullable on disk: under ADR-0113 `required` is a write-time check, and NOT NULL comes only from `storage.notNull`. So no DDL and no migration. - `MetadataActivationRow.packageId` is optional (`@objectstack/core`). The store writes `null` when it is absent. It **refuses `packageId: ''`** with a `TypeError`, before any read or write. `list()` returns `packageId` only when the row names a package. So a package-less row has one spelling, and no reader can take an environment item for a package named `''`. - The door passes `entry.packageId` as it is. The `?? ''` is gone. - The door's `catch` answers through `deps.errorFromThrown(err, 503)`. That is the dispatcher's one thrown-error rule (`resolveThrownHttpError`). A record validation refusal becomes `400 VALIDATION_FAILED`. A hook refusal keeps its own pair (`403 PERMISSION_DENIED`). An error with no status of its own becomes `503 SERVICE_UNAVAILABLE`. - `FlowActivationRow.packageId` is optional to match (type only). Without that, `ObjectStoreFlowActivationStore implements FlowActivationStore` no longer type-checks. This was reverse-verified: putting back `packageId: string` gives 10 × TS2345 in service-automation. **On the four axes:** - **Real business need.** The environment-authored items are real: ruling #22621 → A covers them, and the stage 2c door already resolves them. Measured readers: none reads `package_id`. A marker string (ii) would serve no reader. - **Long-term soundness.** (i) records the truth in the column: there is no package. (ii) would put a sentinel into a column described as a package id. Every future reader that compares it with a package would then have to know the dialect. That is a second spelling of "no package" next to `null`. - **Preventing AI mistakes.** (i) closes the hole on the producer side. The `?? ''` is gone, and the store loudly refuses the `''` placeholder a caller would reach for. (ii) would make the marker a magic value that an AI-written reader could easily treat as a real package. - **No scope spread.** (i) is a smaller diff: one field flag, one optional type, one refusal. No new vocabulary. ## Pins - `catalog-activation-door.test.ts` (runtime, unit). New tests: - an environment-authored position and set, in both directions, each writing exactly `{ metadata_type, name, package_id: null, active }`; - an anti-vacuity check that the catalog resolves both with `packageId` undefined, beside a packaged control that names `crm`; - the resolver honours the row for the position and for the set; - a record validation refusal is `400` with `VALIDATION_FAILED`, and nothing is written. - `metadata-activation-store.test.ts` (core). Inserting and updating with no package write `package_id: null`. `''` is refused before any engine call. `list()` reads a package-less row back with `packageId` absent, beside a packaged row. - `catalog-activation-door.dogfood.test.ts` (real showcase boot). Both items are saved through `/meta`. Anti-vacuity: the booted catalog reader resolves both with no `packageId`. A holder is assigned the position. The test then checks the door, the ledger row (`package_id` null), and `resolveUserAuthzGrants` dropping and restoring the grants, for the position and for the set. ## Verification - **Reproduction.** On base `9f5eca5`, the dogfood case was red (503 above). With the fix it is green: `Tests 8 passed (8)`. - **Ablation**, with `ablation-replace.mjs` and `ablation-dist-preflight.mjs`. Only `required: true` was put back on `package_id`, and `@objectstack/platform-objects` was rebuilt (the marker in `dist/` was confirmed). Result: the dogfood case went red as `{"code":"VALIDATION_FAILED",...,"httpStatus":400,"details":{"fields":[{"field":"package_id","code":"required",...}]}}`. That is now 400, not 503, so the code-and-status fix is shown on a real engine. Restore: the blob is byte-identical to HEAD, the build was redone, and the marker is absent from all 66 dist files. - **Full suites** of every edited package, on the pre-merge head `db73adc341`: - core: 2353 passed; - platform-objects: 1082; - service-automation: 2368; - runtime: 5038 passed, 19 skipped. - **Typecheck** of core, objectql, service-automation, runtime and platform-objects: green. - **Dogfood, in full,** on the merged head `bf3f89075e`: 245 files passed and 1 skipped; 1978 tests passed and 9 skipped. - **Gates.** `dispatch-gates --commands` derived 100 families, and all 100 exited 0 on `bf3f89075e`. `--ran` reconciles them: "100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED (a DERIVED zero)". - **eslint** over the 7 changed TypeScript files: clean. This is a narrowed run, not the repo-wide lint, which CI owns. ## Acceptance notes - **The action activation door is not changed.** For an action no package ships, it still passes `''`. That write was refused before (503 `VALIDATION_FAILED`, from the engine) and is still refused, with nothing written. Its answer is now 503 `SERVICE_UNAVAILABLE`, from the store's `TypeError`. Whether an environment-authored action gets a switch is a product question, not decided here. Its `catch` has the same shape as the one this PR fixes, but `actions.ts` is outside this stage's declared files. - The door's authority (`manage_metadata`, operator-only under a wall) is unchanged (#22621 → A). The anchors' 400, the unknown-name 404 and the last-admin 403 are unchanged. - objectui#12089's round-2 report and the seat note on objectui#7611 could not be read from this session: the objectui API is not attached here. F5 was taken from the restatement in claim amendment 6106681412 and reproduced independently, as above. --- _Generated by [Claude Code](https://claude.ai/code/session_014ifLBrKenJNWpsLxhLfhUg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f313fbc commit 67b669e

9 files changed

Lines changed: 344 additions & 33 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/core": minor
4+
"@objectstack/platform-objects": minor
5+
"@objectstack/service-automation": minor
6+
---
7+
8+
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
9+
10+
Clause-②: yes (widening)
11+
12+
- **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`).
13+
- **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`.
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,14 @@ The ledger row is written through
414414
grant off, `true` back on). The caller needs `manage_metadata`; under a `group`
415415
or `isolated` tenancy posture the caller must also be the platform operator,
416416
because the row reaches every organization (ADR-0126 §5). The name must resolve
417-
in the security catalog, or the route answers `404`. It is the same authority
417+
in the security catalog, or the route answers `404`. That covers a position or
418+
permission set the environment authored through the metadata door as well as
419+
one a package ships: the ledger row records the package when there is one and
420+
leaves `package_id` empty when there is none, and the resolver honours both
421+
alike, because it keys the row by type and name. A write the ledger refuses
422+
answers with the status its code means: a record validation failure is `400`
423+
`VALIDATION_FAILED`, a hook's refusal keeps its own pair, and a store failure
424+
is `503` `SERVICE_UNAVAILABLE`. It is the same authority
418425
as the flow and action activation switches. The two audience anchors,
419426
`everyone` and `guest`, are refused with `400` in either direction: one row
420427
would switch the authenticated or anonymous baseline off for every principal.

‎packages/core/src/utils/metadata-activation-store.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,62 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics,
296296
});
297297
});
298298

299+
// [#22621 → A] The security catalog's activation door switches a position or
300+
// permission set the environment authored, and no package ships those. The row
301+
// says so with ONE spelling — no package value — and the store refuses the
302+
// other spelling a caller reaches for (`?? ''`), so a reader can never take an
303+
// environment item for a package named ''.
304+
describe('ObjectStoreMetadataActivationStore — an item no package ships', () => {
305+
it('INSERTS a row with no package when packageId is absent', async () => {
306+
const { engine, calls } = makeStoreEngine([]);
307+
308+
await new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
309+
name: 'env_position', active: false,
310+
});
311+
312+
expect(calls.find((c) => c.op === 'insert')?.data).toEqual({
313+
metadata_type: 'position', name: 'env_position', package_id: null, active: false,
314+
});
315+
});
316+
317+
it('UPDATES its row with no package on the way back on', async () => {
318+
const { engine, calls } = makeStoreEngine([
319+
{ id: 'r1', metadata_type: 'permission', name: 'env_set', package_id: null, active: false },
320+
]);
321+
322+
await new ObjectStoreMetadataActivationStore(engine, 'permission').setActive({
323+
name: 'env_set', active: true,
324+
});
325+
326+
expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0);
327+
expect(calls.find((c) => c.op === 'update')?.data).toEqual({ id: 'r1', active: true, package_id: null });
328+
});
329+
330+
it('REFUSES an empty-string package before reading or writing anything', async () => {
331+
const { engine, calls } = makeStoreEngine([]);
332+
333+
await expect(new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
334+
name: 'env_position', packageId: '', active: false,
335+
})).rejects.toThrow(TypeError);
336+
expect(calls).toEqual([]);
337+
});
338+
339+
it('reads a package-less row back with packageId ABSENT, beside a packaged one that keeps its package', async () => {
340+
const { engine } = makeStoreEngine([
341+
{ id: 'r1', metadata_type: 'position', name: 'env_position', package_id: null, active: false },
342+
{ id: 'r2', metadata_type: 'position', name: 'sales_rep', package_id: 'crm', active: false },
343+
]);
344+
345+
const rows = await new ObjectStoreMetadataActivationStore(engine, 'position').list();
346+
347+
expect(rows).toEqual([
348+
{ name: 'env_position', active: false },
349+
{ name: 'sales_rep', packageId: 'crm', active: false },
350+
]);
351+
expect('packageId' in rows[0]).toBe(false);
352+
});
353+
});
354+
299355
describe('InMemoryMetadataActivationStore', () => {
300356
it('round-trips a row and reflects the latest flip', async () => {
301357
const store = new InMemoryMetadataActivationStore();

‎packages/core/src/utils/metadata-activation-store.ts‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,25 @@ export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation';
8686
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
8787

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

@@ -190,7 +197,9 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
190197
if (typeof r.name !== 'string' || !r.name) continue;
191198
out.push({
192199
name: r.name,
193-
packageId: typeof r.package_id === 'string' ? r.package_id : '',
200+
// A row with no package — an environment-authored item — reads
201+
// back with `packageId` absent, the spelling it was written in.
202+
...(typeof r.package_id === 'string' && r.package_id !== '' ? { packageId: r.package_id } : {}),
194203
// The column defaults to `true`; only an explicit `false`
195204
// disarms. A driver that round-trips booleans as 0/1
196205
// (SQLite/libsql) is read through the same `=== false || === 0`
@@ -216,8 +225,22 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
216225
* out of the result, back when the table carried a reserved tenant column;
217226
* with no such column the set it was choosing from can no longer hold more
218227
* than one member.
228+
*
229+
* `packageId` absent writes NO package (`null`), the ledger's spelling for
230+
* an item no package ships. An empty string is refused before anything is
231+
* read or written: it is the placeholder a caller reaches for with
232+
* `?? ''`, and a stored `''` would be one more spelling of "no package"
233+
* beside the absent one — exactly the ambiguity the field's contract
234+
* rules out.
219235
*/
220236
async setActive(row: MetadataActivationRow): Promise<void> {
237+
if (row.packageId === '') {
238+
throw new TypeError(
239+
`Activation row for ${this.metadataType} '${row.name}' names the empty string as its package. ` +
240+
`Omit packageId for an item no package ships; an empty package id is not a package.`,
241+
);
242+
}
243+
const packageId = row.packageId ?? null;
221244
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
222245
where: { metadata_type: this.metadataType, name: row.name },
223246
context: SYSTEM_CTX,
@@ -227,7 +250,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
227250
if (current && (current as { id?: unknown }).id != null) {
228251
await this.engine.update(
229252
METADATA_ACTIVATION_TABLE,
230-
{ id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId },
253+
{ id: (current as { id: unknown }).id, active: row.active, package_id: packageId },
231254
{ context: SYSTEM_CTX },
232255
);
233256
return;
@@ -238,7 +261,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
238261
{
239262
metadata_type: this.metadataType,
240263
name: row.name,
241-
package_id: row.packageId,
264+
package_id: packageId,
242265
active: row.active,
243266
},
244267
{ context: SYSTEM_CTX },

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
5555
* Writers: the enable/disable actions (ADR-0126 L2/L3). Readers: each runtime's
5656
* own consult point.
5757
*
58+
* Packaged and environment-authored: flows and actions reach this ledger only
59+
* when a package ships them, but the security catalog's activation door
60+
* (`POST /security/_activation/:type/:name`) switches every position and
61+
* permission set the catalog holds, the environment's own included — so
62+
* `package_id` is empty for those (see the field).
63+
*
5864
* @namespace sys
5965
*/
6066
export const SysMetadataActivation = ObjectSchema.create({
@@ -65,7 +71,7 @@ export const SysMetadataActivation = ObjectSchema.create({
6571
isSystem: true,
6672
managedBy: 'engine-owned',
6773
description:
68-
'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.',
74+
'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.',
6975
displayNameField: 'name',
7076
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
7177
highlightFields: ['metadata_type', 'name', 'package_id', 'active'],
@@ -124,22 +130,31 @@ export const SysMetadataActivation = ObjectSchema.create({
124130
required: true,
125131
searchable: true,
126132
maxLength: 255,
127-
description: "The packaged artifact's machine name.",
133+
description: "The artifact's machine name.",
128134
group: 'Identity',
129135
}),
130136

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

139154
active: Field.boolean({
140155
label: 'Active',
141156
defaultValue: true,
142-
description: 'Is the packaged artifact armed for this scope.',
157+
description: 'Is the artifact armed for this scope.',
143158
group: 'State',
144159
}),
145160
},

0 commit comments

Comments
 (0)