diff --git a/.changeset/app-only-slot-stamping.md b/.changeset/app-only-slot-stamping.md new file mode 100644 index 00000000..c0758104 --- /dev/null +++ b/.changeset/app-only-slot-stamping.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Hotfix for the rc.26 slot-stamping incident: the deploy train now stamps only `app.*` slots (the base-runtime slots children never author — auth, host) from the release manifest's slots map. Plugin slots were being stamped into the child's leaf payload, which both expanded a declared-plugin child's composed plugin set to the whole parent set (violating the extends contract — a child's declared `plugins` object owns the entire plugin domain via a wholesale replace) and produced schema-invalid `plugins..ui` records (stamped ui entries carry no required `name`, and the wholesale replace never fills it) — blocking every child with authored plugins from deploying. Children pinned to rc.26 with authored `plugins` are CI-blocked until they run `bos upgrade`; live apps are unaffected (the boot-check refused the publish before any registry write). diff --git a/packages/everything-dev/src/services/deploy/composition.ts b/packages/everything-dev/src/services/deploy/composition.ts index 07ccfdee..cc428644 100644 --- a/packages/everything-dev/src/services/deploy/composition.ts +++ b/packages/everything-dev/src/services/deploy/composition.ts @@ -71,12 +71,12 @@ export interface StampOutcome { } /** - * 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 `app.*` 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: { @@ -89,6 +89,14 @@ export function stampInheritedSlotsFromRelease(input: { const stamped: string[] = []; for (const [slotPath, record] of Object.entries(slots)) { + // KEEP: only app.* slots are stampable — a child's declared `plugins` + // object owns the whole plugin domain in one wholesale replace + // (mergeBosConfigWithExtends), so stamping parent plugin slots would + // both expand the child's composed plugin set and produce + // schema-invalid entries (the stamped ui record alone carries no + // required `name`; the wholesale replace never fills it — the + // feedback.nearbuilders.org rc.26 incident). + if (!/^app\.[^.]+(\.ui)?$/.test(slotPath)) continue; if (authored.has(slotPath)) continue; stampSlotRecord(input.payload, slotPath, record); stamped.push(slotPath); 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 55a226e7..609acd42 100644 --- a/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts +++ b/packages/everything-dev/tests/integration/platform-upgrade-e2e.test.ts @@ -39,7 +39,10 @@ import { PlatformReleaseManifestSchema, resolvePlatformManifest, } from "../../src/services/platform/manifest"; -import { resolveBootShape } from "../../src/services/resolution/boot-check"; +import { + resolveBootShape, + verifyResolvedBootShape, +} from "../../src/services/resolution/boot-check"; import { applyPlatformPin } from "../../src/services/scaffold/upgrade-migration"; import { type ExtendedRootSourceOptions, @@ -534,6 +537,7 @@ describe("release-scoped inheritance — a pinned child deploy survives the root ); const authPin = pinFor("/auth/", `versions/auth-${version}.json`, shared); const authUiPin = pinFor("/auth-ui/", `versions/auth-ui-${version}.json`, shared); + const parentUi = { production: `${base}/auth-ui/`, pin: authUiPin, name: "auth" }; const entry = composePlatformManifestPublishEntry({ configDir: rootDir, account: ACCOUNT, @@ -544,7 +548,18 @@ describe("release-scoped inheritance — a pinned child deploy survives the root auth: { production: `${base}/auth/`, pin: authPin, - ui: { production: `${base}/auth-ui/`, pin: authUiPin }, + ui: parentUi, + }, + }, + plugins: { + votes: { + production: `${base}/votes/`, + pin: pinFor("/votes/", `versions/votes-${version}.json`, shared), + ui: { + production: `${base}/votes-ui/`, + pin: pinFor("/votes-ui/", `versions/votes-ui-${version}.json`, shared), + name: "votes-ui", + }, }, }, }, @@ -556,10 +571,13 @@ describe("release-scoped inheritance — a pinned child deploy survives the root account: ACCOUNT, domain: GATEWAY, app: { + host: { production: `${base}/host/` }, + ui: { production: `${base}/ui/` }, + api: { production: `${base}/api/` }, auth: { production: `${base}/auth/`, pin: authPin, - ui: { production: `${base}/auth-ui/`, pin: authUiPin }, + ui: parentUi, }, }, }); @@ -567,12 +585,19 @@ describe("release-scoped inheritance — a pinned child deploy survives the root } /** The deploy train's payload assembly, compressed: authored config, the - * train-applied own-ui pin, optional stamps, one boot walk, then the gate. */ + * train-applied own-ui pin, optional stamps, one boot walk, the boot-check + * schema parse of the merged shape, then the gate. */ async function simulateDeploy( authoredConfig: Record, ownUiPin: { manifest: string; integrity: string }, stamps: boolean, - ): Promise<{ ok: boolean; violations: string[]; merged: Record }> { + ): Promise<{ + ok: boolean; + violations: string[]; + merged: Record; + stamped: string[]; + bootOk: boolean; + }> { const resolvedRelease = await resolveReleaseManifestForDeploy({ configDir: childDir, authoredConfig, @@ -582,14 +607,16 @@ 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; + let stamped: string[] = []; if (stamps && resolvedRelease?.slots) { - stampInheritedSlotsFromRelease({ + stamped = stampInheritedSlotsFromRelease({ payload, authoredConfig, manifest: resolvedRelease, - }); + }).stamped; } const bootShape = await resolveBootShape(payload, CHILD_SLOT, { io: registryIo }); + const bootCheck = verifyResolvedBootShape(payload, bootShape); const gate = await runCompositionGate({ configDir: childDir, authoredConfig, @@ -601,6 +628,8 @@ describe("release-scoped inheritance — a pinned child deploy survives the root ok: gate.ok, violations: gate.violations, merged: bootShape.config as Record, + stamped, + bootOk: bootCheck.ok, }; } @@ -655,8 +684,9 @@ describe("release-scoped inheritance — a pinned child deploy survives the root domain: "child.example", platform: "2.0.0-rc.1", app: { ui: { development: "local:ui" } }, + plugins: { feedback: { extends: `bos://${ACCOUNT}/${GATEWAY}/votes` } }, }; - const { ok, violations, merged } = await simulateDeploy( + const { ok, violations, merged, stamped, bootOk } = await simulateDeploy( authoredConfig, { manifest: "versions/fedcba0987654321.json", @@ -664,11 +694,18 @@ describe("release-scoped inheritance — a pinned child deploy survives the root }, true, ); + expect(bootOk).toBe(true); expect(violations).toEqual([]); + // the release records the parent's plugin slots too — the child's own + // declared plugin set owns that domain, so none of them stamp + expect(stamped).toEqual(["app.auth", "app.auth.ui"]); 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(merged.plugins).toEqual({ + feedback: { extends: `bos://${ACCOUNT}/${GATEWAY}/votes` }, + }); expect(ok).toBe(true); }); @@ -679,8 +716,9 @@ describe("release-scoped inheritance — a pinned child deploy survives the root domain: "child.example", platform: "2.0.0-rc.1", app: { ui: { development: "local:ui" } }, + plugins: { feedback: { extends: `bos://${ACCOUNT}/${GATEWAY}/votes` } }, }; - const { ok, violations } = await simulateDeploy( + const { ok, violations, bootOk } = await simulateDeploy( authoredConfig, { manifest: "versions/fedcba0987654321.json", @@ -688,6 +726,7 @@ describe("release-scoped inheritance — a pinned child deploy survives the root }, false, ); + expect(bootOk).toBe(true); 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 e9babb9c..f0c2f9ad 100644 --- a/packages/everything-dev/tests/unit/composition.test.ts +++ b/packages/everything-dev/tests/unit/composition.test.ts @@ -176,7 +176,7 @@ describe("stampInheritedSlotsFromRelease", () => { expect(outcome.stamped).toEqual([]); }); - it("a plugin authored without a ui key stamps its computed ui slot", () => { + it("a plugin authored without a ui key still owns the plugin domain — no stamps", () => { const authored = { plugins: { votes: { extends: "bos://root.near/root" } }, }; @@ -194,7 +194,49 @@ describe("stampInheritedSlotsFromRelease", () => { }, }), }); - expect(outcome.stamped).toEqual(["plugins.votes.ui"]); + expect(outcome.stamped).toEqual([]); + }); + + it("never stamps plugin slots — a child's declared plugin set owns the whole domain (the rc.26 incident)", () => { + const authored = { + account: "feedback.near", + app: { ui: { production: "https://feedback.test/ui/" } }, + plugins: { + legion: { extends: "bos://root.near/root" }, + storage: { extends: "bos://root.near/root" }, + }, + }; + const payload = structuredClone(authored); + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: authored, + 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" }, + }, + "plugins.ai": { + base: "https://cdn.test/ai/", + pin: { manifest: "ai.json", integrity: "sha384-ai" }, + }, + "plugins.ai.ui": { + base: "https://cdn.test/ai-ui/", + pin: { manifest: "ai-ui.json", integrity: "sha384-ai-ui" }, + }, + "plugins.votes": { + base: "https://cdn.test/votes/", + pin: { manifest: "votes.json", integrity: "sha384-votes" }, + }, + }), + }); + expect(outcome.stamped).toEqual(["app.auth", "app.auth.ui"]); + // the child's plugin set is exactly what it declared — the parent's + // plugins must not appear (and never schema-invalidated ui stamps) + expect(payload.plugins).toEqual(authored.plugins); }); });