From cf17d5ab345b350b1be58184b529967413eedc1c Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Wed, 28 Oct 2026 20:31:18 -0500 Subject: [PATCH 1/2] fix(everything-dev): release-scoped extends inheritance + deploy-time composition gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root publish moved the inherited `app.auth` slot (children never author it; `bos init` deletes it) to the newest release while a platform-pinned child's own ui kept building against its lockfile — the next child deploy/restart composed auth@rc.N+1 with ui@rc.N and the #546 boot guard degraded health. The platform pin governed image, sync, and compat reporting, but nothing mapped it onto inherited slot pins: extends inheritance was pointer-live, not release-scoped. Two layers (ADR 0034 amendment): - The release manifest records its own slot-pin map (`slots`, additive, composed from the release payload on the release leg — sorted keys so identical payloads compose identical bytes). - The deploy train stamps every inherited slot (absent from the authored config) with the authored platform pin's release-recorded production URL + pin — child-wins over the parent's live pointer at every consumer boot; a child's runtime becomes a deterministic function of (code, pinned release). Config-only publishes carry the stamps forward via mergeAuthoredOverPublished; rollback payloads skip stamping. - The deploy train gains the composition gate (`PublishTrainError` kind "composition"): extends-merged shared-singleton cross-check pre-publish — the host boot guard's mirror — plus the platform compat check scoped to the child's own resolved release. A skewed composition now dies as a red CI run with the `bos upgrade` remediation named. - boot-check gains `resolveBootShape` (the walk shared with `verifyBootPayload`) and `boot-check`'s e2e coverage extends to the incident replay. --- .../release-scoped-extends-inheritance.md | 5 + docs/adr/0034-platform-release-manifest.md | 48 +- .../src/services/deploy/composition.ts | 244 +++++++++ .../src/services/deploy/errors.ts | 1 + .../src/services/deploy/platform-manifest.ts | 60 +++ .../src/services/deploy/train.ts | 89 +++- .../src/services/platform/manifest.ts | 36 ++ .../src/services/resolution/boot-check.ts | 106 ++-- .../integration/platform-upgrade-e2e.test.ts | 252 ++++++++++ .../tests/unit/composition.test.ts | 471 ++++++++++++++++++ 10 files changed, 1272 insertions(+), 40 deletions(-) create mode 100644 .changeset/release-scoped-extends-inheritance.md create mode 100644 packages/everything-dev/src/services/deploy/composition.ts create mode 100644 packages/everything-dev/tests/unit/composition.test.ts diff --git a/.changeset/release-scoped-extends-inheritance.md b/.changeset/release-scoped-extends-inheritance.md new file mode 100644 index 000000000..5d26ee8b6 --- /dev/null +++ b/.changeset/release-scoped-extends-inheritance.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +Release-scoped extends inheritance + deploy-time composition gate — a platform-pinned child can no longer be skewed or degraded by the parent publishing a newer framework release. The release manifest records its own slot-pin map (`slots`, additive), and the deploy train re-points every inherited slot (e.g. `app.auth`) the child does not author at its pinned release's bytes instead of the parent's live config pointer; the same train now cross-checks the extends-merged shape on shared singletons (the boot guard's mirror) and runs the platform compat check against the child's own pin, failing the publish with the `bos upgrade` remediation named instead of shipping a broken composition. diff --git a/docs/adr/0034-platform-release-manifest.md b/docs/adr/0034-platform-release-manifest.md index 65267bb4c..e2edb1c10 100644 --- a/docs/adr/0034-platform-release-manifest.md +++ b/docs/adr/0034-platform-release-manifest.md @@ -93,10 +93,56 @@ The pin lifecycle is exercised end-to-end by `packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts` (publish → pin → deploy derivation → stale fail-loud → re-pin → skew replay). +## Amendment (2026-10-28 — release-scoped extends inheritance) + +The skew-loud boot (item 3 above) exposed a residual structural gap, observed +often in the wild: a child pinned to rc.N — its lockfile, and therefore its own +`ui`/plugin builds, at rc.N — inherits the `app.auth` slot (children never +author it; `bos init` deletes the whole `app` slot) from the parent's **live +config pointer**, whose auth bundle moved to rc.N+1 the moment the root +published. The next child deploy/restart composed auth@rc.N+1 with ui@rc.N and +degraded health. The platform pin governed image, sync, and compat *reporting* +— but nothing mapped it onto inherited slot pins: extends inheritance was +pointer-live, not release-scoped. + +Two changes, shipped together, make a pinned child's runtime a deterministic +function of (child code, pinned release): + +1. **The release manifest records its own slot-pin map** (the `slots` field — + additive, optional; old readers strip it). The release leg composes it from + the release's publish payload: every pinned slot, keyed by dotted config + path (`app.auth`, `app.auth.ui`, `plugins.votes.ui`, …), each carrying the + slot's production base and its immutable version-manifest pin. Old releases + carry no `slots`; old pins stay resolvable forever, so the slots the map + does record survive every later root publish. +2. **The deploy train stamps inherited slots from that map.** Before the + boot-check walk, every remote slot the leaf does *not* author (present in + the release's `slots` map, absent from the authored config) is re-pointed at + the release's recorded production URL + pin — child-wins over the parent's + live pointer at every consumer boot. Slots the child authors stay untouched; + a no-`slots` manifest stamps nothing; rollback payloads are canonical and + skip stamping. + +Finally, the deploy train gained the **composition gate** (`PublishTrainError` +kind `composition`, Layer 1 of the fix): before publish, the extends-merged +shape is cross-compared on embedded shared singletons — the same check the +booted host performs, moved to preflight — and a pinned child additionally runs +`checkPlatformCompat` against **its own** resolved release (never the parent's +latest). A skewed composition now dies as a red CI run with the `bos upgrade` +remediation named, instead of as a degraded production boot. + +Relaxing the `everything-dev/ui/auth` strict singleton to sonner-style +tolerance was explicitly rejected: the singleton exists because two divergent +session-read copies disagreed into redirect loops (d57b8f4); tolerance +reintroduces that bug class. Children pinned to pre-`slots` releases are +protected by the composition gate until their next `bos upgrade` + deploy makes +release-scoping effective. + ## Consequences - The manual `bump + pin image` chore commit dies; dev/prod framework skew becomes mechanically impossible — one field in, catalog, image, sync source, and compat all derived out. - Sha-tags stop mattering to children; digests derive at deploy time from the manifest's tags. - Framework releases gain a machine-readable contract; `upgradeTemplate`'s catalog source moves from "walk the parent repo tarball" to one registry read. - The citynode correlator-commit class of error is caught at deploy time by the platform check instead of at runtime by a federation failure. -- Future seams, recorded not built: caret-tolerant `platform` pins (auto-minor within compat) once range resolution proves out; per-dep compat granularity; a versioned manifest history beside the pointer slot (the registry's history API covers this for now — and per the #546 amendment, it is also the fix for stale pins outliving a republish). +- With release-scoped extends inheritance, a root publish can no longer skew or break an un-upgraded platform-pinned child's runtime; child deploys stay green between `bos upgrade`s. +- Future seams, recorded not built: caret-tolerant `platform` pins (auto-minor within compat) once range resolution proves out; per-dep compat granularity; a versioned manifest history beside the pointer slot (the registry's history API covers this for now — and per the #546 amendment, it is also the fix for stale pins outliving a republish); boot-time release scoping for children pinned to pre-`slots` releases that cannot redeploy. diff --git a/packages/everything-dev/src/services/deploy/composition.ts b/packages/everything-dev/src/services/deploy/composition.ts new file mode 100644 index 000000000..c9a0ee5bd --- /dev/null +++ b/packages/everything-dev/src/services/deploy/composition.ts @@ -0,0 +1,244 @@ +import type { BosConfigInput } from "../../types"; +import { readInstalledFrameworkVersion } from "../framework-version"; +import { + checkPlatformCompat, + type PlatformReleaseManifest, + type ReleaseSlotRecord, + type ResolvePlatformManifestOptions, + resolvePlatformManifest, +} from "../platform/manifest"; +import { isPlainObject } from "../resolution/merge"; +import { readWorkspaceCatalog } from "../workspace/catalog"; +import { findWorkspaceRoot } from "../workspace/workspace"; +import { collectSlotPinRefs } from "./rollback"; +import { resolveSlotVersion } from "./version-manifest"; + +/** + * The deploy train's composition plane (ADR 0034 amendment — release-scoped + * extends inheritance): every remote slot the leaf does not author is a + * base-runtime slot, and its bytes must come from the child's pinned release + * instead of drifting with the parent's live config pointer. + * + * Without this plane, a root publish (rc.N+1) moves the inherited auth pin + * while a platform-pinned child's own ui stays built against rc.N — the + * host's shared-singleton guard (#546) refuses the composition at boot and + * the child's health degrades after its next redeploy. Two layers close it: + * the release manifest records its own slot-pin map (the `slots` field, + * composed on the release leg), and the deploy train stamps those pins onto + * the inherited slots of the child's publish payload — child-wins over the + * parent's live pointer at every consumer boot. + */ + +export interface ResolvedReleaseManifest { + manifest: PlatformReleaseManifest; +} + +/** + * Resolve the release manifest for a deploy: the authored `platform` pin via + * slot-first resolution (children), otherwise the latest pointer (root runtimes). + * Returns null when no manifest is published; null is a normal state for + * children pinned to pre-manifest releases. Resolution failures surface — the + * caller warns and continues unpinned (the composition gate still cross-checks + * the actual resolved bytes). + */ +export async function resolveReleaseManifestForDeploy(input: { + configDir: string; + authoredConfig: Record; + /** test/registry seam — threaded to the platform-manifest resolution */ + resolveOptions?: Partial; +}): Promise { + const resolved = await resolvePlatformManifest({ + configDir: input.configDir, + localConfig: input.authoredConfig, + ...input.resolveOptions, + }); + return resolved ? { manifest: resolved.manifest } : null; +} + +/** Authored slot paths: every remote slot the leaf itself declares (extracted or null). */ +export function authoredSlotPaths(config: Record): Set { + const paths = new Set(); + const app = config.app; + if (isPlainObject(app)) { + for (const [key, entry] of Object.entries(app)) { + if (isPlainObject(entry)) { + paths.add(`app.${key}`); + if ("ui" in (entry as object)) paths.add(`app.${key}.ui`); + } + } + } + const plugins = config.plugins; + if (isPlainObject(plugins)) { + for (const [id, entry] of Object.entries(plugins)) { + if (typeof entry === "string" && entry.length > 0) { + paths.add(`plugins.${id}`); + paths.add(`plugins.${id}.ui`); + continue; + } + if (isPlainObject(entry)) { + paths.add(`plugins.${id}`); + if ("ui" in (entry as object)) paths.add(`plugins.${id}.ui`); + } + } + } + return paths; +} + +/** Stamped slot paths (diagnostic), in stamp order. */ +export interface StampOutcome { + stamped: string[]; +} + +/** + * Layer 2 — stamp inherited slots: for every slot in the release manifest's + * `slots` map that the authored config does not declare, write the release's + * recorded production URL + pin into the payload. Child-wins deep merge then + * shadows the parent's live config pointer at every consumer boot, so the + * child composes exactly the bytes its pinned release published. Slots the + * child authors stay untouched, and a manifest without `slots` (pre-amendment + * releases) stamps nothing — the composition gate covers those children. + */ +export function stampInheritedSlotsFromRelease(input: { + payload: Record; + authoredConfig: Record; + manifest: PlatformReleaseManifest; +}): StampOutcome { + const slots = input.manifest.slots ?? {}; + const authored = authoredSlotPaths(input.authoredConfig); + const stamped: string[] = []; + + for (const [slotPath, record] of Object.entries(slots)) { + if (authored.has(slotPath)) continue; + stampSlotRecord(input.payload, slotPath, record); + stamped.push(slotPath); + } + return { stamped }; +} + +/** Write `{ production, pin }` for one dotted slot path, creating containers. */ +function stampSlotRecord( + payload: Record, + slotPath: string, + record: ReleaseSlotRecord, +): void { + const segments = slotPath.split("."); + if ((segments.length !== 2 && segments.length !== 3) || !segments[1]) return; + let container: Record = payload; + for (const segment of segments.slice(0, -1)) { + const next = container[segment]; + if (!isPlainObject(next)) { + const created: Record = {}; + container[segment] = created; + container = created; + } else { + container = next; + } + } + const leaf = segments[segments.length - 1]; + const existing = container[leaf]; + const entry: Record = isPlainObject(existing) ? { ...existing } : {}; + entry.production = record.base; + entry.pin = { manifest: record.pin.manifest, integrity: record.pin.integrity }; + container[leaf] = entry; +} + +export interface CompositionGateReport { + ok: boolean; + violations: string[]; +} + +/** + * Layer 1 — the deploy-train composition gate: cross-compare the shared-single + * versions of every pinned UI slot in the extends-merged shape (the deploy's + * mirror of the host's boot guard, #546), plus — for authored platform pins — + * the ADR 0034 compat check of the child against its own release. Runs after + * pin verification, before publish. + */ +export async function runCompositionGate(input: { + configDir: string; + authoredConfig: Record; + resolvedShape: Record; + resolvedRelease: ResolvedReleaseManifest | null; + /** test seam — threaded to the version-manifest fetch */ + fetchImpl?: typeof fetch; +}): Promise { + const violations: string[] = [ + ...(await compositionSkewViolations(input.resolvedShape, input.fetchImpl)), + ]; + if (typeof input.authoredConfig.platform === "string" && input.resolvedRelease) { + violations.push(...platformCompatViolations(input.resolvedRelease.manifest, input.configDir)); + } + return { ok: violations.length === 0, violations }; +} + +/** + * Cross-compare each pinned UI slot's embedded shared-singleton versions. + * Slots whose pin does not resolve are skipped — the pin/SRI verification + * already refused broken pins this train. + */ +export async function compositionSkewViolations( + merged: Record, + fetchImpl?: typeof fetch, +): Promise { + const uiSlots = collectSlotPinRefs(merged as BosConfigInput).filter( + (ref) => ref.slot === "app.ui" || ref.slot.endsWith(".ui"), + ); + if (uiSlots.length === 0) return []; + + const versionsByName = new Map>(); + const violations: string[] = []; + + await Promise.all( + uiSlots.map(async ({ slot, base, pin }) => { + try { + const resolved = await resolveSlotVersion({ + base, + pin, + ...(fetchImpl ? { fetchImpl } : {}), + }); + for (const [name, version] of Object.entries(resolved.sharedVersions ?? {})) { + const uses = versionsByName.get(name) ?? []; + uses.push({ slot, version }); + versionsByName.set(name, uses); + } + } catch { + // a pin that cannot resolve is either already rejected by the pin + // verification leg or not a UI-skew concern — skip + } + }), + ); + + for (const [name, uses] of versionsByName) { + const first = uses[0]; + if (!first) continue; + const diverged = uses.find((use) => use.version !== first.version); + if (!diverged) continue; + violations.push( + `Shared singleton "${name}" skew across UI sources: "${first.slot}" was built against ` + + `${first.version}, "${diverged.slot}" against ${diverged.version} — the composition refuses to load ` + + `a split module graph. Redeploy the older bundle: \`bos upgrade\` realigns the platform pin, ` + + `\`bos deploy\` rebuilds against it (issue #546).`, + ); + } + return violations; +} + +/** + * The ADR 0034 compat check, scoping the child's installed framework version + * and workspace catalog against its OWN pinned release — a child resolving + * via slot-first never fails for being older than the parent's latest. + */ +export function platformCompatViolations( + manifest: PlatformReleaseManifest, + configDir: string, +): string[] { + const root = findWorkspaceRoot(configDir)?.dir ?? configDir; + const violations = checkPlatformCompat(manifest, { + platformVersion: readInstalledFrameworkVersion(configDir, "everything-dev"), + catalog: readWorkspaceCatalog(root), + }); + return violations.map( + (violation) => + `${violation.message} — run \`bos upgrade\` to realign your platform pin with \`bos deploy\``, + ); +} diff --git a/packages/everything-dev/src/services/deploy/errors.ts b/packages/everything-dev/src/services/deploy/errors.ts index ad8860b38..34ab6414a 100644 --- a/packages/everything-dev/src/services/deploy/errors.ts +++ b/packages/everything-dev/src/services/deploy/errors.ts @@ -19,6 +19,7 @@ export type TrainErrorKind = | "missing-manifest" | "memory-backend" | "config-write" + | "composition" | "pin-verification" | "platform-manifest" | "platform-truth" diff --git a/packages/everything-dev/src/services/deploy/platform-manifest.ts b/packages/everything-dev/src/services/deploy/platform-manifest.ts index 39d943d23..e66430593 100644 --- a/packages/everything-dev/src/services/deploy/platform-manifest.ts +++ b/packages/everything-dev/src/services/deploy/platform-manifest.ts @@ -13,6 +13,7 @@ import { getPlatformManifestKey, getPlatformVersionSlotKey, type PlatformReleaseManifest, + type ReleaseSlotRecord, resolvePlatformManifest, serializePlatformReleaseManifest, } from "../platform/manifest"; @@ -25,6 +26,61 @@ export { getPlatformManifestKey, getPlatformVersionSlotKey }; /** The universal image (ADR 0020/0021) — the release manifest's image ref. */ export const PLATFORM_IMAGE_REF = "ghcr.io/nearbuilders/everything-dev"; +export interface ReleaseSlotRef extends ReleaseSlotRecord { + slot: string; +} + +/** + * The release's pinned slots: every config slot (dotted config path, keyed by + * the same shape the release-slot stamping writes back — `app.auth`, + * `app.auth.ui`, `plugins.votes.ui`, …) with a production URL and an + * immutable version-manifest pin in the release's publish payload. The + * release leg records them in the release manifest so pinned children can + * re-point inherited slots at these bytes. + */ +export function collectReleaseSlotPins(config: Record): ReleaseSlotRef[] { + const slots: ReleaseSlotRef[] = []; + + const push = (slot: string, entry: unknown): void => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return; + const ref = entry as Record; + const base = typeof ref.production === "string" ? ref.production : undefined; + const pin = ref.pin as { manifest?: unknown; integrity?: unknown } | undefined; + const manifest = typeof pin?.manifest === "string" ? pin.manifest : undefined; + const integrity = typeof pin?.integrity === "string" ? pin.integrity : undefined; + if (base && manifest && integrity) { + slots.push({ slot, base, pin: { manifest, integrity } }); + } + }; + + const app = config.app as Record> | undefined; + if (app && typeof app === "object") { + for (const [key, entry] of Object.entries(app)) { + push(`app.${key}`, entry); + const ui = (entry as { ui?: unknown } | undefined)?.ui; + if (ui) push(`app.${key}.ui`, ui); + } + } + const plugins = config.plugins as Record | undefined; + if (plugins && typeof plugins === "object") { + for (const [key, entry] of Object.entries(plugins)) { + push(`plugins.${key}`, entry); + if (typeof entry === "object" && entry !== null) { + const ui = (entry as { ui?: unknown }).ui; + if (ui) push(`plugins.${key}.ui`, ui); + } + } + } + return slots; +} + +/** The `Record` shape the manifest schema stores. */ +export function releaseSlotMap(config: Record): Record { + return Object.fromEntries( + collectReleaseSlotPins(config).map(({ slot, base, pin }) => [slot, { base, pin }]), + ); +} + function readWorkspacePackageVersion(configDir: string, name: string): string | undefined { try { const raw = readFileSync(join(configDir, "packages", name, "package.json"), "utf-8"); @@ -55,6 +111,9 @@ export function composePlatformManifestPublishEntry(opts: { /** The deploy's repo HEAD — the serving build's identity on the wire. */ gitSha?: string; imageRef?: string; + /** The release's own publish payload — its pinned slots ride the manifest + * (ADR 0034 amendment: release-scoped extends inheritance). */ + publishPayload?: Record; }): PlatformManifestPublishEntry | null { const frameworkVersion = readFrameworkVersion(opts.configDir); const everyPluginVersion = readEveryPluginVersion(opts.configDir); @@ -68,6 +127,7 @@ export function composePlatformManifestPublishEntry(opts: { catalog: readWorkspaceCatalog(opts.configDir), templateInventory: computeTemplateInventory(opts.configDir), compatMinChild: opts.previousFrameworkVersion ?? frameworkVersion, + ...(opts.publishPayload ? { slots: releaseSlotMap(opts.publishPayload) } : {}), }); const value = serializePlatformReleaseManifest(manifest); return { diff --git a/packages/everything-dev/src/services/deploy/train.ts b/packages/everything-dev/src/services/deploy/train.ts index 9486e0f0a..3eb487b55 100644 --- a/packages/everything-dev/src/services/deploy/train.ts +++ b/packages/everything-dev/src/services/deploy/train.ts @@ -5,7 +5,11 @@ import { Context, Effect, Layer, Option } from "effect"; import { hasFolderFormUi } from "every-plugin/build/ui"; import { findConfigPath, isAppDescriptorPath, resolveUiRuntimeName } from "../../config"; import type { WorkspaceDeployResult } from "../../contract"; -import { verifyBootPayload } from "../../services/resolution/boot-check"; +import { + type ResolvedBootShape, + resolveBootShape, + verifyBootPayload, +} from "../../services/resolution/boot-check"; import { openResolution } from "../../services/resolution/session"; import type { BosConfig, BosConfigInput, RuntimeConfig } from "../../types"; import { run } from "../../utils/run"; @@ -27,6 +31,12 @@ import { buildWorkspaceTargets, resolveWorkspaceTarget, } from "../workspace/build-train"; +import { + type ResolvedReleaseManifest, + resolveReleaseManifestForDeploy, + runCompositionGate, + stampInheritedSlotsFromRelease, +} from "./composition"; import { BundleStorage, RegistryStore, type RegistryStoreShape } from "./deps"; import { type PreviousPin, @@ -746,18 +756,67 @@ export async function runPublishTrain( publishPayload = stripLocalDevelopmentTargets(publishPayload); } + // Release-scoped extends inheritance (ADR 0034 amendment): every remote + // slot the authored config does not declare is a base-runtime slot, and its + // bytes re-point at the authored platform pin's release — the parent's + // live config pointer can move (a root rc.N+1 publish) without skewing a + // pinned child's composition. Resolution failures warn and continue + // unpinned; the composition gate below still cross-checks the bytes. + let resolvedRelease: ResolvedReleaseManifest | null = null; + if (input.exactPayload !== true) { + try { + resolvedRelease = await resolveReleaseManifestForDeploy({ + configDir, + authoredConfig: publishRawConfig as Record, + }); + if (resolvedRelease) { + const stamped = stampInheritedSlotsFromRelease({ + payload: publishPayload as Record, + authoredConfig: publishRawConfig as Record, + manifest: resolvedRelease.manifest, + }); + if (stamped.stamped.length > 0) { + console.log( + colors.dim( + ` Release-scoped slots (${resolvedRelease.manifest.frameworkVersion}): stamped ${stamped.stamped.join(", ")}`, + ), + ); + } + } + } catch (error) { + console.log( + colors.dim( + ` Note: release-manifest resolution failed — continuing without release-scoped slots: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + } + // Every runtime boot validates the extends-merged config with // BosConfigSchema (resolvePublishedConfig) — a leaf payload is only the // boot input when it carries no extends ref. The check re-runs the boot's // own walk-merge-parse with this payload as the leaf, catching // canonicalization/schema drift AND extends-chain breakage at preflight // instead of failing the post-publish smoke and rolling back. + let bootShape: ResolvedBootShape | undefined; + try { + bootShape = await resolveBootShape( + publishPayload, + { account, gateway }, + { + ...(input.registry ? { registry: input.registry } : {}), + }, + ); + } catch { + // verifyBootPayload re-walks and reports the chain failure as issues + } const bootCheck = await verifyBootPayload( publishPayload, { account, gateway }, { ...(input.registry ? { registry: input.registry } : {}), }, + bootShape, ); if (!bootCheck.ok) { const detail = bootCheck.issues @@ -795,6 +854,33 @@ export async function runPublishTrain( }); } + // Layer 1 — the composition gate (deploy-time mirror of the host's boot + // guard, #546): the extends-merged shape must cross-agree on shared + // singletons before anything publishes; a pinned child additionally checks + // its platform compat against its own release. Skew here is a red CI with + // the remediation named — never a degraded production boot. + const compositionGate = await runCompositionGate({ + configDir, + authoredConfig: publishRawConfig as Record, + resolvedShape: (bootShape ? bootShape.config : { ...publishPayload }) as Record< + string, + unknown + >, + resolvedRelease, + }); + if (!compositionGate.ok) { + throw new PublishTrainError({ + kind: "composition", + registryUrl, + built, + skipped, + deployResults, + error: + `Publish blocked — a consumer boot would refuse this composition:\n` + + compositionGate.violations.map((line) => ` ${line}`).join("\n"), + }); + } + const registryKey = `apps/${account}/${gateway}/bos.config.json`; const publishedAt = new Date().toISOString(); // version identity printed + returned for every deploy (atomic-deploys 12) @@ -860,6 +946,7 @@ export async function runPublishTrain( gateway, previousFrameworkVersion: pointer?.frameworkVersion, gitSha, + publishPayload: publishPayload as Record, }); // The truth gate (#546, extended by #573): runs on EVERY production // deploy against the composed release manifest — a workspace whose diff --git a/packages/everything-dev/src/services/platform/manifest.ts b/packages/everything-dev/src/services/platform/manifest.ts index f13accacd..de4a35576 100644 --- a/packages/everything-dev/src/services/platform/manifest.ts +++ b/packages/everything-dev/src/services/platform/manifest.ts @@ -56,9 +56,37 @@ export const PlatformReleaseManifestSchema = z.object({ }), ), compat: z.object({ minChild: z.string().min(1) }), + /** + * The release's own slot-pin map (ADR 0034 amendment — release-scoped + * extends inheritance): each entry records the immutable version-manifest + * pointer the release published for a config slot (dotted config path, e.g. + * `app.auth`, `app.auth.ui`, `plugins.votes.ui`) — the son of the store, so + * children pinned to this release can re-point inherited slots at THESE + * bytes instead of the parent's live config whenever the parent publishes. + * Optional: releases composed before the field carry no slots, and old + * readers strip unknown keys. + */ + slots: z + .record( + z.string(), + z.object({ + base: z.string().min(1), + pin: z.object({ + manifest: z.string().min(1), + integrity: z.string().min(1), + }), + }), + ) + .optional(), }); export type PlatformReleaseManifest = z.infer; +/** One recorded release slot (see {@link PlatformReleaseManifestSchema.slots}). */ +export interface ReleaseSlotRecord { + base: string; + pin: { manifest: string; integrity: string }; +} + export interface TemplateInventoryEntry { path: string; sha256: string; @@ -144,6 +172,10 @@ export interface ComposePlatformReleaseManifestInputs { catalog: Record; templateInventory: TemplateInventoryEntry[]; compatMinChild: string; + /** The release's own slot-pin map — sorted here so identical payloads + * compose identical manifest bytes across machines (the burned-version + * guard compares serialized bytes). */ + slots?: Record; } export function composePlatformReleaseManifest( @@ -160,6 +192,9 @@ export function composePlatformReleaseManifest( `v${semver.major}.${semver.minor}.${semver.patch}${semver.prerelease ? `-${semver.prerelease}` : ""}`, ] : []; + const slots = inputs.slots + ? Object.fromEntries(Object.entries(inputs.slots).sort(([a], [b]) => (a < b ? -1 : 1))) + : undefined; return PlatformReleaseManifestSchema.parse({ schemaVersion: 1, kind: "everything-dev/platform-manifest", @@ -171,6 +206,7 @@ export function composePlatformReleaseManifest( sharedDeps: composeSharedDepsFromCatalog(inputs.catalog), templateInventory: inputs.templateInventory, compat: { minChild: inputs.compatMinChild }, + ...(slots ? { slots } : {}), }); } diff --git a/packages/everything-dev/src/services/resolution/boot-check.ts b/packages/everything-dev/src/services/resolution/boot-check.ts index a9d308bc5..aa0f16805 100644 --- a/packages/everything-dev/src/services/resolution/boot-check.ts +++ b/packages/everything-dev/src/services/resolution/boot-check.ts @@ -78,58 +78,88 @@ export interface BootCheckOptions { io?: ResolutionIo; } -export async function verifyBootPayload( +export interface ResolvedBootShape { + /** The parent refs walked (leaf excluded), in walk order — nearest parent + * first; empty for root payloads. */ + chain: readonly string[]; + /** The extends-merged (child-wins) shape — a structured clone, safe to + * hand to callers that may mutate it (the defu merge reuses input + * subtrees). Root payloads clone the payload itself. */ + config: BosConfigInput; +} + +/** + * The boot walk itself, exposed so consumers beside `verifyBootPayload` can + * gate on the merged shape (the deploy train's composition gate) instead of + * re-walking under their own walker. Throws on chain failure; the caller + * decides how to report it. + */ +export async function resolveBootShape( payload: unknown, slot: { account: string; gateway: string }, opts: BootCheckOptions = {}, -): Promise { +): Promise { if (payload === null || typeof payload !== "object") { - return { - ok: false, - issues: [ - { - code: "schema", - path: "", - origin: "payload", - message: "payload is not a config object", - }, - ], - chain: [], - }; + throw new Error("payload is not a config object"); } const input = payload as BosConfigInput; const extendsRef = input.extends ? resolveExtendsRef(input.extends, PUBLISHED_CONFIG_ENV) : undefined; + if (!extendsRef) { + return { chain: [], config: structuredClone(input) }; + } + const walked = await walkExtendsChain(`bos://${slot.account}/${slot.gateway}`, { + env: PUBLISHED_CONFIG_ENV, + ...(opts.registry ? { registry: opts.registry } : {}), + ...(opts.io ? { io: opts.io } : {}), + seedConfig: structuredClone(input), + visit: async () => {}, + }); + return { chain: walked.chain.slice(1), config: structuredClone(walked.config) }; +} + +export async function verifyBootPayload( + payload: unknown, + slot: { account: string; gateway: string }, + opts: BootCheckOptions = {}, + /** The caller may hand over a shape it already walked (the deploy train's + * composition gate shares one walk with this check) — it must be the + * result of `resolveBootShape` for this exact payload. */ + preResolved?: ResolvedBootShape, +): Promise { + const extendsRef = + payload !== null && typeof payload === "object" && (payload as BosConfigInput).extends + ? resolveExtendsRef((payload as BosConfigInput).extends, PUBLISHED_CONFIG_ENV) + : undefined; if (!extendsRef) { - return { chain: [], ...parseShape(input, input, false) }; + return { chain: [], ...parseShape(payload as object, payload, false) }; } - try { - const walked = await walkExtendsChain(`bos://${slot.account}/${slot.gateway}`, { - env: PUBLISHED_CONFIG_ENV, - ...(opts.registry ? { registry: opts.registry } : {}), - ...(opts.io ? { io: opts.io } : {}), - seedConfig: structuredClone(input), - visit: async () => {}, - }); - return { chain: walked.chain.slice(1), ...parseShape(input, walked.config, true) }; - } catch (error) { - if (!(error instanceof Error)) throw error; - return { - ok: false, - chain: [], - issues: [ - { - code: "chain", - path: extendsRef, - origin: "parent", - message: chainFailureMessage(error), - }, - ], - }; + let shape: ResolvedBootShape; + if (preResolved) { + shape = preResolved; + } else { + try { + shape = await resolveBootShape(payload, slot, opts); + } catch (error) { + if (!(error instanceof Error)) throw error; + return { + ok: false, + chain: [], + issues: [ + { + code: "chain", + path: extendsRef, + origin: "parent", + message: chainFailureMessage(error), + }, + ], + }; + } } + return { chain: shape.chain, ...parseShape(payload as object, shape.config, true) }; } /** Parse a shape with BosConfigSchema and map the outcome onto a report — diff --git a/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts b/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts index f4dbfa60f..259361a1d 100644 --- a/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts +++ b/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts @@ -19,6 +19,12 @@ import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + resolveReleaseManifestForDeploy, + runCompositionGate, + stampInheritedSlotsFromRelease, +} from "../../src/services/deploy/composition"; +import { computeManifestSri } from "../../src/services/deploy/manifest-verify"; import { composePlatformManifestPublishEntry, deriveImageRefFromPlatformManifest, @@ -33,6 +39,7 @@ import { PlatformReleaseManifestSchema, resolvePlatformManifest, } from "../../src/services/platform/manifest"; +import { resolveBootShape } from "../../src/services/resolution/boot-check"; import { applyPlatformPin } from "../../src/services/scaffold/upgrade-migration"; import { type ExtendedRootSourceOptions, @@ -462,3 +469,248 @@ describe("#546 replay — drifted-react republish after the child stayed pinned" expect(violations.some((v) => v.kind === "minChild")).toBe(true); }); }); + +/** + * Release-scoped extends inheritance (ADR 0034 amendment) — the incident + * replay on the deploy train's composition plane: + * + * The root publishes rc.1 (its release manifest records the auth slot + * pins), then rc.2 whose LIVE config moves the inherited auth slots to + * react 19.3.0. A child pinned rc.1 — its own ui built against react + * 19.2.4 — deploys: the train stamps app.auth/app.auth.ui from the rc.1 + * release's slots map, the extends-merged shape composes rc.1 auth against + * rc.1 ui, and the composition gate is green. The same deploy WITHOUT the + * stamps (a pre-slots release) composes live rc.2 auth against rc.1 ui — + * the #546 skew — and the gate blocks publish with the remediation named. + */ +describe("release-scoped inheritance — a pinned child deploy survives the root's newer publish", () => { + const ACCOUNT = "root.near"; + const GATEWAY = "root"; + const CHILD_SLOT = { account: "child.near", gateway: "child.example" }; + const liveConfigKey = `apps/${ACCOUNT}/${GATEWAY}/bos.config.json`; + + const authRc1Shared = { "everything-dev/ui/auth": "2.0.0-rc.1", react: "19.2.4" }; + const authRc2Shared = { "everything-dev/ui/auth": "2.0.0-rc.2", react: "19.3.0" }; + const childUiShared = { react: "19.2.4" }; + + let server: Server; + let base: string; + let routes: Record; + let rootDir: string; + let childDir: string; + + const versionManifestText = (shared: Record): string => + JSON.stringify({ + version: "0123456789abcdef", + entry: "remoteEntry.js", + entryIntegrity: "sha384-AAAAAA", + shared, + }); + + const pinFor = (slotBase: string, file: string, shared: Record) => { + const text = versionManifestText(shared); + routes[`${base}${slotBase}${file}`] = text; + return { manifest: file, integrity: computeManifestSri(text) }; + }; + + const registryIo = { + fetchBosConfig: async (bosUrl: string) => { + const [account, gateway] = bosUrl.replace(/^bos:\/\//, "").split("/"); + const entry = registry.get(`apps/${account}/${gateway}/bos.config.json`); + if (!entry) throw new Error(`No config found for ${bosUrl}`); + return entry; + }, + readFileOrNull: async () => null, + importModule: async () => { + throw new Error("importModule must not be reached"); + }, + }; + + /** The root's release compose: the release manifest records its slot pins, + * and the live parent config carries the same pins — the moving pointer the + * incident trips over. Each release's live auth slots carry that release's + * shared-singleton versions (rc.1: react 19.2.4; rc.2: 19.3.0). */ + function publishRootRelease( + version: string, + previousVersion: string | undefined, + shared: Record, + ): PlatformReleaseManifest { + writeFileSync( + join(rootDir, "packages", "everything-dev", "package.json"), + `${JSON.stringify({ name: "everything-dev", version })}\n`, + ); + const authPin = pinFor("/auth/", `versions/auth-${version}.json`, shared); + const authUiPin = pinFor("/auth-ui/", `versions/auth-ui-${version}.json`, shared); + const entry = composePlatformManifestPublishEntry({ + configDir: rootDir, + account: ACCOUNT, + gateway: GATEWAY, + ...(previousVersion ? { previousFrameworkVersion: previousVersion } : {}), + publishPayload: { + app: { + auth: { + production: `${base}/auth/`, + pin: authPin, + ui: { production: `${base}/auth-ui/`, pin: authUiPin }, + }, + }, + }, + }); + expect(entry).not.toBeNull(); + registry.set(entry!.key, JSON.parse(entry!.value)); + registry.set(getPlatformVersionSlotKey(ACCOUNT, GATEWAY, version), JSON.parse(entry!.value)); + registry.set(liveConfigKey, { + account: ACCOUNT, + domain: GATEWAY, + app: { + auth: { + production: `${base}/auth/`, + pin: authPin, + ui: { production: `${base}/auth-ui/`, pin: authUiPin }, + }, + }, + }); + return entry!.manifest; + } + + /** The deploy train's payload assembly, compressed: authored config, the + * train-applied own-ui pin, optional stamps, one boot walk, then the gate. */ + async function simulateDeploy( + authoredConfig: Record, + ownUiPin: { manifest: string; integrity: string }, + stamps: boolean, + ): Promise<{ ok: boolean; violations: string[]; merged: Record }> { + const resolvedRelease = await resolveReleaseManifestForDeploy({ + configDir: childDir, + authoredConfig, + resolveOptions: { fetchManifest, fetchParent, fetchSlot }, + }); + const payload = structuredClone(authoredConfig); + const app = payload.app as Record>; + app.ui.production = `${base}/child-ui/`; + app.ui.pin = ownUiPin; + if (stamps && resolvedRelease?.manifest.slots) { + stampInheritedSlotsFromRelease({ + payload, + authoredConfig, + manifest: resolvedRelease.manifest, + }); + } + const bootShape = await resolveBootShape(payload, CHILD_SLOT, { io: registryIo }); + const gate = await runCompositionGate({ + configDir: childDir, + authoredConfig, + resolvedShape: bootShape.config as Record, + resolvedRelease, + fetchImpl: (async (input: Parameters[0]) => { + const text = routes[String(input)]; + if (!text) { + return { ok: false, status: 404, text: async () => "" } as unknown as Response; + } + return { ok: true, status: 200, text: async () => text } as unknown as Response; + }) as typeof fetch, + }); + return { + ok: gate.ok, + violations: gate.violations, + merged: bootShape.config as Record, + }; + } + + beforeAll(async () => { + routes = {}; + server = createServer((req, res) => { + const text = routes[req.url ?? ""]; + res.writeHead(text !== undefined ? 200 : 404, { "content-type": "application/json" }); + res.end(text ?? ""); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("no server address"); + base = `http://127.0.0.1:${address.port}`; + + routes[`${base}/child-ui/versions/fedcba0987654321.json`] = versionManifestText(childUiShared); + + rootDir = frameworkRootFixture("2.0.0-rc.1", "19.2.4"); + childDir = childFixture("2.0.0-rc.1"); + writeFileSync( + join(childDir, "package.json"), + `${JSON.stringify({ name: "child", dependencies: { "everything-dev": "2.0.0-rc.1" } })}\n`, + ); + writeFileSync( + join(childDir, "pnpm-workspace.yaml"), + [ + "packages:", + " - ui", + "", + "catalog:", + ' react: "19.2.4"', + ' effect: "4.0.0-rc.112"', + "", + ].join("\n"), + ); + + publishRootRelease("2.0.0-rc.1", undefined, authRc1Shared); + // the parent moves on: rc.2's live config serves react 19.3.0 auth bundles + publishRootRelease("2.0.0-rc.2", "2.0.0-rc.1", authRc2Shared); + }); + + afterAll(() => { + server.close(); + for (const dir of tempDirs.splice(-2)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("the child deploy stamps inherited auth slots from its pinned release — the composition gate is green", async () => { + const authoredConfig = { + extends: `bos://${ACCOUNT}/${GATEWAY}`, + account: "child.near", + domain: "child.example", + platform: "2.0.0-rc.1", + app: { ui: { development: "local:ui" } }, + }; + const { ok, violations, merged } = await simulateDeploy( + authoredConfig, + { + manifest: "versions/fedcba0987654321.json", + integrity: computeManifestSri(versionManifestText(childUiShared)), + }, + true, + ); + expect(violations).toEqual([]); + + // the merged shape re-pointed the inherited auth slots at the rc.1 + // release — NOT the parent's live rc.2 pins + const app = merged.app as Record>>; + expect(app.auth.pin.manifest).toBe("versions/auth-2.0.0-rc.1.json"); + expect(app.auth.ui.pin.manifest).toBe("versions/auth-ui-2.0.0-rc.1.json"); + expect(ok).toBe(true); + }); + + it("without the stamps (a pre-slots release) the deploy gate blocks with the #546 skew and the remediation", async () => { + const authoredConfig = { + extends: `bos://${ACCOUNT}/${GATEWAY}`, + account: "child.near", + domain: "child.example", + platform: "2.0.0-rc.1", + app: { ui: { development: "local:ui" } }, + }; + const { ok, violations } = await simulateDeploy( + authoredConfig, + { + manifest: "versions/fedcba0987654321.json", + integrity: computeManifestSri(versionManifestText(childUiShared)), + }, + false, + ); + // the stamps were skipped: the merged shape keeps the live rc.2 auth + // against the child's own rc.1 ui — the incident + expect(ok).toBe(false); + const combined = violations.join("\n"); + expect(combined).toContain("Shared singleton"); + expect(combined).toContain("19.2.4"); + expect(combined).toContain("19.3.0"); + expect(combined).toContain("bos upgrade"); + }); +}); diff --git a/packages/everything-dev/tests/unit/composition.test.ts b/packages/everything-dev/tests/unit/composition.test.ts new file mode 100644 index 000000000..9e81e9f72 --- /dev/null +++ b/packages/everything-dev/tests/unit/composition.test.ts @@ -0,0 +1,471 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + authoredSlotPaths, + compositionSkewViolations, + runCompositionGate, + stampInheritedSlotsFromRelease, +} from "../../src/services/deploy/composition"; +import { computeManifestSri } from "../../src/services/deploy/manifest-verify"; +import { composePlatformManifestPublishEntry } from "../../src/services/deploy/platform-manifest"; +import { clearSlotVersionCache } from "../../src/services/deploy/version-manifest"; +import { + type PlatformReleaseManifest, + PlatformReleaseManifestSchema, +} from "../../src/services/platform/manifest"; + +/** + * The composition plane (ADR 0034 amendment — release-scoped extends + * inheritance): inherited base-runtime slots re-point at a platform pin's + * release, and the train gates the extends-merged shape on shared-singleton + * skew before publishing. + */ + +const authBase = "https://cdn.test/bundles/root.near/root/auth/"; +const authUiBase = "https://cdn.test/bundles/root.near/root/auth-ui/"; + +function uiVersionManifest(shared: Record): string { + return JSON.stringify({ + version: "fedcba0987654321", + entry: "remoteEntry.js", + entryIntegrity: "sha384-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", + shared, + }); +} + +function versionManifestFetcher(routes: Record): typeof fetch { + return (async (input: Parameters[0]) => { + const text = routes[String(input)]; + if (!text) { + return { ok: false, status: 404, text: async () => "" } as unknown as Response; + } + return { ok: true, status: 200, text: async () => text } as unknown as Response; + }) as typeof fetch; +} + +function manifestWithSlots(slots: unknown): PlatformReleaseManifest { + return PlatformReleaseManifestSchema.parse({ + schemaVersion: 1, + kind: "everything-dev/platform-manifest", + frameworkVersion: "2.0.0-rc.24", + everyPluginVersion: "3.0.0-rc.14", + image: { ref: "ghcr.io/nearbuilders/everything-dev", tags: ["v2.0.0-rc.24"] }, + catalog: {}, + sharedDeps: {}, + templateInventory: [], + compat: { minChild: "2.0.0-rc.23" }, + ...(slots ? { slots } : {}), + }); +} + +let tmpRoot: string; + +beforeEach(() => { + clearSlotVersionCache(); + tmpRoot = mkdtempSync(join(tmpdir(), "composition-")); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + clearSlotVersionCache(); +}); + +describe("stampInheritedSlotsFromRelease", () => { + it("stamps inherited slots (auth + auth.ui) without touching authored slots", () => { + const payload: Record = { + account: "child.near", + app: { + ui: { + production: "https://child.test/ui/", + pin: { manifest: "mine.json", integrity: "sha384-mine" }, + }, + }, + }; + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: structuredClone(payload), + manifest: manifestWithSlots({ + "app.auth": { + base: authBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth" }, + }, + "app.auth.ui": { + base: authUiBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth-ui" }, + }, + "app.ui": { + base: "https://attacker.test/", + pin: { manifest: "x.json", integrity: "sha384-x" }, + }, + }), + }); + + const app = payload.app as Record>; + expect(app.auth).toEqual({ + production: authBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth" }, + ui: { + production: authUiBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth-ui" }, + }, + }); + expect(outcome.stamped).toEqual(["app.auth", "app.auth.ui"]); + expect(app.ui.production).toBe("https://child.test/ui/"); + expect(app.ui.pin).toEqual({ manifest: "mine.json", integrity: "sha384-mine" }); + }); + + it("stamps nothing when the release carries no slots map (pre-amendment releases)", () => { + const payload: Record = { account: "child.near" }; + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: {}, + manifest: manifestWithSlots(undefined), + }); + expect(outcome.stamped).toEqual([]); + expect(payload.app).toBeUndefined(); + }); + + it("overrides stale stamps from a previous release-scoped deploy", () => { + const payload: Record = { + app: { + auth: { + production: "https://old.test/auth/", + pin: { manifest: "versions/old.json", integrity: "sha384-old" }, + }, + }, + }; + stampInheritedSlotsFromRelease({ + payload, + authoredConfig: {}, + manifest: manifestWithSlots({ + "app.auth": { + base: authBase, + pin: { manifest: "versions/new.json", integrity: "sha384-new" }, + }, + }), + }); + expect((payload.app as Record>).auth).toEqual({ + production: authBase, + pin: { manifest: "versions/new.json", integrity: "sha384-new" }, + }); + }); + + it("a ui:false opt-out owns the plugin and ui slots — no stamp resurrects the surface", () => { + const payload: Record = { + plugins: { votes: { extends: "bos://root.near/root", ui: false } }, + }; + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: { plugins: { votes: { extends: "bos://root.near/root", ui: false } } }, + manifest: manifestWithSlots({ + "plugins.votes": { + base: "https://cdn.test/votes/", + pin: { manifest: "v.json", integrity: "sha384-v" }, + }, + "plugins.votes.ui": { + base: "https://cdn.test/votes-ui/", + pin: { manifest: "vu.json", integrity: "sha384-vu" }, + }, + }), + }); + expect(outcome.stamped).toEqual([]); + }); + + it("a plugin authored without a ui key stamps its computed ui slot", () => { + const payload: Record = { + plugins: { votes: { extends: "bos://root.near/root" } }, + }; + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: { plugins: { votes: { extends: "bos://root.near/root" } } }, + manifest: manifestWithSlots({ + "plugins.votes": { + base: "https://cdn.test/votes/", + pin: { manifest: "v.json", integrity: "sha384-v" }, + }, + "plugins.votes.ui": { + base: "https://cdn.test/votes-ui/", + pin: { manifest: "vu.json", integrity: "sha384-vu" }, + }, + }), + }); + expect(outcome.stamped).toEqual(["plugins.votes.ui"]); + }); +}); + +describe("authoredSlotPaths", () => { + it("counts app entries with their ui, and string plugin refs as fully owned", () => { + const paths = authoredSlotPaths({ + app: { ui: { x: 1 }, host: { ui: {} }, api: { ui: false } }, + plugins: { votes: { y: 1 }, legacy: "bos://parent/p", detached: { ui: false } }, + }); + expect(paths.has("app.ui")).toBe(true); + expect(paths.has("app.ui.ui")).toBe(false); + expect(paths.has("app.host")).toBe(true); + expect(paths.has("app.api")).toBe(true); + // a ui:false opt-out is an authored decision — the slot is owned + expect(paths.has("app.api.ui")).toBe(true); + expect(paths.has("plugins.votes")).toBe(true); + // no ui key in the authored entry — the extends chain owns the ui slot + expect(paths.has("plugins.votes.ui")).toBe(false); + expect(paths.has("plugins.legacy")).toBe(true); + expect(paths.has("plugins.legacy.ui")).toBe(true); + expect(paths.has("plugins.detached")).toBe(true); + expect(paths.has("plugins.detached.ui")).toBe(true); + }); +}); + +describe("compositionSkewViolations", () => { + const uiText = uiVersionManifest({ + react: "19.2.9", + "everything-dev/ui/auth": "2.0.0-rc.24", + }); + const authUiSkewedText = uiVersionManifest({ + react: "19.2.9", + "everything-dev/ui/auth": "2.0.0-rc.25", + }); + const authUiAgreeText = uiVersionManifest({ + react: "19.2.9", + "everything-dev/ui/auth": "2.0.0-rc.24", + }); + + function mergedWithUiSlots(authUiIntegrity: string): Record { + return { + app: { + ui: { + production: "https://cdn.test/bundles/child.near/ui/", + pin: { + manifest: "versions/fedcba0987654321.json", + integrity: computeManifestSri(uiText), + }, + }, + auth: { + production: authBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth" }, + ui: { + production: authUiBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: authUiIntegrity }, + }, + }, + }, + }; + } + + const verifiedAuthUiPin = { + manifest: "versions/0123456789abcdef.json", + integrity: computeManifestSri(authUiSkewedText), + }; + const brokenAuthUiPin = { + manifest: "versions/0123456789abcdef.json", + integrity: "sha384-not-the-served-bytes", + }; + + it("flags the skew the incident class produces — the host boot guard's mirror", async () => { + const merged = mergedWithUiSlots(verifiedAuthUiPin.integrity); + const fetcher = versionManifestFetcher({ + "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, + [`${authUiBase}versions/0123456789abcdef.json`]: authUiSkewedText, + }); + + const violations = await compositionSkewViolations(merged, fetcher); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('Shared singleton "everything-dev/ui/auth"'); + expect(violations[0]).toContain("2.0.0-rc.24"); + expect(violations[0]).toContain("2.0.0-rc.25"); + expect(violations[0]).toContain("bos upgrade"); + }); + + it("an unresolvable source pin is skipped, not guessed", async () => { + const merged = mergedWithUiSlots(brokenAuthUiPin.integrity); + const fetcher = versionManifestFetcher({ + "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, + [`${authUiBase}versions/0123456789abcdef.json`]: authUiSkewedText, + }); + + expect(await compositionSkewViolations(merged, fetcher)).toEqual([]); + }); + + it("silent when all sources agree", async () => { + const merged = mergedWithUiSlots(computeManifestSri(authUiAgreeText)); + const fetcher = versionManifestFetcher({ + "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, + [`${authUiBase}versions/0123456789abcdef.json`]: authUiAgreeText, + }); + expect(await compositionSkewViolations(merged, fetcher)).toEqual([]); + }); + + it("ignores non-UI slots entirely", async () => { + const merged = { + app: { + api: { + production: "https://cdn.test/bundles/child.near/api/", + pin: { manifest: "broken.json", integrity: "sha384-nothing" }, + }, + }, + }; + expect(await compositionSkewViolations(merged, versionManifestFetcher({}))).toEqual([]); + }); +}); + +describe("runCompositionGate — compat leg", () => { + function scaffoldChild(platform: string, catalogReact: string): string { + mkdirSync(join(tmpRoot, "ui"), { recursive: true }); + writeFileSync( + join(tmpRoot, "package.json"), + `${JSON.stringify({ + name: "child", + dependencies: { "everything-dev": platform }, + })}\n`, + ); + writeFileSync( + join(tmpRoot, "pnpm-workspace.yaml"), + ["packages:", " - ui", "", "catalog:", ` react: "${catalogReact}"`, ""].join("\n"), + ); + return tmpRoot; + } + + it("a pinned child aligned with its own release passes", async () => { + const configDir = scaffoldChild("2.0.0-rc.24", "19.2.9"); + const report = await runCompositionGate({ + configDir, + authoredConfig: { platform: "2.0.0-rc.24" }, + resolvedShape: {}, + resolvedRelease: { manifest: manifestWithSlots(undefined) }, + }); + expect(report.ok).toBe(true); + }); + + it("a lockfile drift (newer than the pinned release) is a red gate with the remediation", async () => { + const configDir = scaffoldChild("2.0.0-rc.25", "19.2.9"); + const report = await runCompositionGate({ + configDir, + authoredConfig: { platform: "2.0.0-rc.24" }, + resolvedShape: {}, + resolvedRelease: { manifest: manifestWithSlots(undefined) }, + }); + expect(report.ok).toBe(false); + const combined = report.violations.join("\n"); + expect(combined).toContain("newer than this release"); + expect(combined).toContain("bos upgrade"); + }); + + it("an unpinned config skips the compat leg — the skew cross-check is its net", async () => { + const configDir = scaffoldChild("2.0.0-rc.25", "19.2.9"); + const report = await runCompositionGate({ + configDir, + authoredConfig: {}, + resolvedShape: {}, + resolvedRelease: { manifest: manifestWithSlots(undefined) }, + }); + expect(report.ok).toBe(true); + }); +}); + +describe("composePlatformManifestPublishEntry — release slots", () => { + function scaffoldFrameworkRoot(): string { + mkdirSync(join(tmpRoot, "packages", "everything-dev"), { recursive: true }); + mkdirSync(join(tmpRoot, "packages", "every-plugin"), { recursive: true }); + writeFileSync( + join(tmpRoot, "packages", "everything-dev", "package.json"), + `${JSON.stringify({ name: "everything-dev", version: "2.0.0-rc.24" })}\n`, + ); + writeFileSync( + join(tmpRoot, "packages", "every-plugin", "package.json"), + `${JSON.stringify({ name: "every-plugin", version: "3.0.0-rc.14" })}\n`, + ); + writeFileSync(join(tmpRoot, "package.json"), "{}\n"); + return tmpRoot; + } + + const releasePayload = { + account: "root.near", + domain: "root", + app: { + ui: { + production: "https://cdn.test/bundles/root.near/root/ui/", + pin: { manifest: "versions/ui.json", integrity: "sha384-ui" }, + }, + api: { + production: "https://cdn.test/bundles/root.near/root/api/", + pin: { manifest: "versions/api.json", integrity: "sha384-api" }, + }, + auth: { + production: authBase, + pin: { manifest: "versions/auth.json", integrity: "sha384-auth" }, + ui: { + production: authUiBase, + pin: { manifest: "versions/auth-ui.json", integrity: "sha384-auth-ui" }, + }, + }, + }, + plugins: { + votes: { + extends: "bos://root.near/root/votes", + production: "https://cdn.test/bundles/root.near/root/votes/", + }, + }, + }; + + it("records the release's pinned slots in the manifest", () => { + const entry = composePlatformManifestPublishEntry({ + configDir: scaffoldFrameworkRoot(), + account: "root.near", + gateway: "root", + publishPayload: releasePayload, + }); + expect(entry).not.toBeNull(); + const parsed = PlatformReleaseManifestSchema.parse(JSON.parse(entry!.value)); + expect(parsed.slots).toMatchObject({ + "app.auth": { + base: authBase, + pin: { manifest: "versions/auth.json", integrity: "sha384-auth" }, + }, + "app.auth.ui": { + base: authUiBase, + pin: { manifest: "versions/auth-ui.json", integrity: "sha384-auth-ui" }, + }, + "app.ui": { + base: "https://cdn.test/bundles/root.near/root/ui/", + pin: { manifest: "versions/ui.json", integrity: "sha384-ui" }, + }, + "app.api": { base: "https://cdn.test/bundles/root.near/root/api/" }, + }); + expect(parsed.slots?.["plugins.votes"]).toBeUndefined(); + }); + + it("is byte-stable across compositions (slot keys sorted)", () => { + const entry = composePlatformManifestPublishEntry({ + configDir: scaffoldFrameworkRoot(), + account: "root.near", + gateway: "root", + publishPayload: releasePayload, + }); + const reversed = { + ...releasePayload, + app: { + auth: releasePayload.app.auth, + api: releasePayload.app.api, + ui: releasePayload.app.ui, + }, + }; + const again = composePlatformManifestPublishEntry({ + configDir: tmpRoot, + account: "root.near", + gateway: "root", + publishPayload: reversed, + }); + expect(entry!.value).toBe(again!.value); + }); + + it("omits slots entirely without a publish payload — old readers unaffected", () => { + const entry = composePlatformManifestPublishEntry({ + configDir: scaffoldFrameworkRoot(), + account: "root.near", + gateway: "root", + }); + const parsed = PlatformReleaseManifestSchema.parse(JSON.parse(entry!.value)); + expect(parsed.slots).toBeUndefined(); + }); +}); From df808d15864b71eec312fb4222db841b41d8345c Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Wed, 28 Oct 2026 20:59:45 -0500 Subject: [PATCH 2/2] refactor(everything-dev): composition-plane review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runPublishTrain skips the composition gate for exactPayload rollbacks — the recovery path must never be blockable by the parent's moved-on pins (boot-check + pin verification already gate rollback bytes); a missing boot shape fails loud instead of silently gating a stale fallback. - boot-check splits verifyBootPayload into resolveBootShape (walk) + verifyResolvedBootShape (pure verify); the train shares one walk with the gate, the untyped preResolved param and its unreachable payload fallback are gone, and the pre-rewrite non-object payload message is restored. - new services/config-slots.ts — the one app/plugins slot walker (canonical dotted paths; ui:false and bare-ref visits included) plus ensureObjectContainers; collectPinnedSlots, collectReleaseSlotPins, authoredSlotPaths, and slotPins/pointerFingerprint all derive from it, ending the four silently-divergent traversal copies (rollback's hardcoded app keys vs iterate-all). Rollback slot names canonicalize to the shared dotted paths ("auth" → "plugins.auth"). - the composition gate reports unresolvable merged-shape UI pins outside the pin-verification-verified set as violations instead of skipping silently — inherited pins were not covered by that leg. - resolveReleaseManifestForDeploy returns the manifest directly (the single-field wrapper is gone); shared tests/helpers/version-manifests.ts replaces the duplicated manifest-fixture fetchers; unmarked comments in the touched hunks are deleted or KEEP-marked. --- .../src/services/config-slots.ts | 82 +++++++++++ .../src/services/deploy/composition.ts | 138 +++++++++--------- .../src/services/deploy/platform-manifest.ts | 34 +---- .../src/services/deploy/rollback.ts | 24 +-- .../src/services/deploy/train.ts | 112 +++++++------- .../src/services/registry/fingerprint.ts | 41 ++---- .../src/services/resolution/boot-check.ts | 88 ++++++----- .../tests/helpers/version-manifests.ts | 35 +++++ .../integration/platform-upgrade-e2e.test.ts | 26 +--- .../tests/unit/composition.test.ts | 113 +++++++------- .../tests/unit/rollback-verify.test.ts | 2 +- 11 files changed, 384 insertions(+), 311 deletions(-) create mode 100644 packages/everything-dev/src/services/config-slots.ts create mode 100644 packages/everything-dev/tests/helpers/version-manifests.ts diff --git a/packages/everything-dev/src/services/config-slots.ts b/packages/everything-dev/src/services/config-slots.ts new file mode 100644 index 000000000..8dcf7046e --- /dev/null +++ b/packages/everything-dev/src/services/config-slots.ts @@ -0,0 +1,82 @@ +import { isPlainObject } from "./resolution/merge"; + +/** + * The one app/plugins config-slot walker — every consumer that names config + * slots (pin verification, release-slot records, deploy-state keys, slot + * stamps, authorship checks, pointer fingerprints) derives its traversal from + * here so the dotted-slot-path domain cannot fork per module into silently + * divergent copies (coding-standards: a hand-maintained constant paralleling + * what the config schema knows is a driftable copy). + * + * Canonical dotted paths: `app.`, `app..ui`, `plugins.`, + * `plugins..ui`. A `ui: false` opt-out is visited (the entry owns the + * no-surface decision); a bare-string plugin ref is visited with + * `isPluginRef` true (it owns the plugin and its whole ui surface). + */ + +export interface ConfigSlot { + slot: string; + entry: Record | string | false; + readonly isPluginRef: boolean; +} + +export function walkConfigSlots( + config: Record, + visit: (slot: ConfigSlot) => void, +): void { + const app = config.app; + if (isPlainObject(app)) { + for (const [key, entry] of Object.entries(app)) { + if (!isPlainObject(entry)) continue; + visit({ slot: `app.${key}`, entry, isPluginRef: false }); + if ("ui" in entry) { + const ui = (entry as Record).ui; + if (isPlainObject(ui) || ui === false) { + visit({ slot: `app.${key}.ui`, entry: ui, isPluginRef: false }); + } + } + } + } + const plugins = config.plugins; + if (isPlainObject(plugins)) { + for (const [id, entry] of Object.entries(plugins)) { + if (typeof entry === "string") { + if (entry.length > 0) { + visit({ slot: `plugins.${id}`, entry, isPluginRef: true }); + } + continue; + } + if (!isPlainObject(entry)) continue; + visit({ slot: `plugins.${id}`, entry, isPluginRef: false }); + if ("ui" in entry) { + const ui = entry.ui; + if (isPlainObject(ui) || ui === false) { + visit({ slot: `plugins.${id}.ui`, entry: ui, isPluginRef: false }); + } + } + } + } +} + +/** + * Ensure every container of a dotted path exists (creating plain objects along + * the way) and return the container that holds the final segment. Consumers + * keep the final-set semantics (replace vs merge-into) local. + */ +export function ensureObjectContainers( + target: Record, + segments: readonly string[], +): Record { + let container = target; + for (const segment of segments) { + const next = container[segment]; + if (!isPlainObject(next)) { + const created: Record = {}; + container[segment] = created; + container = created; + } else { + container = next; + } + } + return container; +} diff --git a/packages/everything-dev/src/services/deploy/composition.ts b/packages/everything-dev/src/services/deploy/composition.ts index c9a0ee5bd..07ccfdeea 100644 --- a/packages/everything-dev/src/services/deploy/composition.ts +++ b/packages/everything-dev/src/services/deploy/composition.ts @@ -1,4 +1,5 @@ import type { BosConfigInput } from "../../types"; +import { ensureObjectContainers, walkConfigSlots } from "../config-slots"; import { readInstalledFrameworkVersion } from "../framework-version"; import { checkPlatformCompat, @@ -29,58 +30,38 @@ import { resolveSlotVersion } from "./version-manifest"; * parent's live pointer at every consumer boot. */ -export interface ResolvedReleaseManifest { - manifest: PlatformReleaseManifest; -} - /** * Resolve the release manifest for a deploy: the authored `platform` pin via - * slot-first resolution (children), otherwise the latest pointer (root runtimes). - * Returns null when no manifest is published; null is a normal state for - * children pinned to pre-manifest releases. Resolution failures surface — the - * caller warns and continues unpinned (the composition gate still cross-checks - * the actual resolved bytes). + * slot-first resolution (children), otherwise the latest pointer (root + * runtimes). Returns null when no manifest is published — a normal state for + * children pinned to pre-manifest releases. Resolution failures surface; the + * caller warns and continues unpinned (the composition gate still + * cross-checks the actual resolved bytes). */ export async function resolveReleaseManifestForDeploy(input: { configDir: string; authoredConfig: Record; /** test/registry seam — threaded to the platform-manifest resolution */ resolveOptions?: Partial; -}): Promise { +}): Promise { const resolved = await resolvePlatformManifest({ configDir: input.configDir, localConfig: input.authoredConfig, ...input.resolveOptions, }); - return resolved ? { manifest: resolved.manifest } : null; + return resolved?.manifest ?? null; } -/** Authored slot paths: every remote slot the leaf itself declares (extracted or null). */ +/** Authored slot paths: every slot the leaf itself declares. */ export function authoredSlotPaths(config: Record): Set { const paths = new Set(); - const app = config.app; - if (isPlainObject(app)) { - for (const [key, entry] of Object.entries(app)) { - if (isPlainObject(entry)) { - paths.add(`app.${key}`); - if ("ui" in (entry as object)) paths.add(`app.${key}.ui`); - } - } - } - const plugins = config.plugins; - if (isPlainObject(plugins)) { - for (const [id, entry] of Object.entries(plugins)) { - if (typeof entry === "string" && entry.length > 0) { - paths.add(`plugins.${id}`); - paths.add(`plugins.${id}.ui`); - continue; - } - if (isPlainObject(entry)) { - paths.add(`plugins.${id}`); - if ("ui" in (entry as object)) paths.add(`plugins.${id}.ui`); - } - } - } + walkConfigSlots(config, ({ slot, isPluginRef }) => { + paths.add(slot); + // KEEP: a bare-string plugin ref (`extends: "bos://…"`) owns its whole + // surface — composed resolution derives its ui from the extended config, + // so the ownership set must include it independently of the `ui` key. + if (isPluginRef) paths.add(`${slot}.ui`); + }); return paths; } @@ -90,12 +71,12 @@ export interface StampOutcome { } /** - * Layer 2 — stamp inherited slots: for every slot in the release manifest's - * `slots` map that the authored config does not declare, write the release's - * recorded production URL + pin into the payload. Child-wins deep merge then - * shadows the parent's live config pointer at every consumer boot, so the - * child composes exactly the bytes its pinned release published. Slots the - * child authors stay untouched, and a manifest without `slots` (pre-amendment + * Stamp inherited slots: for every slot in the release manifest's `slots` map + * that the authored config does not declare, write the release's recorded + * production URL + pin into the payload. Child-wins deep merge then shadows + * the parent's live config pointer at every consumer boot, so the child + * composes exactly the bytes its pinned release published. Slots the child + * authors stay untouched, and a manifest without `slots` (pre-amendment * releases) stamps nothing — the composition gate covers those children. */ export function stampInheritedSlotsFromRelease(input: { @@ -115,7 +96,8 @@ export function stampInheritedSlotsFromRelease(input: { return { stamped }; } -/** Write `{ production, pin }` for one dotted slot path, creating containers. */ +/** Write `{ production, pin }` for one dotted slot path (2–3 segments), + * merging into an existing entry rather than replacing it. */ function stampSlotRecord( payload: Record, slotPath: string, @@ -123,18 +105,9 @@ function stampSlotRecord( ): void { const segments = slotPath.split("."); if ((segments.length !== 2 && segments.length !== 3) || !segments[1]) return; - let container: Record = payload; - for (const segment of segments.slice(0, -1)) { - const next = container[segment]; - if (!isPlainObject(next)) { - const created: Record = {}; - container[segment] = created; - container = created; - } else { - container = next; - } - } const leaf = segments[segments.length - 1]; + const container = ensureObjectContainers(payload, segments.slice(0, -1)); + if (!leaf) return; const existing = container[leaf]; const entry: Record = isPlainObject(existing) ? { ...existing } : {}; entry.production = record.base; @@ -147,38 +120,53 @@ export interface CompositionGateReport { violations: string[]; } -/** - * Layer 1 — the deploy-train composition gate: cross-compare the shared-single - * versions of every pinned UI slot in the extends-merged shape (the deploy's - * mirror of the host's boot guard, #546), plus — for authored platform pins — - * the ADR 0034 compat check of the child against its own release. Runs after - * pin verification, before publish. - */ -export async function runCompositionGate(input: { +export interface CompositionGateInput { configDir: string; authoredConfig: Record; resolvedShape: Record; - resolvedRelease: ResolvedReleaseManifest | null; + resolvedRelease: PlatformReleaseManifest | null; + /** + * The slot names the leaf-payload pin verification leg proved servable; + * an unresolvable merged-shape pin outside this set is reported as a + * violation instead of skipped-silently (inherited pins are not covered + * by that leg). + */ + verifiedSlots?: ReadonlySet; /** test seam — threaded to the version-manifest fetch */ fetchImpl?: typeof fetch; -}): Promise { +} + +/** + * The deploy-train composition gate: cross-compare the shared-singleton + * versions of every pinned UI slot in the extends-merged shape (the boot + * guard's mirror, #546), plus — for authored platform pins — the ADR 0034 + * compat check of the child against its own release. Runs after pin + * verification, before publish. + */ +export async function runCompositionGate( + input: CompositionGateInput, +): Promise { const violations: string[] = [ - ...(await compositionSkewViolations(input.resolvedShape, input.fetchImpl)), + ...(await compositionSkewViolations(input.resolvedShape, { + ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), + ...(input.verifiedSlots ? { verifiedSlots: input.verifiedSlots } : {}), + })), ]; if (typeof input.authoredConfig.platform === "string" && input.resolvedRelease) { - violations.push(...platformCompatViolations(input.resolvedRelease.manifest, input.configDir)); + violations.push(...platformCompatViolations(input.resolvedRelease, input.configDir)); } return { ok: violations.length === 0, violations }; } /** - * Cross-compare each pinned UI slot's embedded shared-singleton versions. - * Slots whose pin does not resolve are skipped — the pin/SRI verification - * already refused broken pins this train. + * Cross-compare each pinned UI slot's embedded shared-singleton versions. An + * unresolvable pin the pin/SRI verification leg already proved servable is + * skipped (transient fetch failure); any unresolvable pin outside that set is + * reported — the boot's version-manifest derivation would refuse it anyway. */ export async function compositionSkewViolations( merged: Record, - fetchImpl?: typeof fetch, + opts: { fetchImpl?: typeof fetch; verifiedSlots?: ReadonlySet } = {}, ): Promise { const uiSlots = collectSlotPinRefs(merged as BosConfigInput).filter( (ref) => ref.slot === "app.ui" || ref.slot.endsWith(".ui"), @@ -194,16 +182,20 @@ export async function compositionSkewViolations( const resolved = await resolveSlotVersion({ base, pin, - ...(fetchImpl ? { fetchImpl } : {}), + ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}), }); for (const [name, version] of Object.entries(resolved.sharedVersions ?? {})) { const uses = versionsByName.get(name) ?? []; uses.push({ slot, version }); versionsByName.set(name, uses); } - } catch { - // a pin that cannot resolve is either already rejected by the pin - // verification leg or not a UI-skew concern — skip + } catch (error) { + if (opts.verifiedSlots?.has(slot)) return; + const reason = error instanceof Error ? error.message : String(error); + violations.push( + `Pinned UI slot "${slot}" version manifest does not serve at its production URL (${reason}) — ` + + `a consumer boot cannot derive its shared singletons; investigate the storage origin or redeploy the bundle.`, + ); } }), ); diff --git a/packages/everything-dev/src/services/deploy/platform-manifest.ts b/packages/everything-dev/src/services/deploy/platform-manifest.ts index e66430593..c9b75cd63 100644 --- a/packages/everything-dev/src/services/deploy/platform-manifest.ts +++ b/packages/everything-dev/src/services/deploy/platform-manifest.ts @@ -7,6 +7,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { satisfiesConstraint } from "../../utils/semver"; +import { walkConfigSlots } from "../config-slots"; import { composePlatformReleaseManifest, computeTemplateInventory, @@ -32,7 +33,7 @@ export interface ReleaseSlotRef extends ReleaseSlotRecord { /** * The release's pinned slots: every config slot (dotted config path, keyed by - * the same shape the release-slot stamping writes back — `app.auth`, + * the same path shape the release-slot stamping writes back — `app.auth`, * `app.auth.ui`, `plugins.votes.ui`, …) with a production URL and an * immutable version-manifest pin in the release's publish payload. The * release leg records them in the release manifest so pinned children can @@ -40,37 +41,16 @@ export interface ReleaseSlotRef extends ReleaseSlotRecord { */ export function collectReleaseSlotPins(config: Record): ReleaseSlotRef[] { const slots: ReleaseSlotRef[] = []; - - const push = (slot: string, entry: unknown): void => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return; - const ref = entry as Record; - const base = typeof ref.production === "string" ? ref.production : undefined; - const pin = ref.pin as { manifest?: unknown; integrity?: unknown } | undefined; + walkConfigSlots(config, ({ slot, entry }) => { + if (typeof entry !== "object" || entry === null) return; + const base = typeof entry.production === "string" ? entry.production : undefined; + const pin = entry.pin as { manifest?: unknown; integrity?: unknown } | undefined; const manifest = typeof pin?.manifest === "string" ? pin.manifest : undefined; const integrity = typeof pin?.integrity === "string" ? pin.integrity : undefined; if (base && manifest && integrity) { slots.push({ slot, base, pin: { manifest, integrity } }); } - }; - - const app = config.app as Record> | undefined; - if (app && typeof app === "object") { - for (const [key, entry] of Object.entries(app)) { - push(`app.${key}`, entry); - const ui = (entry as { ui?: unknown } | undefined)?.ui; - if (ui) push(`app.${key}.ui`, ui); - } - } - const plugins = config.plugins as Record | undefined; - if (plugins && typeof plugins === "object") { - for (const [key, entry] of Object.entries(plugins)) { - push(`plugins.${key}`, entry); - if (typeof entry === "object" && entry !== null) { - const ui = (entry as { ui?: unknown }).ui; - if (ui) push(`plugins.${key}.ui`, ui); - } - } - } + }); return slots; } diff --git a/packages/everything-dev/src/services/deploy/rollback.ts b/packages/everything-dev/src/services/deploy/rollback.ts index f3111219c..57ecd224c 100644 --- a/packages/everything-dev/src/services/deploy/rollback.ts +++ b/packages/everything-dev/src/services/deploy/rollback.ts @@ -1,4 +1,5 @@ import type { BosConfigInput } from "../../types"; +import { walkConfigSlots } from "../config-slots"; import { fetchVerifiedManifest } from "./manifest-verify"; /** @@ -32,26 +33,9 @@ interface PinnedSlotRef { function collectPinnedSlots(config: BosConfigInput): Array<{ slot: string; ref: PinnedSlotRef }> { const slots: Array<{ slot: string; ref: PinnedSlotRef }> = []; - const app = config.app as Record | undefined; - const plugins = config.plugins as Record | undefined; - - const push = (slot: string, ref: unknown): void => { - if (ref && typeof ref === "object") slots.push({ slot, ref: ref as PinnedSlotRef }); - }; - - if (app) { - for (const key of ["host", "ui", "api", "auth"] as const) push(`app.${key}`, app[key]); - const auth = app.auth as { ui?: unknown } | undefined; - if (auth?.ui) push("app.auth.ui", auth.ui); - } - if (plugins) { - for (const [key, plugin] of Object.entries(plugins)) { - if (typeof plugin !== "object" || plugin === null) continue; - push(key, plugin); - const ui = (plugin as { ui?: unknown }).ui; - if (ui) push(`${key}.ui`, ui); - } - } + walkConfigSlots(config as unknown as Record, ({ slot, entry }) => { + if (entry && typeof entry === "object") slots.push({ slot, ref: entry as PinnedSlotRef }); + }); return slots; } diff --git a/packages/everything-dev/src/services/deploy/train.ts b/packages/everything-dev/src/services/deploy/train.ts index 3eb487b55..d5f17c559 100644 --- a/packages/everything-dev/src/services/deploy/train.ts +++ b/packages/everything-dev/src/services/deploy/train.ts @@ -9,6 +9,7 @@ import { type ResolvedBootShape, resolveBootShape, verifyBootPayload, + verifyResolvedBootShape, } from "../../services/resolution/boot-check"; import { openResolution } from "../../services/resolution/session"; import type { BosConfig, BosConfigInput, RuntimeConfig } from "../../types"; @@ -20,6 +21,7 @@ import { ensureDelegateKey, submitRegistryWriteDelegated } from "../auth/delegat import { submitRegistryWrite } from "../auth/near-signer"; import { dispatchSubmit, SignerService, type SignerServiceShape } from "../auth/signer"; import { generateCodeArtifacts } from "../codegen/artifacts"; +import type { PlatformReleaseManifest } from "../platform/manifest"; import { fetchBosConfigFromFastKv, fetchConfigHistory, fetchRegistryKey } from "../registry/fastkv"; import { pointerFingerprint, slotPins } from "../registry/fingerprint"; import { mergeAuthoredOverPublished, stripLocalDevelopmentTargets } from "../resolution/merge"; @@ -32,7 +34,6 @@ import { resolveWorkspaceTarget, } from "../workspace/build-train"; import { - type ResolvedReleaseManifest, resolveReleaseManifestForDeploy, runCompositionGate, stampInheritedSlotsFromRelease, @@ -756,13 +757,13 @@ export async function runPublishTrain( publishPayload = stripLocalDevelopmentTargets(publishPayload); } - // Release-scoped extends inheritance (ADR 0034 amendment): every remote - // slot the authored config does not declare is a base-runtime slot, and its - // bytes re-point at the authored platform pin's release — the parent's - // live config pointer can move (a root rc.N+1 publish) without skewing a - // pinned child's composition. Resolution failures warn and continue - // unpinned; the composition gate below still cross-checks the bytes. - let resolvedRelease: ResolvedReleaseManifest | null = null; + // KEEP: release-scoped extends inheritance (ADR 0034 amendment) — every + // remote slot the authored config does not declare is a base-runtime slot + // whose bytes re-point at the authored platform pin's release, so the + // parent's live config pointer can move (a root rc.N+1 publish) without + // skewing a pinned child's composition. Resolution failures warn and + // continue unpinned; the composition gate below still cross-checks bytes. + let resolvedRelease: PlatformReleaseManifest | null = null; if (input.exactPayload !== true) { try { resolvedRelease = await resolveReleaseManifestForDeploy({ @@ -773,12 +774,12 @@ export async function runPublishTrain( const stamped = stampInheritedSlotsFromRelease({ payload: publishPayload as Record, authoredConfig: publishRawConfig as Record, - manifest: resolvedRelease.manifest, + manifest: resolvedRelease, }); if (stamped.stamped.length > 0) { console.log( colors.dim( - ` Release-scoped slots (${resolvedRelease.manifest.frameworkVersion}): stamped ${stamped.stamped.join(", ")}`, + ` Release-scoped slots (${resolvedRelease.frameworkVersion}): stamped ${stamped.stamped.join(", ")}`, ), ); } @@ -792,13 +793,11 @@ export async function runPublishTrain( } } - // Every runtime boot validates the extends-merged config with - // BosConfigSchema (resolvePublishedConfig) — a leaf payload is only the - // boot input when it carries no extends ref. The check re-runs the boot's - // own walk-merge-parse with this payload as the leaf, catching - // canonicalization/schema drift AND extends-chain breakage at preflight - // instead of failing the post-publish smoke and rolling back. - let bootShape: ResolvedBootShape | undefined; + // KEEP: this check re-runs the consumer boot's own walk-merge-parse + // (BosConfigSchema via resolvePublishedConfig) with the payload as the leaf, + // so it shares one walk with the composition gate below via + // verifyResolvedBootShape instead of re-walking. + let bootShape: ResolvedBootShape | null = null; try { bootShape = await resolveBootShape( publishPayload, @@ -808,16 +807,17 @@ export async function runPublishTrain( }, ); } catch { - // verifyBootPayload re-walks and reports the chain failure as issues + // KEEP: re-walked by verifyBootPayload, which owns the chain-failure report } - const bootCheck = await verifyBootPayload( - publishPayload, - { account, gateway }, - { - ...(input.registry ? { registry: input.registry } : {}), - }, - bootShape, - ); + const bootCheck = bootShape + ? verifyResolvedBootShape(publishPayload, bootShape) + : await verifyBootPayload( + publishPayload, + { account, gateway }, + { + ...(input.registry ? { registry: input.registry } : {}), + }, + ); if (!bootCheck.ok) { const detail = bootCheck.issues .slice(0, 5) @@ -854,31 +854,43 @@ export async function runPublishTrain( }); } - // Layer 1 — the composition gate (deploy-time mirror of the host's boot - // guard, #546): the extends-merged shape must cross-agree on shared - // singletons before anything publishes; a pinned child additionally checks - // its platform compat against its own release. Skew here is a red CI with - // the remediation named — never a degraded production boot. - const compositionGate = await runCompositionGate({ - configDir, - authoredConfig: publishRawConfig as Record, - resolvedShape: (bootShape ? bootShape.config : { ...publishPayload }) as Record< - string, - unknown - >, - resolvedRelease, - }); - if (!compositionGate.ok) { - throw new PublishTrainError({ - kind: "composition", - registryUrl, - built, - skipped, - deployResults, - error: - `Publish blocked — a consumer boot would refuse this composition:\n` + - compositionGate.violations.map((line) => ` ${line}`).join("\n"), + if (input.exactPayload !== true) { + // KEEP: a rollback republishes a snapshot that was a valid deploy — the + // gate must never block the recovery path (its snapshot's inherited + // slots merge over the parent's moved-on pins; boot-check + pin + // verification above already gate the bytes). + if (!bootShape) { + throw new PublishTrainError({ + kind: "composition", + registryUrl, + built, + skipped, + deployResults, + error: "the extends walk failed without a boot-check rejection", + }); + } + const verifiedSlots = pinVerification.verifiable + ? new Set(pinVerification.slots.filter((check) => check.ok).map((check) => check.slot)) + : undefined; + const compositionGate = await runCompositionGate({ + configDir, + authoredConfig: publishRawConfig as Record, + resolvedShape: bootShape.config as Record, + resolvedRelease, + ...(verifiedSlots ? { verifiedSlots } : {}), }); + if (!compositionGate.ok) { + throw new PublishTrainError({ + kind: "composition", + registryUrl, + built, + skipped, + deployResults, + error: + `Publish blocked — a consumer boot would refuse this composition:\n` + + compositionGate.violations.map((line) => ` ${line}`).join("\n"), + }); + } } const registryKey = `apps/${account}/${gateway}/bos.config.json`; diff --git a/packages/everything-dev/src/services/registry/fingerprint.ts b/packages/everything-dev/src/services/registry/fingerprint.ts index b09739a99..3cf8b4f32 100644 --- a/packages/everything-dev/src/services/registry/fingerprint.ts +++ b/packages/everything-dev/src/services/registry/fingerprint.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import type { BosConfig } from "../../types"; +import { walkConfigSlots } from "../config-slots"; /** * The published pointer's version identity (atomic-deploys 08/12): a hash @@ -9,7 +10,12 @@ import type { BosConfig } from "../../types"; */ export function pointerFingerprint(config: BosConfig): string { const parts: Array = []; - const slot = (prefix: string, s: { pin?: unknown; integrity?: unknown } | undefined) => { + walkConfigSlots(config as unknown as Record, ({ slot, entry, isPluginRef }) => { + if (isPluginRef) { + parts.push(`${slot}:${entry as string}`); + return; + } + const s = entry as { pin?: unknown; integrity?: unknown } | undefined; if (!s) return; const pin = s.pin as { manifest?: unknown; integrity?: unknown } | undefined; const pinPart = @@ -17,39 +23,20 @@ export function pointerFingerprint(config: BosConfig): string { ? `${pin.manifest}:${pin.integrity}` : ""; const directPart = typeof s.integrity === "string" ? s.integrity : ""; - parts.push(`${prefix}:${pinPart}:${directPart}`); - }; - for (const [key, entry] of Object.entries(config.app ?? {})) { - slot(`app.${key}`, entry); - slot(`app.${key}.ui`, (entry as { ui?: { pin?: unknown; integrity?: unknown } }).ui); - } - for (const [key, entry] of Object.entries(config.plugins ?? {})) { - if (typeof entry === "string") { - parts.push(`plugins.${key}:${entry}`); - continue; - } - slot(`plugins.${key}`, entry); - slot(`plugins.${key}.ui`, (entry as { ui?: { pin?: unknown; integrity?: unknown } }).ui); - } + parts.push(`${slot}:${pinPart}:${directPart}`); + }); return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16); } /** Per-slot pin ids for display (slot → versioned manifest filename). */ export function slotPins(config: BosConfig): Record { const pins: Record = {}; - const slot = (prefix: string, s: { pin?: unknown } | undefined) => { + walkConfigSlots(config as unknown as Record, ({ slot, entry, isPluginRef }) => { + if (isPluginRef) return; + const s = entry as { pin?: unknown } | undefined; if (!s) return; const manifest = (s.pin as { manifest?: unknown } | undefined)?.manifest; - if (typeof manifest === "string") pins[prefix] = manifest; - }; - for (const [key, entry] of Object.entries(config.app ?? {})) { - slot(`app.${key}`, entry); - slot(`app.${key}.ui`, (entry as { ui?: { pin?: unknown } }).ui); - } - for (const [key, entry] of Object.entries(config.plugins ?? {})) { - if (typeof entry === "string") continue; - slot(`plugins.${key}`, entry); - slot(`plugins.${key}.ui`, (entry as { ui?: { pin?: unknown } }).ui); - } + if (typeof manifest === "string") pins[slot] = manifest; + }); return pins; } diff --git a/packages/everything-dev/src/services/resolution/boot-check.ts b/packages/everything-dev/src/services/resolution/boot-check.ts index aa0f16805..b85fcf441 100644 --- a/packages/everything-dev/src/services/resolution/boot-check.ts +++ b/packages/everything-dev/src/services/resolution/boot-check.ts @@ -88,6 +88,12 @@ export interface ResolvedBootShape { config: BosConfigInput; } +function extendsRefOf(payload: unknown): string | undefined { + return payload !== null && typeof payload === "object" && (payload as BosConfigInput).extends + ? resolveExtendsRef((payload as BosConfigInput).extends, PUBLISHED_CONFIG_ENV) + : undefined; +} + /** * The boot walk itself, exposed so consumers beside `verifyBootPayload` can * gate on the merged shape (the deploy train's composition gate) instead of @@ -103,10 +109,7 @@ export async function resolveBootShape( throw new Error("payload is not a config object"); } const input = payload as BosConfigInput; - const extendsRef = input.extends - ? resolveExtendsRef(input.extends, PUBLISHED_CONFIG_ENV) - : undefined; - if (!extendsRef) { + if (!extendsRefOf(input)) { return { chain: [], config: structuredClone(input) }; } const walked = await walkExtendsChain(`bos://${slot.account}/${slot.gateway}`, { @@ -119,47 +122,56 @@ export async function resolveBootShape( return { chain: walked.chain.slice(1), config: structuredClone(walked.config) }; } +/** Verify a payload against a shape the caller already walked with + * `resolveBootShape` for this exact payload — the pure half of + * `verifyBootPayload`, so a shared walk cannot drift from its verification. */ +export function verifyResolvedBootShape( + payload: unknown, + shape: ResolvedBootShape, +): BootCheckReport { + const extendsRef = extendsRefOf(payload); + if (!extendsRef) { + return { chain: [], ...parseShape(payload as object, payload, false) }; + } + return { chain: shape.chain, ...parseShape(payload as object, shape.config, true) }; +} + export async function verifyBootPayload( payload: unknown, slot: { account: string; gateway: string }, opts: BootCheckOptions = {}, - /** The caller may hand over a shape it already walked (the deploy train's - * composition gate shares one walk with this check) — it must be the - * result of `resolveBootShape` for this exact payload. */ - preResolved?: ResolvedBootShape, ): Promise { - const extendsRef = - payload !== null && typeof payload === "object" && (payload as BosConfigInput).extends - ? resolveExtendsRef((payload as BosConfigInput).extends, PUBLISHED_CONFIG_ENV) - : undefined; - - if (!extendsRef) { - return { chain: [], ...parseShape(payload as object, payload, false) }; + if (payload === null || typeof payload !== "object") { + return { + ok: false, + issues: [ + { + code: "schema", + path: "", + origin: "payload", + message: "payload is not a config object", + }, + ], + chain: [], + }; } - - let shape: ResolvedBootShape; - if (preResolved) { - shape = preResolved; - } else { - try { - shape = await resolveBootShape(payload, slot, opts); - } catch (error) { - if (!(error instanceof Error)) throw error; - return { - ok: false, - chain: [], - issues: [ - { - code: "chain", - path: extendsRef, - origin: "parent", - message: chainFailureMessage(error), - }, - ], - }; - } + try { + return verifyResolvedBootShape(payload, await resolveBootShape(payload, slot, opts)); + } catch (error) { + if (!(error instanceof Error)) throw error; + return { + ok: false, + chain: [], + issues: [ + { + code: "chain", + path: extendsRefOf(payload) ?? "", + origin: "parent", + message: chainFailureMessage(error), + }, + ], + }; } - return { chain: shape.chain, ...parseShape(payload as object, shape.config, true) }; } /** Parse a shape with BosConfigSchema and map the outcome onto a report — diff --git a/packages/everything-dev/tests/helpers/version-manifests.ts b/packages/everything-dev/tests/helpers/version-manifests.ts new file mode 100644 index 000000000..111d48a1f --- /dev/null +++ b/packages/everything-dev/tests/helpers/version-manifests.ts @@ -0,0 +1,35 @@ +import { computeManifestSri } from "../../src/services/deploy/manifest-verify"; + +/** + * Shared Module-Federation version-manifest fixtures for the deploy-train + * composition tests: content-shaped manifest text, pins whose SRI matches + * the served bytes, and a fetch seam serving a route map without any socket. + */ + +export function versionManifestText(shared: Record): string { + return JSON.stringify({ + version: "0123456789abcdef", + entry: "remoteEntry.js", + entryIntegrity: "sha384-AAAAAA==", + shared, + }); +} + +/** A pin pointing `file` (relative to a slot base) at these exact bytes. */ +export function pinFor( + file: string, + shared: Record, +): { manifest: string; integrity: string } { + return { manifest: file, integrity: computeManifestSri(versionManifestText(shared)) }; +} + +/** A `fetch` serving JSON documents from an in-memory route map — 404s + * otherwise. Route keys are full URLs including any slot base prefix. */ +export const versionManifestFetcher: (routes: Record) => typeof fetch = (routes) => + (async (input: Parameters[0]) => { + const text = routes[String(input)]; + if (!text) { + return { ok: false, status: 404, text: async () => "" } as unknown as Response; + } + return { ok: true, status: 200, text: async () => text } as unknown as Response; + }) as typeof fetch; diff --git a/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts b/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts index 259361a1d..55a226e72 100644 --- a/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts +++ b/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts @@ -45,6 +45,7 @@ import { type ExtendedRootSourceOptions, readExtendedRootSource, } from "../../src/services/scaffold/upgrade-source"; +import { versionManifestFetcher, versionManifestText } from "../helpers/version-manifests"; const ROOT_ACCOUNT = "dev.everything.near"; const ROOT_GATEWAY = "everything.dev"; @@ -499,14 +500,6 @@ describe("release-scoped inheritance — a pinned child deploy survives the root let rootDir: string; let childDir: string; - const versionManifestText = (shared: Record): string => - JSON.stringify({ - version: "0123456789abcdef", - entry: "remoteEntry.js", - entryIntegrity: "sha384-AAAAAA", - shared, - }); - const pinFor = (slotBase: string, file: string, shared: Record) => { const text = versionManifestText(shared); routes[`${base}${slotBase}${file}`] = text; @@ -589,11 +582,11 @@ describe("release-scoped inheritance — a pinned child deploy survives the root const app = payload.app as Record>; app.ui.production = `${base}/child-ui/`; app.ui.pin = ownUiPin; - if (stamps && resolvedRelease?.manifest.slots) { + if (stamps && resolvedRelease?.slots) { stampInheritedSlotsFromRelease({ payload, authoredConfig, - manifest: resolvedRelease.manifest, + manifest: resolvedRelease, }); } const bootShape = await resolveBootShape(payload, CHILD_SLOT, { io: registryIo }); @@ -602,13 +595,7 @@ describe("release-scoped inheritance — a pinned child deploy survives the root authoredConfig, resolvedShape: bootShape.config as Record, resolvedRelease, - fetchImpl: (async (input: Parameters[0]) => { - const text = routes[String(input)]; - if (!text) { - return { ok: false, status: 404, text: async () => "" } as unknown as Response; - } - return { ok: true, status: 200, text: async () => text } as unknown as Response; - }) as typeof fetch, + fetchImpl: versionManifestFetcher(routes), }); return { ok: gate.ok, @@ -651,7 +638,6 @@ describe("release-scoped inheritance — a pinned child deploy survives the root ); publishRootRelease("2.0.0-rc.1", undefined, authRc1Shared); - // the parent moves on: rc.2's live config serves react 19.3.0 auth bundles publishRootRelease("2.0.0-rc.2", "2.0.0-rc.1", authRc2Shared); }); @@ -680,8 +666,6 @@ describe("release-scoped inheritance — a pinned child deploy survives the root ); expect(violations).toEqual([]); - // the merged shape re-pointed the inherited auth slots at the rc.1 - // release — NOT the parent's live rc.2 pins const app = merged.app as Record>>; expect(app.auth.pin.manifest).toBe("versions/auth-2.0.0-rc.1.json"); expect(app.auth.ui.pin.manifest).toBe("versions/auth-ui-2.0.0-rc.1.json"); @@ -704,8 +688,6 @@ describe("release-scoped inheritance — a pinned child deploy survives the root }, false, ); - // the stamps were skipped: the merged shape keeps the live rc.2 auth - // against the child's own rc.1 ui — the incident expect(ok).toBe(false); const combined = violations.join("\n"); expect(combined).toContain("Shared singleton"); diff --git a/packages/everything-dev/tests/unit/composition.test.ts b/packages/everything-dev/tests/unit/composition.test.ts index 9e81e9f72..e9babb9c4 100644 --- a/packages/everything-dev/tests/unit/composition.test.ts +++ b/packages/everything-dev/tests/unit/composition.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { walkConfigSlots } from "../../src/services/config-slots"; import { authoredSlotPaths, compositionSkewViolations, @@ -15,6 +16,7 @@ import { type PlatformReleaseManifest, PlatformReleaseManifestSchema, } from "../../src/services/platform/manifest"; +import { versionManifestFetcher, versionManifestText } from "../helpers/version-manifests"; /** * The composition plane (ADR 0034 amendment — release-scoped extends @@ -26,25 +28,6 @@ import { const authBase = "https://cdn.test/bundles/root.near/root/auth/"; const authUiBase = "https://cdn.test/bundles/root.near/root/auth-ui/"; -function uiVersionManifest(shared: Record): string { - return JSON.stringify({ - version: "fedcba0987654321", - entry: "remoteEntry.js", - entryIntegrity: "sha384-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", - shared, - }); -} - -function versionManifestFetcher(routes: Record): typeof fetch { - return (async (input: Parameters[0]) => { - const text = routes[String(input)]; - if (!text) { - return { ok: false, status: 404, text: async () => "" } as unknown as Response; - } - return { ok: true, status: 200, text: async () => text } as unknown as Response; - }) as typeof fetch; -} - function manifestWithSlots(slots: unknown): PlatformReleaseManifest { return PlatformReleaseManifestSchema.parse({ schemaVersion: 1, @@ -72,6 +55,26 @@ afterEach(() => { clearSlotVersionCache(); }); +describe("walkConfigSlots", () => { + it("visits canonical dotted paths, ui:false opt-outs, and bare plugin refs", () => { + const seen: Array<{ slot: string; entry: unknown; isPluginRef: boolean }> = []; + walkConfigSlots( + { + app: { ui: { x: 1 }, auth: { ui: false }, title: "not-an-entry" }, + plugins: { votes: { y: 1 }, legacy: "bos://parent/p" }, + }, + (slot) => seen.push({ ...slot }), + ); + expect(seen).toEqual([ + { slot: "app.ui", entry: { x: 1 }, isPluginRef: false }, + { slot: "app.auth", entry: { ui: false }, isPluginRef: false }, + { slot: "app.auth.ui", entry: false, isPluginRef: false }, + { slot: "plugins.votes", entry: { y: 1 }, isPluginRef: false }, + { slot: "plugins.legacy", entry: "bos://parent/p", isPluginRef: true }, + ]); + }); +}); + describe("stampInheritedSlotsFromRelease", () => { it("stamps inherited slots (auth + auth.ui) without touching authored slots", () => { const payload: Record = { @@ -153,12 +156,12 @@ describe("stampInheritedSlotsFromRelease", () => { }); it("a ui:false opt-out owns the plugin and ui slots — no stamp resurrects the surface", () => { - const payload: Record = { + const authored = { plugins: { votes: { extends: "bos://root.near/root", ui: false } }, }; const outcome = stampInheritedSlotsFromRelease({ - payload, - authoredConfig: { plugins: { votes: { extends: "bos://root.near/root", ui: false } } }, + payload: structuredClone(authored), + authoredConfig: authored, manifest: manifestWithSlots({ "plugins.votes": { base: "https://cdn.test/votes/", @@ -174,12 +177,12 @@ describe("stampInheritedSlotsFromRelease", () => { }); it("a plugin authored without a ui key stamps its computed ui slot", () => { - const payload: Record = { + const authored = { plugins: { votes: { extends: "bos://root.near/root" } }, }; const outcome = stampInheritedSlotsFromRelease({ - payload, - authoredConfig: { plugins: { votes: { extends: "bos://root.near/root" } } }, + payload: structuredClone(authored), + authoredConfig: authored, manifest: manifestWithSlots({ "plugins.votes": { base: "https://cdn.test/votes/", @@ -199,34 +202,30 @@ describe("authoredSlotPaths", () => { it("counts app entries with their ui, and string plugin refs as fully owned", () => { const paths = authoredSlotPaths({ app: { ui: { x: 1 }, host: { ui: {} }, api: { ui: false } }, - plugins: { votes: { y: 1 }, legacy: "bos://parent/p", detached: { ui: false } }, + plugins: { votes: { y: 1 }, legacy: "bos://parent/p" }, }); expect(paths.has("app.ui")).toBe(true); expect(paths.has("app.ui.ui")).toBe(false); expect(paths.has("app.host")).toBe(true); expect(paths.has("app.api")).toBe(true); - // a ui:false opt-out is an authored decision — the slot is owned expect(paths.has("app.api.ui")).toBe(true); expect(paths.has("plugins.votes")).toBe(true); - // no ui key in the authored entry — the extends chain owns the ui slot expect(paths.has("plugins.votes.ui")).toBe(false); expect(paths.has("plugins.legacy")).toBe(true); expect(paths.has("plugins.legacy.ui")).toBe(true); - expect(paths.has("plugins.detached")).toBe(true); - expect(paths.has("plugins.detached.ui")).toBe(true); }); }); describe("compositionSkewViolations", () => { - const uiText = uiVersionManifest({ + const uiText = versionManifestText({ react: "19.2.9", "everything-dev/ui/auth": "2.0.0-rc.24", }); - const authUiSkewedText = uiVersionManifest({ + const authUiSkewedText = versionManifestText({ react: "19.2.9", "everything-dev/ui/auth": "2.0.0-rc.25", }); - const authUiAgreeText = uiVersionManifest({ + const authUiAgreeText = versionManifestText({ react: "19.2.9", "everything-dev/ui/auth": "2.0.0-rc.24", }); @@ -253,23 +252,14 @@ describe("compositionSkewViolations", () => { }; } - const verifiedAuthUiPin = { - manifest: "versions/0123456789abcdef.json", - integrity: computeManifestSri(authUiSkewedText), - }; - const brokenAuthUiPin = { - manifest: "versions/0123456789abcdef.json", - integrity: "sha384-not-the-served-bytes", - }; - it("flags the skew the incident class produces — the host boot guard's mirror", async () => { - const merged = mergedWithUiSlots(verifiedAuthUiPin.integrity); + const merged = mergedWithUiSlots(computeManifestSri(authUiSkewedText)); const fetcher = versionManifestFetcher({ "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, [`${authUiBase}versions/0123456789abcdef.json`]: authUiSkewedText, }); - const violations = await compositionSkewViolations(merged, fetcher); + const violations = await compositionSkewViolations(merged, { fetchImpl: fetcher }); expect(violations).toHaveLength(1); expect(violations[0]).toContain('Shared singleton "everything-dev/ui/auth"'); expect(violations[0]).toContain("2.0.0-rc.24"); @@ -277,14 +267,29 @@ describe("compositionSkewViolations", () => { expect(violations[0]).toContain("bos upgrade"); }); - it("an unresolvable source pin is skipped, not guessed", async () => { - const merged = mergedWithUiSlots(brokenAuthUiPin.integrity); + it("a pin the leaf verification proved servable is skipped on a transient fetch failure", async () => { + const merged = mergedWithUiSlots(computeManifestSri(authUiSkewedText)); const fetcher = versionManifestFetcher({ "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, - [`${authUiBase}versions/0123456789abcdef.json`]: authUiSkewedText, }); - expect(await compositionSkewViolations(merged, fetcher)).toEqual([]); + const violations = await compositionSkewViolations(merged, { + fetchImpl: fetcher, + verifiedSlots: new Set(["app.auth.ui"]), + }); + expect(violations).toEqual([]); + }); + + it("an unresolvable inherited pin (outside the verified set) is a violation, not a silent skip", async () => { + const merged = mergedWithUiSlots(computeManifestSri(authUiSkewedText)); + const fetcher = versionManifestFetcher({ + "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, + }); + + const violations = await compositionSkewViolations(merged, { fetchImpl: fetcher }); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('Pinned UI slot "app.auth.ui" version manifest does not serve'); + expect(violations[0]).toContain("redeploy the bundle"); }); it("silent when all sources agree", async () => { @@ -293,7 +298,7 @@ describe("compositionSkewViolations", () => { "https://cdn.test/bundles/child.near/ui/versions/fedcba0987654321.json": uiText, [`${authUiBase}versions/0123456789abcdef.json`]: authUiAgreeText, }); - expect(await compositionSkewViolations(merged, fetcher)).toEqual([]); + expect(await compositionSkewViolations(merged, { fetchImpl: fetcher })).toEqual([]); }); it("ignores non-UI slots entirely", async () => { @@ -305,7 +310,9 @@ describe("compositionSkewViolations", () => { }, }, }; - expect(await compositionSkewViolations(merged, versionManifestFetcher({}))).toEqual([]); + expect( + await compositionSkewViolations(merged, { fetchImpl: versionManifestFetcher({}) }), + ).toEqual([]); }); }); @@ -332,7 +339,7 @@ describe("runCompositionGate — compat leg", () => { configDir, authoredConfig: { platform: "2.0.0-rc.24" }, resolvedShape: {}, - resolvedRelease: { manifest: manifestWithSlots(undefined) }, + resolvedRelease: manifestWithSlots(undefined), }); expect(report.ok).toBe(true); }); @@ -343,7 +350,7 @@ describe("runCompositionGate — compat leg", () => { configDir, authoredConfig: { platform: "2.0.0-rc.24" }, resolvedShape: {}, - resolvedRelease: { manifest: manifestWithSlots(undefined) }, + resolvedRelease: manifestWithSlots(undefined), }); expect(report.ok).toBe(false); const combined = report.violations.join("\n"); @@ -357,7 +364,7 @@ describe("runCompositionGate — compat leg", () => { configDir, authoredConfig: {}, resolvedShape: {}, - resolvedRelease: { manifest: manifestWithSlots(undefined) }, + resolvedRelease: manifestWithSlots(undefined), }); expect(report.ok).toBe(true); }); diff --git a/packages/everything-dev/tests/unit/rollback-verify.test.ts b/packages/everything-dev/tests/unit/rollback-verify.test.ts index f885e8e52..478aaa928 100644 --- a/packages/everything-dev/tests/unit/rollback-verify.test.ts +++ b/packages/everything-dev/tests/unit/rollback-verify.test.ts @@ -153,7 +153,7 @@ describe("verifyRollbackSnapshot", () => { expect(report.ok).toBe(false); expect(report.slots).toHaveLength(2); - expect(report.slots.map((s) => s.slot).sort()).toEqual(["app.ui", "auth"]); + expect(report.slots.map((s) => s.slot).sort()).toEqual(["app.ui", "plugins.auth"]); }); });