diff --git a/.changeset/activation-ledger-tenant-less.md b/.changeset/activation-ledger-tenant-less.md new file mode 100644 index 0000000000..bf2a7d26a6 --- /dev/null +++ b/.changeset/activation-ledger-tenant-less.md @@ -0,0 +1,61 @@ +--- +"@objectstack/platform-objects": patch +"@objectstack/core": patch +"@objectstack/objectql": patch +"@objectstack/service-automation": patch +--- + +fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024) + +The ADR-0126 activation ledger records that **this environment** switched a +packaged artifact off. That is deployment-level state, owned by no +organization — so the table ships with no tenant column at all. + +It briefly declared one: an `organization_id` marked "RESERVED", nullable, and +written by nobody, held for a per-organization dimension ADR-0126 §5 +pre-charted. A reserved nullable tenant column is exactly the shape the +total-organization-ownership record proposed in PR #14976 rules out, and this +one had no reader either. **This is a plain removal, not a migration:** the +table landed after the 17.2.0 tag, so no released version ever carried the +column and no deployment has data in it. Should a per-organization dimension +ever be wanted, it returns as a separate org-owned object — never as a column +on this ledger. + +What changed: + +- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and + no longer declares the column. Both halves are needed: the tenant anchor is + INJECTED at registration, so deleting the field alone would have left the + column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }` + — that key is the ADR-0066 D2 platform-global *posture*, which the sibling + `sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant + column and needs the wall over it stood down). Here there is no column to + wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is + required rather than incidental: a Layer 0 wall composing an equality on a + column the table does not have denies every row. +- **The declared unique index states `unique: 'global'`** over + `(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL + is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part + only when the table HAS a tenant column, so `'organization'` already degraded + to exactly these two columns. What changes is that the declaration now states + the boundary it actually gets, rather than claiming a per-organization one + that does not exist. Still explicit rather than bare `unique: true`, which + lint `unique/unscoped-declared-index` warns on and protocol 18 rejects. +- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its + org-row skip.** `list()` is now every activation row of its type, scoped by + the `metadata_type` discriminator alone, and `setActive` takes the single row + its keyed read returns instead of picking the NULL-organization one out of + the result. Both guarded a column that no longer exists; the declared unique + index over the two columns the lookup keys on is what makes that read + single-valued. `ObjectStoreFlowActivationStore` and + `ObjectStoreActionActivationStore` inherit the change. + +Unchanged, and pinned: the operator gate on activation writes under walled +postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time +action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row +rather than deleting it, and a driver `0` reading as false. The pins that +asserted the reserved column and the org-row skip are rewritten to pin the +column's ABSENCE rather than deleted — including at the injection authority +(`resolveInjectedSystemColumns`, which decides whether the column exists) and +in a real booted stack, where the row's key set is a reading of the physical +table. diff --git a/.changeset/ledger-convergence-registration-and-one-store.md b/.changeset/ledger-convergence-registration-and-one-store.md index d9751dd0e9..a63e6e3736 100644 --- a/.changeset/ledger-convergence-registration-and-one-store.md +++ b/.changeset/ledger-convergence-registration-and-one-store.md @@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator. `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. +shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by +the `metadata_type` discriminator, 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. +proof the consolidation lost nothing — verified by ablation: mutating the one +shared implementation turns both of them red on their own assertions, so both +really reach it. diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md index 9a1cd4f094..b56a68273b 100644 --- a/.changeset/sys-metadata-activation-ledger.md +++ b/.changeset/sys-metadata-activation-ledger.md @@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina platform object, not a metadata type. (The one spec file touched is the mechanical name census described below, not protocol surface.) -The whole schema, per §4: `metadata_type` · `name` · `package_id` · -`organization_id` (nullable, **reserved** — NULL on this entire line; the -per-org dimension is an additive column later, never a redesign) · `active`. +The whole schema, per §4: `metadata_type` · `name` · `package_id` · `active`. +There is **no tenant column**: a row records that THIS ENVIRONMENT switched a +packaged artifact off, which is deployment-level state owned by no +organization. (An earlier revision of this line declared a nullable +`organization_id` marked "reserved"; it was removed before release — see the +sibling changeset for that removal.) An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); amendment ruling 2 removed them — there is **no recorded linkage** between a clone and its base, matching the landed #11513 posture ("an ordinary org-owned set with no upgrade linkage"). The pin test asserts the column set by EQUALITY and names both removed columns separately, so re-growing the linkage is loud. -Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled -as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That -spelling is load-bearing, and the two obvious alternatives are both wrong here: - -- bare `unique: true` on a declared index is the positional spelling of - `'global'` — installation-wide over exactly the listed columns — and is - already warned by lint `unique/unscoped-declared-index` in 17.x; -- a hand-written `['metadata_type', 'name', 'organization_id']` composite is - NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row - by construction, so that index would enforce **nothing at all** (#5030, - measured) and one artifact could carry two contradictory `active` rows. - -`'organization'` is the arm that closes exactly that hole: the driver prepends -`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which -is what §4's "NULL-collapsed" names. +Row identity is `(metadata_type, name)`, spelled as a declared index with +**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two +columns, which is the whole of the identity now that the table carries no +tenant column. Stated explicitly rather than as bare `unique: true`: the bare +spelling materializes the same index but leaves the scope unstated, which lint +`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects. The name is also registered in `@objectstack/spec`'s platform-object name census (`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 848b910cab..c1e47fefdc 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**. | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` | -| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` | +| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:138`, `:189` | | 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` | diff --git a/packages/core/src/utils/metadata-activation-store.test.ts b/packages/core/src/utils/metadata-activation-store.test.ts index 2552bd3c56..e7064be6da 100644 --- a/packages/core/src/utils/metadata-activation-store.test.ts +++ b/packages/core/src/utils/metadata-activation-store.test.ts @@ -17,7 +17,7 @@ // 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 +// made possible (#12350's own argument: the discriminator scoping 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 @@ -25,10 +25,10 @@ // 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. +// The load-bearing row properties themselves (the ledger is deployment-wide +// and carries no tenant column, 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 @@ -151,9 +151,11 @@ describe('ObjectStoreMetadataActivationStore — the discriminator is a paramete 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. + // No tenant column is written, because the table has none. Kept from + // the era when the column existed-but-was-reserved (where it guarded + // against writing it even as an explicit null): it is now what makes a + // re-introduced tenant write loud at the payload, which is the one + // place the column could come back without touching the declaration. expect(Object.keys(insert?.data ?? {})).not.toContain('organization_id'); }); @@ -190,18 +192,30 @@ describe('ObjectStoreMetadataActivationStore — the discriminator is a paramete }); 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' }, + it('reads EVERY row of its type — the discriminator is the only scope', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'flow', name: 'first', package_id: 'crm', active: false }, + { id: 'r2', metadata_type: 'flow', name: 'second', package_id: 'crm', active: false }, ]); - 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']); + const store = new ObjectStoreMetadataActivationStore(engine, 'flow'); + const rows = await store.list(); + + // ⚠️ This replaces a pin that asserted a SKIP: the store used to drop + // any row carrying an organization, because the table declared a + // reserved-but-never-written tenant column. The column was dropped + // before it ever shipped, so there is no second axis left — every row + // of this type is an answer, and a filter here would now be dead code + // that reads as if it guarded something. + expect(rows.map((r) => r.name)).toEqual(['first', 'second']); + + // The read names the discriminator and NOTHING else. Asserted on the + // query rather than on the result, because a store that had kept a + // tenant predicate would still return both of these rows — the fake's + // rows carry no such column — and the skip would be invisible from the + // result side alone. + expect(calls.find((c) => c.op === 'find')?.options?.where) + .toEqual({ metadata_type: 'flow' }); }); it('reads a driver `0` as FALSE, and a missing column as the packaged default (true)', async () => { @@ -237,20 +251,25 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics, .toEqual({ id: 'r1', active: true, package_id: 'crm' }); }); - it('ignores an org-carrying row when deciding insert-vs-update', async () => { + it('UPDATES the one row the keyed lookup returns — no tenant tie-break left to make', async () => { const { engine, calls } = makeStoreEngine([ - { id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false, organization_id: 'org_1' }, + { 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: false, + name: 'nightly_sync', packageId: 'crm', active: true, }); - // 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'); + // ⚠️ This replaces a pin that asserted the store IGNORED an + // org-carrying row and inserted a second one instead. That choice + // existed only because a reserved tenant column could put more than one + // row behind the same `(metadata_type, name)` key; with the column gone + // the declared `unique: 'global'` index over exactly those two columns + // makes the keyed read single-valued, so taking the first match is + // taking the only one — and inserting a duplicate would now be the bug. + 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('probes the TABLE unscoped — the question is composition, not type', async () => { diff --git a/packages/core/src/utils/metadata-activation-store.ts b/packages/core/src/utils/metadata-activation-store.ts index 13489b32fe..51b1763b92 100644 --- a/packages/core/src/utils/metadata-activation-store.ts +++ b/packages/core/src/utils/metadata-activation-store.ts @@ -40,25 +40,24 @@ * * ## 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`, + * `metadata_type` · `name` · `package_id` · `active`, exactly the four + * ADR-0126 §4 declares. Three 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. + * - **The ledger is DEPLOYMENT-level, and carries no tenant column at all.** + * A row says "this environment switched this managed item off" — a fact no + * organization owns. The table briefly declared a nullable tenant column + * marked RESERVED and never written, and this module correspondingly + * filtered reads to the NULL ones and skipped any row carrying an + * organization. Both are gone: a reserved nullable tenant + * column is the shape the total-organization-ownership record proposed in + * PR #14976 rules out, so the column was dropped before it ever shipped + * (17.2.0 predates the table). There is no filter here any more because + * there is no column to filter on — `list()` is simply every activation + * row of this type. Should a per-organization dimension ever be wanted, it + * returns as a separate org-owned object, never as a column here. * - **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 @@ -90,8 +89,7 @@ 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 + * `active`; `metadata_type` is fixed by the store, so it never reaches a * consumer's projection. */ export interface MetadataActivationRow { @@ -111,9 +109,9 @@ export interface MetadataActivationRow { * always has. */ export interface MetadataActivationStore { - /** Every install-level activation row for this type (`organization_id IS NULL`). */ + /** Every activation row for this type — the ledger is deployment-wide. */ list(): Promise; - /** Insert or update the install-level row for one packaged artifact. */ + /** Insert or update the row for one packaged artifact. */ setActive(row: MetadataActivationRow): Promise; } @@ -173,11 +171,12 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto ) {} /** - * Every install-level row of this type. Read once at boot to hydrate the - * consumer's projection. + * Every 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. + * The only scoping is the `metadata_type` discriminator: the ledger is + * deployment-wide and has no tenant column, so there is no second axis to + * filter on (see the module header). */ async list(): Promise { const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, { @@ -187,8 +186,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto 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; + const r = row as { name?: unknown; package_id?: unknown; active?: unknown }; if (typeof r.name !== 'string' || !r.name) continue; out.push({ name: r.name, @@ -204,25 +202,27 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto } /** - * Insert or update the install-level row for one packaged artifact. + * Insert or update the 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. + * uniqueness is a DECLARED index (`unique: 'global'` over + * `(metadata_type, name)`), 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. + * That index is also why taking the FIRST match is taking the only one: the + * read below is keyed on exactly the index's two columns, so it can match + * at most one row. It used to pick the first row with a NULL organization + * out of the result, back when the table carried a reserved tenant column; + * with no such column the set it was choosing from can no longer hold more + * than one member. */ 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) - ? existing.find((r: any) => r?.organization_id == null) - : undefined; + const current = Array.isArray(existing) ? existing[0] : undefined; if (current && (current as { id?: unknown }).id != null) { await this.engine.update( diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts index 1232bb3f6d..4234a40ce9 100644 --- a/packages/objectql/src/action-activation.test.ts +++ b/packages/objectql/src/action-activation.test.ts @@ -11,10 +11,10 @@ // `action` writer that touched a flow row, or read one, would corrupt a // neighbour's state through a table both are told to treat as generic. // Pinned on both sides — the discriminator is in the read AND in the write. -// 2. **`organization_id` stays NULL**, which is the whole of §5's install-level -// scope on this line. Asserted as an ABSENT key rather than a null value: -// writing it explicitly would be a different row shape, and the one the -// reserved per-org dimension is not. +// 2. **No tenant column is written**, because the table has none — a row is +// deployment-level state. Asserted as an ABSENT key on the payload, which is +// the one place a tenant write could reappear without touching the object +// declaration. // 3. **Absence means ACTIVE.** The stock-boot state is "no rows", and it must // change nothing anywhere (§4). A projection that defaulted the other way // would switch off an entire installation's actions on its first boot. @@ -130,20 +130,20 @@ describe('ObjectStoreActionActivationStore — the ADR-0126 §4 row contract', ( })); }); - it('SKIPS a row carrying an organization_id — the per-org dimension is reserved (§5)', async () => { + it('reads EVERY action row — the ledger is deployment-wide, with no tenant axis', async () => { const { engine } = makeStoreEngine([ - { id: 'r1', metadata_type: 'action', name: 'install_wide', package_id: 'crm', active: false }, - { - id: 'r2', metadata_type: 'action', name: 'org_scoped', package_id: 'crm', - active: false, organization_id: 'org_northwind', - }, + { id: 'r1', metadata_type: 'action', name: 'first', package_id: 'crm', active: false }, + { id: 'r2', metadata_type: 'action', name: 'second', package_id: 'crm', active: false }, ]); const rows = await new ObjectStoreActionActivationStore(engine).list(); - // Reading it as install-level would apply ONE organization's choice to - // the whole installation — #10243 arrived at from the read side. - expect(rows.map((r) => r.name)).toEqual(['install_wide']); + // ⚠️ This replaces a pin that asserted a row carrying an organization + // was SKIPPED. That skip guarded a reserved, never-written tenant + // column, which was dropped before the table ever shipped — so there is + // no per-organization row for a read to mistake for a deployment-wide + // one, and every row of this type is an answer. + expect(rows.map((r) => r.name)).toEqual(['first', 'second']); }); it('reads a driver `0` as DISABLED, not as truthy-by-accident', async () => { diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts index 7ec20fee2a..851cfe2ee1 100644 --- a/packages/objectql/src/action-activation.ts +++ b/packages/objectql/src/action-activation.ts @@ -17,16 +17,17 @@ * * ## Row shape — ⛔ this module writes COLUMNS, never schema * - * `metadata_type: 'action'` · `name` · `package_id` · `organization_id` · - * `active`, exactly the five ADR-0126 §4 declares (the object itself lives in + * `metadata_type: 'action'` · `name` · `package_id` · `active`, exactly the + * four ADR-0126 §4 declares (the object itself lives in * `packages/platform-objects`, which is why this leg needs zero `packages/spec` * surface). Two properties of that shape are load-bearing here: * - * - **`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 what lets + * - **There is no tenant column.** A row is DEPLOYMENT-level state — "this + * environment switched this action off" — owned by no organization. The + * table briefly carried a reserved, never-written organization column; + * it was dropped before it ever shipped. The object's declared + * `unique: 'global'` index over `(metadata_type, name)` is therefore the + * whole of the row identity, which is what lets * {@link ObjectStoreActionActivationStore.setActive} treat "the row for this * action" as at most one row. * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say @@ -115,9 +116,8 @@ const METADATA_TYPE = 'action'; /** * [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. + * `package_id` / `active`; `metadata_type` is fixed to `'action'` by the store, + * so it never reaches 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 diff --git a/packages/platform-objects/src/system/sys-metadata-activation.object.test.ts b/packages/platform-objects/src/system/sys-metadata-activation.object.test.ts index fa7e14bee7..458a87b148 100644 --- a/packages/platform-objects/src/system/sys-metadata-activation.object.test.ts +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { SysMetadataActivation } from './sys-metadata-activation.object.js'; import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '../apps/index.js'; @@ -52,7 +53,6 @@ describe('sys_metadata_activation — the ADR-0126 §4 activation ledger', () => 'id', 'metadata_type', 'name', - 'organization_id', 'package_id', ]); }); @@ -73,23 +73,36 @@ describe('sys_metadata_activation — the ADR-0126 §4 activation ledger', () => expect(f.name.type).toBe('text'); expect(f.package_id.type).toBe('text'); expect(f.active.type).toBe('boolean'); - // `organization_id` is the platform's organization column — a lookup to - // sys_organization, the idiom every data-plane sibling uses - // (`sys_metadata_history`, `sys_automation_run`). It materializes as a - // string column, which is what §4's "string" names. - expect(f.organization_id.type).toBe('lookup'); - expect(f.organization_id.reference).toBe('sys_organization'); }); - it('leaves `organization_id` nullable — it is RESERVED on this line', () => { - // §4: "nullable — reserved"; §5: the row is install-level. No writer in - // any leg of this line sets it. A later `required: true` would be the - // signal that the per-org dimension arrived without its own decision. - expect((SysMetadataActivation.fields as any).organization_id.required).toBe(false); + it('carries NO tenant column — this is deployment-level state, owned by no organization', () => { + // ⚠️ This replaces a pin that asserted the OPPOSITE: the column used to + // be declared here as "nullable — RESERVED", never written. A reserved + // nullable tenant column is the shape the total-organization-ownership + // record proposed in PR #14976 rules out, and a ledger row says "this + // ENVIRONMENT switched this managed item off" — a fact no organization + // owns. So the column is gone, and its absence is the contract. + // + // ⛔ Asserting only `fields` would be a phantom check. The tenant anchor + // is INJECTED at registration, not authored: an object that merely omits + // the field still gets the column. `resolveInjectedSystemColumns` is the + // spec's derivation of which system columns an object actually carries — + // the same one `applySystemFields` consumes to do the injecting — so it, + // not the declaration, is the authority on whether the column exists. + const plan = resolveInjectedSystemColumns(SysMetadataActivation); + + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect(Object.keys(SysMetadataActivation.fields ?? {})).not.toContain('organization_id'); + + // Anti-vacuity: the plan really does report columns for this object, so + // a `names` set that is empty for some unrelated reason cannot make the + // assertion above pass by saying nothing. + expect([...plan.names]).toContain('id'); }); }); - describe('row identity — one row per (metadata_type, name, organization_id NULL-collapsed)', () => { + describe('row identity — one row per (metadata_type, name)', () => { const uniqueIndexes = (SysMetadataActivation.indexes ?? []).filter((i: any) => i.unique); it('declares exactly one unique index, on (metadata_type, name)', () => { @@ -97,24 +110,32 @@ describe('sys_metadata_activation — the ADR-0126 §4 activation ledger', () => expect((uniqueIndexes[0] as any).fields).toEqual(['metadata_type', 'name']); }); - it("spells the scope 'organization' — NOT bare `true`, and NOT a hand-written composite", () => { + it("states the scope as 'global' — NOT bare `true`, and NOT 'organization'", () => { // ⛔ Asserted by EQUALITY, never by truthiness — bare `true` here IS a // bug and a truthy check passes on it. On a DECLARED index bare `true` - // is the positional spelling of `'global'` (ADR-0120 D1): installation- - // wide over exactly the listed columns, which would make the ledger - // un-scopable the moment the reserved per-org dimension is used. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); + // is the positional spelling of `'global'` (ADR-0120 D1) with the scope + // left unstated: same materialized shape, warned by lint + // (`unique/unscoped-declared-index`) and rejected at protocol 18. + // + // `'organization'` was the spelling while the table carried a reserved + // tenant column, and it is now wrong in a way nothing else would catch: + // `normalizeDeclaredIndex` prepends the tenant key part only when the + // table HAS a tenant column, so on this table it would silently degrade + // to exactly these two columns — identical DDL, and a declaration + // claiming a per-organization boundary that does not exist. + expect((uniqueIndexes[0] as any).unique).toBe('global'); expect((uniqueIndexes[0] as any).unique).not.toBe(true); + expect((uniqueIndexes[0] as any).unique).not.toBe('organization'); }); - it('does NOT name organization_id in the column list — that spelling is the NULL hole', () => { - // This is the assertion that carries the "NULL-collapsed" half of §4. - // A hand-written `['metadata_type', 'name', 'organization_id']` composite - // is NULL-DISTINCT in SQL, and `organization_id` is NULL on every row - // this line writes — so that spelling enforces NOTHING (#5030, measured), - // and one artifact could carry two contradictory `active` rows. - // `unique: 'organization'` is the arm that closes it: the driver prepends - // `COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3). + it('names no tenant column in the column list — the two real key parts are the whole identity', () => { + // Kept from the reserved-column era, where it carried the + // "NULL-collapsed" half of §4: a hand-written composite ending in the + // tenant column is NULL-DISTINCT in SQL, so with that column NULL on + // every row it enforced NOTHING (#5030, measured) and one artifact could + // carry two contradictory `active` rows. With no tenant column on the + // table the hazard cannot recur — and this assertion is what makes a + // re-added column loud here rather than silent. expect((uniqueIndexes[0] as any).fields).not.toContain('organization_id'); }); }); diff --git a/packages/platform-objects/src/system/sys-metadata-activation.object.ts b/packages/platform-objects/src/system/sys-metadata-activation.object.ts index 4a4760204b..3050a5bcb0 100644 --- a/packages/platform-objects/src/system/sys-metadata-activation.object.ts +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.ts @@ -41,10 +41,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * linkage"). Do not re-add them. * ───────────────────────────────────────────────────────────────────── * - * Row identity: one row per `(metadata_type, name, organization_id)`, spelled - * as the declared `unique: 'organization'` index below — see the comment there - * for why that spelling, and not a hand-written composite, is what makes the - * NULL-organization rows this line writes actually unique. + * Row identity: one row per `(metadata_type, name)`, spelled as the declared + * `unique: 'global'` index below. There is no third key part, because there is + * no tenant column on this table — see `systemFields` for why. * * Lifecycle: **no `lifecycle` block on purpose.** The absent block is the * back-compat `record` class — durable, never swept. This is not telemetry @@ -53,9 +52,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * administrator disabled. A retention policy here would be a data-loss bug, * not a tuning knob. * - * Writers: the enable/disable actions (ADR-0126 L2/L3 — **not this leg**; no - * writer sets any column here yet, and `organization_id` in particular stays - * NULL on this whole line). Readers: each runtime's own consult point. + * Writers: the enable/disable actions (ADR-0126 L2/L3). Readers: each runtime's + * own consult point. * * @namespace sys */ @@ -72,6 +70,40 @@ export const SysMetadataActivation = ObjectSchema.create({ nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) highlightFields: ['metadata_type', 'name', 'package_id', 'active'], + // ⛔ NO tenant column on this table, and this key is what keeps it off. + // + // (The platform's tenant-scope column is named in words throughout this file + // rather than spelled literally, so that grepping this object for that column + // returns nothing — the absence is the contract, and it should be checkable + // by the same one-line grep an auditor would reach for.) + // + // A row here is DEPLOYMENT-level state — "this environment switched this + // managed item off" — owned by no organization. The tenant column is not + // merely unused here: there is no scope for it to name. It would be + // provisioned by INJECTION even with no field declared for it + // (`resolveInjectedSystemColumns` injects the tenant anchor unless an object + // opts out), so deleting a declaration alone would leave the column exactly + // where it was. `systemFields.tenant: false` is the opt-out that actually + // removes it, and it is the minimal one: it speaks about column injection, + // which is the thing being decided here. + // + // ⚠️ NOT `tenancy: { enabled: false }`, though it too suppresses the column. + // That key is the ADR-0066 D2 platform-global POSTURE, and it is what the + // sibling `sys_sso_provider` uses for the opposite shape — a table that KEEPS + // its tenant column (better-auth writes it unstamped) and needs the wall over + // it stood down. Here there is no column to wall, so the posture declaration + // would assert something broader than the fact. + // + // Both spellings do reach `plugin-security`'s `tenancyDisabled`, and that is + // REQUIRED rather than incidental: a Layer 0 tenant wall composing + // "tenant column equals the caller's organization" over a table with no such + // column denies every row. What only `tenancy.enabled` additionally reaches + // is the spec's `isTenancyDisabled` — driver native scoping, the sticky + // per-table opt-out record — which this table does not need, because with no + // column the driver's `computeTenantField` already resolves to `null` on its + // own. + systemFields: { tenant: false }, + fields: { // The primary key. Provisioned by the driver on every physical table // regardless of this declaration (`resolveInjectedSystemColumns` reports @@ -104,20 +136,6 @@ export const SysMetadataActivation = ObjectSchema.create({ group: 'Identity', }), - // ADR-0126 §4: **nullable — reserved**. NULL on this whole line (the row - // is install-level, §5); the per-org dimension is an additive column - // later, never a redesign. It is declared rather than left to injection so - // the row identity below can name it and so the column is visible to - // author-time readers of `fields` — the sibling idiom - // (`sys_metadata_history`, `sys_automation_run`). - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - group: 'System', - description: - 'Reserved for the per-organization activation dimension (ADR-0126 §5). NULL on every row this line writes — no writer sets it yet.', - }), - active: Field.boolean({ label: 'Active', defaultValue: true, @@ -127,27 +145,32 @@ export const SysMetadataActivation = ObjectSchema.create({ }, indexes: [ - // ADR-0126 §4 row identity: one row per - // `(metadata_type, name, organization_id NULL-collapsed)`. + // ADR-0126 §4 row identity, now that the table carries no tenant column: + // one row per `(metadata_type, name)`, installation-wide. // - // ⛔ NOT a hand-written `{ fields: ['metadata_type', 'name', 'organization_id'] }` - // composite, and ⛔ not bare `unique: true`. Both spell the wrong thing here: + // ⛔ Not bare `unique: true`. On a DECLARED index that is the positional + // spelling of `'global'` (ADR-0120 D1) — the same materialized shape, but + // with the scope left unstated, which lint warns on + // (`unique/unscoped-declared-index`) and protocol 18 rejects. `'global'` is + // the same intent, said out loud. // - // - bare `true` on a DECLARED index is the positional spelling of - // `'global'` (ADR-0120 D1) — installation-wide over exactly the listed - // columns — and is warned by lint `unique/unscoped-declared-index` in - // 17.x, rejected at protocol 18. - // - a hand-written composite naming `organization_id` verbatim is - // NULL-DISTINCT in SQL, so on this line — where the column is NULL on - // every row by construction — it would enforce **nothing at all** - // (#5030, measured). A ledger whose row identity is void would let one - // artifact carry two contradictory `active` rows. + // This used to read `unique: 'organization'`, which asked the driver to + // prepend the tenant column in its NULL-safe `COALESCE(…, '__global__')` + // form (ADR-0120 D3) so that an all-NULL tenant column could not void the + // constraint — a hand-written composite naming that column verbatim would + // have been NULL-DISTINCT in SQL and enforced nothing at all (#5030, + // measured). With the column gone, that hazard is gone with it: there is + // no NULL column left to collapse, and `'global'` over the two real key + // parts is the whole of the row identity. // - // `'organization'` is the arm that closes exactly that hole: the driver - // prepends the tenant column in its NULL-safe form, - // `COALESCE(organization_id, '__global__')` (ADR-0120 D3), which IS the - // "NULL-collapsed" of the ADR-0126 §4 sentence. - { fields: ['metadata_type', 'name'], unique: 'organization' }, + // ⚠️ This is a re-SPELLING, not a change of materialized shape. + // `normalizeDeclaredIndex` prepends the tenant part only `if (idx.unique + // === 'organization' && tenantField)`, and with no tenant column + // `tenantField` resolves to `null` — so `'organization'` would already + // degrade to exactly these two columns. The index DDL is byte-identical + // either way; what changes is that the declaration now states what it + // actually gets. + { fields: ['metadata_type', 'name'], unique: 'global' }, ], enable: { 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 index 0ceae9e3ee..1dd56a47d8 100644 --- a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts @@ -138,19 +138,35 @@ describe('#12359 — actions and NO automation service: the ledger is there', () expect(res.status, `activation flip answered ${res.status}: ${text}`).toBe(200); }); - it('writes ONE install-level row — `organization_id` NULL, `metadata_type: action`', async () => { + it('writes ONE deployment-level row, and the TABLE has no tenant column at all', 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); + + // ⚠️ The tenant-column assertion here used to read + // `expect(row!.organization_id ?? null).toBeNull()`, back when the + // table declared that column as RESERVED and never written. That + // spelling cannot be carried forward: once the column is gone the + // property is `undefined`, `undefined ?? null` is `null`, and the + // assertion passes while measuring NOTHING — green for precisely the + // reason it should have gone red. + // + // So it is inverted into a key-set assertion. This row came back from + // the driver's own SELECT over the physical table in a real booted + // stack, so its keys ARE the materialized columns — this is a DDL + // reading, not a re-statement of the declaration. + expect(Object.keys(row!)).not.toContain('organization_id'); + + // Anti-vacuity: the same key-set really does report the columns that + // ARE there, so `not.toContain` above cannot be passing because the + // row is empty or opaque. + expect(Object.keys(row!)).toEqual( + expect.arrayContaining(['id', 'metadata_type', 'name', 'active']), + ); }); it('dispatch consults it — the disabled action is refused, nothing runs', async () => { diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 8848829bca..a8670a18ce 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -281,8 +281,8 @@ async function handleActionActivationWrite( /** * [ADR-0126 §4] Refuse a flip whose NAME does not identify one action. * - * The ledger's row identity is `(metadata_type, name, organization_id)` — one - * row per machine name — and ADR-0110 D1 says the same about actions: identity + * The ledger's row identity is `(metadata_type, name)` — one row per machine + * name — and ADR-0110 D1 says the same about actions: identity * is the declarative `name`. Two objects may nevertheless declare the same * action name, and then one row would address both. The three ways out were * weighed and only this one is honest: diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 80886ddc75..d24e3a7631 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -15,10 +15,9 @@ * * ## What it enforces * - * The activation row these routes write is **install-level** - * (`organization_id NULL`, §5): one row, one environment, every tenant. So the - * authority required to write it scales with how many tenants that reach - * covers: + * The activation row these routes write is **deployment-level** (§5): the + * ledger carries no tenant column, so one row covers one environment and every + * tenant in it. The authority required to write it scales with that reach: * * - **`single` posture** — one logical tenant, so install-level and org-level * are the SAME scope. The org admin who already passed the caller's own diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 10aa591dd3..e0dc235910 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1490,11 +1490,11 @@ export interface FlowDispatchStore { } /** - * [ADR-0126 §4] One packaged flow'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 - * `'flow'` by the store and `organization_id` is never written on this line - * (§5), so those two never reach the engine. + * [ADR-0126 §4] One packaged flow's deployment-level activation row, as the + * engine sees it. The ledger's own columns are `metadata_type` / `name` / + * `package_id` / `active`; `metadata_type` is fixed to `'flow'` by the store, + * so it never reaches the engine. There is no tenant column on the table — + * a row records that THIS ENVIRONMENT switched a packaged flow off. */ export interface FlowActivationRow { /** The packaged flow's machine name. */ @@ -1521,9 +1521,9 @@ export interface FlowActivationRow { * always has. */ export interface FlowActivationStore { - /** Every install-level flow activation row (`organization_id IS NULL`). */ + /** Every flow activation row — the ledger is deployment-wide. */ list(): Promise; - /** Insert or update the install-level row for one packaged flow. */ + /** Insert or update the row for one packaged flow. */ setActive(row: FlowActivationRow): Promise; } diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index 3e319a3bda..cd158ec866 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -457,7 +457,7 @@ describe('ADR-0126 §4/§5 — the row this line writes', () => { }; } - it('writes metadata_type `flow` and leaves organization_id UNSET (install-level, §5)', async () => { + it('writes metadata_type `flow` and no tenant column — the table has none', async () => { const fake = fakeEngine(); const store = new ObjectStoreFlowActivationStore(fake as any); @@ -470,15 +470,16 @@ describe('ADR-0126 §4/§5 — the row this line writes', () => { package_id: 'crm', active: false, }); - // ⛔ The absence of the key is what leaves the column NULL, which is - // the whole of §5's install-level scope on this line. Writing an - // organization here would be #10243 with persistence. + // A row here is DEPLOYMENT-level state, owned by no organization, and + // the table carries no tenant column at all. Asserted as an absent key + // because the payload is the one place a tenant write could reappear + // without touching the object declaration. expect(fake.inserted[0]).not.toHaveProperty('organization_id'); }); it('UPDATES the existing install-level row rather than inserting a second one', async () => { const fake = fakeEngine([ - { id: 'r1', metadata_type: 'flow', name: 'welcome', package_id: 'crm', active: false, organization_id: null }, + { id: 'r1', metadata_type: 'flow', name: 'welcome', package_id: 'crm', active: false }, ]); const store = new ObjectStoreFlowActivationStore(fake as any); @@ -488,23 +489,25 @@ describe('ADR-0126 §4/§5 — the row this line writes', () => { expect(fake.updated).toEqual([{ id: 'r1', active: true, package_id: 'crm' }]); }); - it('SKIPS rows carrying an organization_id — a per-org row is not an install-level answer', async () => { + it('reads EVERY flow row — the ledger is deployment-wide, with no tenant axis', async () => { const fake = fakeEngine([ - { id: 'r1', metadata_type: 'flow', name: 'install_wide', active: false, organization_id: null }, - { id: 'r2', metadata_type: 'flow', name: 'one_tenant_only', active: false, organization_id: 'org_42' }, + { id: 'r1', metadata_type: 'flow', name: 'first', active: false }, + { id: 'r2', metadata_type: 'flow', name: 'second', active: false }, ]); const store = new ObjectStoreFlowActivationStore(fake as any); const rows = await store.list(); - // Reading `r2` as install-level would apply one organization's choice - // to the whole installation — the #10243 direction, from the read side. - expect(rows.map((r: FlowActivationRow) => r.name)).toEqual(['install_wide']); + // ⚠️ This replaces a pin that asserted rows carrying an organization + // were SKIPPED. That skip guarded a reserved, never-written tenant + // column, dropped before the table ever shipped — so no row can carry + // an organization for a read to mistake for a deployment-wide answer. + expect(rows.map((r: FlowActivationRow) => r.name)).toEqual(['first', 'second']); }); it('reads a driver 0/1 boolean as disabled, not as active', async () => { const fake = fakeEngine([ - { id: 'r1', metadata_type: 'flow', name: 'f', active: 0, organization_id: null }, + { id: 'r1', metadata_type: 'flow', name: 'f', active: 0 }, ]); const store = new ObjectStoreFlowActivationStore(fake as any); diff --git a/packages/services/service-automation/src/flow-activation-store.ts b/packages/services/service-automation/src/flow-activation-store.ts index 5e1ed37302..8aaf1aa0ba 100644 --- a/packages/services/service-automation/src/flow-activation-store.ts +++ b/packages/services/service-automation/src/flow-activation-store.ts @@ -27,10 +27,10 @@ import type { FlowActivationStore } from './engine.js'; * * ⚠️ 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. + * module for the load-bearing properties (the ledger is deployment-wide and + * carries no tenant column, absence means ACTIVE, a driver `0` reads as false) + * and for why `core` is the home rather than the package that declares the + * object. * * This file is now exactly what is FLOW-specific: the `metadata_type` * discriminator, and the names the automation engine and its `index.ts` export.