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/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 new file mode 100644 index 000000000..07ccfdeea --- /dev/null +++ b/packages/everything-dev/src/services/deploy/composition.ts @@ -0,0 +1,236 @@ +import type { BosConfigInput } from "../../types"; +import { ensureObjectContainers, walkConfigSlots } from "../config-slots"; +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. + */ + +/** + * 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 — 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 ?? null; +} + +/** Authored slot paths: every slot the leaf itself declares. */ +export function authoredSlotPaths(config: Record): Set { + const paths = new Set(); + 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; +} + +/** Stamped slot paths (diagnostic), in stamp order. */ +export interface StampOutcome { + stamped: string[]; +} + +/** + * 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 (2–3 segments), + * merging into an existing entry rather than replacing it. */ +function stampSlotRecord( + payload: Record, + slotPath: string, + record: ReleaseSlotRecord, +): void { + const segments = slotPath.split("."); + if ((segments.length !== 2 && segments.length !== 3) || !segments[1]) return; + 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; + entry.pin = { manifest: record.pin.manifest, integrity: record.pin.integrity }; + container[leaf] = entry; +} + +export interface CompositionGateReport { + ok: boolean; + violations: string[]; +} + +export interface CompositionGateInput { + configDir: string; + authoredConfig: Record; + resolvedShape: Record; + 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; +} + +/** + * 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 ? { fetchImpl: input.fetchImpl } : {}), + ...(input.verifiedSlots ? { verifiedSlots: input.verifiedSlots } : {}), + })), + ]; + if (typeof input.authoredConfig.platform === "string" && input.resolvedRelease) { + violations.push(...platformCompatViolations(input.resolvedRelease, input.configDir)); + } + return { ok: violations.length === 0, violations }; +} + +/** + * 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, + opts: { fetchImpl?: typeof fetch; verifiedSlots?: ReadonlySet } = {}, +): 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, + ...(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 (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.`, + ); + } + }), + ); + + 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..c9b75cd63 100644 --- a/packages/everything-dev/src/services/deploy/platform-manifest.ts +++ b/packages/everything-dev/src/services/deploy/platform-manifest.ts @@ -7,12 +7,14 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { satisfiesConstraint } from "../../utils/semver"; +import { walkConfigSlots } from "../config-slots"; import { composePlatformReleaseManifest, computeTemplateInventory, getPlatformManifestKey, getPlatformVersionSlotKey, type PlatformReleaseManifest, + type ReleaseSlotRecord, resolvePlatformManifest, serializePlatformReleaseManifest, } from "../platform/manifest"; @@ -25,6 +27,40 @@ 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 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 + * re-point inherited slots at these bytes. + */ +export function collectReleaseSlotPins(config: Record): ReleaseSlotRef[] { + const slots: ReleaseSlotRef[] = []; + 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 } }); + } + }); + 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 +91,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 +107,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/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 9486e0f0a..d5f17c559 100644 --- a/packages/everything-dev/src/services/deploy/train.ts +++ b/packages/everything-dev/src/services/deploy/train.ts @@ -5,7 +5,12 @@ 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, + verifyResolvedBootShape, +} from "../../services/resolution/boot-check"; import { openResolution } from "../../services/resolution/session"; import type { BosConfig, BosConfigInput, RuntimeConfig } from "../../types"; import { run } from "../../utils/run"; @@ -16,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"; @@ -27,6 +33,11 @@ import { buildWorkspaceTargets, resolveWorkspaceTarget, } from "../workspace/build-train"; +import { + resolveReleaseManifestForDeploy, + runCompositionGate, + stampInheritedSlotsFromRelease, +} from "./composition"; import { BundleStorage, RegistryStore, type RegistryStoreShape } from "./deps"; import { type PreviousPin, @@ -746,19 +757,67 @@ export async function runPublishTrain( publishPayload = stripLocalDevelopmentTargets(publishPayload); } - // 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. - const bootCheck = await verifyBootPayload( - publishPayload, - { account, gateway }, - { - ...(input.registry ? { registry: input.registry } : {}), - }, - ); + // 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({ + configDir, + authoredConfig: publishRawConfig as Record, + }); + if (resolvedRelease) { + const stamped = stampInheritedSlotsFromRelease({ + payload: publishPayload as Record, + authoredConfig: publishRawConfig as Record, + manifest: resolvedRelease, + }); + if (stamped.stamped.length > 0) { + console.log( + colors.dim( + ` Release-scoped slots (${resolvedRelease.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)}`, + ), + ); + } + } + + // 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, + { account, gateway }, + { + ...(input.registry ? { registry: input.registry } : {}), + }, + ); + } catch { + // KEEP: re-walked by verifyBootPayload, which owns the chain-failure report + } + 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) @@ -795,6 +854,45 @@ export async function runPublishTrain( }); } + 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`; const publishedAt = new Date().toISOString(); // version identity printed + returned for every deploy (atomic-deploys 12) @@ -860,6 +958,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/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 a9d308bc5..b85fcf441 100644 --- a/packages/everything-dev/src/services/resolution/boot-check.ts +++ b/packages/everything-dev/src/services/resolution/boot-check.ts @@ -78,6 +78,64 @@ export interface BootCheckOptions { io?: ResolutionIo; } +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; +} + +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 + * 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 { + if (payload === null || typeof payload !== "object") { + throw new Error("payload is not a config object"); + } + const input = payload as BosConfigInput; + if (!extendsRefOf(input)) { + 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) }; +} + +/** 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 }, @@ -97,24 +155,8 @@ export async function verifyBootPayload( chain: [], }; } - const input = payload as BosConfigInput; - const extendsRef = input.extends - ? resolveExtendsRef(input.extends, PUBLISHED_CONFIG_ENV) - : undefined; - - if (!extendsRef) { - return { chain: [], ...parseShape(input, input, 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) }; + return verifyResolvedBootShape(payload, await resolveBootShape(payload, slot, opts)); } catch (error) { if (!(error instanceof Error)) throw error; return { @@ -123,7 +165,7 @@ export async function verifyBootPayload( issues: [ { code: "chain", - path: extendsRef, + path: extendsRefOf(payload) ?? "", origin: "parent", message: chainFailureMessage(error), }, 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 f4dbfa60f..55a226e72 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,11 +39,13 @@ 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, 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"; @@ -462,3 +470,229 @@ 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 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?.slots) { + stampInheritedSlotsFromRelease({ + payload, + authoredConfig, + manifest: resolvedRelease, + }); + } + const bootShape = await resolveBootShape(payload, CHILD_SLOT, { io: registryIo }); + const gate = await runCompositionGate({ + configDir: childDir, + authoredConfig, + resolvedShape: bootShape.config as Record, + resolvedRelease, + fetchImpl: versionManifestFetcher(routes), + }); + 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); + 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([]); + + 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, + ); + 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..e9babb9c4 --- /dev/null +++ b/packages/everything-dev/tests/unit/composition.test.ts @@ -0,0 +1,478 @@ +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, + 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"; +import { versionManifestFetcher, versionManifestText } from "../helpers/version-manifests"; + +/** + * 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 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("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 = { + 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 authored = { + plugins: { votes: { extends: "bos://root.near/root", ui: false } }, + }; + const outcome = stampInheritedSlotsFromRelease({ + payload: structuredClone(authored), + authoredConfig: authored, + 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 authored = { + plugins: { votes: { extends: "bos://root.near/root" } }, + }; + const outcome = stampInheritedSlotsFromRelease({ + payload: structuredClone(authored), + authoredConfig: authored, + 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" }, + }); + 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); + expect(paths.has("app.api.ui")).toBe(true); + expect(paths.has("plugins.votes")).toBe(true); + expect(paths.has("plugins.votes.ui")).toBe(false); + expect(paths.has("plugins.legacy")).toBe(true); + expect(paths.has("plugins.legacy.ui")).toBe(true); + }); +}); + +describe("compositionSkewViolations", () => { + const uiText = versionManifestText({ + react: "19.2.9", + "everything-dev/ui/auth": "2.0.0-rc.24", + }); + const authUiSkewedText = versionManifestText({ + react: "19.2.9", + "everything-dev/ui/auth": "2.0.0-rc.25", + }); + const authUiAgreeText = versionManifestText({ + 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 }, + }, + }, + }, + }; + } + + it("flags the skew the incident class produces — the host boot guard's mirror", 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, + }); + + 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"); + expect(violations[0]).toContain("2.0.0-rc.25"); + expect(violations[0]).toContain("bos upgrade"); + }); + + 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, + }); + + 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 () => { + 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, { fetchImpl: 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, { fetchImpl: 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: 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: 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: 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(); + }); +}); 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"]); }); });