Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .changeset/22817-action-activation-unpackaged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/runtime": minor
"@objectstack/core": minor
---

The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means

Clause-②: yes (widening)

- **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. 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 action `404`, an ambiguous name `409`, an engine with no ledger `501`).
- **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes.
- **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`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`.
- **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead.
29 changes: 29 additions & 0 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,33 @@ describe('InMemoryMetadataActivationStore', () => {
await store.setActive({ name: 'nightly_sync', packageId: 'crm', active: true });
expect(await store.list()).toEqual([{ name: 'nightly_sync', packageId: 'crm', active: true }]);
});

// The flow and action stores' test double refuses exactly the row the
// durable store refuses: a double more lenient than what it stands for lets
// a test pass a write the real ledger refuses.
it('REFUSES an empty-string package as the durable store does, and keeps nothing', async () => {
const store = new InMemoryMetadataActivationStore();
const { engine } = makeStoreEngine([]);

const fromDouble = await store.setActive({ name: 'env_action', packageId: '', active: false }).catch((e) => e);
const fromStore = await new ObjectStoreMetadataActivationStore(engine, 'action')
.setActive({ name: 'env_action', packageId: '', active: false }).catch((e) => e);

expect(fromDouble).toBeInstanceOf(TypeError);
expect(fromStore).toBeInstanceOf(TypeError);
// One refusal, two stores: the same text, except that the durable store
// names its row's type in the subject and the double has no type.
expect(fromDouble.message).toBe(fromStore.message.replace(`action 'env_action'`, `'env_action'`));
expect(await store.list()).toEqual([]);
});

it('keeps an item no package ships with packageId ABSENT', async () => {
const store = new InMemoryMetadataActivationStore();

await store.setActive({ name: 'env_action', active: false });

const rows = await store.list();
expect(rows).toEqual([{ name: 'env_action', active: false }]);
expect('packageId' in rows[0]).toBe(false);
});
});
49 changes: 34 additions & 15 deletions packages/core/src/utils/metadata-activation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ export interface MetadataActivationRow {
name: string;
/**
* 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 `''`.
* position, permission set or action the environment authored, which the
* security catalog's and the action's activation doors switch 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}), and so does the
* in-memory double, so no reader can take an environment item for a
* package named `''`.
*/
packageId?: string;
/** Is the artifact armed for this installation. */
Expand Down Expand Up @@ -134,12 +136,36 @@ export interface MetadataActivationStoreEngine {
update(object: string, data: any, options?: any): Promise<any>;
}

/**
* Refuse the empty string as a row's package, before anything is read or
* written — the ONE refusal both stores apply, so the in-memory double can
* never accept a row the durable store refuses.
*
* `''` 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 {@link MetadataActivationRow.packageId} rules out.
*
* @param subject how the refusal names the row's artifact (`flow 'x'`, `'x'`).
*/
function refuseEmptyPackageId(row: MetadataActivationRow, subject: string): void {
if (row.packageId !== '') return;
throw new TypeError(
`Activation row for ${subject} names the empty string as its package. ` +
`Omit packageId for an item no package ships; an empty package id is not a package.`,
);
}

/**
* In-memory {@link MetadataActivationStore} — process-lifetime only, for tests
* and for hosts with no durable plane. What it lacks versus the ObjectStore
* implementation is DURABILITY, which is exactly the property ADR-0126 §6
* wall 3 asks for; it is not a sanctioned production off-switch.
*
* It refuses exactly the rows the durable store refuses (an empty package id,
* {@link refuseEmptyPackageId}): it is the flow and action stores' test
* double, and a double more lenient than what it stands for lets a test pass
* a write the real ledger refuses.
*
* No discriminator: an in-memory map is per-instance, so there is no shared
* table for a neighbouring type's rows to be in.
*/
Expand All @@ -151,6 +177,7 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore
}

async setActive(row: MetadataActivationRow): Promise<void> {
refuseEmptyPackageId(row, `'${row.name}'`);
this.rows.set(row.name, { ...row });
}
}
Expand Down Expand Up @@ -228,18 +255,10 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
*
* `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.
* read or written ({@link refuseEmptyPackageId}).
*/
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.`,
);
}
refuseEmptyPackageId(row, `${this.metadataType} '${row.name}'`);
const packageId = row.packageId ?? null;
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
where: { metadata_type: this.metadataType, name: row.name },
Expand Down
Loading
Loading