From 86599c0b98a66a41650b85ec4bb78d38ace83c4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:53:00 +0000 Subject: [PATCH 1/2] feat(metadata): the artifact door registers stack-declared capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the maintainer's 2026-08-29 ruling on the route-ownership question for the five artifact security collections (option 1 — the door owns the registration route): add `capabilities: 'capability'` to `ARTIFACT_FIELD_TO_TYPE` and retire the `check:stack-collection-maps` waiver that recorded its absence as drift. `capabilities` is an authorable top-level stack collection (ADR-0066 D1) that `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block registered and the door did not, making that block the collection's sole registrar on an artifact boot — and it registers the raw bundle bytes: no strict parse, no schema default, no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime where `AppPlugin` never runs, the declaration reached no registry at all. Step 2 (that block stops registering the five on the artifact path, after a census of the non-artifact boots) is deliberately NOT in this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../artifact-door-registers-capabilities.md | 38 +++++++ .../src/artifact-door-capabilities.test.ts | 107 ++++++++++++++++++ packages/metadata/src/plugin.ts | 20 ++++ scripts/check-stack-collection-maps.mjs | 21 +++- 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 .changeset/artifact-door-registers-capabilities.md create mode 100644 packages/metadata/src/artifact-door-capabilities.test.ts diff --git a/.changeset/artifact-door-registers-capabilities.md b/.changeset/artifact-door-registers-capabilities.md new file mode 100644 index 0000000000..ea6787c59d --- /dev/null +++ b/.changeset/artifact-door-registers-capabilities.md @@ -0,0 +1,38 @@ +--- +"@objectstack/metadata": minor +--- + +feat(metadata): the artifact door registers stack-declared `capabilities` (#12892 step 1) + +`ARTIFACT_FIELD_TO_TYPE` — the map that decides which collections of a compiled +artifact reach `MetadataManager` — now carries `capabilities: 'capability'`. +This is step 1 of the maintainer's 2026-08-29 ruling on #12892 (option 1: *the +door owns the registration route* for the five artifact security collections). + +**FROM.** `capabilities` is an authorable top-level stack collection (ADR-0066 +D1), but the door did not map it while `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` +block did — making that block the collection's **sole registrar on an artifact +boot**, and it registers the raw bundle bytes with no strict parse, no schema +default and no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime +where `AppPlugin` does not run, a package's declared capabilities reached no +registry at all: `GET /meta/capability` answered **empty**, and +`bootstrapDeclaredCapabilities` seeded **no `sys_capability` row** for them. + +**TO.** The door registers them like every other mapped collection: strict +parse, schema defaults, ADR-0010 provenance. Measured on a real artifact-only +kernel boot with no `AppPlugin`, over a package declaring +`{ name: 'crm.export', label: 'Export CRM data' }`: + +- `GET /meta/capability` went from `[]` to one item carrying `scope:'platform'` + (the `CapabilitySchema` default) plus `_packageId` / `_packageVersion` / + `_provenance`; +- `sys_capability` went from 9 rows (platform-curated only) to 10 — the + declaration now materializes with `managed_by:'package'` and its `package_id`. + +**What this does NOT change, deliberately.** On the ordinary artifact boot +`AppPlugin` still registers `capabilities` and still runs last, so its unparsed +copy still wins the registry — measured byte-identical before and after this +change. Two registrars on one route is the interim state the ruling explicitly +permits while step 2 (that block stops registering the five on the **artifact** +path, after a census of the non-artifact boots that depend on it) lands. No +authoring surface moves, and no artifact that parses today stops parsing. diff --git a/packages/metadata/src/artifact-door-capabilities.test.ts b/packages/metadata/src/artifact-door-capabilities.test.ts new file mode 100644 index 0000000000..ce25917982 --- /dev/null +++ b/packages/metadata/src/artifact-door-capabilities.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The artifact door registers stack-declared `capabilities` (#12892 step 1). + * + * `capabilities` is an authorable top-level stack collection (ADR-0066 D1). + * Until this entry landed, `ARTIFACT_FIELD_TO_TYPE` did not map it while + * `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block did — so on an artifact boot + * that block was the collection's SOLE registrar, and it registers the raw + * bundle bytes: no strict parse, no schema default, no ADR-0010 provenance. + * That asymmetry is what `scripts/check-stack-collection-maps.mjs` waived as + * "DRIFT with a real, bounded consequence"; the maintainer's ruling on #12892 + * (2026-08-29, option 1 — "the door owns the registration route") closes it, + * and this file is the door half. + * + * Driven through the real `_parseAndRegisterArtifact`, so what is asserted is + * what a sealed (`bootstrap: 'artifact-only'`) runtime actually serves under + * `GET /meta/capability`, not what the map literal says. + * + * ⚠️ This does NOT make the door the only registrar: `AppPlugin` still + * registers `capabilities`, and on a real artifact boot it runs LAST, so its + * unparsed copy still wins the registry. Measured on a real artifact-only + * kernel boot for the PR, and that is precisely why step 2 of the ruling + * exists. What step 1 changes on its own is the boot where `AppPlugin` does + * not run: there, `GET /meta/capability` answered EMPTY and now answers the + * parsed, defaulted, provenance-stamped item. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataPlugin } from './plugin.js'; + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +function newPlugin(): any { + return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); +} + +/** The authored bytes — deliberately the MINIMUM a capability may declare. */ +function artifact(overrides: Record = {}): any { + return { + manifest: { + id: 'com.test.cap-door', + name: 'Capability Door Probe', + type: 'app', + version: '3.4.5', + }, + capabilities: [{ name: 'crm.export', label: 'Export CRM data' }], + ...overrides, + }; +} + +describe('artifact door — stack-declared capabilities (#12892 step 1, ADR-0066 D1)', () => { + it('registers a declared capability under the `capability` metadata type', async () => { + const plugin = newPlugin(); + await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(), 'cap-door-probe'); + + const registered = await plugin.manager.get('capability', 'crm.export'); + expect(registered, 'the door must register the declared capability').toBeDefined(); + expect(await plugin.manager.list('capability')).toHaveLength(1); + }); + + it('the registered copy carries what only the door can add: the schema default and the ADR-0010 provenance envelope', async () => { + const plugin = newPlugin(); + await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(), 'cap-door-probe'); + const registered: any = await plugin.manager.get('capability', 'crm.export'); + + // The authored bytes carry NEITHER of these — this is the whole + // difference between the door's copy and the bundle reader's, and + // asserting the authored keys alone would pass on either. + expect(registered.scope, 'CapabilitySchema default (authored bytes omit it)').toBe('platform'); + expect(registered._packageId).toBe('com.test.cap-door'); + expect(registered._packageVersion).toBe('3.4.5'); + expect(registered._provenance).toBe('package'); + + // …and the authored fields survive unchanged. + expect(registered).toMatchObject({ name: 'crm.export', label: 'Export CRM data' }); + }); + + it('NEGATIVE control — an artifact declaring no capabilities registers none', async () => { + // Guards the two cases above against passing on a constant: the + // assertion has to track the input, not the map. + const plugin = newPlugin(); + const bare = artifact(); + delete bare.capabilities; + await plugin._parseAndRegisterArtifact(fakeCtx(), bare, 'cap-door-probe-empty'); + expect(await plugin.manager.list('capability')).toEqual([]); + }); + + it('the strict parse still governs the item — a malformed capability reaches no registry', async () => { + // #12894 measured that the map entry adds NO validation: the door + // strict-parses the whole definition BEFORE consulting the map, so a + // malformed capability was already refused and still is. Pinned here so + // "the door registers capabilities" is never read as "the door + // registers whatever the bytes say". + const plugin = newPlugin(); + const bad = artifact({ capabilities: [{ name: 'crm.export', label: 'Export CRM data', nope: 1 }] }); + await plugin._parseAndRegisterArtifact(fakeCtx(), bad, 'cap-door-probe-bad').catch(() => undefined); + expect(await plugin.manager.list('capability')).toEqual([]); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index c2747661f6..9f1134158e 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -89,6 +89,26 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { // positions from artifact ingestion. positions: 'position', permissions: 'permission', + // [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the + // maintainer's `option 1` ruling ("the door owns the registration + // route" for the five artifact security collections). Until #12894 + // measured it, `AppPlugin`'s `SECURITY_FIELDS` block + // (packages/runtime/src/app-plugin.ts) was this collection's SOLE + // registrar on an artifact boot — the one security collection the door + // could not reach — so a declared capability was registered from bytes + // nothing strict-parses, with no schema default and no ADR-0010 + // provenance. Measured on the two-reader harness, the door's copy adds + // exactly four keys the raw copy lacks: `scope` (the schema default) + // and `_packageId` / `_packageVersion` / `_provenance`. + // + // ⚠️ This entry makes the door a SECOND writer, not yet the only one: + // `AppPlugin` still registers `capabilities`, and it runs last, so the + // raw copy still wins a real artifact boot. Step 2 of the ruling (that + // block stops registering these five on the artifact path, after a + // census of the non-artifact boot paths) is what makes this the only + // copy. Until then the divergence is the interim reality the ruling + // explicitly permits, and #12878's pins are what keep it visible. + capabilities: 'capability', sharingRules: 'sharing_rule', // `policies: 'policy'` removed at #12894: the stack schema is a // `strictObject` that declares no top-level `policies` key, so a diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index 6ac93cbd94..bb65aa22de 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -568,15 +568,21 @@ const SITES = [ }, { direction: 'missing', - keys: ['datasets', 'jobs', 'datasources', 'translations', 'capabilities'], + keys: ['datasets', 'jobs', 'datasources', 'translations'], reason: - 'DRIFT with a real, bounded consequence — #6242 row 4(b). Four of the five are consumed ' + 'DRIFT with a real, bounded consequence — #6242 row 4(b). All four are consumed ' + 'functionally by AppPlugin straight off the bundle, so boot is not broken; what they never do is ' + 'register as METADATA ITEMS, so under `bootstrap: \'artifact-only\'` (edge / serverless / ' + 'immutable image) `GET /meta/job`, `/meta/translation`, `/meta/datasource` and `/meta/dataset` ' + 'answer empty for a package that ships them. Adding them changes what a sealed runtime serves ' + 'and must be measured on a real artifact-only boot first — the filing card says so, and this ' - + 'gate does not smuggle it in.', + + 'gate does not smuggle it in. `capabilities` was the FIFTH key on this row and left it at ' + + '#12892 step 1 (maintainer ruling, option 1: the door owns the registration route for the five ' + + 'security collections). It was never the same fact as the other four: AppPlugin registers it as ' + + 'a METADATA ITEM through `registerInMemory`, so `GET /meta/capability` was already non-empty on ' + + 'an artifact-only boot — what was missing from that answer was the strict-parsed shape, the ' + + '`scope` default and the ADR-0010 provenance stamp. Driven on a real artifact-only boot before ' + + 'the entry landed, per the sentence above.', }, { direction: 'missing', @@ -653,9 +659,12 @@ const SITES = [ + 'never runs; every other collection reaches the registry through the door or its own seam. ' + 'Recorded as one row rather than left implicit so that a NEW security collection has to be ' + 'considered here once — which is the direction this site was actually wrong in: `capabilities` ' - + 'is registered here and NOT by the door, making this block that collection\'s sole registrar on ' - + 'an artifact boot (#12894 half 2, carried to #12892 for the ownership decision — measured, ' - + 'deliberately not changed here).', + + 'was registered here and NOT by the door, making this block that collection\'s sole registrar ' + + 'on an artifact boot (#12894 half 2, carried to #12892 for the ownership decision). The door ' + + 'reaches it as of #12892 step 1, so this block is no longer that collection\'s sole registrar — ' + + 'it is the SECOND one, on the same boot path, which is the interim state the ruling permits ' + + 'while step 2 (this block stops registering the five on the ARTIFACT path only, after a census ' + + 'of the non-artifact boots that depend on it) lands.', }, ], }, From a273636e95f642cd53306e1b2a4683b2293037a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 05:49:35 +0000 Subject: [PATCH 2/2] test(runtime): rewrite the door-coverage pins to the measured interim divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option B on the maintainer/PM ruling for #12892 step 1, authorized 2026-08-29. The two pins that went red were NOT the key-by-key divergence pins the ruling anticipated (those stay green: a capability's two copies differ only outside CONVERSION_GOVERNED_PATHS). They were door-COVERAGE statements — a vacuity guard listing which keys have two readers, and a case asserting the very absence step 1 removes. An assertion of an absence stops being a guard the moment the absence is deliberately removed, so it is rewritten UPWARD, never relaxed: - the vacuity guard's `shared` list gains `capability:crm.export`, and only that; - the `capabilities` case now pins that BOTH readers register the collection and that the two copies diverge on exactly `[_packageId, _packageVersion, _provenance, scope]` at this seam, with `scope` and `_packageVersion` — the two that survive every seam — pinned by name with the value each side carries. Its docblock states the acceptance criterion in place: this case exists to go RED when step 2 lands and AppPlugin stops registering capabilities on the artifact path. No assertion is skipped, weakened, `it.fails`ed or deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- ...plugin-artifact-forward-conversion.test.ts | 87 ++++++++++++++++--- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts index 7a75ff165e..7ce9c9be50 100644 --- a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts +++ b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts @@ -251,7 +251,12 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', const shared = [...bundle.keys()].filter((k) => door.has(k)).sort(); // Guard the comparison against being vacuously green. + // `capability:crm.export` joined this list at #12892 step 1: the door's + // `ARTIFACT_FIELD_TO_TYPE` now maps `capabilities`, so that collection + // has TWO readers here for the first time. Measured, not predicted — + // and the only edit this list took. expect(shared).toEqual([ + 'capability:crm.export', 'permission:support_agent', 'position:sales_rep', 'sharing_rule:share_open_deals', @@ -336,23 +341,79 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes', expect(typeof (bundleFirst.get('sharing_rule:share_open_deals') as any).condition).toBe('object'); }); - // ── The two collections that have no second copy to diverge ────────── + // ── `capabilities`: TWO readers since #12892 step 1 · `policies`: none ──── // - // Recorded as measurements, not omissions: the card names five security - // collections, and two of them never travel this path in a way that could - // produce two copies. Neither is a reason to skip the collection — it is - // what "covered" means for them. + // Recorded as measurements, not omissions. `policies` still never travels + // this path in a way that could produce two copies. `capabilities` did not + // either until #12892 step 1 put it in the door's map — the case below used + // to assert that ABSENCE, and an assertion of an absence stops being a + // guard the moment the absence is deliberately removed. It is REWRITTEN + // here rather than relaxed, and rewritten UPWARD: it now pins the interim + // divergence key by key. - it('capabilities: only ONE reader exists — the door never registers them', async () => { + /** + * ⚠️ THIS CASE EXISTS TO GO RED WHEN STEP 2 LANDS. That is its job, not a + * regression. + * + * The maintainer's 2026-08-29 ruling on #12892 is two ordered steps: + * + * step 1 (landed) — the door's `ARTIFACT_FIELD_TO_TYPE` maps + * `capabilities`, so BOTH readers now register the collection. Two + * writers on one route is the INTERIM state the ruling permits, and + * what this case measures is exactly how the two copies differ while + * it lasts. + * step 2 (not landed) — `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block + * stops registering these five on the ARTIFACT path (it must keep + * registering on non-artifact boots), leaving the door's parsed, + * defaulted, provenance-stamped copy as the only one. + * + * The day step 2 lands, `readerBundle()` stops producing + * `capability:crm.export`, and EVERY assertion below goes red — the + * membership pin, the key-by-key divergence set, and the four named-key + * pins alike. Whoever lands step 2 rewrites this case to assert the single + * remaining copy; ⛔ never by deleting, skipping or weakening it, which is + * the one repair that would let the route silently keep two writers. + * + * Two seams, two answers, both real — do not read one as refuting the other: + * HERE the two copies differ on FOUR keys, because `readerBundle()` drives + * `AppPlugin` against a bare `registerInMemory` capture. On a full kernel + * boot the ObjectQL SchemaRegistry stamps `_packageId` / `_provenance` onto + * that same object during package install, so the end-to-end divergence + * narrows to the TWO the registry cannot supply: `scope` (the schema + * default) and `_packageVersion`. Those two are the seam-invariant core and + * are pinned by name below in addition to the set. + */ + it('capabilities: BOTH readers register them since #12892 step 1, and the two copies diverge on exactly four keys', async () => { const door = collapse(await readerDoor()); const bundle = collapse(await readerBundle()); - // `capabilities` is an authorable stack collection (ADR-0066 D1) that - // `ARTIFACT_FIELD_TO_TYPE` (`packages/metadata/src/plugin.ts`) does not - // map, so the artifact door registers nothing under `capability` and - // AppPlugin is the sole registrar. No divergence is constructible. - expect(bundle.get('capability:crm.export')).toBeDefined(); - expect(door.has('capability:crm.export')).toBe(false); - expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual([]); + + // Membership: two readers, not one. (Before step 1 the door registered + // nothing under `capability` and this collection had a single writer.) + expect(bundle.get('capability:crm.export'), 'AppPlugin must still register the capability').toBeDefined(); + expect(door.get('capability:crm.export'), 'the door must now register it too').toBeDefined(); + expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual(['capability:crm.export']); + + // The divergence, key by key — the whole set, so a key that appears or + // disappears fails here rather than passing under a looser shape. + expect(diffPaths(door.get('capability:crm.export'), bundle.get('capability:crm.export')).sort()) + .toEqual(['_packageId', '_packageVersion', '_provenance', 'scope']); + + // …and the two that survive every seam, pinned BY NAME with the value + // each side actually carries. `scope` is the `CapabilitySchema` + // default, `_packageVersion` half of the ADR-0010 envelope; the authored + // bytes declare neither, so only the copy that met the schema has them. + const doorCopy = door.get('capability:crm.export') as any; + const bundleCopy = bundle.get('capability:crm.export') as any; + expect(doorCopy.scope).toBe('platform'); + expect(bundleCopy.scope).toBeUndefined(); + expect(doorCopy._packageVersion).toBe('1.0.0'); + expect(bundleCopy._packageVersion).toBeUndefined(); + + // The authored fields agree — "they differ" must not be satisfiable by + // the two copies being different documents altogether. + for (const copy of [doorCopy, bundleCopy]) { + expect(copy).toMatchObject({ name: 'crm.export', label: 'Export CRM data' }); + } }); it('policies: not an authorable stack collection at all — neither reader can see one', async () => {