From 99590074f4cd6dbcc1f78f4a8d1cd0f195eeaa7a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:05:12 +0000 Subject: [PATCH 1/2] feat(platform-objects): declare sys_metadata_activation, the activation ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0126 §4 (D2): one data-plane platform object for the disable+clone family, declared beside its siblings so it needs zero packages/spec surface. Schema is exactly §4's five columns plus the primary key. The designation columns an earlier draft carried (replaced_by, cloned_from) are absent per amendment ruling 2 — there is no recorded linkage between a clone and its base — and the pin test asserts the column set by equality 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). A hand-written composite naming organization_id verbatim would be NULL-distinct, and this line's organization_id is NULL on every row by construction, so that spelling would enforce nothing (#5030). The 'organization' arm makes the driver prepend COALESCE(organization_id, '__global__') at registration (ADR-0120 D3). No lifecycle block on purpose: a row is durable configuration, not telemetry, and reaping one would silently re-arm a disabled artifact. No consumers in this leg — the enable/disable actions and the per-runtime consult points are separate legs. An empty ledger changes nothing. --- .changeset/sys-metadata-activation-ledger.md | 45 +++++ packages/platform-objects/src/system/index.ts | 6 +- .../sys-metadata-activation.object.test.ts | 173 ++++++++++++++++++ .../system/sys-metadata-activation.object.ts | 159 ++++++++++++++++ 4 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 .changeset/sys-metadata-activation-ledger.md create mode 100644 packages/platform-objects/src/system/sys-metadata-activation.object.test.ts create mode 100644 packages/platform-objects/src/system/sys-metadata-activation.object.ts diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md new file mode 100644 index 0000000000..5e563a8755 --- /dev/null +++ b/.changeset/sys-metadata-activation-ledger.md @@ -0,0 +1,45 @@ +--- +"@objectstack/platform-objects": 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` surface** — it is an ordinary platform object, +not a metadata type. + +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. + +**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'], + }, +}); From e539438a9fb166ad232435854c03b207f337431f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:48:58 +0000 Subject: [PATCH 2/2] fix(spec): register sys_metadata_activation in the platform-object name census MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform-object name census is a curated set of REAL names rather than a sys_-prefix pattern, so that a cross-reference check can tell a real platform object from a fictional platform-prefixed one. Its module contract states the obligation outright: adding an object to a platform package means adding its name here, and the owning package's conformance pin fails otherwise. Caught by CI (Test Core 2/6, platform-object-names.test.ts:115 — registry group "platform-objects" is out of date: expected 38 names, received 39). The local gate derivation could not reach it: the change set at the time touched no packages/spec path, so nothing named the spec-owned pin that scans packages/**. This is a one-name roster registration, not protocol or schema surface — the ledger stays an ordinary platform object with no zod/contract surface, exactly as ADR-0126 §4 requires. User-visible effect: isPlatformProvidedObjectName now answers true for the name, so lint stops reading it as a typo. The changeset gains @objectstack/spec accordingly. --- .changeset/sys-metadata-activation-ledger.md | 18 ++++++++++++++++-- .../system/constants/platform-object-names.ts | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md index 5e563a8755..9a1cd4f094 100644 --- a/.changeset/sys-metadata-activation-ledger.md +++ b/.changeset/sys-metadata-activation-ledger.md @@ -1,13 +1,15 @@ --- "@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` surface** — it is an ordinary platform object, -not a metadata type. +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 @@ -34,6 +36,18 @@ spelling is load-bearing, and the two obvious alternatives are both wrong here: `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 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