From 599b368148ff9fb23539e5731a63b2f03a879416 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Sun, 1 Nov 2026 00:03:49 +0500 Subject: [PATCH] =?UTF-8?q?fix(everything-dev):=20stamp=20configured-only?= =?UTF-8?q?=20slots=20=E2=80=94=20authored=20means=20sourced,=20not=20conf?= =?UTF-8?q?igured=20(#679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authoredSlotPaths counted every plain-object app. as authored, so a child setting auth: { variables, secrets } had app.auth skipped by release-scoped stamping. The auth server then followed the parent's live config pointer under a host and auth UI pinned to the child's release. A slot is now authored only when its entry names a code source (production, development, pin, extends, proxy), or is a ui:false opt-out or bare plugin ref. stampSlotRecord already merges, so variables and secrets survive the stamp. Co-Authored-By: Claude Opus 5.5 --- .changeset/configured-slot-stamping.md | 5 + docs/adr/0034-platform-release-manifest.md | 8 +- .../src/services/deploy/composition.ts | 25 +++- .../tests/unit/composition.test.ts | 110 +++++++++++++++++- 4 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 .changeset/configured-slot-stamping.md diff --git a/.changeset/configured-slot-stamping.md b/.changeset/configured-slot-stamping.md new file mode 100644 index 000000000..b8901c6d0 --- /dev/null +++ b/.changeset/configured-slot-stamping.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Release-scoped slot stamping no longer skips a slot the child only configures. A child that sets `auth: { variables, secrets }` (or a remote-only `api: { variables, secrets }`) had that slot counted as authored, so the deploy train left it unpinned and the auth server (or api) kept following the parent's live config pointer — under a host and auth UI pinned to the child's release. A slot now counts as authored only when it names a code source (`production`, `development`, `pin`, `extends`, `proxy`, a bare plugin ref, or `ui: false`); configuration-only entries get the release's `production` + `pin` stamped with their `variables`/`secrets` kept. Affected children pick this up on their next deploy after upgrading. diff --git a/docs/adr/0034-platform-release-manifest.md b/docs/adr/0034-platform-release-manifest.md index e2edb1c10..14e6b536b 100644 --- a/docs/adr/0034-platform-release-manifest.md +++ b/docs/adr/0034-platform-release-manifest.md @@ -117,9 +117,13 @@ function of (child code, pinned release): 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 `slots` map, not authored by the leaf) 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; + live pointer at every consumer boot. A slot is authored only when its entry + names a code source (`production`, `development`, `pin`, `extends`, `proxy`, + a bare plugin ref, or `ui: false`); an entry carrying only configuration + (`variables`, `secrets`, …) is still stamped, its configuration kept + (amended 2026-11-01, #679). Slots the child authors stay untouched; a no-`slots` manifest stamps nothing; rollback payloads are canonical and skip stamping. diff --git a/packages/everything-dev/src/services/deploy/composition.ts b/packages/everything-dev/src/services/deploy/composition.ts index cc4286440..4022db0e7 100644 --- a/packages/everything-dev/src/services/deploy/composition.ts +++ b/packages/everything-dev/src/services/deploy/composition.ts @@ -1,5 +1,5 @@ import type { BosConfigInput } from "../../types"; -import { ensureObjectContainers, walkConfigSlots } from "../config-slots"; +import { type ConfigSlot, ensureObjectContainers, walkConfigSlots } from "../config-slots"; import { readInstalledFrameworkVersion } from "../framework-version"; import { checkPlatformCompat, @@ -52,11 +52,25 @@ export async function resolveReleaseManifestForDeploy(input: { return resolved?.manifest ?? null; } -/** Authored slot paths: every slot the leaf itself declares. */ +/** Keys that name where a slot's code comes from — owning the slot's bytes. */ +const SLOT_SOURCE_KEYS = ["production", "development", "pin", "extends", "proxy"] as const; + +/** + * A slot is authored when its entry names a code source (or opts out with + * `ui: false` / owns it via a bare plugin ref). An entry that only configures + * an inherited slot — `variables`, `secrets`, `routes`, … — does not own its + * bytes, so the release still pins them. + */ +function declaresSlotSource(entry: ConfigSlot["entry"]): boolean { + if (entry === false || typeof entry === "string") return true; + return SLOT_SOURCE_KEYS.some((key) => entry[key] !== undefined); +} + +/** Authored slot paths: every slot whose code source the leaf itself declares. */ export function authoredSlotPaths(config: Record): Set { const paths = new Set(); - walkConfigSlots(config, ({ slot, isPluginRef }) => { - paths.add(slot); + walkConfigSlots(config, ({ slot, entry, isPluginRef }) => { + if (declaresSlotSource(entry)) 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. @@ -72,7 +86,8 @@ export interface StampOutcome { /** * 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 + * `slots` map that the authored config does not author (see + * `authoredSlotPaths` — configuring a slot is not authoring it), 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 diff --git a/packages/everything-dev/tests/unit/composition.test.ts b/packages/everything-dev/tests/unit/composition.test.ts index f0c2f9ad3..bdcd8cc5e 100644 --- a/packages/everything-dev/tests/unit/composition.test.ts +++ b/packages/everything-dev/tests/unit/composition.test.ts @@ -119,6 +119,91 @@ describe("stampInheritedSlotsFromRelease", () => { expect(app.ui.pin).toEqual({ manifest: "mine.json", integrity: "sha384-mine" }); }); + it("stamps a configured-only slot (variables/secrets, no code source) and keeps its configuration", () => { + const authored = { + account: "child.near", + app: { + ui: { development: "local:ui" }, + api: { development: "local:api", variables: { a: 1 } }, + auth: { + variables: { siwn: { recipients: { mainnet: "child.sputnik-dao.near" } } }, + secrets: ["BETTER_AUTH_SECRET"], + }, + }, + }; + const payload = structuredClone(authored) as Record; + const outcome = stampInheritedSlotsFromRelease({ + payload, + authoredConfig: authored, + manifest: manifestWithSlots({ + "app.api": { + base: "https://cdn.test/api/", + pin: { manifest: "api.json", integrity: "sha384-api" }, + }, + "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://cdn.test/ui/", + pin: { manifest: "ui.json", integrity: "sha384-ui" }, + }, + }), + }); + + const app = payload.app as Record>; + expect(outcome.stamped).toEqual(["app.auth", "app.auth.ui"]); + expect(app.auth).toEqual({ + variables: { siwn: { recipients: { mainnet: "child.sputnik-dao.near" } } }, + secrets: ["BETTER_AUTH_SECRET"], + production: authBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth" }, + ui: { + production: authUiBase, + pin: { manifest: "versions/0123456789abcdef.json", integrity: "sha384-auth-ui" }, + }, + }); + expect(app.ui).toEqual({ development: "local:ui" }); + expect(app.api).toEqual({ development: "local:api", variables: { a: 1 } }); + }); + + it("stamps a remote-only api (variables/secrets, no workspace) but not a proxied one", () => { + const apiSlot = { + "app.api": { + base: "https://cdn.test/api/", + pin: { manifest: "api.json", integrity: "sha384-api" }, + }, + }; + const remoteOnly = { app: { api: { variables: { a: 1 }, secrets: ["S"] } } }; + const remotePayload = structuredClone(remoteOnly) as Record; + expect( + stampInheritedSlotsFromRelease({ + payload: remotePayload, + authoredConfig: remoteOnly, + manifest: manifestWithSlots(apiSlot), + }).stamped, + ).toEqual(["app.api"]); + expect((remotePayload.app as Record).api).toEqual({ + variables: { a: 1 }, + secrets: ["S"], + production: "https://cdn.test/api/", + pin: { manifest: "api.json", integrity: "sha384-api" }, + }); + + const proxied = { app: { api: { proxy: "https://api.child.test", variables: { a: 1 } } } }; + expect( + stampInheritedSlotsFromRelease({ + payload: structuredClone(proxied), + authoredConfig: proxied, + manifest: manifestWithSlots(apiSlot), + }).stamped, + ).toEqual([]); + }); + it("stamps nothing when the release carries no slots map (pre-amendment releases)", () => { const payload: Record = { account: "child.near" }; const outcome = stampInheritedSlotsFromRelease({ @@ -243,12 +328,17 @@ describe("stampInheritedSlotsFromRelease", () => { 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" }, + app: { + ui: { development: "local:ui" }, + host: { production: "https://child.test/host/", ui: { name: "h" } }, + api: { proxy: "https://api.child.test", ui: false }, + }, + plugins: { votes: { extends: "bos://parent/p" }, 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.host.ui")).toBe(false); expect(paths.has("app.api")).toBe(true); expect(paths.has("app.api.ui")).toBe(true); expect(paths.has("plugins.votes")).toBe(true); @@ -256,6 +346,22 @@ describe("authoredSlotPaths", () => { expect(paths.has("plugins.legacy")).toBe(true); expect(paths.has("plugins.legacy.ui")).toBe(true); }); + + it("an entry that only configures an inherited slot does not author it", () => { + const paths = authoredSlotPaths({ + app: { + auth: { + variables: { a: 1 }, + secrets: ["S"], + ui: { pin: { manifest: "m", integrity: "i" } }, + }, + api: { variables: { a: 1 } }, + }, + }); + expect(paths.has("app.auth")).toBe(false); + expect(paths.has("app.auth.ui")).toBe(true); + expect(paths.has("app.api")).toBe(false); + }); }); describe("compositionSkewViolations", () => {