fix(runtime): the artifact boot has one registrar for its security collections — the door (#12892 step 2) - #14398
Conversation
…strar; pins red by design Implementation only. The #12878 divergence pins in app-plugin-artifact-forward-conversion.test.ts are RED at this commit on purpose: the harness's reader 2 now constructs AppPlugin the way createStandaloneStack does (securityMetadataRegistrar: 'artifact-door'), and 6 of 8 cases fail — the red-first evidence the ruling asked for. The next commit rewrites them to pin the unified state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…llections — the door (#12892 step 2) `createStandaloneStack` composes the artifact door (`MetadataPlugin` with `artifactSource`) and the artifact's `AppPlugin` over the same bytes; the door strict-parses, forward-converts and ADR-0010-stamps, `AppPlugin`'s ADR-0057 block did not, and because `AppPlugin` started last its raw copy won (a sharing rule's `condition` read as a STRING where the door's copy carries `{ dialect, source }`; a capability lost `scope` and `_packageVersion`). Maintainer ruling: the door owns the route. - `AppPlugin` gains `securityMetadataRegistrar: 'app-plugin' | 'artifact-door'` (default `'app-plugin'`, unknown values refused). Under `'artifact-door'` the ADR-0057 block registers nothing and logs one debug line naming the collections left to the door. `SECURITY_FIELDS` stays enumerated. - `createStandaloneStack` declares `'artifact-door'` on the AppPlugin it composes beside the door. Every door-less composition is unchanged. - The #12878 divergence pins went red on the change (6 of 8, at 65c744e) and are rewritten to pin the unified state; a real-boot test with a pre-step-2 control and a composition pin are added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…tifact-boot-single-registrar
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 406bc3a1ac2059b7620e768d382b498ba68b09d1 && git checkout 406bc3a1ac2059b7620e768d382b498ba68b09d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 fc1581e364950c862e4dcb5ce26a4467d9397daa && git checkout -B drift-repro 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 && git merge --no-ff fc1581e364950c862e4dcb5ce26a4467d9397daa
node scripts/docs-audit/affected-docs.mjs --json 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4
|
|
Landing provenance — director seat (summon #9, session Generated by Claude Code |
Fixes #12892
Step 2 of the maintainer's two-step ruling (2026-08-29, quoted in the step-1 claim): step 1 — the door covers
capabilities— landed in PR #13125; this PR is step 2.AppPluginstops registering the four artifact security collections (positions/permissions/capabilities/sharingRules;policieswas retired from both maps by PR #13009) on the ARTIFACT boot, so the artifact door is that boot's single registrar — preceded by the census below of every non-artifact-boot path that depends onAppPlugin's ADR-0057SECURITY_FIELDSblock, and stating per path who registers after this change. Option 3 (two permanent writers with matched shapes) ends here, as the ruling required.What was measured before deciding
MetadataPlugin._parseAndRegisterArtifact) strict-parses, forward-converts and ADR-0010-stamps;AppPlugin's block forward-converts only. On a real artifact bootAppPluginstarts last, so its copy won: the sharing-ruleconditiona consumer read frommetadata.list('sharing_rule')was a bare STRING (the door's copy carries{ dialect: 'cel', source }), and a capability carried noscopedefault and no_packageVersion. Reproduced in this PR's real-boot CONTROL case (below), not inferred._loadFromLocalFileswallows only ENOENT on the optional path), so on the artifact boot either the door registers every collection or the boot dies — there is no partial-door state that this removal could widen into a silent hole.AppPlugin'smetadata:reloadedsubscriber seeds new objects only; the ADR-0057 block isAppPlugin's ONLY registration site for these kinds. On an artifact HMR reload the door re-registers and its copy already overwroteAppPlugin's before this change — the boot-time copy was the one exception, and it is gone now.The change
AppPlugintakessecurityMetadataRegistrar: 'app-plugin' | 'artifact-door'in its constructoropts(default'app-plugin', a public readonly field). Under'artifact-door'the ADR-0057 block registers nothing and logs onedebugline naming the collections left to the door; an unknown value is refused loudly at construction instead of falling into either silent branch.createStandaloneStack— the ONE composition in this repo that runs the door (MetadataPlugin({ artifactSource: { path: artifactPath } })) over the same artifact it handsAppPlugin— declares'artifact-door'on thatAppPlugin. Nothing else changes;SECURITY_FIELDSstays enumerated (thecheck:stack-collection-mapseighth site) and only the registration around it became conditional.#12878divergence pins are rewritten to pin the unified state (red-first evidence below); a real-boot test with a pre-step-2 control and a composition pin are added.The boot-path discriminator, and why it is a declaration
Zone 2 asked for the existing signal before inventing one. Measured on
origin/main:AppPluginProjectContext.sourceis'package' | 'user'— app provenance for the ADR-0010 stamp, not boot shape; overloading it would misroute the stamp.loadArtifactBundlereturns bare parsed JSON; withunwrapEnvelope: true(what the standalone stack passes) the bundle is byte-for-byte adefineStack()-shaped definition. No marker survives that AppPlugin could read, and stamping one there would equate "read from an artifact file" with "the door runs on this boot" — true in every composition this repo contains, unverifiable for compositions it cannot see (the cloud stack composes its own kernel;loadArtifactBundleis a public export). A stamp fails SILENT in the wrong composition (zero registrars, a boot that stays green — measured at step 1); the declaration fails SAFE (two copies, today's state).MetadataPlugin'sartifactSourceis a private option of another plugin; reading it needs a door change (fenced: engine lane) or a reach into another plugin's internals.AppPlugin.start()("has the door already registered these?") turns the decision on start ORDER — the dependence the ruling removes — and is the startup-registry-verdict shape AGENTS.md forbids (no declared ordering betweencom.objectstack.metadataandAppPlugin; the standalone list order is positional).So the option is declared where the two facts that make it true sit 20 lines apart (
standalone-stack.ts), named for the door it defers to rather than for the boot shape, and pinned on the factory's real output (standalone-stack.test.ts: the composedAppPlugincarries'artifact-door'AND the composedMetadataPluginreads the sameartifactPath).Census — every path that depends on
SECURITY_FIELDS, and who registers after this PRTwo registries hold copies of these declarations on a full boot, and the readers pick differently: the ObjectQL SchemaRegistry (filled by
AppPlugin.init()→manifest.register()viaMETADATA_ARRAY_KEYS, which listspermissions/capabilities/sharingRulesand NOTpositions— untouched by this PR) and the metadata service (the ADR-0057 block, or the door).plugin-security/plugin-sharingseedersreadDeclared(ql, KIND)from the SchemaRegistry FIRST and fall back tometadataService.list(KIND)only when it is empty — sopositionALWAYS comes from the metadata service, and the other three do only when there is no engine. The permission evaluator (resolvePermissionSets),GET /meta/KINDand Studio read the metadata service directly.AppPluginbundlecreateStandaloneStack(packages/runtime/src/standalone-stack.ts) — reached byos serve/os dev/os startin standalone mode (serve.tsboot-mode dispatch),createDefaultHostConfig(artifact fallback and emptyos start),os migrate plan/apply(schema-migrate.ts→createStandaloneStack)loadArtifactBundle(artifactPath)— the compiled artifactMetadataPlugin({ artifactSource: { mode: 'local-file', path: artifactPath } })in the same list,bootstrapdefaulteager→_loadFromLocalFile→ door'artifact-door')positionvia the door's copy), permission evaluator (door's copy), sharing seeder,GET /meta/KINDserve.ts:2811new AppPlugin(config)— host configs (plugins[]holds instantiated plugins →shouldBootWithLibraryfalse),OS_MODE=off, or a standalone stack that found no artifactdefineStack()moduleos devwith a server, a dev-only HMRMetadataPlugin({ artifactSource: dist/objectstack.json })is ALSO composed when that file resolves (serve.ts:3024) — see "Residual"AppPlugin(default'app-plugin'), exactly as todayserve.ts:3708— plain stack objects insideconfig.plugins[]wrapped inAppPlugineachAppPlugin, as todayschema-migration-plugins.ts:784new AppPlugin(config)—os migratecomposing a host config that carries top-level metadata (composeHostStack)AppPlugin, as todayos migratecomposes noSecurityPlugin/SharingServicePlugin(the file's own header records why)plugin-devDevPlugin(dev-plugin.ts:503)new AppPlugin(this.options.stack)MetadataPlugin; the kernel's metadata service is core'screateMemoryMetadata()fallback, which implementsregisterInMemoryfor exactly this blockAppPlugin, as today (sole registrar into the fallback)GET /meta/KINDwhen REST is mounted@objectstack/verifybootStack(harness.ts:409)new AppPlugin(config)— andpackages/qa/dogfoodthrough itMetadataPlugin(core fallback)AppPlugin, as todaySecurityPlugin+SharingServicePluginseeders (registry first;positionfrom the fallback's copy =AppPlugin's), evaluatorcreateCloudStack, "no compiled artifact at all" perschema-migration-plugins.ts), embedders constructingAppPluginor calling the publicloadArtifactBundlethemselvesAppPlugin, as today — the default is the registering branch, so a composition that never heard of the option cannot lose a registrarMetadataPlugin._reloadAndAnnounce) on row 1AppPlugin'smetadata:reloadedsubscriber seeds new objects onlyThe "boot it and see whether anything complains" shortcut was not used: a declared collection with zero registrars boots green and logs nothing (H8 clearance measurement on the card). Every row above names its registrar from the composition source, and rows 1 and 6 are driven by tests in this PR.
Red-first evidence (the maintainer's pins did their job)
At
65c744efc3(the WIP commit on this branch: implementation landed, harness reader 2 constructed the waycreateStandaloneStacknow constructs it, pins untouched)pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/app-plugin-artifact-forward-conversion.test.ts→Tests 6 failed | 2 passed (8), verbatim failures:registration ORDER no longer changes any conversion-governed value — but the two copies are STILL not interchangeable→AssertionError: expected [] to deeply equal [ '_packageId', …(4) ]capabilities: BOTH readers register them since #12892 step 1, and the two copies diverge on exactly four keys→AssertionError: AppPlugin must still register the capability: expected undefined to be definedthe two readers agree on every ADR-0087 CONVERSION-governed key(the vacuity guard) →expected [] to deeply equal [ 'capability:crm.export', …(3) ]permissions/sharingRules/positions) that doubled as the artifact-boot reader's positive controls.⚠ Stated plainly rather than fitted to the template: the pins went red only once the harness constructed reader 2 with the declaration. A default-constructed
AppPlugin(bytes)is the door-less boot and legitimately stayed green on the production edit alone — the discriminator is a composition-site declaration, not a property of the bytes.The pins are REWRITTEN (never skipped,
it.fails'd or relaxed —grep -cE "it\.(skip|todo|fails)|describe\.(skip|todo)|\.only\("over both test files = 0): artifact boot ⇒ reader 2 writes nothing and the door reaches all four kinds; both start orders ⇒ exactly the door's copy, deep-equal to a measuredDOOR_COPYkey by key; the non-artifact boot as the positive control; the parse-axis residual kept as a measured difference between BOOT SHAPES.Real-boot measurement (new
standalone-stack-security-registrar.test.ts)pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/app-plugin-artifact-forward-conversion.test.ts src/standalone-stack-security-registrar.test.ts src/standalone-stack.test.tsatfc1581e364, afterturbo run build --filter='@objectstack/runtime^...'on the merged tree (the runtime suite resolves@objectstack/metadatathroughdist/, no vitest alias; this PR's change is inruntimeand is reached from source) →Test Files 3 passed (3) / Tests 34 passed (34), lock VERDICT command-exit 0.Runtimekernel overcreateStandaloneStack({ artifactPath, projectRoot, databaseUrl: 'memory://…', skipSeedData: true, runPlatformMigrations: false }).plugins, the kernel ordering the starts:metadata.list('sharing_rule')[0].conditionis{ dialect: 'cel', source: 'record.status == "open"' }; the capability carriesscope: 'platform',_packageId,_packageVersion: '2.0.0',_provenance: 'package'; the position carriesdelegatable: false; the permission set carriesisDefault: falseand neitherallowRestorenor the RLSpriority; exactly one item per kind.AppPluginswapped for a default-constructed one over the same bytes (the pre-step-2 composition, byte for byte): still one item per kind (two WRITERS, not two items) and the survivor is the raw copy —typeof condition === 'string',scopeundefined,_packageVersionundefined. The defect reproduces on the pre-step-2 composition and not on the fixed one, on one kernel, in the order the kernel chooses.DOOR_COPYin the registry, deep-equal key by key; the parse-axis residual is pinned as a difference between boot shapes; the default AppPlugin (positive control) still registers all four, forward-converted.standalone-stack.test.ts): the factory's real output carries anAppPluginwithsecurityMetadataRegistrar === 'artifact-door'AND aMetadataPluginwhoseartifactSourceis{ mode: 'local-file', path: artifactPath }— the two halves of the invariant, on one object.Verification
All readings at head
fc1581e364(merge oforigin/main1dcb995f23); exit codes captured by redirect-then-capture; verdict lines quoted from the gates' own output.pnpm --filter @objectstack/runtime typecheck→ exit 0, 0 errors. ⚠ That program excludes**/*.test.ts, so the test files were measured directly: the same config with the exclusion lifted (temp tsconfig, removed by trap) → 206error TSlines = EXACTLY thecheck-type-check-coverageDEBT entry for@objectstack/runtime(206), 0 of them naming any file this PR touches, and all three test files present in the program (--listFiles, 1 hit each).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsatfc1581e364(change set derived from git: 6 paths vs merge base1dcb995f2) → 36 families; the pre-merge derivation at65c744efc3named 35 and the merge addedcheck-system-context-census, which was run as well. 33 green on their own verdict line, among themcheck:stack-collection-maps("8 enumerations reconciled against 31 declared collections (17 waiver rows, each with a reason)" —SECURITY_FIELDSintact as the eighth site),check:nul-bytes("scanned 7833 text file(s) … no raw ASCII control bytes"),check:cross-package-test-inputs,check:test-source-alias,check:type-source-resolution,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:dispatcher-error-vocabulary,check:published-files,check-plugin-teardown-shape,check-system-context-census, and the changeset family (check-adr-0087-registration: "this PR adds no declared-breaking changeset";check-changeset-no-major;check-empty-changeset;check:changeset-gate-self-tests). Three declare their own non-measurement, quoted:node scripts/pm/check-half-states.mjs→ "PREREQUISITE NOT MET — the transport authenticates but repo-scoped reads are refused";node scripts/check-test-completeness.mjs→ "PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named";pnpm check:type-check-debt/pnpm check:dual-build-cjs-loads→ "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … NOT a pass: nothing was measured" (both need the full workspace build; the direct per-package tsc reading above substitutes for the first, CI owns both).pnpm lint, narrowed and proven: population read fromeslint.config.mjs(files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minus itsignores);eslint --no-inline-config --format jsonover the 5 touched files → 5 files, 0 errors, 0 warnings, and over all ofpackages/runtime/src→ 287 files, 0 errors, 0 warnings (re-measured at this head); invariance: the config enables noparserOptions.projectand no typed rules (its own comment, line 328), so this diff cannot move any untouched file's verdict.grep -naPfor the ASCII control class (backslash-x 00 through 08, 0b, 0c, 0e through 1f, 7f) over every touched file → grep exit 1, nothing found;check:nul-bytesabove agrees.@objectstack/runtime's suite beyond the three files (CI's Test Core shards own it); the HTTP hop ofGET /meta/KIND(the producer read is driven on the real boot,RestServeris not); the@objectstack/verify/ dogfood boots (census row 6 is unchanged code, read from source, not re-driven).Residual — out of scope, recorded
os devover a HOST config (serve.ts:2811wrap + the dev-only HMRMetadataPluginatserve.ts:3024) is a second boot shape with two writers over two SOURCES of one stack (the config module and its compiled twin). Not an artifact boot —AppPlugin's bundle is the module — and not this card's: filed asos devover a HOST config composes two registrars for stack-declared security metadata —AppPluginover the config module and the dev-only HMRMetadataPluginover dist/objectstack.json #14397 (unassigned, dedup-searched) rather than patched here;os devover a HOST config composes two registrars for stack-declared security metadata —AppPluginover the config module and the dev-only HMRMetadataPluginover dist/objectstack.json #14397 remains open.manifest.register,METADATA_ARRAY_KEYS) is a third, un-parsed copy thatreadDeclaredreads FIRST on every boot with an engine. Pre-existing, unchanged, and the seam ObjectQL's two collection-registration copies diverge: jobs / emailTemplates / tools / skills register from a manifest but NOT from a nested plugin — a package shipping them via a nested plugin registers nothing, stamps no ADR-0010 provenance #7049 dealt with for its own two copies; noted so the census is complete.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code