Skip to content

Commit 32448d4

Browse files
os-litantclaude
andauthored
feat(metadata): the artifact door registers stack-declared capabilities (#12892 step 1) (#13125)
* feat(metadata): the artifact door registers stack-declared capabilities 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * test(runtime): rewrite the door-coverage pins to the measured interim divergence 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a571d3 commit 32448d4

5 files changed

Lines changed: 254 additions & 19 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/metadata": minor
3+
---
4+
5+
feat(metadata): the artifact door registers stack-declared `capabilities` (#12892 step 1)
6+
7+
`ARTIFACT_FIELD_TO_TYPE` — the map that decides which collections of a compiled
8+
artifact reach `MetadataManager` — now carries `capabilities: 'capability'`.
9+
This is step 1 of the maintainer's 2026-08-29 ruling on #12892 (option 1: *the
10+
door owns the registration route* for the five artifact security collections).
11+
12+
**FROM.** `capabilities` is an authorable top-level stack collection (ADR-0066
13+
D1), but the door did not map it while `AppPlugin`'s ADR-0057 `SECURITY_FIELDS`
14+
block did — making that block the collection's **sole registrar on an artifact
15+
boot**, and it registers the raw bundle bytes with no strict parse, no schema
16+
default and no ADR-0010 provenance. On a `bootstrap: 'artifact-only'` runtime
17+
where `AppPlugin` does not run, a package's declared capabilities reached no
18+
registry at all: `GET /meta/capability` answered **empty**, and
19+
`bootstrapDeclaredCapabilities` seeded **no `sys_capability` row** for them.
20+
21+
**TO.** The door registers them like every other mapped collection: strict
22+
parse, schema defaults, ADR-0010 provenance. Measured on a real artifact-only
23+
kernel boot with no `AppPlugin`, over a package declaring
24+
`{ name: 'crm.export', label: 'Export CRM data' }`:
25+
26+
- `GET /meta/capability` went from `[]` to one item carrying `scope:'platform'`
27+
(the `CapabilitySchema` default) plus `_packageId` / `_packageVersion` /
28+
`_provenance`;
29+
- `sys_capability` went from 9 rows (platform-curated only) to 10 — the
30+
declaration now materializes with `managed_by:'package'` and its `package_id`.
31+
32+
**What this does NOT change, deliberately.** On the ordinary artifact boot
33+
`AppPlugin` still registers `capabilities` and still runs last, so its unparsed
34+
copy still wins the registry — measured byte-identical before and after this
35+
change. Two registrars on one route is the interim state the ruling explicitly
36+
permits while step 2 (that block stops registering the five on the **artifact**
37+
path, after a census of the non-artifact boots that depend on it) lands. No
38+
authoring surface moves, and no artifact that parses today stops parsing.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The artifact door registers stack-declared `capabilities` (#12892 step 1).
5+
*
6+
* `capabilities` is an authorable top-level stack collection (ADR-0066 D1).
7+
* Until this entry landed, `ARTIFACT_FIELD_TO_TYPE` did not map it while
8+
* `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block did — so on an artifact boot
9+
* that block was the collection's SOLE registrar, and it registers the raw
10+
* bundle bytes: no strict parse, no schema default, no ADR-0010 provenance.
11+
* That asymmetry is what `scripts/check-stack-collection-maps.mjs` waived as
12+
* "DRIFT with a real, bounded consequence"; the maintainer's ruling on #12892
13+
* (2026-08-29, option 1 — "the door owns the registration route") closes it,
14+
* and this file is the door half.
15+
*
16+
* Driven through the real `_parseAndRegisterArtifact`, so what is asserted is
17+
* what a sealed (`bootstrap: 'artifact-only'`) runtime actually serves under
18+
* `GET /meta/capability`, not what the map literal says.
19+
*
20+
* ⚠️ This does NOT make the door the only registrar: `AppPlugin` still
21+
* registers `capabilities`, and on a real artifact boot it runs LAST, so its
22+
* unparsed copy still wins the registry. Measured on a real artifact-only
23+
* kernel boot for the PR, and that is precisely why step 2 of the ruling
24+
* exists. What step 1 changes on its own is the boot where `AppPlugin` does
25+
* not run: there, `GET /meta/capability` answered EMPTY and now answers the
26+
* parsed, defaulted, provenance-stamped item.
27+
*/
28+
29+
import { describe, it, expect, vi } from 'vitest';
30+
import { MetadataPlugin } from './plugin.js';
31+
32+
function fakeCtx() {
33+
return {
34+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
35+
registerService: vi.fn(),
36+
getService: vi.fn(() => undefined),
37+
trigger: vi.fn(),
38+
} as any;
39+
}
40+
41+
function newPlugin(): any {
42+
return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } });
43+
}
44+
45+
/** The authored bytes — deliberately the MINIMUM a capability may declare. */
46+
function artifact(overrides: Record<string, unknown> = {}): any {
47+
return {
48+
manifest: {
49+
id: 'com.test.cap-door',
50+
name: 'Capability Door Probe',
51+
type: 'app',
52+
version: '3.4.5',
53+
},
54+
capabilities: [{ name: 'crm.export', label: 'Export CRM data' }],
55+
...overrides,
56+
};
57+
}
58+
59+
describe('artifact door — stack-declared capabilities (#12892 step 1, ADR-0066 D1)', () => {
60+
it('registers a declared capability under the `capability` metadata type', async () => {
61+
const plugin = newPlugin();
62+
await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(), 'cap-door-probe');
63+
64+
const registered = await plugin.manager.get('capability', 'crm.export');
65+
expect(registered, 'the door must register the declared capability').toBeDefined();
66+
expect(await plugin.manager.list('capability')).toHaveLength(1);
67+
});
68+
69+
it('the registered copy carries what only the door can add: the schema default and the ADR-0010 provenance envelope', async () => {
70+
const plugin = newPlugin();
71+
await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(), 'cap-door-probe');
72+
const registered: any = await plugin.manager.get('capability', 'crm.export');
73+
74+
// The authored bytes carry NEITHER of these — this is the whole
75+
// difference between the door's copy and the bundle reader's, and
76+
// asserting the authored keys alone would pass on either.
77+
expect(registered.scope, 'CapabilitySchema default (authored bytes omit it)').toBe('platform');
78+
expect(registered._packageId).toBe('com.test.cap-door');
79+
expect(registered._packageVersion).toBe('3.4.5');
80+
expect(registered._provenance).toBe('package');
81+
82+
// …and the authored fields survive unchanged.
83+
expect(registered).toMatchObject({ name: 'crm.export', label: 'Export CRM data' });
84+
});
85+
86+
it('NEGATIVE control — an artifact declaring no capabilities registers none', async () => {
87+
// Guards the two cases above against passing on a constant: the
88+
// assertion has to track the input, not the map.
89+
const plugin = newPlugin();
90+
const bare = artifact();
91+
delete bare.capabilities;
92+
await plugin._parseAndRegisterArtifact(fakeCtx(), bare, 'cap-door-probe-empty');
93+
expect(await plugin.manager.list('capability')).toEqual([]);
94+
});
95+
96+
it('the strict parse still governs the item — a malformed capability reaches no registry', async () => {
97+
// #12894 measured that the map entry adds NO validation: the door
98+
// strict-parses the whole definition BEFORE consulting the map, so a
99+
// malformed capability was already refused and still is. Pinned here so
100+
// "the door registers capabilities" is never read as "the door
101+
// registers whatever the bytes say".
102+
const plugin = newPlugin();
103+
const bad = artifact({ capabilities: [{ name: 'crm.export', label: 'Export CRM data', nope: 1 }] });
104+
await plugin._parseAndRegisterArtifact(fakeCtx(), bad, 'cap-door-probe-bad').catch(() => undefined);
105+
expect(await plugin.manager.list('capability')).toEqual([]);
106+
});
107+
});

packages/metadata/src/plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
8989
// positions from artifact ingestion.
9090
positions: 'position',
9191
permissions: 'permission',
92+
// [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the
93+
// maintainer's `option 1` ruling ("the door owns the registration
94+
// route" for the five artifact security collections). Until #12894
95+
// measured it, `AppPlugin`'s `SECURITY_FIELDS` block
96+
// (packages/runtime/src/app-plugin.ts) was this collection's SOLE
97+
// registrar on an artifact boot — the one security collection the door
98+
// could not reach — so a declared capability was registered from bytes
99+
// nothing strict-parses, with no schema default and no ADR-0010
100+
// provenance. Measured on the two-reader harness, the door's copy adds
101+
// exactly four keys the raw copy lacks: `scope` (the schema default)
102+
// and `_packageId` / `_packageVersion` / `_provenance`.
103+
//
104+
// ⚠️ This entry makes the door a SECOND writer, not yet the only one:
105+
// `AppPlugin` still registers `capabilities`, and it runs last, so the
106+
// raw copy still wins a real artifact boot. Step 2 of the ruling (that
107+
// block stops registering these five on the artifact path, after a
108+
// census of the non-artifact boot paths) is what makes this the only
109+
// copy. Until then the divergence is the interim reality the ruling
110+
// explicitly permits, and #12878's pins are what keep it visible.
111+
capabilities: 'capability',
92112
sharingRules: 'sharing_rule',
93113
// `policies: 'policy'` removed at #12894: the stack schema is a
94114
// `strictObject` that declares no top-level `policies` key, so a

packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,12 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes',
251251
const shared = [...bundle.keys()].filter((k) => door.has(k)).sort();
252252

253253
// Guard the comparison against being vacuously green.
254+
// `capability:crm.export` joined this list at #12892 step 1: the door's
255+
// `ARTIFACT_FIELD_TO_TYPE` now maps `capabilities`, so that collection
256+
// has TWO readers here for the first time. Measured, not predicted —
257+
// and the only edit this list took.
254258
expect(shared).toEqual([
259+
'capability:crm.export',
255260
'permission:support_agent',
256261
'position:sales_rep',
257262
'sharing_rule:share_open_deals',
@@ -336,23 +341,79 @@ describe('#12844 — the artifact boot\'s two readers register the same bytes',
336341
expect(typeof (bundleFirst.get('sharing_rule:share_open_deals') as any).condition).toBe('object');
337342
});
338343

339-
// ── The two collections that have no second copy to diverge ──────────
344+
// ── `capabilities`: TWO readers since #12892 step 1 · `policies`: none ────
340345
//
341-
// Recorded as measurements, not omissions: the card names five security
342-
// collections, and two of them never travel this path in a way that could
343-
// produce two copies. Neither is a reason to skip the collection — it is
344-
// what "covered" means for them.
346+
// Recorded as measurements, not omissions. `policies` still never travels
347+
// this path in a way that could produce two copies. `capabilities` did not
348+
// either until #12892 step 1 put it in the door's map — the case below used
349+
// to assert that ABSENCE, and an assertion of an absence stops being a
350+
// guard the moment the absence is deliberately removed. It is REWRITTEN
351+
// here rather than relaxed, and rewritten UPWARD: it now pins the interim
352+
// divergence key by key.
345353

346-
it('capabilities: only ONE reader exists — the door never registers them', async () => {
354+
/**
355+
* ⚠️ THIS CASE EXISTS TO GO RED WHEN STEP 2 LANDS. That is its job, not a
356+
* regression.
357+
*
358+
* The maintainer's 2026-08-29 ruling on #12892 is two ordered steps:
359+
*
360+
* step 1 (landed) — the door's `ARTIFACT_FIELD_TO_TYPE` maps
361+
* `capabilities`, so BOTH readers now register the collection. Two
362+
* writers on one route is the INTERIM state the ruling permits, and
363+
* what this case measures is exactly how the two copies differ while
364+
* it lasts.
365+
* step 2 (not landed) — `AppPlugin`'s ADR-0057 `SECURITY_FIELDS` block
366+
* stops registering these five on the ARTIFACT path (it must keep
367+
* registering on non-artifact boots), leaving the door's parsed,
368+
* defaulted, provenance-stamped copy as the only one.
369+
*
370+
* The day step 2 lands, `readerBundle()` stops producing
371+
* `capability:crm.export`, and EVERY assertion below goes red — the
372+
* membership pin, the key-by-key divergence set, and the four named-key
373+
* pins alike. Whoever lands step 2 rewrites this case to assert the single
374+
* remaining copy; ⛔ never by deleting, skipping or weakening it, which is
375+
* the one repair that would let the route silently keep two writers.
376+
*
377+
* Two seams, two answers, both real — do not read one as refuting the other:
378+
* HERE the two copies differ on FOUR keys, because `readerBundle()` drives
379+
* `AppPlugin` against a bare `registerInMemory` capture. On a full kernel
380+
* boot the ObjectQL SchemaRegistry stamps `_packageId` / `_provenance` onto
381+
* that same object during package install, so the end-to-end divergence
382+
* narrows to the TWO the registry cannot supply: `scope` (the schema
383+
* default) and `_packageVersion`. Those two are the seam-invariant core and
384+
* are pinned by name below in addition to the set.
385+
*/
386+
it('capabilities: BOTH readers register them since #12892 step 1, and the two copies diverge on exactly four keys', async () => {
347387
const door = collapse(await readerDoor());
348388
const bundle = collapse(await readerBundle());
349-
// `capabilities` is an authorable stack collection (ADR-0066 D1) that
350-
// `ARTIFACT_FIELD_TO_TYPE` (`packages/metadata/src/plugin.ts`) does not
351-
// map, so the artifact door registers nothing under `capability` and
352-
// AppPlugin is the sole registrar. No divergence is constructible.
353-
expect(bundle.get('capability:crm.export')).toBeDefined();
354-
expect(door.has('capability:crm.export')).toBe(false);
355-
expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual([]);
389+
390+
// Membership: two readers, not one. (Before step 1 the door registered
391+
// nothing under `capability` and this collection had a single writer.)
392+
expect(bundle.get('capability:crm.export'), 'AppPlugin must still register the capability').toBeDefined();
393+
expect(door.get('capability:crm.export'), 'the door must now register it too').toBeDefined();
394+
expect([...door.keys()].filter((k) => k.startsWith('capability:'))).toEqual(['capability:crm.export']);
395+
396+
// The divergence, key by key — the whole set, so a key that appears or
397+
// disappears fails here rather than passing under a looser shape.
398+
expect(diffPaths(door.get('capability:crm.export'), bundle.get('capability:crm.export')).sort())
399+
.toEqual(['_packageId', '_packageVersion', '_provenance', 'scope']);
400+
401+
// …and the two that survive every seam, pinned BY NAME with the value
402+
// each side actually carries. `scope` is the `CapabilitySchema`
403+
// default, `_packageVersion` half of the ADR-0010 envelope; the authored
404+
// bytes declare neither, so only the copy that met the schema has them.
405+
const doorCopy = door.get('capability:crm.export') as any;
406+
const bundleCopy = bundle.get('capability:crm.export') as any;
407+
expect(doorCopy.scope).toBe('platform');
408+
expect(bundleCopy.scope).toBeUndefined();
409+
expect(doorCopy._packageVersion).toBe('1.0.0');
410+
expect(bundleCopy._packageVersion).toBeUndefined();
411+
412+
// The authored fields agree — "they differ" must not be satisfiable by
413+
// the two copies being different documents altogether.
414+
for (const copy of [doorCopy, bundleCopy]) {
415+
expect(copy).toMatchObject({ name: 'crm.export', label: 'Export CRM data' });
416+
}
356417
});
357418

358419
it('policies: not an authorable stack collection at all — neither reader can see one', async () => {

scripts/check-stack-collection-maps.mjs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,15 +592,21 @@ const SITES = [
592592
},
593593
{
594594
direction: 'missing',
595-
keys: ['datasets', 'jobs', 'datasources', 'translations', 'capabilities'],
595+
keys: ['datasets', 'jobs', 'datasources', 'translations'],
596596
reason:
597-
'DRIFT with a real, bounded consequence — #6242 row 4(b). Four of the five are consumed '
597+
'DRIFT with a real, bounded consequence — #6242 row 4(b). All four are consumed '
598598
+ 'functionally by AppPlugin straight off the bundle, so boot is not broken; what they never do is '
599599
+ 'register as METADATA ITEMS, so under `bootstrap: \'artifact-only\'` (edge / serverless / '
600600
+ 'immutable image) `GET /meta/job`, `/meta/translation`, `/meta/datasource` and `/meta/dataset` '
601601
+ 'answer empty for a package that ships them. Adding them changes what a sealed runtime serves '
602602
+ 'and must be measured on a real artifact-only boot first — the filing card says so, and this '
603-
+ 'gate does not smuggle it in.',
603+
+ 'gate does not smuggle it in. `capabilities` was the FIFTH key on this row and left it at '
604+
+ '#12892 step 1 (maintainer ruling, option 1: the door owns the registration route for the five '
605+
+ 'security collections). It was never the same fact as the other four: AppPlugin registers it as '
606+
+ 'a METADATA ITEM through `registerInMemory`, so `GET /meta/capability` was already non-empty on '
607+
+ 'an artifact-only boot — what was missing from that answer was the strict-parsed shape, the '
608+
+ '`scope` default and the ADR-0010 provenance stamp. Driven on a real artifact-only boot before '
609+
+ 'the entry landed, per the sentence above.',
604610
},
605611
{
606612
direction: 'missing',
@@ -677,9 +683,12 @@ const SITES = [
677683
+ 'never runs; every other collection reaches the registry through the door or its own seam. '
678684
+ 'Recorded as one row rather than left implicit so that a NEW security collection has to be '
679685
+ 'considered here once — which is the direction this site was actually wrong in: `capabilities` '
680-
+ 'is registered here and NOT by the door, making this block that collection\'s sole registrar on '
681-
+ 'an artifact boot (#12894 half 2, carried to #12892 for the ownership decision — measured, '
682-
+ 'deliberately not changed here).',
686+
+ 'was registered here and NOT by the door, making this block that collection\'s sole registrar '
687+
+ 'on an artifact boot (#12894 half 2, carried to #12892 for the ownership decision). The door '
688+
+ 'reaches it as of #12892 step 1, so this block is no longer that collection\'s sole registrar — '
689+
+ 'it is the SECOND one, on the same boot path, which is the interim state the ruling permits '
690+
+ 'while step 2 (this block stops registering the five on the ARTIFACT path only, after a census '
691+
+ 'of the non-artifact boots that depend on it) lands.',
683692
},
684693
],
685694
},

0 commit comments

Comments
 (0)