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/release-scoped-extends-inheritance.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 47 additions & 1 deletion docs/adr/0034-platform-release-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
82 changes: 82 additions & 0 deletions packages/everything-dev/src/services/config-slots.ts
Original file line number Diff line number Diff line change
@@ -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.<key>`, `app.<key>.ui`, `plugins.<id>`,
* `plugins.<id>.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, unknown> | string | false;
readonly isPluginRef: boolean;
}

export function walkConfigSlots(
config: Record<string, unknown>,
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<string, unknown>).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<string, unknown>,
segments: readonly string[],
): Record<string, unknown> {
let container = target;
for (const segment of segments) {
const next = container[segment];
if (!isPlainObject(next)) {
const created: Record<string, unknown> = {};
container[segment] = created;
container = created;
} else {
container = next;
}
}
return container;
}
236 changes: 236 additions & 0 deletions packages/everything-dev/src/services/deploy/composition.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
/** test/registry seam — threaded to the platform-manifest resolution */
resolveOptions?: Partial<ResolvePlatformManifestOptions>;
}): Promise<PlatformReleaseManifest | null> {
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<string, unknown>): Set<string> {
const paths = new Set<string>();
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<string, unknown>;
authoredConfig: Record<string, unknown>;
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<string, unknown>,
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<string, unknown> = 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<string, unknown>;
resolvedShape: Record<string, unknown>;
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<string>;
/** 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<CompositionGateReport> {
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<string, unknown>,
opts: { fetchImpl?: typeof fetch; verifiedSlots?: ReadonlySet<string> } = {},
): Promise<string[]> {
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<string, Array<{ slot: string; version: string }>>();
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\``,
);
}
1 change: 1 addition & 0 deletions packages/everything-dev/src/services/deploy/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type TrainErrorKind =
| "missing-manifest"
| "memory-backend"
| "config-write"
| "composition"
| "pin-verification"
| "platform-manifest"
| "platform-truth"
Expand Down
Loading
Loading