Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/configured-slot-stamping.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions docs/adr/0034-platform-release-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 20 additions & 5 deletions packages/everything-dev/src/services/deploy/composition.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, unknown>): Set<string> {
const paths = new Set<string>();
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.
Expand All @@ -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
Expand Down
110 changes: 108 additions & 2 deletions packages/everything-dev/tests/unit/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, Record<string, unknown>>;
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<string, unknown>;
expect(
stampInheritedSlotsFromRelease({
payload: remotePayload,
authoredConfig: remoteOnly,
manifest: manifestWithSlots(apiSlot),
}).stamped,
).toEqual(["app.api"]);
expect((remotePayload.app as Record<string, unknown>).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<string, unknown> = { account: "child.near" };
const outcome = stampInheritedSlotsFromRelease({
Expand Down Expand Up @@ -243,19 +328,40 @@ 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);
expect(paths.has("plugins.votes.ui")).toBe(false);
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", () => {
Expand Down
Loading