Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/artifact-door-registers-capabilities.md
Original file line numberDiff line numberDiff line change
@@ -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.
107 changes: 107 additions & 0 deletions packages/metadata/src/artifact-door-capabilities.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown> = {}): 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([]);
});
});
20 changes: 20 additions & 0 deletions packages/metadata/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,26 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
// 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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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 () => {
Expand Down
21 changes: 15 additions & 6 deletions scripts/check-stack-collection-maps.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -592,15 +592,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',
Expand DownExpand Up@@ -677,9 +683,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.',
},
],
},
Expand Down
Loading