diff --git a/.changeset/ledger-convergence-registration-and-one-store.md b/.changeset/ledger-convergence-registration-and-one-store.md new file mode 100644 index 0000000000..d9751dd0e9 --- /dev/null +++ b/.changeset/ledger-convergence-registration-and-one-store.md @@ -0,0 +1,91 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/service-automation": minor +"@objectstack/objectql": minor +"@objectstack/core": minor +--- + +feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + +Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling +(2026-08-26, verbatim and untranslated: 「同意」). + +## The registration follows the declaration (#12359) + +`sys_metadata_activation` is declared in `@objectstack/platform-objects`, but +the only thing that REGISTERED it was the automation service's manifest — +because flows were the ledger's first and, until packaged actions landed, only +consumer. Packaged actions are a second consumer with a different owner: their +consult and write path live on the ObjectQL engine, present in every +composition that can execute an action. + +So a deployment with actions and no automation service had no ledger table, and +the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — +correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be +reported as one) and permanently. Measured on a real boot; it is now this +change's positive test, measured on the same boot: + +``` +POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED +``` + +`PlatformObjectsPlugin` registers it now, so every composition carrying +platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, +`skill`, `position`) inherits it. **MOVE, not add** — the automation service no +longer names the object. That was not a style choice: a second code package +claiming one object throws `Object "…" is already owned by package "…"` +(ADR-0029 D3/D7), measured, so adding a registrant would have been a boot +failure rather than a duplicate. + +**Upgrade is a no-op for existing data, and that is measured rather than +asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` +step 4 routes an object by its owning package's `defaultDatasource` — so the +registrar carries the table's datasource with it: + +``` +owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' +owner com.objectstack.platform-objects (none) -> undefined (global default driver) +``` + +The ledger table already exists in live databases, so on any deployment +carrying a `cloud` datasource that difference would leave the rows in one +database and read another — every disabled artifact silently re-arming. The +ledger therefore rides its own manifest from the same plugin, carrying the +automation manifest's `scope` / `namespace` / `defaultDatasource` triple +verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, +`sys_secret`) deliberately do not get it and keep riding the project database. + +## One implementation of the §4 row contract (#12350) + +ADR-0126 §4 declares one activation ledger; it had two independent +implementations of that one row contract — `ObjectStoreFlowActivationStore` +(service-automation) and `ObjectStoreActionActivationStore` (objectql). They +agreed because the second was written from the first, and nothing structurally +held them together; §8 pre-charts `tool`, `skill` and `position`, and a third +and fourth copy is where the org-row skip and the `0`-is-false read get lost +quietly, in the direction (an artifact re-arming) nothing else measures. + +Neither consumer could import the other, so the contract now lives once in +`@objectstack/core` — the package both already depend on — as +`ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside +`InMemoryMetadataActivationStore`, `MetadataActivationRow`, +`MetadataActivationStore`, `MetadataActivationStoreEngine` and +`METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own +one-argument constructor and its own docs, and fixes the discriminator. + +**No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / +`InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and +`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / +`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` +/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same +shapes. Row semantics are byte-equivalent: install-level rows only +(`organization_id` never written), org-carrying rows skipped on read and +ignored when deciding insert-vs-update, a driver `0` read as false, +read-then-write rather than a blind upsert, and no `delete` in the engine slice +because re-enabling rewrites the row. + +Both existing pin suites stay green **unchanged**, which is what makes them the +proof the consolidation lost nothing — verified by ablation: removing the +org-row skip from the one shared implementation turns both of them red on their +own org-skip assertion, so both really reach it. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 423b673d37..44a2db434d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,15 @@ export * from './utils/filter-tokens.js'; // on each other. export * from './utils/temporal-comparand.js'; +// [#12350 / ADR-0126 §4] THE activation-ledger row contract, parameterized by +// `metadata_type`. Same reason as the two entries above: its consumers — +// `@objectstack/objectql` (packaged actions) and +// `@objectstack/service-automation` (packaged flows) — cannot import each +// other in either direction, and this is the package both already depend on. +// The ledger's OBJECT stays declared in `@objectstack/platform-objects`; only +// the row contract lives here, reached by table NAME. +export * from './utils/metadata-activation-store.js'; + // Export the shared single-record 404 (#4435/#5138, moved down here in #7867) — // the one `RECORD_NOT_FOUND` envelope `protocol.updateData`/`deleteData`, // `callData`'s ObjectQL fallback and the engine's own by-id write gate answer diff --git a/packages/core/src/utils/metadata-activation-store.test.ts b/packages/core/src/utils/metadata-activation-store.test.ts new file mode 100644 index 0000000000..2552bd3c56 --- /dev/null +++ b/packages/core/src/utils/metadata-activation-store.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12350] ADR-0126 §4 — the activation-ledger row contract, now written ONCE +// and parameterized by `metadata_type`. +// +// ## What this file pins that neither consumer's suite can +// +// The two consumer suites — `flow-activation-ledger.test.ts` (packaged flows) +// and `action-activation.test.ts` (packaged actions) — are unchanged by the +// consolidation, deliberately: they pin the row contract from each binding's +// side, so their staying green IS the proof that moving the implementation +// lost nothing. What they cannot pin is the property that only exists now that +// there is one implementation: +// +// 1. **The discriminator is a PARAMETER, not a constant.** Each consumer +// suite sees exactly one value, so both would stay green against a store +// that had quietly hard-coded the other one's. +// 2. **Two bindings over one table do not see each other's rows** — in BOTH +// directions, from the same store class. That is the drift the two copies +// made possible (#12350's own argument: the org-row skip and the +// `0`-is-false read are what a copy loses quietly), and it can only be +// measured where both types are constructed side by side. +// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8 +// pre-charts `tool` / `skill` / `position`; a third consumer must inherit +// the semantics rather than re-derive them, and the cheapest proof is an +// unknown discriminator asserted through the same battery. +// +// The four load-bearing row properties themselves (`organization_id` never +// written, org-carrying rows skipped on read, absence means ACTIVE, a driver +// `0` reads as false) are pinned here too — this is where they now live, so +// this is where a change to them has to argue. + +import { describe, it, expect, vi } from 'vitest'; +// The real engine's OWN update-dispatch predicate, so the double below cannot +// accept a call `ObjectQL.update` refuses (`pnpm check:engine-double-contract`). +// `@objectstack/metadata-core` is where it lives precisely so packages on both +// sides of the engine can reach it without closing a dependency cycle. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { + InMemoryMetadataActivationStore, + METADATA_ACTIVATION_TABLE, + ObjectStoreMetadataActivationStore, +} from './metadata-activation-store.js'; + +const TABLE = 'sys_metadata_activation'; + +/** + * The double's WHERE predicate, at MODULE scope on purpose. + * + * `pnpm check:where-matcher` judges a matcher by LIFTING it — transpiling it + * with the same-file declarations it references and running a combinator + * battery against it. Declared inside the factory below, the lift would have to + * carry that factory's scope, which reaches `vi` and fails to evaluate: the + * gate then reports the matcher UNJUDGED, and unjudged is never treated as + * passing. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function matches(row: any, where: any): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + // Equality ONLY, and a loud REFUSAL for anything else rather than a + // quiet mismatch. A matcher with no combinator branch reads `$or` / + // `$in` as a FIELD NAME, compares `row.$or` (undefined) against the + // operand, matches nothing, and leaves the suite asserting on an empty + // result set with nothing erroring — the silent-wrong class + // `pnpm check:where-matcher` exists to catch. Refusing is the honest + // answer for a double that only ever sees scalar equality: the store's + // two reads are `{ metadata_type }` and `{ metadata_type, name }`, and + // its probe is `{}`. Same refusal both consumer doubles carry. + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error( + `makeStoreEngine: unsupported WHERE combinator '${k}' — this double implements equality only`, + ); + } + return row?.[k] === v; + }); +} + +/** + * A store engine that records what it was asked and answers `find` from a fixed + * row set filtered by the WHERE it was given. + * + * The filter is APPLIED rather than ignored on purpose: a store that scoped its + * read by `metadata_type` and a double that answered every row regardless would + * pin nothing about the discriminator — which is this file's whole subject. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeStoreEngine(rows: any[] = []) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const calls: Array<{ op: string; object: string; data?: any; options?: any }> = []; + const engine = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + find: vi.fn(async (object: string, options?: any) => { + calls.push({ op: 'find', object, options }); + return rows.filter((r) => matches(r, options?.where)); + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + insert: vi.fn(async (object: string, data: any, options?: any) => { + calls.push({ op: 'insert', object, data, options }); + return { id: 'row_new', ...data }; + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update: vi.fn(async (object: string, data: any, options?: any) => { + // Routed through the producer's OWN dispatch predicate, so this + // fake cannot be looser than the engine it stands in for — #4434 + // shipped a dead REST route with its suite green off exactly that + // gap. `pnpm check:engine-double-contract` is the gate. + assertEngineUpdateDispatch(data, options); + calls.push({ op: 'update', object, data, options }); + return data; + }), + }; + return { engine, calls }; +} + +describe('ObjectStoreMetadataActivationStore — the discriminator is a parameter (#12350)', () => { + it('scopes the read by the metadata_type it was constructed with', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false }, + ]); + + await new ObjectStoreMetadataActivationStore(engine, 'flow').list(); + + expect(engine.find).toHaveBeenCalledWith(TABLE, expect.objectContaining({ + where: { metadata_type: 'flow' }, + })); + }); + + it('two bindings over ONE table never see each other\'s rows — both directions', async () => { + const rows = [ + { id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false }, + { id: 'r2', metadata_type: 'action', name: 'mark_done', package_id: 'crm', active: false }, + ]; + + const flows = await new ObjectStoreMetadataActivationStore(makeStoreEngine(rows).engine, 'flow').list(); + const actions = await new ObjectStoreMetadataActivationStore(makeStoreEngine(rows).engine, 'action').list(); + + expect(flows.map((r) => r.name)).toEqual(['nightly_sync']); + expect(actions.map((r) => r.name)).toEqual(['mark_done']); + }); + + it('stamps the metadata_type it was given on an INSERT, and never a neighbour\'s', async () => { + const { engine, calls } = makeStoreEngine([]); + + await new ObjectStoreMetadataActivationStore(engine, 'action').setActive({ + name: 'mark_done', packageId: 'crm', active: false, + }); + + const insert = calls.find((c) => c.op === 'insert'); + expect(insert?.object).toBe(TABLE); + expect(insert?.data).toEqual({ + metadata_type: 'action', name: 'mark_done', package_id: 'crm', active: false, + }); + // ⛔ §5: `organization_id` is not written — asserted as an ABSENT key, + // because writing it explicitly (even as null) would be a different row + // shape, and the one the reserved per-org dimension is not. + expect(Object.keys(insert?.data ?? {})).not.toContain('organization_id'); + }); + + it('scopes the read-then-write lookup by BOTH discriminator and name', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'mark_done', package_id: 'crm', active: false }, + ]); + + // Same NAME as the flow row above, different type. A store that scoped + // the lookup by name alone would UPDATE the flow's row here. + await new ObjectStoreMetadataActivationStore(engine, 'action').setActive({ + name: 'mark_done', packageId: 'crm', active: false, + }); + + expect(calls.find((c) => c.op === 'find')?.options?.where) + .toEqual({ metadata_type: 'action', name: 'mark_done' }); + expect(calls.some((c) => c.op === 'update')).toBe(false); + expect(calls.find((c) => c.op === 'insert')?.data?.metadata_type).toBe('action'); + }); + + it('a type nobody has written yet (ADR-0126 §8: tool / skill / position) behaves identically', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'tool', name: 'summarize', package_id: 'ai', active: 0 }, + { id: 'r2', metadata_type: 'flow', name: 'summarize', package_id: 'ai', active: true }, + ]); + const store = new ObjectStoreMetadataActivationStore(engine, 'tool'); + + expect(await store.list()).toEqual([{ name: 'summarize', packageId: 'ai', active: false }]); + + await store.setActive({ name: 'summarize', packageId: 'ai', active: true }); + expect(calls.find((c) => c.op === 'update')?.data) + .toEqual({ id: 'r1', active: true, package_id: 'ai' }); + }); +}); + +describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics, one home', () => { + it('SKIPS a row carrying an organization rather than reading it install-level', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'install_level', package_id: 'crm', active: false }, + { id: 'r2', metadata_type: 'flow', name: 'org_scoped', package_id: 'crm', active: false, organization_id: 'org_1' }, + ]); + + const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list(); + + // Skipped, not merged: reading it install-level would apply one + // organization's choice to the whole installation — #10243 from the + // read side. + expect(rows.map((r) => r.name)).toEqual(['install_level']); + }); + + it('reads a driver `0` as FALSE, and a missing column as the packaged default (true)', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'sqlite_off', package_id: 'crm', active: 0 }, + { id: 'r2', metadata_type: 'flow', name: 'explicit_off', package_id: 'crm', active: false }, + { id: 'r3', metadata_type: 'flow', name: 'default_on', package_id: 'crm' }, + ]); + + const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list(); + + expect(rows).toEqual([ + { name: 'sqlite_off', packageId: 'crm', active: false }, + { name: 'explicit_off', packageId: 'crm', active: false }, + { name: 'default_on', packageId: 'crm', active: true }, + ]); + }); + + it('UPDATES the existing install-level row rather than inserting a second one', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false }, + ]); + + await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({ + name: 'nightly_sync', packageId: 'crm', active: true, + }); + + // Re-enabling records the administrator's CHOICE (§6 wall 3); it never + // deletes the row — which is why the engine slice has no `delete` at + // all, so a double cannot pretend one exists. + expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0); + expect(calls.find((c) => c.op === 'update')?.data) + .toEqual({ id: 'r1', active: true, package_id: 'crm' }); + }); + + it('ignores an org-carrying row when deciding insert-vs-update', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false, organization_id: 'org_1' }, + ]); + + await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({ + name: 'nightly_sync', packageId: 'crm', active: false, + }); + + // The write side of the same wall: overwriting one organization's row + // as if it were the install-level one is the #10243 leak with the + // arrow reversed. + expect(calls.some((c) => c.op === 'update')).toBe(false); + expect(calls.find((c) => c.op === 'insert')?.data?.name).toBe('nightly_sync'); + }); + + it('probes the TABLE unscoped — the question is composition, not type', async () => { + const { engine } = makeStoreEngine([]); + + await new ObjectStoreMetadataActivationStore(engine, 'flow').probe(); + + expect(engine.find).toHaveBeenCalledWith(TABLE, expect.objectContaining({ where: {}, limit: 1 })); + }); + + it('surfaces the driver error verbatim when the table cannot be read', async () => { + const engine = { + find: vi.fn(async () => { throw new Error(`no such table: ${TABLE}`); }), + insert: vi.fn(), + update: vi.fn(), + }; + + await expect(new ObjectStoreMetadataActivationStore(engine, 'flow').probe()) + .rejects.toThrow(/no such table: sys_metadata_activation/); + }); + + it('exports the table name the consumers re-export, so it cannot be spelled two ways', () => { + expect(METADATA_ACTIVATION_TABLE).toBe(TABLE); + }); +}); + +describe('InMemoryMetadataActivationStore', () => { + it('round-trips a row and reflects the latest flip', async () => { + const store = new InMemoryMetadataActivationStore(); + + expect(await store.list()).toEqual([]); + await store.setActive({ name: 'nightly_sync', packageId: 'crm', active: false }); + expect(await store.list()).toEqual([{ name: 'nightly_sync', packageId: 'crm', active: false }]); + await store.setActive({ name: 'nightly_sync', packageId: 'crm', active: true }); + expect(await store.list()).toEqual([{ name: 'nightly_sync', packageId: 'crm', active: true }]); + }); +}); diff --git a/packages/core/src/utils/metadata-activation-store.ts b/packages/core/src/utils/metadata-activation-store.ts new file mode 100644 index 0000000000..f34176b568 --- /dev/null +++ b/packages/core/src/utils/metadata-activation-store.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §4] THE activation-ledger row contract — one implementation, + * parameterized by `metadata_type`. + * + * ## Why this file exists (#12350) + * + * ADR-0126 §4 declares ONE activation ledger for the whole disable+clone + * family. It briefly had two implementations of that one row contract: + * + * | Implementation | Package | Landed | + * | :-------------------------------- | :--------------------------------- | :----- | + * | `ObjectStoreFlowActivationStore` | `@objectstack/service-automation` | #12296 | + * | `ObjectStoreActionActivationStore`| `@objectstack/objectql` | #12348 | + * + * They agreed on every load-bearing detail because the second was written from + * the first — and nothing structurally held them together. ADR-0126 §8 + * pre-charts `tool`, `skill` and `position` as later consumers, and a third + * and fourth copy is where the row semantics start drifting: the org-row skip + * and the `0`-is-false read are exactly the kind of detail a copy loses + * quietly, in a direction (an artifact silently re-arming) nothing else + * measures. + * + * ## Why the code lives HERE and the object does not + * + * Neither consumer could import the other: `@objectstack/service-automation` + * does not depend on `@objectstack/objectql` (devDependency only), and the + * engine must not depend on a service — the dependency arrow points the other + * way. `@objectstack/core` is the package BOTH already depend on, so this is + * the one home that needs no new edge. ⛔ NOT `@objectstack/platform-objects`, + * which declares the OBJECT: `objectql` does not depend on it and adding that + * edge would invert the tiering, since platform-objects is a catalog the + * engine serves. That is a MODULE-IMPORT question, and it is independent of + * where the object's REGISTRATION lives (a composition question, ruled + * separately on #12359 — `PlatformObjectsPlugin`). + * + * The table is reached by NAME, never by importing the declaration, exactly as + * the engine already reaches `sys_metadata` / `sys_secret`. + * + * ## The row shape — ⛔ this module writes COLUMNS, never schema + * + * `metadata_type` · `name` · `package_id` · `organization_id` · `active`, + * exactly the five ADR-0126 §4 declares. Four properties are load-bearing and + * each is pinned on both consumers' sides (`flow-activation-ledger.test.ts`, + * `action-activation.test.ts` — unchanged by the consolidation, which is what + * makes them the proof it lost nothing): + * + * - **`organization_id` is never written.** It is declared nullable and + * RESERVED (§5): every row written here is install-level, so the column + * stays NULL. The object's `unique: 'organization'` index collapses NULL + * through the driver's `COALESCE(organization_id, '__global__')`, so NULL + * rows are still unique per `(metadata_type, name)` — which is what lets + * {@link ObjectStoreMetadataActivationStore.setActive} treat "the row for + * this artifact" as at most one row. + * - **Rows carrying an organization are SKIPPED on read, not merged.** A row + * with one set was not written by this line, and reading it as + * install-level would apply one organization's choice to the whole + * installation — the #10243 direction, arrived at from the read side. A + * future per-org consumer adds its own scoped read; it does not widen + * this one. + * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say + * "active by default", and `list()` returning nothing is the normal + * stock-boot state, not an error. Re-enabling UPDATES the row to + * `active: true` rather than deleting it, so the ledger records the + * administrator's CHOICE instead of erasing it (§6 wall 3) — which is why + * {@link MetadataActivationStoreEngine} deliberately has no `delete`. + * - **A driver `0` reads as false.** SQLite/libsql round-trip booleans as + * 0/1; a `!== false` test alone would read a disabled artifact as armed. + * + * ## The discriminator is never optional + * + * The ledger is generic and shared — flow rows and action rows live in the + * same table today, and §8 charts more. Every read and write below is scoped + * by `metadata_type`, so no consumer can touch a neighbour's state through a + * table all of them are told to treat as generic. It is a constructor + * argument, not a per-call one, so a caller cannot forget it at a single site. + */ + +/** + * The ledger table. A NAME, not an import: the object is declared in + * `@objectstack/platform-objects` and this package must not depend on it. + */ +export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation'; + +/** Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** + * [ADR-0126 §4] One packaged artifact's install-level activation row. + * + * The ledger's own columns are `metadata_type` / `name` / `package_id` / + * `organization_id` / `active`; `metadata_type` is fixed by the store and + * `organization_id` is never written on this line (§5), so neither reaches a + * consumer's projection. + */ +export interface MetadataActivationRow { + /** The packaged 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. */ + active: boolean; +} + +/** + * [ADR-0126 §4] The durable off-switch for one class of packaged artifact. + * + * Absence of a row means the packaged default — ACTIVE — so a runtime with no + * store attached, or a store with no rows, behaves exactly as a stock boot + * always has. + */ +export interface MetadataActivationStore { + /** Every install-level activation row for this type (`organization_id IS NULL`). */ + list(): Promise; + /** Insert or update the install-level row for one packaged artifact. */ + setActive(row: MetadataActivationRow): Promise; +} + +/** + * The exact engine slice this store needs: a keyed read, an insert and an + * update. Deliberately WITHOUT `delete` — re-enabling updates the `active` + * bit, it never removes the row (see the module header), and demanding only + * what is used keeps every test double honest about that. + */ +export interface MetadataActivationStoreEngine { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + find(object: string, options?: any): Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + insert(object: string, data: any, options?: any): Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update(object: string, data: any, options?: any): Promise; +} + +/** + * 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. + * + * No discriminator: an in-memory map is per-instance, so there is no shared + * table for a neighbouring type's rows to be in. + */ +export class InMemoryMetadataActivationStore implements MetadataActivationStore { + private readonly rows = new Map(); + + async list(): Promise { + return [...this.rows.values()]; + } + + async setActive(row: MetadataActivationRow): Promise { + this.rows.set(row.name, { ...row }); + } +} + +/** + * Durable {@link MetadataActivationStore} backed by the + * `sys_metadata_activation` object (ADR-0126 §4), scoped to one + * `metadata_type`. + * + * All access uses a system context: the object is `managedBy: 'engine-owned'` + * and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot + * write it at all — these rows are written by the ADR-0126 enable/disable + * doors and by nothing else. + */ +export class ObjectStoreMetadataActivationStore implements MetadataActivationStore { + constructor( + private readonly engine: MetadataActivationStoreEngine, + /** + * The ledger's `metadata_type` discriminator for this consumer — + * `'flow'`, `'action'`, … Required, and fixed for the store's + * lifetime: see the module header on why it is never a per-call + * argument. + */ + private readonly metadataType: string, + ) {} + + /** + * Every install-level row of this type. Read once at boot to hydrate the + * consumer's projection. + * + * Rows carrying an `organization_id` are SKIPPED, not merged — see the + * module header for why that is a wall and not a filter. + */ + async list(): Promise { + const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, { + where: { metadata_type: this.metadataType }, + context: SYSTEM_CTX, + }); + if (!Array.isArray(rows)) return []; + const out: MetadataActivationRow[] = []; + for (const row of rows) { + const r = row as { name?: unknown; package_id?: unknown; active?: unknown; organization_id?: unknown }; + if (r.organization_id != null) continue; + if (typeof r.name !== 'string' || !r.name) continue; + out.push({ + name: r.name, + packageId: typeof r.package_id === 'string' ? 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` + // test, so a `0` is not mistaken for `true`. + active: !(r.active === false || r.active === 0), + }); + } + return out; + } + + /** + * Insert or update the install-level row for one packaged artifact. + * + * Read-then-write rather than a blind upsert because the object's + * uniqueness is a DECLARED index (`unique: 'organization'`), not a primary + * key this store controls: there is no id to collide on, so an + * insert-and-catch could not tell "already there" from a real store + * failure. + * + * ⛔ `organization_id` is not in either payload. Omitting it is what leaves + * it NULL, which is the whole of §5's install-level scope on this line. + */ + async setActive(row: MetadataActivationRow): Promise { + const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, { + where: { metadata_type: this.metadataType, name: row.name }, + context: SYSTEM_CTX, + }); + const current = Array.isArray(existing) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? existing.find((r: any) => r?.organization_id == null) + : undefined; + + 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 }, + { context: SYSTEM_CTX }, + ); + return; + } + + await this.engine.insert( + METADATA_ACTIVATION_TABLE, + { + metadata_type: this.metadataType, + name: row.name, + package_id: row.packageId, + active: row.active, + }, + { context: SYSTEM_CTX }, + ); + } + + /** + * Read the backing table once so a misconfiguration surfaces at BOOT + * rather than as a failed toggle later. Throws the driver error verbatim — + * `no such table: sys_metadata_activation` means the object was never + * registered (or its schema never synced) in this composition. + * + * ⚠️ Unscoped by design: the question is "does the TABLE read at all", + * which is a property of the composition, not of one `metadata_type`. + */ + async probe(): Promise { + await this.engine.find(METADATA_ACTIVATION_TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); + } +} diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts index 105efa5ce3..7ec20fee2a 100644 --- a/packages/objectql/src/action-activation.ts +++ b/packages/objectql/src/action-activation.ts @@ -51,16 +51,29 @@ * in `@objectstack/runtime`'s `/actions` domain, which is where a caller and a * resolvable object set exist to name. * - * ## Relationship to the flow twin — one contract, two implementations, on record + * ## Relationship to the flow twin — ONE implementation now (#12350) * * `packages/services/service-automation/src/flow-activation-store.ts` is the - * same store one tier up, landed first (#12296). This module deliberately does - * NOT import it and is not imported by it: `service-automation` does not depend - * on `@objectstack/objectql`, and the engine must not depend on a service, so - * neither direction is available today. What holds the two together is the row - * contract in ADR-0126 §4 and the pins on both sides; consolidating them onto - * one implementation (in a package both may depend on) is filed as its own - * card rather than smuggled into this leg. + * same ledger one tier up, landed first (#12296). The two briefly carried + * independent copies of the §4 row contract, because neither direction of + * import exists: `service-automation` does not depend on + * `@objectstack/objectql`, and the engine must not depend on a service. #12350 + * closed that by moving the contract into `@objectstack/core` — the package + * BOTH already depend on — as + * {@link ObjectStoreMetadataActivationStore}. + * + * ⚠️ So the row semantics are NOT written in this file any more. Read them in + * `@objectstack/core`'s `utils/metadata-activation-store.ts`; what stays here + * is exactly what is ACTION-specific — the discriminator, the engine's + * projection, and the refusal sentences below. The section headings above that + * describe the row shape are kept because they are what an action author needs + * in the file they open, but the code they describe has one home. + * + * ⛔ The code home is not the package that declares the OBJECT: `objectql` + * does not depend on `@objectstack/platform-objects` and adding that edge + * would invert the tiering. Where the object's REGISTRATION lives is a + * different question with a different answer (`PlatformObjectsPlugin`, ruled + * on #12359) — a composition decision, not a module-import one. * * ## Why the ENGINE holds the projection * @@ -73,41 +86,44 @@ * handler registry, so the projection sits beside the map it governs. */ +import { + InMemoryMetadataActivationStore, + METADATA_ACTIVATION_TABLE, + ObjectStoreMetadataActivationStore, + type MetadataActivationRow, + type MetadataActivationStore, + type MetadataActivationStoreEngine, +} from '@objectstack/core'; + /** * The ledger table. A NAME, not an import: this package does not depend on * `@objectstack/platform-objects` (which declares the object) and must not — * the same posture the flow twin takes, and the same one this engine already - * takes for `sys_metadata` / `sys_secret`. + * takes for `sys_metadata` / `sys_secret`. Re-exported from `@objectstack/core` + * so the two consumers cannot spell it differently (#12350). */ -export const ACTION_ACTIVATION_TABLE = 'sys_metadata_activation'; -const TABLE = ACTION_ACTIVATION_TABLE; +export const ACTION_ACTIVATION_TABLE = METADATA_ACTIVATION_TABLE; /** * The ledger's `metadata_type` discriminator for this consumer. Every read and - * write here is scoped by it: the ledger is generic (ADR-0126 §4) and this - * module never assumes it owns the table — flow rows share it today, permission - * rows may later. + * write is scoped by it: the ledger is generic (ADR-0126 §4) and this module + * never assumes it owns the table — flow rows share it today, permission rows + * may later. */ const METADATA_TYPE = 'action'; -/** Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. */ -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - /** * [ADR-0126 §4] One packaged action's install-level activation row, as the * engine sees it. The ledger's own columns are `metadata_type` / `name` / * `package_id` / `organization_id` / `active`; `metadata_type` is fixed to * `'action'` by the store and `organization_id` is never written on this line * (§5), so those two never reach the projection. + * + * An alias of the shared row (#12350): the ADR declares ONE row shape, so a + * separate declaration here could only ever drift from it. `name` here is the + * action's declarative machine name (ADR-0110 D1). */ -export interface ActionActivationRow { - /** The packaged action's declarative machine name (ADR-0110 D1). */ - name: string; - /** The package that ships the base artifact. */ - packageId: string; - /** Is the packaged action armed for this installation. */ - active: boolean; -} +export type ActionActivationRow = MetadataActivationRow; /** * [ADR-0126 §8] The durable off-switch for packaged actions. @@ -116,12 +132,7 @@ export interface ActionActivationRow { * store attached, or a store with no rows, dispatches exactly as a stock boot * always has. */ -export interface ActionActivationStore { - /** Every install-level action activation row (`organization_id IS NULL`). */ - list(): Promise; - /** Insert or update the install-level row for one packaged action. */ - setActive(row: ActionActivationRow): Promise; -} +export type ActionActivationStore = MetadataActivationStore; /** * The exact slice of the engine this store needs: a keyed read, an insert and @@ -129,11 +140,7 @@ export interface ActionActivationStore { * bit, it never removes the row (see the module header), and demanding only * what is used keeps every test double honest about that. */ -export interface ActionActivationStoreEngine { - find(object: string, options?: any): Promise; - insert(object: string, data: any, options?: any): Promise; - update(object: string, data: any, options?: any): Promise; -} +export type ActionActivationStoreEngine = MetadataActivationStoreEngine; /** * In-memory {@link ActionActivationStore} — process-lifetime only, for tests @@ -141,115 +148,26 @@ export interface ActionActivationStoreEngine { * off-switch: what it lacks versus the ObjectStore implementation is * DURABILITY, which is exactly the property ADR-0126 §6 wall 3 asks for. */ -export class InMemoryActionActivationStore implements ActionActivationStore { - private readonly rows = new Map(); - - async list(): Promise { - return [...this.rows.values()]; - } - - async setActive(row: ActionActivationRow): Promise { - this.rows.set(row.name, { ...row }); - } -} +export class InMemoryActionActivationStore extends InMemoryMetadataActivationStore {} /** * Durable {@link ActionActivationStore} backed by the `sys_metadata_activation` * object (ADR-0126 §4). * + * A binding, not an implementation (#12350): it fixes the `metadata_type` and + * nothing else — the §4 row semantics live once, in `@objectstack/core`. The + * one-argument constructor is deliberate: a caller that had to pass the + * discriminator could pass the wrong one, and the action leg has exactly one + * correct value. + * * All access uses a system context: the object is `managedBy: 'engine-owned'` * and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot * write it at all — these rows are written by the ADR-0126 enable/disable door * and by nothing else. */ -export class ObjectStoreActionActivationStore implements ActionActivationStore { - constructor(private readonly engine: ActionActivationStoreEngine) {} - - /** - * Every install-level action row. Read once at boot to hydrate the - * projection. - * - * Rows carrying an `organization_id` are SKIPPED, not merged: the per-org - * dimension is reserved and unwritten on this line (§5), so a row with one - * set was not written by this code. Reading it as install-level would apply - * one organization's choice to the whole installation — the #10243 - * direction, arrived at from the read side. A future per-org consumer adds - * its own scoped read; it does not widen this one. - */ - async list(): Promise { - const rows = await this.engine.find(TABLE, { - where: { metadata_type: METADATA_TYPE }, - context: SYSTEM_CTX, - }); - if (!Array.isArray(rows)) return []; - const out: ActionActivationRow[] = []; - for (const row of rows) { - const r = row as { name?: unknown; package_id?: unknown; active?: unknown; organization_id?: unknown }; - if (r.organization_id != null) continue; - if (typeof r.name !== 'string' || !r.name) continue; - out.push({ - name: r.name, - packageId: typeof r.package_id === 'string' ? 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` - // test the flow twin uses, so a `0` is not mistaken for `true`. - active: !(r.active === false || r.active === 0), - }); - } - return out; - } - - /** - * Insert or update the install-level row for one packaged action. - * - * Read-then-write rather than a blind upsert because the object's - * uniqueness is a DECLARED index (`unique: 'organization'`), not a primary - * key this store controls: there is no id to collide on, so an - * insert-and-catch could not tell "already there" from a real store - * failure. - * - * ⛔ `organization_id` is not in either payload. Omitting it is what leaves - * it NULL, which is the whole of §5's install-level scope on this line. - */ - async setActive(row: ActionActivationRow): Promise { - const existing = await this.engine.find(TABLE, { - where: { metadata_type: METADATA_TYPE, name: row.name }, - context: SYSTEM_CTX, - }); - const current = Array.isArray(existing) - ? existing.find((r: any) => r?.organization_id == null) - : undefined; - - if (current && (current as { id?: unknown }).id != null) { - await this.engine.update( - TABLE, - { id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId }, - { context: SYSTEM_CTX }, - ); - return; - } - - await this.engine.insert( - TABLE, - { - metadata_type: METADATA_TYPE, - name: row.name, - package_id: row.packageId, - active: row.active, - }, - { context: SYSTEM_CTX }, - ); - } - - /** - * Read the backing table once so a misconfiguration surfaces at BOOT rather - * than as a failed toggle later. Throws the driver error verbatim — `no - * such table: sys_metadata_activation` means the object was never - * registered (or its schema never synced) in this composition. - */ - async probe(): Promise { - await this.engine.find(TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); +export class ObjectStoreActionActivationStore extends ObjectStoreMetadataActivationStore { + constructor(engine: ActionActivationStoreEngine) { + super(engine, METADATA_TYPE); } } diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index c33b31a470..2c05d109f4 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2295,8 +2295,9 @@ export class ObjectQLPlugin implements Plugin { * FUNCTIONAL degradation — a capability this deployment does not have — * so it is `warn` per the AGENTS.md degradation-log-level rule. It is * deliberately not `error`: `sys_metadata_activation` is registered by - * whichever composition consumes it (the automation service registers it - * today), so an ObjectQL host without that composition is a legitimate + * `PlatformObjectsPlugin` (#12359 ruling, 2026-08-26 — registration + * follows the declaration), which a lean or translations-only kernel is + * free not to compose, so an ObjectQL host without it is a legitimate * deployment, and an `error` on every one of them is exactly the * over-application that trains operators to skim `error`. * @@ -2322,11 +2323,11 @@ export class ObjectQLPlugin implements Plugin { // // 1. `find` on a table that does not exist is a driver FAULT, and the // engine logs it at `error` on its way out. A composition that simply - // does not register `sys_metadata_activation` (no automation service - // — that plugin is the object's registrant today) would print that - // error on every single boot, for a capability it never asked for. - // An unregistered object is a fact this can read without touching the - // datasource at all. + // does not register `sys_metadata_activation` (no + // `PlatformObjectsPlugin` — the object's single registrant since the + // #12359 ruling) would print that error on every single boot, for a + // capability it never asked for. An unregistered object is a fact this + // can read without touching the datasource at all. // 2. It splits the two cases the log levels below distinguish: absent // from this composition (ordinary, `debug`) versus registered but // unreadable (a real misconfiguration, `warn`). diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 99936f1877..649a9ec658 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; // the predicate into a package that depends on neither side. import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -53,11 +53,11 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 await new PlatformObjectsPlugin().init(ctx); - expect(manifests).toHaveLength(1); - expect(manifests[0].id).toBe('com.objectstack.platform-objects'); - expect(manifests[0].scope).toBe('system'); - expect(manifests[0].objects).toEqual([SysMigration, SysMigrationJournal, SysSecret]); - expect(manifests[0].objects.map((o: any) => o.name)).toEqual([ + const infra = manifests.find((m) => m.id === 'com.objectstack.platform-objects'); + expect(infra).toBeDefined(); + expect(infra.scope).toBe('system'); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret]); + expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag // ledger rather than by the migration CLI that writes it: recovery has @@ -66,6 +66,52 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 'sys_migration_journal', 'sys_secret', ]); + // ⛔ The three above must NOT acquire the ledger's routing triple. They + // ride the project database today; moving them is a different question. + expect(infra.defaultDatasource).toBeUndefined(); + }); + + /** + * [#12359 ruling, 2026-08-26 — 「同意」] ADR-0126 §4's activation ledger. + * Registration follows the DECLARATION: the object is declared in this + * package, so this plugin registers it. Until the ruling its only registrant + * was the automation service's manifest — which made packaged-ACTION + * disable, whose consult and write path live on the ObjectQL engine, + * unavailable in every actions-and-no-automation composition (measured on a + * real boot: 503 SERVICE_UNAVAILABLE on the flip). + */ + it('registers SysMetadataActivation under its OWN manifest, carrying the routing verbatim', async () => { + const ctx = makeCtx(); + const manifests: any[] = []; + ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); + + await new PlatformObjectsPlugin().init(ctx); + + const ledger = manifests.find((m) => m.objects?.some((o: any) => o.name === 'sys_metadata_activation')); + expect(ledger, 'no manifest registers sys_metadata_activation').toBeDefined(); + expect(ledger.objects).toEqual([SysMetadataActivation]); + + // The load-bearing half. `ObjectQL.resolveDatasourceBinding` step 4 routes + // an object by its OWNING PACKAGE's `defaultDatasource`, so a registrar + // carries the table's datasource with it. The ledger table already exists + // in live databases; dropping this triple would leave its rows in one + // database and start reading another on any deployment that registers a + // `cloud` datasource — every disabled artifact silently re-arming, which is + // the ADR-0126 §6 wall 3 failure through an unwatched door. Measured on a + // real engine: owner `com.objectstack.service-automation` resolves 'cloud', + // owner `com.objectstack.platform-objects` resolves undefined (the global + // default driver — a different database). + expect(ledger.scope).toBe('system'); + expect(ledger.namespace).toBe('sys'); + expect(ledger.defaultDatasource).toBe('cloud'); + + // Exactly one manifest claims it. Two would not be a duplicate, it would be + // a boot FAILURE — `registerObject` throws `Object "…" is already owned by + // package "…"` (ADR-0029 D3 single owner). Measured, because the triage + // that raised #12359 left it as the open question. + expect( + manifests.filter((m) => m.objects?.some((o: any) => o.name === 'sys_metadata_activation')), + ).toHaveLength(1); }); /** diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 4a25b10659..11f1f9369c 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -2,6 +2,7 @@ import { SetupAppTranslations } from './apps/translations/index.js'; import { MetadataFormsTranslations } from './metadata-translations/index.js'; +import { SysMetadataActivation } from './system/sys-metadata-activation.object.js'; import { SysMigration } from './system/sys-migration.object.js'; import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; @@ -47,6 +48,36 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * service-settings registered it, a kernel composed without settings * hard-failed every secret-field write with an error that blamed * platform-objects. Registered here, that error message is true. + * - **`sys_metadata_activation`** — the packaged-metadata activation + * ledger (ADR-0126 §4). Same story a third time, and the ruling that + * moved it here is recorded verbatim on #12359 (maintainer, 2026-08-26, + * 「同意」): registration follows the DECLARATION. Until then the only + * registrant was the automation service's manifest, because flows were + * the ledger's first and only consumer; the moment packaged ACTIONS + * became the second, the consult and write path moved to the ObjectQL + * engine — present in every composition that can execute an action — + * while the object it needs was still gated on an unrelated service. + * Measured cost of that gap: a `bootStack(showcaseStack)` boot (actions, + * no automation service) answered the activation door 503 + * SERVICE_UNAVAILABLE, correctly but permanently. Registered here, every + * composition carrying platform-objects has the ledger, so packaged + * disable works without the automation service and each future ADR-0126 + * §8 consumer (`tool`, `skill`, `position`) inherits it. + * + * ⚠️ MOVE, not add — **single owner, one registration**. The automation + * service no longer names this object in its own manifest. The triage that + * raised #12359 left "is a double registration benign?" unmeasured; it is + * measured now, and it is NOT: `SchemaRegistry.registerObject` throws + * `Object "sys_metadata_activation" is already owned by package + * "com.objectstack.service-automation"` the moment a second code package + * claims the same name (ADR-0029 D3's single-owner invariant, D7's + * contributor kinds). Adding a registrant would have been a boot failure, + * not a duplicate — so MOVE was the only shape available, whichever way + * the ownership question was answered. + * + * ⚠️ It rides its OWN manifest, not the one above, and that is a + * smooth-upgrade requirement rather than tidiness — see + * {@link ACTIVATION_LEDGER_MANIFEST}. * - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) — * travels with the ledger registration: a store this boot created from * empty is attested once that boot's own data has settled @@ -75,6 +106,63 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * Structurally typed against `@objectstack/core`'s `Plugin` contract so * this package does not need to depend on the kernel at compile time. */ +/** + * [#12359 / ADR-0126 §4] The manifest the activation ledger is registered + * under — this plugin's, but SEPARATE from the one carrying `sys_migration` / + * `sys_migration_journal` / `sys_secret`, and carrying the automation + * manifest's `scope` / `namespace` / `defaultDatasource` triple verbatim. + * + * ## Why a second manifest rather than a fourth entry in the first one + * + * Because a manifest is not only a list of objects — it is also a ROUTING + * decision, and #12359's ruling was about registration, not about where the + * rows live. `ObjectQL.resolveDatasourceBinding` step 4 routes an object by + * its OWNING PACKAGE's `defaultDatasource`, so the registrar carries the + * table's datasource with it. Measured on a real engine holding a default + * driver plus one named `cloud`, registering the same object definition under + * each manifest in turn: + * + * owner com.objectstack.service-automation (defaultDatasource: 'cloud') + * -> resolveEffectiveDatasource('sys_metadata_activation') === 'cloud' + * owner com.objectstack.platform-objects (no defaultDatasource) + * -> resolveEffectiveDatasource('sys_metadata_activation') === undefined + * (i.e. rides the global default driver — a DIFFERENT database) + * + * The ledger table already exists in live databases. On any deployment that + * registers a datasource named `cloud`, folding the object into the manifest + * above would leave its rows in one database and start reading the other: + * every artifact an administrator had switched off would silently re-arm, + * which is precisely the failure ADR-0126 §6 wall 3 exists to close, arriving + * through a door nobody was watching. So the routing is carried across + * unchanged and the move is a no-op for existing data — the smooth-upgrade + * wall this card was given, discharged by preserving the status quo rather + * than by asserting it. + * + * ⛔ The three siblings are deliberately NOT given this triple. They ride the + * project database today and moving them is a different, larger question. + * ⛔ And it is NOT spelled as `datasource: 'cloud'` on the object itself: that + * is binding step 1, which answers even when no such driver is registered, so + * `getDriver` would throw `Datasource 'cloud' … is not registered` on every + * deployment that has no control plane. Step 4 is conditional on the driver + * existing, which is exactly the behaviour this object has today. + * + * ⚠️ Which datasource this ledger SHOULD live on is a real question and this + * constant does not answer it — it only refuses to answer it by accident. A + * card that wants to move it must move the rows too. + */ +const ACTIVATION_LEDGER_MANIFEST = { + id: 'com.objectstack.platform-objects.activation-ledger', + name: 'Packaged-Metadata Activation Ledger', + version: '1.0.0', + type: 'plugin', + // The same three the automation service's manifest carried. `scope: 'system'` + // packages are documented as also setting `defaultDatasource: 'cloud'` + // (`manifest.zod.ts`), and this one does — which is the whole point. + scope: 'system', + defaultDatasource: 'cloud', + namespace: 'sys', +} as const; + export class PlatformObjectsPlugin { readonly name = 'com.objectstack.platform-objects'; readonly type = 'standard'; @@ -98,10 +186,16 @@ export class PlatformObjectsPlugin { scope: 'system', objects: [SysMigration, SysMigrationJournal, SysSecret], }); + ctx?.getService?.('manifest')?.register?.({ + ...ACTIVATION_LEDGER_MANIFEST, + objects: [SysMetadataActivation], + }); } catch { // No manifest service (lean / i18n-only kernels) — the ledger stays - // unregistered (every flag reader answers "not verified") and the - // secret store stays unregistered (secret-field writes fail closed). + // unregistered (every flag reader answers "not verified"), the secret + // store stays unregistered (secret-field writes fail closed), and the + // activation ledger stays unregistered (every enable/disable door + // refuses loudly with 503 rather than keeping a bit that reverts). } } diff --git a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts new file mode 100644 index 0000000000..0ceae9e3ee --- /dev/null +++ b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12359 / #12159] ADR-0126 §4 — the packaged-metadata activation ledger is +// reachable in every composition that carries `PlatformObjectsPlugin`. +// +// ## The measurement this file turns around +// +// #12359 was filed FROM a real boot, and the boot was this one. Booted showcase +// stack, authenticated as the dev admin, no automation service: +// +// POST /api/v1/actions/_activation/showcase_task/showcase_mark_done +// {"enabled": false} +// -> 503 SERVICE_UNAVAILABLE +// "Cannot disable packaged action 'showcase_mark_done' — no activation +// ledger is attached to this engine (sys_metadata_activation, +// ADR-0126 §4) …" +// +// The refusal was correct: ADR-0126 §6 wall 3 says a flip that cannot be made +// durable must not be reported as one. What was wrong was the CAUSE — the +// ledger's object was declared in `@objectstack/platform-objects` and +// registered by the automation service's manifest, so a deployment with actions +// and no automation had no table. Packaged actions are a second consumer with a +// different owner: their consult and write path live on the ObjectQL engine, +// present wherever an action can execute. +// +// Maintainer ruling, 2026-08-26, verbatim and untranslated: 「同意」 — +// registration follows the DECLARATION. So the 503 above is this file's first +// describe, inverted into a positive: same boot, the flip lands, and dispatch +// consults it. +// +// ## Why the automation-carrying boot is measured too, not reasoned about +// +// A MOVE has two ends. The second describe is the end that already worked, and +// it has to still work: flows and actions both, in one composition, with both +// projections hydrated from the same table. It also measures the two facts a +// unit test cannot see, because both are properties of a whole assembled +// kernel: +// +// - the ledger object has exactly ONE owner. Two code packages claiming one +// object is not a duplicate, it is a boot FAILURE (`registerObject` throws +// `already owned by package …`, ADR-0029 D3/D7) — which is the answer to +// the question #12359's triage left open, and the reason MOVE was the only +// available shape; +// - the table's DATASOURCE BINDING is unchanged by the move. The registrar +// carries it (`resolveDatasourceBinding` step 4 routes an object by its +// owning package's `defaultDatasource`), and the ledger table already +// exists in live databases — so a registrar change that moved the binding +// would leave the rows in one database and read another, silently +// re-arming every artifact an administrator had switched off. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +// The showcase declares `connectors:` bound to these providers, and the +// automation service REFUSES TO START without their factories (ADR-0097) — +// exactly as `objectstack dev` would. Only the automation-carrying boot needs +// them; the first describe composes no automation, so it composes none of +// these either, which is what keeps it the composition #12359 measured. +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { fileURLToPath } from 'node:url'; + +/** + * The showcase's declared connectors carry PACKAGE-RELATIVE file refs (the + * OpenAPI provider reads `./src/system/connectors/status-openapi.json`), which + * resolve against the process cwd. Booting from this package's directory + * resolves them into `packages/qa/dogfood/` and the automation service refuses + * to start (ADR-0097). Same `chdir` `showcase-declarative-endpoints.dogfood.test.ts` + * does, and restored in `afterAll`. + */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +const LEDGER = 'sys_metadata_activation'; +/** A real showcase action — the one #12359 measured the 503 on. */ +const ACTION = 'showcase_mark_done'; +const ACTION_OBJECT = 'showcase_task'; +const ACTIVATION_PATH = `/actions/_activation/${ACTION_OBJECT}/${ACTION}`; +/** A real showcase flow, for the flows-still-work half. */ +const FLOW = 'showcase_task_completed'; + +/** Infrastructure rows, not tenant data — the store's own posture. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +interface LedgerReader { + find(object: string, options?: unknown): Promise>>; + resolveEffectiveDatasource?(objectName: string): string | undefined; + registry?: { getObjectOwner?(fqn: string): { packageId?: string } | undefined }; +} + +async function engineOf(stack: VerifyStack): Promise { + return (await stack.kernel.getServiceAsync('objectql')) as unknown as LedgerReader; +} + +/** Every install-level `action` row, read the way the store reads it. */ +async function actionRows(stack: VerifyStack): Promise>> { + const ql = await engineOf(stack); + return ql.find(LEDGER, { where: { metadata_type: 'action' }, context: SYSTEM_CTX }); +} + +describe('#12359 — actions and NO automation service: the ledger is there', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + // The exact boot #12359 measured: no `automation` option, so + // `@objectstack/service-automation` is not composed at all. + stack = await bootStack(showcaseStack); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the automation service really is absent from this composition', async () => { + // The anti-vacuity control. Every assertion below is about a + // composition WITHOUT automation, and a harness that quietly composed + // it would make the whole file green for the wrong reason — it would be + // re-measuring the case that already worked. + let present = true; + try { + await stack.kernel.getServiceAsync('automation'); + } catch { + present = false; + } + expect(present, 'the automation service is composed — this boot does not measure #12359').toBe(false); + }); + + it('the flip that answered 503 SERVICE_UNAVAILABLE now succeeds', async () => { + const res = await stack.apiAs(token, 'POST', ACTIVATION_PATH, { enabled: false }); + const text = await res.clone().text(); + + // Named explicitly rather than folded into `toBeLessThan(300)`: 503 is + // the exact answer this card exists to turn around, so a regression + // must read as "the ledger went away again", not as "some error". + expect(res.status, `activation flip answered ${res.status}: ${text}`).not.toBe(503); + expect(res.status, `activation flip answered ${res.status}: ${text}`).toBe(200); + }); + + it('writes ONE install-level row — `organization_id` NULL, `metadata_type: action`', async () => { + const rows = await actionRows(stack); + const row = rows.find((r) => r.name === ACTION); + + expect(row, `no '${ACTION}' row in ${LEDGER}: ${JSON.stringify(rows)}`).toBeDefined(); + expect(row!.metadata_type).toBe('action'); + // ADR-0126 §5: the per-org dimension is RESERVED and unwritten on this + // line. A driver may materialize the column as NULL, so this asserts + // the VALUE is nullish rather than the key's absence (that half is + // pinned at the store, where the payload is visible). + expect(row!.organization_id ?? null).toBeNull(); + // SQLite/libsql round-trip booleans as 0/1 — either spelling is "off". + expect(row!.active === false || row!.active === 0).toBe(true); + }); + + it('dispatch consults it — the disabled action is refused, nothing runs', async () => { + const res = await stack.apiAs(token, 'POST', `/actions/${ACTION_OBJECT}/${ACTION}`, { + params: { recordId: 'does-not-matter' }, + }); + const text = await res.text(); + + // 409 ACTION_DISABLED, refused at the DECLARATION — ahead of the param + // contract and ahead of the record load, so a disabled action discloses + // no param shape and reads no record. A 400 here would mean the param + // contract ran first and the switch is not doing its job. + expect(res.status, `dispatch of a disabled action answered ${res.status}: ${text}`).toBe(409); + expect(text).toMatch(/ACTION_DISABLED|is disabled/); + }); + + it('re-enabling re-arms dispatch, and the row records the choice rather than vanishing', async () => { + const flip = await stack.apiAs(token, 'POST', ACTIVATION_PATH, { enabled: true }); + expect(flip.status, `re-enable: ${await flip.clone().text()}`).toBe(200); + + const rows = await actionRows(stack); + const row = rows.find((r) => r.name === ACTION); + // §6 wall 3: re-enabling UPDATES the row, it never deletes it — the + // ledger records the administrator's choice instead of erasing it. + expect(row, 're-enabling deleted the row instead of updating it').toBeDefined(); + expect(row!.active === true || row!.active === 1).toBe(true); + + const res = await stack.apiAs(token, 'POST', `/actions/${ACTION_OBJECT}/${ACTION}`, { + params: { recordId: 'does-not-matter' }, + }); + // Whatever this action does with a bogus record id, it is no longer + // refused BY THE SWITCH — which is the only thing this asserts. + expect(res.status, 're-enabled action is still refused as disabled').not.toBe(409); + }); +}); + +describe('#12159 Part 1 — a composition WITH automation: flows and actions both keep working', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + stack = await bootStack(showcaseStack, { + automation: true, + extraPlugins: [ + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), + ], + }); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + }); + + it('the automation service really is composed here', async () => { + // The other half of the anti-vacuity control above. + const automation = await stack.kernel.getServiceAsync('automation'); + expect(automation, 'no automation service — this boot does not measure the MOVE\'s second end').toBeDefined(); + }); + + it('the ledger object has exactly ONE owner, and it is the package that declares it', async () => { + const ql = await engineOf(stack); + const owner = ql.registry?.getObjectOwner?.(LEDGER); + + expect(owner, `${LEDGER} has no owning package in this composition`).toBeDefined(); + // ADR-0029 D3/D7. The boot reaching this line at all is half the proof: + // a second code package claiming the name throws `Object "…" is already + // owned by package "…"`, so a double registration would have failed the + // `beforeAll`, not produced a duplicate. + expect(owner!.packageId).toBe('com.objectstack.platform-objects.activation-ledger'); + }); + + it('the table keeps the datasource binding it had before the registrar moved', async () => { + const ql = await engineOf(stack); + + // The smooth-upgrade wall, measured rather than asserted. Step 4 of + // `resolveDatasourceBinding` routes an object by its OWNING PACKAGE's + // `defaultDatasource`, so the registrar carries the table's datasource. + // On this composition no driver named `cloud` is registered, so step 4 + // does not fire and the ledger rides the global default driver + // (`undefined` — see `resolveEffectiveDatasource`'s contract) exactly + // as it did when the automation service owned it. The half that only + // shows up on a control-plane deployment — the owning manifest still + // carrying `defaultDatasource: 'cloud'` — is pinned in + // `platform-objects/src/plugin.test.ts`, where the manifest is visible + // without a `cloud` driver having to exist. + expect(ql.resolveEffectiveDatasource?.(LEDGER)).toBeUndefined(); + // Same answer for the two objects that rode the same manifest before — + // so the assertion above is a measurement of THIS composition, not of a + // resolver that answers `undefined` for everything. + expect(ql.resolveEffectiveDatasource?.('sys_automation_run')).toBeUndefined(); + }); + + it('the ACTION projection hydrates: a flip lands and dispatch refuses', async () => { + const flip = await stack.apiAs(token, 'POST', ACTIVATION_PATH, { enabled: false }); + expect(flip.status, `action flip with automation composed: ${await flip.clone().text()}`).toBe(200); + + const res = await stack.apiAs(token, 'POST', `/actions/${ACTION_OBJECT}/${ACTION}`, { + params: { recordId: 'does-not-matter' }, + }); + expect(res.status, 'the action switch stopped working once automation was composed').toBe(409); + + const restore = await stack.apiAs(token, 'POST', ACTIVATION_PATH, { enabled: true }); + expect(restore.status).toBe(200); + }); + + it('the FLOW projection hydrates from the same table: the flow toggle still works', async () => { + const off = await stack.apiAs(token, 'POST', `/automation/${FLOW}/toggle`, { enabled: false }); + expect(off.status, `flow toggle off: ${await off.clone().text()}`).toBe(200); + expect(await off.json()).toMatchObject({ data: { name: FLOW, enabled: false } }); + + const ql = await engineOf(stack); + const rows = await ql.find(LEDGER, { where: { metadata_type: 'flow' }, context: SYSTEM_CTX }); + const row = rows.find((r) => r.name === FLOW); + // The durable half. A toggle that only moved the engine's in-process + // projection is the #10243 mechanism ADR-0126 §7.2 retires, and it + // would look identical on the wire. + expect(row, `no durable '${FLOW}' row — the flow ledger is not attached: ${JSON.stringify(rows)}`).toBeDefined(); + expect(row!.active === false || row!.active === 0).toBe(true); + + const on = await stack.apiAs(token, 'POST', `/automation/${FLOW}/toggle`, { enabled: true }); + expect(on.status, `flow toggle on: ${await on.clone().text()}`).toBe(200); + }); + + it('both consumers share ONE table without touching each other\'s rows', async () => { + const ql = await engineOf(stack); + const all = await ql.find(LEDGER, { where: {}, context: SYSTEM_CTX }); + const types = new Set(all.map((r) => r.metadata_type)); + + // Both legs wrote to the same table in this boot, which is what makes + // the discriminator load-bearing rather than decorative. + expect(types.has('action'), `no action rows: ${JSON.stringify(all)}`).toBe(true); + expect(types.has('flow'), `no flow rows: ${JSON.stringify(all)}`).toBe(true); + + const flowNames = all.filter((r) => r.metadata_type === 'flow').map((r) => r.name); + const actionNames = all.filter((r) => r.metadata_type === 'action').map((r) => r.name); + expect(flowNames).toContain(FLOW); + expect(actionNames).toContain(ACTION); + expect(flowNames).not.toContain(ACTION); + expect(actionNames).not.toContain(FLOW); + }); +}); diff --git a/packages/services/service-automation/src/activation-ledger-registration.test.ts b/packages/services/service-automation/src/activation-ledger-registration.test.ts new file mode 100644 index 0000000000..3667a5e614 --- /dev/null +++ b/packages/services/service-automation/src/activation-ledger-registration.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12359 / #12159] This plugin does NOT register the ADR-0126 §4 activation +// ledger's object — the other half of "MOVE, not add". +// +// ## Why the pin lives here and not beside the new registrant +// +// Registration follows the DECLARATION since the maintainer's 2026-08-26 +// ruling (verbatim and untranslated: 「同意」), so `sys_metadata_activation` is +// registered by `PlatformObjectsPlugin`, where the object is declared. That +// package's own suite pins the positive half — the ledger IS registered there, +// under its own manifest, carrying the routing triple. +// +// The negative half has to be pinned in THIS package, because this is the file +// a later edit would touch. Re-adding the object to the manifest below would +// not produce a harmless duplicate: `SchemaRegistry.registerObject` throws +// +// Object "sys_metadata_activation" is already owned by package +// "com.objectstack.platform-objects.activation-ledger". Package +// "com.objectstack.service-automation" cannot claim ownership. +// +// (ADR-0029 D3's single-owner invariant, D7's contributor kinds — measured; it +// is also the answer to the question #12359's triage left open, which is why +// MOVE was the only shape available). So the regression this guards against is +// a BOOT FAILURE for every composition carrying both plugins — i.e. every +// composition `objectstack serve` produces, since it auto-injects +// platform-objects unconditionally. +// +// Asserted BEHAVIOURALLY, over a real `init()` against a recording manifest +// service, rather than by grepping the module: a grep would pass against an +// object added through some other spelling, and this is exactly the kind of +// invariant that gets re-broken by a well-meaning refactor rather than by +// someone typing the old symbol name back. + +import { describe, it, expect, vi } from 'vitest'; + +import { AutomationServicePlugin } from './plugin.js'; + +/** The slice of `PluginContext` this plugin's `init()` touches. */ +function pluginCtx(manifest: { register(m: unknown): void }) { + const services = new Map([['manifest', manifest]]); + return { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService(name: string) { + if (services.has(name)) return services.get(name); + throw new Error(`Service '${name}' not registered`); + }, + registerService(name: string, svc: unknown) { services.set(name, svc); }, + hook() {}, + async trigger() {}, + } as never; +} + +async function registeredObjectNames(): Promise { + const registered: Array<{ objects?: Array<{ name?: string }> }> = []; + await new AutomationServicePlugin().init( + pluginCtx({ register: (m: unknown) => { registered.push(m as never); } }), + ); + return registered.flatMap((m) => (m.objects ?? []).map((o) => String(o?.name))); +} + +describe('#12359 — the automation service is NOT the activation ledger\'s registrant', () => { + it('registers its own two objects and nothing else', async () => { + // Equality, not `not.toContain`: an assertion that only names the + // absent object would stay green while this manifest grew a THIRD + // registration nobody reviewed, which is the same class of drift. + expect(await registeredObjectNames()).toEqual(['sys_automation_run', 'sys_flow_dispatch']); + }); + + it('does not name sys_metadata_activation in any manifest it registers', async () => { + // Stated separately from the equality above so a failure reads as the + // SPECIFIC regression — the ledger came back — rather than as "the + // object list changed". + expect(await registeredObjectNames()).not.toContain('sys_metadata_activation'); + }); +}); diff --git a/packages/services/service-automation/src/flow-activation-store.ts b/packages/services/service-automation/src/flow-activation-store.ts index 71fafbcdad..5e1ed37302 100644 --- a/packages/services/service-automation/src/flow-activation-store.ts +++ b/packages/services/service-automation/src/flow-activation-store.ts @@ -1,71 +1,64 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import type { FlowActivationStore, FlowActivationRow } from './engine.js'; +import { + InMemoryMetadataActivationStore, + ObjectStoreMetadataActivationStore, + type MetadataActivationStoreEngine, +} from '@objectstack/core'; + +import type { FlowActivationStore } from './engine.js'; /** - * [ADR-0126 §4/§7.2] Durable activation ledger for PACKAGED flows — - * `sys_metadata_activation`, install-level rows. + * [ADR-0126 §4/§7.2] The packaged-FLOW binding of the activation ledger — + * `sys_metadata_activation` rows carrying `metadata_type: 'flow'`. * * ## What this replaces, and why the replacement is durable * - * The engine used to carry its off-switch in a process-local - * `flowEnabled` map. #10243 measured what that costs: the bit was NOT a row, - * so no organization wall scoped it — `toggleFlow` wrote an in-process map - * keyed by flow NAME only, and the automation service is ONE instance per - * environment. On a real `isolated` posture a tenant org owner switched a - * shipped flow off and an unrelated tenant in a DIFFERENT organization read it - * off. ADR-0126 §7.2 retires that mechanism rather than refining it: the - * durable ledger row IS the sanctioned off-switch, and this module is how the - * engine reaches it. + * The engine used to carry its off-switch in a process-local `flowEnabled` + * map. #10243 measured what that costs: the bit was NOT a row, so no + * organization wall scoped it — `toggleFlow` wrote an in-process map keyed by + * flow NAME only, and the automation service is ONE instance per environment. + * On a real `isolated` posture a tenant org owner switched a shipped flow off + * and an unrelated tenant in a DIFFERENT organization read it off. ADR-0126 + * §7.2 retires that mechanism rather than refining it: the durable ledger row + * IS the sanctioned off-switch, and this module is how the engine reaches it. * - * ## Row shape (ADR-0126 §4 — ⛔ this module writes columns, never schema) + * ## Where the row contract lives now (#12350) * - * `metadata_type: 'flow'` · `name` · `package_id` · `organization_id` · - * `active`. Two properties of that shape are load-bearing here: + * ⚠️ The §4 row semantics are NOT written here any more. They live once, in + * `@objectstack/core`'s {@link ObjectStoreMetadataActivationStore} — read that + * module for the four load-bearing properties (`organization_id` never + * written, org-carrying rows skipped on read, absence means ACTIVE, a driver + * `0` reads as false) and for why `core` is the home rather than the package + * that declares the object. * - * - **`organization_id` is never written.** It is declared nullable and - * RESERVED (§5): every row this line writes is install-level, so the - * column stays NULL. The object's `unique: 'organization'` index collapses - * NULL through the driver's `COALESCE(organization_id, '__global__')`, so - * NULL rows are still unique per `(metadata_type, name)` — which is why - * {@link ObjectStoreFlowActivationStore.setActive} can treat "the row for - * this flow" as at most one row. - * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say - * "active by default", and {@link FlowActivationStore.list} returning - * nothing is the normal stock-boot state, not an error. Re-enabling a flow - * updates its row to `active: true` rather than deleting it, so the ledger - * records the administrator's choice instead of erasing it (§6 wall 3: - * the ledger records CHOICES). + * This file is now exactly what is FLOW-specific: the `metadata_type` + * discriminator, and the names the automation engine and its `index.ts` export. + * The action twin in `@objectstack/objectql` is the same three lines over the + * same class — which is the whole point of #12350, since neither package can + * import the other. * * Two implementations, mirroring the `sys_flow_dispatch` pair next door: * - {@link InMemoryFlowActivationStore} — tests and hosts with no ObjectQL. * - {@link ObjectStoreFlowActivationStore} — the real `sys_metadata_activation`. */ -const TABLE = 'sys_metadata_activation'; - /** * The ledger's `metadata_type` for this consumer. Flows are the first consumer - * (ADR-0126 §7); the ledger is generic, so every read and write here is scoped - * by this discriminator and never assumes it owns the table. + * (ADR-0126 §7); the ledger is generic, so every read and write is scoped by + * this discriminator and never assumes it owns the table. */ const METADATA_TYPE = 'flow'; -/** Infrastructure rows, not tenant data — the `sys_flow_dispatch` posture. */ -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - /** * The exact ObjectQL slice this store needs: a keyed read, an insert, and an * update. Narrower than `SuspendedRunStoreEngine` on purpose, and deliberately * WITHOUT `delete`: re-enabling updates the `active` bit, it never removes the - * row (see the module header), and demanding only what is used keeps every - * test double honest about that. + * row, and demanding only what is used keeps every test double honest about + * that. An alias of the shared slice — one contract, so a double that + * satisfies one satisfies the other. */ -export interface FlowActivationStoreEngine { - find(object: string, options?: any): Promise; - insert(object: string, data: any, options?: any): Promise; - update(object: string, data: any, options?: any): Promise; -} +export type FlowActivationStoreEngine = MetadataActivationStoreEngine; /** * In-memory {@link FlowActivationStore} — process-lifetime only. @@ -75,119 +68,31 @@ export interface FlowActivationStoreEngine { * through {@link AutomationEngine.toggleFlow}, which is reached from the wire * only through a door that refuses a tenant admin in a walled posture * (ADR-0126 §5). What it lacks versus the ObjectStore implementation is - * DURABILITY, not scoping — and a host running without ObjectQL has no - * durable plane to write to in the first place. + * DURABILITY, not scoping — and a host running without ObjectQL has no durable + * plane to write to in the first place. */ -export class InMemoryFlowActivationStore implements FlowActivationStore { - private readonly rows = new Map(); - - async list(): Promise { - return [...this.rows.values()]; - } - - async setActive(row: FlowActivationRow): Promise { - this.rows.set(row.name, { ...row }); - } -} +export class InMemoryFlowActivationStore + extends InMemoryMetadataActivationStore + implements FlowActivationStore {} /** * Durable {@link FlowActivationStore} backed by the `sys_metadata_activation` * object (ADR-0126 §4). * - * All access uses a system context: the object is `managedBy: - * 'engine-owned'` and declares `apiMethods: ['get', 'list']`, i.e. the generic - * data API cannot write it at all — these rows are written by the ADR-0126 - * enable/disable action and by nothing else. + * A binding, not an implementation: it fixes the `metadata_type` and nothing + * else. The one-argument constructor is deliberate — a caller that had to pass + * the discriminator could pass the wrong one, and the flow leg has exactly one + * correct value. + * + * All access uses a system context: the object is `managedBy: 'engine-owned'` + * and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot + * write it at all — these rows are written by the ADR-0126 enable/disable + * action and by nothing else. */ -export class ObjectStoreFlowActivationStore implements FlowActivationStore { - constructor(private readonly engine: FlowActivationStoreEngine) {} - - /** - * Every install-level flow row. Read once at boot to hydrate the engine's - * projection — see {@link AutomationEngine.hydrateFlowActivations} for why - * the engine holds a projection at all rather than reading this per - * `execute()`. - * - * Rows carrying an `organization_id` are SKIPPED, not merged: the per-org - * dimension is reserved and unwritten on this line (§5), so a row with one - * set was not written by this code. Reading it as install-level would apply - * one organization's choice to the whole installation — the #10243 - * direction, arrived at from the read side. A future per-org consumer adds - * its own scoped read; it does not widen this one. - */ - async list(): Promise { - const rows = await this.engine.find(TABLE, { - where: { metadata_type: METADATA_TYPE }, - context: SYSTEM_CTX, - }); - if (!Array.isArray(rows)) return []; - const out: FlowActivationRow[] = []; - for (const row of rows) { - const r = row as { name?: unknown; package_id?: unknown; active?: unknown; organization_id?: unknown }; - if (r.organization_id != null) continue; - if (typeof r.name !== 'string' || !r.name) continue; - out.push({ - name: r.name, - packageId: typeof r.package_id === 'string' ? 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`-style test the engine uses, so a `0` - // is not mistaken for `true` — see the falsy-explicit test below. - active: !(r.active === false || r.active === 0), - }); - } - return out; - } - - /** - * Insert or update the install-level row for one packaged flow. - * - * Read-then-write rather than a blind upsert because the object's uniqueness - * is a DECLARED index (`unique: 'organization'`), not a primary key this - * store controls: there is no id to collide on, so an insert-and-catch would - * not reliably distinguish "already there" from a real store failure the way - * `sys_flow_dispatch`'s id-keyed claim can. - * - * ⛔ `organization_id` is not in either payload. Omitting it is what leaves - * it NULL, which is the whole of §5's install-level scope on this line. - */ - async setActive(row: FlowActivationRow): Promise { - const existing = await this.engine.find(TABLE, { - where: { metadata_type: METADATA_TYPE, name: row.name }, - context: SYSTEM_CTX, - }); - const current = Array.isArray(existing) - ? existing.find((r: any) => r?.organization_id == null) - : undefined; - - if (current && (current as { id?: unknown }).id != null) { - await this.engine.update( - TABLE, - { id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId }, - { context: SYSTEM_CTX }, - ); - return; - } - - await this.engine.insert( - TABLE, - { - metadata_type: METADATA_TYPE, - name: row.name, - package_id: row.packageId, - active: row.active, - }, - { context: SYSTEM_CTX }, - ); - } - - /** - * Read the backing table once so a misconfiguration surfaces at BOOT rather - * than as a failed toggle later. Throws the driver error verbatim — `no such - * table: sys_metadata_activation` means the object was never registered (or - * its schema never synced). - */ - async probe(): Promise { - await this.engine.find(TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); +export class ObjectStoreFlowActivationStore + extends ObjectStoreMetadataActivationStore + implements FlowActivationStore { + constructor(engine: FlowActivationStoreEngine) { + super(engine, METADATA_TYPE); } } diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 846878a134..3ea3130eab 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -26,12 +26,16 @@ import { type SuspendedRunStoreEngine, } from './suspended-run-store.js'; import { ObjectStoreFlowDispatchStore } from './flow-dispatch-store.js'; -// [ADR-0126 §4] The activation ledger's object is declared in -// `packages/platform-objects`, beside its data-plane siblings — the ADR puts it -// there so it needs zero `packages/spec` surface. This plugin REGISTERS it for -// the same reason it registers `sys_automation_run`: the table has to exist -// wherever automation runs, which is exactly where the ledger is consumed. -import { SysMetadataActivation } from '@objectstack/platform-objects'; +// [ADR-0126 §4 · #12359 ruling, 2026-08-26] ⛔ `SysMetadataActivation` is +// deliberately NOT imported here any more. This plugin registered the +// activation ledger's object while flows were its only consumer; registration +// now follows the DECLARATION and lives in `PlatformObjectsPlugin`, beside +// `SysMigration` / `SysSecret`. It is a MOVE — re-adding it here would make two +// manifests own one object, which is the governed case (ADR-0029 D7) the ruling +// closed by naming a single owner. The store attached in `start()` below still +// probes the table before it is trusted, so a composition that carries +// automation without platform-objects degrades exactly as an unreadable table +// always did: loudly, and without claiming a durable flip. import { ObjectStoreFlowActivationStore, type FlowActivationStoreEngine } from './flow-activation-store.js'; /** @@ -549,7 +553,7 @@ export class AutomationServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysAutomationRun, SysFlowDispatch, SysMetadataActivation], + objects: [SysAutomationRun, SysFlowDispatch], }); return true; } catch (err) { @@ -728,12 +732,26 @@ export class AutomationServicePlugin implements Plugin { this.engine.setFlowDispatchStore(new ObjectStoreFlowDispatchStore(dataEngine)); ctx.logger.info('[Automation] Flow-dispatch idempotency ledger enabled (sys_flow_dispatch)'); // [ADR-0126 §4/§7.2] The packaged-flow activation ledger. - // Attached under the same guard as the two stores above — - // it needs the same engine surface and its object rode the - // same manifest registration, so `runObjectRegistered` - // vouches for this table too. Without it, `toggleFlow` - // degrades to an in-process flip and WARNS on every flip - // that the change will not survive a restart. + // Without it, `toggleFlow` degrades to an in-process flip + // and WARNS on every flip that the change will not survive + // a restart. + // + // ⚠️ [#12359 ruling, 2026-08-26] `runObjectRegistered` no + // longer vouches for THIS table — `sys_metadata_activation` + // is registered by `PlatformObjectsPlugin` now, not by the + // manifest call above, so the flag says nothing about it. + // The gate that actually decides is the `probe()` below, + // which is a read of the real table and was always the + // authoritative one. The nesting is kept deliberately: it + // costs nothing on every composition that reaches here (a + // kernel with no manifest service registers neither object, + // so the probe would fail anyway), and widening the flow + // leg's attach policy is a behaviour change no ruling + // covers — Part 1 moves a REGISTRATION, not this policy. + // What the move does fix is the consumer that had no + // service of its own to gate on: packaged-ACTION disable, + // whose door answered 503 on every actions-and-no- + // automation boot. const activationStore = new ObjectStoreFlowActivationStore( dataEngine as unknown as FlowActivationStoreEngine, ); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 2370f0d3b2..c27abb288b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1,6 +1,11 @@ { "$comment": "GENERATED — the RETAINED ledger of check-engine-double-contract.mjs (#9680). Regenerate with `node scripts/check-engine-double-contract.mjs --write`; never hand-edit. Each row is one (file, verb) whose engine double routes through the producer's dispatch predicate. This is the OPPOSITE polarity to engine-double-contract.baseline.json: that ledger records DEBT and may only shrink, this one records COVERAGE and may only grow. A row that disappears is a pinned double that left the population — the blind spot #9680 measured, where deleting a double's delete() member took 319 pinned to 318 with the gate green. Read a removal in this file's diff as a coverage loss and check it was intended.", "entries": [ + { + "file": "packages/core/src/utils/metadata-activation-store.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/core/src/utils/migration-journal.test.ts", "verb": "delete",