diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md new file mode 100644 index 0000000000..9a1cd4f094 --- /dev/null +++ b/.changeset/sys-metadata-activation-ledger.md @@ -0,0 +1,59 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/spec": minor +--- + +feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) + +Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone +family gets **one** data-plane platform object, declared beside its siblings so +it needs **zero `packages/spec` schema or contract surface** — it is an ordinary +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`. +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. + +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 +curated set of REAL names, not a `sys_`-prefix pattern, precisely so a +cross-reference check can tell `sys_user` (real) from a fictional +platform-prefixed name; its module contract is explicit that "adding an object +to a platform package means adding its name here", and the owning package's +conformance pin fails otherwise. This is a one-name roster registration, **not** +protocol or schema surface — the ledger remains an ordinary platform object with +no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its +user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` +now answers `true`, so lint stops reading a reference to the ledger as a typo. + +**No behavior change.** This leg ships the declaration only — the enable/disable +actions that write the ledger and the per-runtime consult points that read it +are separate legs, and nothing in the tree reads the object yet. Absence of a +row means the packaged default (**active**), so an empty ledger changes nothing +anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. +The object deliberately declares **no `lifecycle` block** — unlike its telemetry +siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable +configuration, and reaping one would silently re-arm an artifact an +administrator disabled. diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index c2feebe519..d19ba5769a 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -5,12 +5,14 @@ * * Cross-cutting system-level objects that don't belong to identity, * security, audit, or integration. Hosts the generic settings K/V store - * backing ADR-0007 (Settings Manifest + K/V Store + Resolver) and the - * deployment-level data-migration flags (#3617). + * backing ADR-0007 (Settings Manifest + K/V Store + Resolver), the + * deployment-level data-migration flags (#3617), and the packaged-metadata + * activation ledger (ADR-0126 §4). */ export { SysSetting } from './sys-setting.object.js'; export { SysSecret } from './sys-secret.object.js'; +export { SysMetadataActivation } from './sys-metadata-activation.object.js'; export { SysSettingAudit } from './sys-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; 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 new file mode 100644 index 0000000000..fa7e14bee7 --- /dev/null +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysMetadataActivation } from './sys-metadata-activation.object.js'; +import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '../apps/index.js'; + +/** + * ADR-0126 §4 — `sys_metadata_activation`, the packaged-metadata activation + * ledger, pinned at its DECLARATION. + * + * This leg ships the object and nothing else: the enable/disable actions that + * write it and the per-runtime consult points that read it are separate legs. + * So the two things worth pinning here are the two things a later leg could + * silently get wrong — the SHAPE the writers will target, and the claim that + * shipping the shape alone changes no behavior. + * + * ## Why the column set is asserted by EQUALITY, not by membership + * + * ADR-0126 §4 says "that is the whole schema", and it says so because an + * earlier draft carried designation columns (`replaced_by`, `cloned_from`) + * that amendment ruling 2 removed — there is no recorded linkage between a + * clone and its base. A membership check ("has `metadata_type`") passes just + * as green on a table that has quietly regrown the linkage, which is the exact + * drift the ruling forbids. Equality on the key set is what makes an added + * column loud, so the whole set is asserted at once and the two removed names + * are additionally called out by name below — a reader who deletes the + * equality assertion still trips the named one. + */ +describe('sys_metadata_activation — the ADR-0126 §4 activation ledger', () => { + describe('object identity', () => { + it('uses the canonical sys_ short name', () => { + expect(SysMetadataActivation.name).toBe('sys_metadata_activation'); + }); + + it('is a system object, engine-owned (ADR-0103)', () => { + expect(SysMetadataActivation.isSystem).toBe(true); + expect(SysMetadataActivation.managedBy).toBe('engine-owned'); + }); + + it('does not use deprecated storage identity fields', () => { + expect((SysMetadataActivation as any).namespace).toBeUndefined(); + expect((SysMetadataActivation as any).tableName).toBeUndefined(); + }); + }); + + describe('schema identity — the columns, by name', () => { + const columns = Object.keys(SysMetadataActivation.fields ?? {}).sort(); + + it('declares exactly the ADR-0126 §4 column set (plus the primary key)', () => { + expect(columns).toEqual([ + 'active', + 'id', + 'metadata_type', + 'name', + 'organization_id', + 'package_id', + ]); + }); + + it('carries NO designation linkage — amendment ruling 2 removed it', () => { + // 「行为类 能否搞一个启用停用的功能,我不想要可以停用,然后克隆一个。」 + // A clone is an ordinary org-owned artifact with no upgrade linkage back + // to its base (the landed #11513 posture). Re-adding either column would + // re-introduce the designation model this ADR decided against, so they + // are named here rather than left to the set-equality above. + expect(columns).not.toContain('replaced_by'); + expect(columns).not.toContain('cloned_from'); + }); + + it('types each ADR column as §4 specifies', () => { + const f = SysMetadataActivation.fields as Record; + expect(f.metadata_type.type).toBe('text'); + 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); + }); + }); + + describe('row identity — one row per (metadata_type, name, organization_id NULL-collapsed)', () => { + const uniqueIndexes = (SysMetadataActivation.indexes ?? []).filter((i: any) => i.unique); + + it('declares exactly one unique index, on (metadata_type, name)', () => { + expect(uniqueIndexes).toHaveLength(1); + expect((uniqueIndexes[0] as any).fields).toEqual(['metadata_type', 'name']); + }); + + it("spells the scope 'organization' — NOT bare `true`, and NOT a hand-written composite", () => { + // ⛔ 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'); + expect((uniqueIndexes[0] as any).unique).not.toBe(true); + }); + + 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). + expect((uniqueIndexes[0] as any).fields).not.toContain('organization_id'); + }); + }); + + describe('an empty ledger changes nothing (the acceptance claim of this leg)', () => { + it('ships no seed rows — nothing writes a row at install, so the ledger boots EMPTY', () => { + // The declaration surface has no seeding mechanism at all (no + // `defaultRecords`-style key exists on the object schema), so "empty on a + // stock boot" follows from the object carrying no such key. Pinned so a + // future seeding feature cannot quietly arrive here first. + expect((SysMetadataActivation as any).defaultRecords).toBeUndefined(); + expect((SysMetadataActivation as any).records).toBeUndefined(); + }); + + it('absence of a row means the packaged default — active', () => { + // §4: "absence of a row means the packaged default — active". The column + // default is the storage-side half of that sentence: a row written + // without the flag is armed, so no writer can produce a row that reads + // as "disabled" by omission. + expect((SysMetadataActivation.fields as any).active.defaultValue).toBe(true); + }); + + it('declares NO lifecycle block — a row is durable configuration, never swept', () => { + // The absent block is the back-compat `record` class. Its telemetry + // siblings (`sys_flow_dispatch`, `sys_automation_run`) DO declare + // retention; this one must not. Reaping a row here would silently re-arm + // an artifact an administrator disabled — a data-loss bug wearing a + // tuning knob's clothes. + expect((SysMetadataActivation as any).lifecycle).toBeUndefined(); + }); + + it('opens no generic write door — reads only, writes go through the engine', () => { + // [ADR-0103] The enable/disable actions write under a system context. + // Until they land there is no way to put a row in this table at all, + // which is the strongest form of "changes nothing". + expect(SysMetadataActivation.enable?.apiMethods).toEqual(['get', 'list']); + }); + + it('adds no UI surface — no list views, and no shipped app or nav names it', () => { + // A stock boot renders the shipped apps and their nav contributions. If + // none of them mentions the object, the ledger cannot change what an + // administrator sees on a boot with no rows — the visible half of "no + // behavior change". Serialized rather than walked so a nav shape change + // cannot make this assertion quietly stop looking. + expect((SysMetadataActivation as any).listViews).toBeUndefined(); + + const shippedSurfaces = JSON.stringify([ + SETUP_APP, + STUDIO_APP, + ACCOUNT_APP, + SETUP_NAV_CONTRIBUTIONS, + ]); + expect(shippedSurfaces).not.toContain('sys_metadata_activation'); + }); + }); +}); diff --git a/packages/platform-objects/src/system/sys-metadata-activation.object.ts b/packages/platform-objects/src/system/sys-metadata-activation.object.ts new file mode 100644 index 0000000000..4a4760204b --- /dev/null +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_metadata_activation — the packaged-metadata ACTIVATION LEDGER + * (ADR-0126 §4, decision D2). + * + * The disable+clone family shares **one data-plane platform object**. A row + * here says one thing about one packaged artifact: is it armed. Absence of a + * row means the packaged default — **active** — so an empty ledger changes + * nothing anywhere, which is the property that lets this declaration land + * before any consumer exists. + * + * ───────────────────────────────────────────────────────────────────── + * What this object is NOT (ADR-0126 §4, verbatim posture): + * + * • ⛔ **Not a definition store.** `sys_metadata` remains the sole definition + * ledger. This table holds a boolean per artifact and nothing else — never + * fields, nodes, grants, or any fragment of an artifact body. The recorded + * grounds are the #6190 phantom-overlay wall and the upgrade-vs-choice + * separation (ADR-0126 §6). + * + * • ⛔ **Not a metadata type.** It is an ordinary platform object with + * ordinary rows and an ordinary read path — declared here beside its + * data-plane siblings, so it needs **zero `packages/spec` surface**. The + * #11513 deactivation carve-out (row state is not a customization of the + * definition, #4669) is the precedent validating this plane split. + * + * • **Not a central interceptor.** Consult points stay per-runtime: the + * automation engine consults it in `execute()` beside the existing + * `FLOW_DISABLED` guard; the permission projection keeps its own row-state + * door until convergence (ADR-0126 §8). Each consumer documents its own + * consult point; this ledger imposes no global dispatch layer. + * + * • ⛔ **No designation linkage.** An earlier ADR-0126 draft carried + * `replaced_by` / `cloned_from`; amendment ruling 2 removed them + * (「行为类 能否搞一个启用停用的功能,我不想要可以停用,然后克隆一个。」). + * There is **no recorded link** between a clone and its base — matching the + * landed #11513 posture ("an ordinary org-owned set with no upgrade + * 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. + * + * Lifecycle: **no `lifecycle` block on purpose.** The absent block is the + * back-compat `record` class — durable, never swept. This is not telemetry + * like its `sys_flow_dispatch` / `sys_automation_run` siblings: a row is + * durable configuration, and reaping one would silently re-arm an artifact an + * 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. + * + * @namespace sys + */ +export const SysMetadataActivation = ObjectSchema.create({ + name: 'sys_metadata_activation', + label: 'Metadata Activation', + pluralLabel: 'Metadata Activations', + icon: 'badge-check', + isSystem: true, + managedBy: 'engine-owned', + description: + 'Activation ledger for packaged metadata artifacts (ADR-0126 §4): one row per packaged artifact whose armed state has been changed from the packaged default. No row means the packaged default — active.', + displayNameField: 'name', + nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + highlightFields: ['metadata_type', 'name', 'package_id', 'active'], + + fields: { + // The primary key. Provisioned by the driver on every physical table + // regardless of this declaration (`resolveInjectedSystemColumns` reports + // it unconditionally); declared here to match every data-plane sibling. + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + + metadata_type: Field.text({ + label: 'Metadata Type', + required: true, + searchable: true, + maxLength: 100, + description: "The artifact's registry type ('flow', 'permission', …).", + group: 'Identity', + }), + + name: Field.text({ + label: 'Name', + required: true, + searchable: true, + maxLength: 255, + description: "The packaged artifact's machine name.", + group: 'Identity', + }), + + package_id: Field.text({ + label: 'Package', + required: true, + maxLength: 255, + description: 'The package that ships the base artifact.', + 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, + description: 'Is the packaged artifact armed for this scope.', + group: 'State', + }), + }, + + indexes: [ + // ADR-0126 §4 row identity: one row per + // `(metadata_type, name, organization_id NULL-collapsed)`. + // + // ⛔ NOT a hand-written `{ fields: ['metadata_type', 'name', 'organization_id'] }` + // composite, and ⛔ not bare `unique: true`. Both spell the wrong thing here: + // + // - 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. + // + // `'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' }, + ], + + enable: { + // [ADR-0103] Engine-owned: written only by the ADR-0126 enable/disable + // actions under a system context, never via the generic data API. Reads + // stay open so operability surfaces can answer "what is disabled here?". + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index f094248680..5fbc300353 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -60,6 +60,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly