From 9b0e06572dc31ea22b9415516a318287b95a7e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 01:12:53 +0000 Subject: [PATCH 1/4] feat(platform-objects): the activation ledger's registration follows its declaration (#12359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys_metadata_activation` is declared in this package, beside `SysMigration` / `SysMigrationJournal` / `SysSecret`, 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. Actions are a second consumer with a different owner: the consult and the write path live on the ObjectQL engine, which is present in every composition that can execute an action, automation service or not. 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 (a flip that cannot be made durable must not be reported as one, ADR-0126 section 6 wall 3) and permanently. Registration now follows the DECLARATION. Maintainer ruling, 2026-08-26, verbatim and untranslated: 「同意」. Every composition carrying platform-objects has the ledger, so packaged disable works without the automation service and each future ADR-0126 section 8 consumer (`tool`, `skill`, `position`) inherits it. MOVE, not add — single owner, one registration. The automation service no longer names the object in its own manifest: two manifests owning one object is the governed contributor case (ADR-0029 D7), and the ruling closed it by naming one owner rather than by declaring a double registration benign. A grep-level pin in `plugin.test.ts` is what a later edit re-adding it has to argue with, since the two registrations live in different packages and neither one's unit tests can see the other. `runObjectRegistered` no longer vouches for this table, and the comment at the flow store's attach site now says so — the `probe()` beside it is the gate that actually decides, and always was. The attach policy itself is untouched: this moves a registration, not a policy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- packages/objectql/src/plugin.ts | 15 ++++--- packages/platform-objects/src/plugin.test.ts | 45 +++++++++++++++++-- packages/platform-objects/src/plugin.ts | 30 +++++++++++-- .../services/service-automation/src/plugin.ts | 44 ++++++++++++------ 4 files changed, 108 insertions(+), 26 deletions(-) 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..b3c58494cf 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -1,6 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; // [#5855] The fake engine's `update` routes through the producer's OWN dispatch // predicate (#5480), so this double cannot accept a call `ObjectQL.update` // refuses. Imported from `@objectstack/metadata-core` (already a `dependencies` @@ -10,7 +13,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 @@ -46,7 +49,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal and SysSecret through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret and SysMetadataActivation through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +59,12 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 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).toEqual([ + SysMigration, + SysMigrationJournal, + SysSecret, + SysMetadataActivation, + ]); expect(manifests[0].objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -65,9 +73,40 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // compose and others do not is a journal a boot scanner cannot rely on. 'sys_migration_journal', 'sys_secret', + // [#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: 503 SERVICE_UNAVAILABLE on the flip). + 'sys_metadata_activation', ]); }); + /** + * [#12359] The other half of "MOVE, not add". A second manifest naming the + * same object is the governed contributor case (ADR-0029 D7), and the ruling + * closed it by giving the ledger ONE owner rather than by declaring a double + * registration benign. This pin is what a later edit re-adding the object to + * the automation service's manifest has to argue with — a grep-level check, + * because the two registrations live in different packages and neither one's + * unit tests can see the other. + */ + it('is the SINGLE registrant — the automation service no longer names the ledger', async () => { + const automationPlugin = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + '../../services/service-automation/src/plugin.ts', + ), + 'utf8', + ); + // Only the explanatory comment may mention the symbol; no import of it, and + // no manifest entry for it. + expect(automationPlugin).not.toMatch(/import\s*\{[^}]*SysMetadataActivation/); + expect(automationPlugin).not.toMatch(/objects:\s*\[[^\]]*SysMetadataActivation/); + }); + /** * #4270 pin: the engine's secret-field write path is fail-CLOSED — with no * `sys_secret` store it throws, and its error message tells the operator to diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 4a25b10659..5bee5a4d0f 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,27 @@ 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: two manifests + * registering one object is a governed contributor question (ADR-0029 + * D7), and the ruling closed it by giving the ledger one owner rather + * than by measuring whether a double registration is benign. * - **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 @@ -96,12 +118,14 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret], + objects: [SysMigration, SysMigrationJournal, SysSecret, 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/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, ); From 6064eaec8875b530dd584a0ab774bdc4f69e4f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 01:13:11 +0000 Subject: [PATCH 2/4] refactor(core): one implementation of the ADR-0126 section 4 activation-ledger row contract (#12350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0126 section 4 declares ONE activation ledger for the whole disable+clone family. It had two independent implementations of that one row contract: 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. Section 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 the direction (an artifact silently re-arming) nothing else measures. Neither consumer could import the other: `service-automation` does not depend on `@objectstack/objectql` (devDependency only), and the engine must not depend on a service. `@objectstack/core` is the package BOTH already depend on, so it is the one home that needs no new edge — the same reasoning that put `recordNotFoundError` there for the ADR-0076 D2 closure. NOT `@objectstack/platform-objects`, which declares the OBJECT: `objectql` does not depend on it and that edge would invert the tiering. Where the code lives is a module-import question; where the object's registration lives is a composition question with its own answer. `ObjectStoreMetadataActivationStore(engine, metadataType)` carries the row semantics once. Each consumer keeps its own name, its own one-argument constructor and its own docs, and fixes the discriminator — a binding, not an implementation. A caller that had to pass the discriminator could pass the wrong one; each leg has exactly one correct value. Byte-equivalent semantics: 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, no `delete` in the engine slice because re-enabling rewrites the row. Both existing pin suites stay green UNCHANGED — they pin the contract from each binding's side, so their staying green is the proof the consolidation lost nothing. The new suite beside the implementation pins what neither of them can: that the discriminator is a PARAMETER rather than a constant, that two bindings over one table see none of each other's rows in either direction, and that a type nobody has written yet behaves identically. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- packages/core/src/index.ts | 9 + .../utils/metadata-activation-store.test.ts | 290 ++++++++++++++++++ .../src/utils/metadata-activation-store.ts | 264 ++++++++++++++++ packages/objectql/src/action-activation.ts | 188 ++++-------- .../src/flow-activation-store.ts | 203 ++++-------- 5 files changed, 670 insertions(+), 284 deletions(-) create mode 100644 packages/core/src/utils/metadata-activation-store.test.ts create mode 100644 packages/core/src/utils/metadata-activation-store.ts 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/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); } } From 1f309525ca25bbe8aeef67115c547dda8b2f09eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 01:32:10 +0000 Subject: [PATCH 3/4] fix(platform-objects): carry the ledger's datasource routing across the registrar move (#12359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured while discharging this card's smooth-upgrade wall, and it is not what the ruling considered: a manifest is not only a list of objects, it is also a ROUTING decision. `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 (rides the global default driver — a DIFFERENT database) The ledger table already exists in live databases. On any deployment carrying a `cloud` datasource, folding the object into the platform-objects manifest would leave its rows in one database and start reading the other: every artifact an administrator had switched off would silently re-arm — the ADR-0126 section 6 wall 3 failure arriving through a door nobody was watching. So the ledger is registered under its OWN manifest from the same plugin, carrying the automation manifest's scope / namespace / defaultDatasource triple verbatim. Single owner, one registration, routing byte-identical everywhere. The three siblings deliberately do NOT get the triple — they ride the project database today and moving them is a larger question. It is also NOT spelled as `datasource: 'cloud'` on the object: that is binding step 1, which answers even when no such driver exists, so `getDriver` would throw on every deployment without a control plane. Which datasource this ledger SHOULD live on is a real question and this refuses to answer it by accident, not on purpose. Also measured, because the triage that raised #12359 left it as the open question: a double registration is NOT benign. `registerObject` throws `Object "sys_metadata_activation" is already owned by package "…"` the moment a second code package claims the name (ADR-0029 D3/D7), so adding a registrant would have been a boot failure rather than a duplicate — MOVE was the only shape available. Both ends of the move are now measured on real boots rather than reasoned about (`packaged-activation-ledger-reach.dogfood.test.ts`), each with its own anti-vacuity control on whether the automation service is actually composed: - actions and NO automation, the exact boot #12359 measured: the flip that answered 503 SERVICE_UNAVAILABLE answers 200, the row is install-level (`organization_id` NULL, `metadata_type: 'action'`), dispatch refuses the disabled action 409 ACTION_DISABLED ahead of the param contract and the record load, and re-enabling updates the row rather than deleting it; - WITH automation: exactly one owner, the datasource binding unchanged, both projections hydrating from the same table, and neither consumer's rows visible to the other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- packages/platform-objects/src/plugin.test.ts | 71 +++-- packages/platform-objects/src/plugin.ts | 80 ++++- ...ed-activation-ledger-reach.dogfood.test.ts | 301 ++++++++++++++++++ 3 files changed, 428 insertions(+), 24 deletions(-) create mode 100644 packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index b3c58494cf..c356248e19 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -49,23 +49,18 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal, SysSecret and SysMetadataActivation through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal and SysSecret through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); 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, - SysMetadataActivation, - ]); - 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 @@ -73,15 +68,53 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // compose and others do not is a journal a boot scanner cannot rely on. 'sys_migration_journal', 'sys_secret', - // [#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: 503 SERVICE_UNAVAILABLE on the flip). - 'sys_metadata_activation', ]); + // ⛔ 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 5bee5a4d0f..11f1f9369c 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -65,10 +65,19 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * §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: two manifests - * registering one object is a governed contributor question (ADR-0029 - * D7), and the ruling closed it by giving the ledger one owner rather - * than by measuring whether a double registration is benign. + * 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 @@ -97,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'; @@ -118,7 +184,11 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret, SysMetadataActivation], + objects: [SysMigration, SysMigrationJournal, SysSecret], + }); + ctx?.getService?.('manifest')?.register?.({ + ...ACTIVATION_LEDGER_MANIFEST, + objects: [SysMetadataActivation], }); } catch { // No manifest service (lean / i18n-only kernels) — the ledger stays 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); + }); +}); From 3ad9030e7078c8bf7325e0e66e8241f2837d07e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 02:05:12 +0000 Subject: [PATCH 4/4] test(service-automation): pin the negative half of the ledger move where the file lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "the automation service no longer registers the ledger" pin started life in `platform-objects/src/plugin.test.ts`, reading the automation plugin's source across the package boundary. `check:cross-package-test-inputs` is right to refuse that: a test whose real inputs are wider than its package is invisible to both the affected-subset filter and the turbo cache, so it can go red on `main` while every PR reports green (#7802). Moved, and improved on the way. It is now a BEHAVIOURAL pin in the package that owns the file — a real `init()` against a recording manifest service, asserting the registered object list by EQUALITY — rather than a grep over module text. A grep passes against an object added through a different spelling, and this is the kind of invariant a well-meaning refactor re-breaks without ever typing the old symbol name. What it guards is not a duplicate but a BOOT FAILURE: `registerObject` throws `Object "sys_metadata_activation" is already owned by package "…"` (ADR-0029 D3), and `objectstack serve` auto-injects platform-objects unconditionally, so every served kernel would fail to start. Also records the new store suite's engine double in the RETAINED coverage ledger (`--write`, grow-only, +1 row) — new pinned coverage the gate asks for by name, not a debt baseline movement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --- ...-convergence-registration-and-one-store.md | 91 +++++++++++++++++++ packages/platform-objects/src/plugin.test.ts | 26 ------ .../activation-ledger-registration.test.ts | 76 ++++++++++++++++ scripts/engine-double-contract.pinned.json | 5 + 4 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 .changeset/ledger-convergence-registration-and-one-store.md create mode 100644 packages/services/service-automation/src/activation-ledger-registration.test.ts 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/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index c356248e19..649a9ec658 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -1,9 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; // [#5855] The fake engine's `update` routes through the producer's OWN dispatch // predicate (#5480), so this double cannot accept a call `ObjectQL.update` // refuses. Imported from `@objectstack/metadata-core` (already a `dependencies` @@ -117,29 +114,6 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 ).toHaveLength(1); }); - /** - * [#12359] The other half of "MOVE, not add". A second manifest naming the - * same object is the governed contributor case (ADR-0029 D7), and the ruling - * closed it by giving the ledger ONE owner rather than by declaring a double - * registration benign. This pin is what a later edit re-adding the object to - * the automation service's manifest has to argue with — a grep-level check, - * because the two registrations live in different packages and neither one's - * unit tests can see the other. - */ - it('is the SINGLE registrant — the automation service no longer names the ledger', async () => { - const automationPlugin = readFileSync( - resolve( - dirname(fileURLToPath(import.meta.url)), - '../../services/service-automation/src/plugin.ts', - ), - 'utf8', - ); - // Only the explanatory comment may mention the symbol; no import of it, and - // no manifest entry for it. - expect(automationPlugin).not.toMatch(/import\s*\{[^}]*SysMetadataActivation/); - expect(automationPlugin).not.toMatch(/objects:\s*\[[^\]]*SysMetadataActivation/); - }); - /** * #4270 pin: the engine's secret-field write path is fail-CLOSED — with no * `sys_secret` store it throws, and its error message tells the operator to 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/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",