From f7f5d6064a69a69c7b3e7316fa32903977a9461f Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:26:55 +0800 Subject: [PATCH 1/3] fix(platform-objects,core): sys_metadata_activation ships tenant-less Drop the reserved organization column from the activation ledger: the object opts out of tenant-column injection, the declared unique index states the 'global' scope it actually materializes, and the store's NULL-filter and org-row skip go with the column they guarded. Co-Authored-By: Claude Fable 5.1 --- .../utils/metadata-activation-store.test.ts | 71 +++++++----- .../src/utils/metadata-activation-store.ts | 72 ++++++------- .../sys-metadata-activation.object.test.ts | 73 ++++++++----- .../system/sys-metadata-activation.object.ts | 101 +++++++++++------- 4 files changed, 190 insertions(+), 127 deletions(-) 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/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: { From 35ed1e10743f6c90a3f590c421a5387e2707578c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:33:55 +0800 Subject: [PATCH 2/3] fix(platform-objects,core): rewrite ledger pins onto the column's absence Rewrites the pins that asserted the reserved column and the org-row skip so they pin the column's ABSENCE instead of being deleted, updates the row-shape docblocks in both consumers and the runtime activation doors, corrects the two pending changesets that would otherwise describe the column in 17.3's release notes, and adds this change's changeset. Co-Authored-By: Claude Fable 5.1 --- .changeset/activation-ledger-tenant-less.md | 61 +++++++++++++++++++ ...-convergence-registration-and-one-store.md | 15 +++-- .changeset/sys-metadata-activation-ledger.md | 30 ++++----- .../objectql/src/action-activation.test.ts | 26 ++++---- packages/objectql/src/action-activation.ts | 20 +++--- ...ed-activation-ledger-reach.dogfood.test.ts | 28 +++++++-- packages/runtime/src/domains/actions.ts | 4 +- .../runtime/src/domains/activation-gate.ts | 7 +-- .../services/service-automation/src/engine.ts | 14 ++--- .../src/flow-activation-ledger.test.ts | 27 ++++---- .../src/flow-activation-store.ts | 8 +-- 11 files changed, 156 insertions(+), 84 deletions(-) create mode 100644 .changeset/activation-ledger-tenant-less.md 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/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/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. From 36695cd33ac9a012a3714956dbdb3e1907e1002b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:09:31 +0800 Subject: [PATCH 3/3] fix(docs): re-anchor system-context census after activation-gate docblock shift The activation-gate docblock correction in this branch is one line shorter than the text it replaced, so both `ec.isSystem` elevation reads moved up by one line. Row 56's anchors on the system-context census page still pointed at the old lines, which the census gate reports from both directions at once: [site-without-a-row] for :138 and :189, and [anchor-is-not-a-read-site] for the stale :139 and :190. Pure line rot, repaired by the gate's own `--fix`. Only the two anchor numbers move; no elevation behaviour text changes. Co-Authored-By: Claude Fable 5.1 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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` |