feat(slice-05): compatibility block in v1+v2 bundle manifests (Item 2 type layer) (OPR.0.3.2.5)

Mirrors the Item 1 type-layer pattern (82d39715) for compatibility:
optional BundleCompatibility block with minDaemonVersion +
minCliVersion + schemaVersion (reaffirmation). All fields optional;
missing block is backward-compat (bundles install unchanged).
Install-time version check + operator override flag land at
Checkpoint 3.3 (this commit ships the manifest shape only).

This commit ships:
- BundleCompatibility interface: minDaemonVersion / minCliVersion /
  schemaVersion — all fields optional
- Shared validateCompatibilityBlock helper (rejects non-object
  compatibility; rejects per-field type mismatch when present;
  missing block is valid)
- Shared normalizeCompatibilityBlock helper (exported alongside the
  Item-1 provenance normalizer; v2 inspect projection in 77bed48b set
  the precedent that the /api/bundles/inspect contract is one
  camelCase shape regardless of schema version)
- compatibilityToYamlRecord helper for snake_case YAML emission
- v1 + v2 manifest interfaces extended with optional compatibility
- v1 + v2 validators check compatibility via the helper
- v1 + v2 serializers emit snake_case compatibility block when present
- v1 normalizer surfaces camelCase BundleCompatibility when present
  (v2 has no normalizer surface today; raw round-trip path preserved)
- 12 new tests (7 v1 + 5 v2): missing-valid, full-valid, partial-valid
  (only minDaemonVersion), non-object-rejected, wrong-field-type-
  rejected, round-trip-preserves, missing-round-trips-cleanly
- All 81 prior daemon bundle tests preserved; 93/93 green

Diff: 3 files, +255 lines, all additive.

PRD bounce conditions verified:
- No schema bump (v1+v2 extended with optional field only)
- No new SQLite migrations
- No event types
- No core-routing or bootstrap mutation
- No sidebar entries touched
- scope-and-versioning sections 1 and 3 coherence re-audit clean

Watched risks applied:
- Audit-every-layer: helpers added at module scope are function
  definitions, not constants reading env/host/version. Validator and
  normalizer take their inputs from the parsed manifest (no env reads
  at all). Verified: no top-level const reads of os, process.env, or
  version sources introduced.
- Optional-field backward-compat both directions: missing compatibility
  is valid (pre-Item-2 bundles still install); manifests with
  compatibility are accepted by both validators; older daemons reading
  new manifests will ignore compatibility (no validator rejects
  unknown fields). Verified via missing-compatibility-valid test plus
  the existing validator behavior on unrecognized fields.

Item-2 chain on this branch:
- 77bed48b (Item 1 close-out + B1 repair, CLEAR) <- prior tip
- THIS COMMIT — Item 2 type layer
- Next commit (3.2) — assembler capture + route create/inspect plumbing
  + CLI min-daemon-version / min-cli-version flags
- Next commit (3.3) — install-time version check at /install + 3-part
  error + CLI skip-version-check operator override

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 12:52:38 -07:00
1 parent 77bed48ba6
commit 1fd11b762b
3 files changed
+255

No files matched your search

@@ -67,6 +67,66 @@ function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
return out;
}
/**
* Compatibility block — operator-declared install-time requirements for a
* bundle artifact. All fields optional; missing block keeps backward compat
* (bundles install unchanged). Install-time version check (Item 2 Checkpoint
* 3.3) consults this block before bootstrap delegation; --skip-version-check
* is the operator-explicit override.
*/
export interface BundleCompatibility {
/** Minimum daemon version required to install this bundle (semver string). */
minDaemonVersion?: string;
/** Minimum CLI version required to install this bundle (semver string). */
minCliVersion?: string;
/** Schema version reaffirmed; mirrors root schemaVersion when set. */
schemaVersion?: number;
}
/** Validate optional compatibility block. Appends to errors if present-but-malformed. */
function validateCompatibilityBlock(raw: unknown, errors: string[]): void {
if (raw === undefined) return;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
errors.push("compatibility must be an object");
return;
}
const c = raw as Record<string, unknown>;
if ("min_daemon_version" in c && typeof c["min_daemon_version"] !== "string") {
errors.push("compatibility.min_daemon_version must be a string");
}
if ("min_cli_version" in c && typeof c["min_cli_version"] !== "string") {
errors.push("compatibility.min_cli_version must be a string");
}
if ("schema_version" in c && typeof c["schema_version"] !== "number") {
errors.push("compatibility.schema_version must be a number");
}
}
/** Serialize a typed BundleCompatibility to the snake_case YAML record shape. */
function compatibilityToYamlRecord(c: BundleCompatibility): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (c.minDaemonVersion !== undefined) out["min_daemon_version"] = c.minDaemonVersion;
if (c.minCliVersion !== undefined) out["min_cli_version"] = c.minCliVersion;
if (c.schemaVersion !== undefined) out["schema_version"] = c.schemaVersion;
return out;
}
/**
* Normalize raw snake_case compatibility to typed camelCase BundleCompatibility.
* Returns undefined when absent or empty. Exported alongside the provenance
* normalizer so the v2 inspect-route projection can produce a single
* camelCase shape for both manifest schemas.
*/
export function normalizeCompatibilityBlock(raw: unknown): BundleCompatibility | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const c = raw as Record<string, unknown>;
const result: BundleCompatibility = {};
if (typeof c["min_daemon_version"] === "string") result.minDaemonVersion = c["min_daemon_version"];
if (typeof c["min_cli_version"] === "string") result.minCliVersion = c["min_cli_version"];
if (typeof c["schema_version"] === "number") result.schemaVersion = c["schema_version"];
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Normalize raw snake_case provenance (as parsed from YAML or received from
* a request body) to typed camelCase BundleProvenance. Returns undefined when
@@ -118,6 +178,7 @@ export interface PodBundleManifest {
cultureFile?: string;
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
compatibility?: BundleCompatibility;
}
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
@@ -145,6 +206,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
}
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
return { valid: errors.length === 0, errors };
}
@@ -174,6 +236,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
if (manifest.cultureFile) doc["culture_file"] = manifest.cultureFile;
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
return stringifyYaml(doc);
}
@@ -210,6 +273,7 @@ export interface LegacyBundleManifest {
packages: LegacyBundlePackageEntry[];
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
compatibility?: BundleCompatibility;
}
/** Validation options */
@@ -301,6 +365,7 @@ export function validateLegacyBundleManifest(
}
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
return { valid: errors.length === 0, errors };
}
@@ -346,6 +411,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
const provenance = normalizeProvenanceBlock(m["provenance"]);
if (provenance) result.provenance = provenance;
const compatibility = normalizeCompatibilityBlock(m["compatibility"]);
if (compatibility) result.compatibility = compatibility;
return result;
}
@@ -375,5 +443,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
return stringifyYaml(doc);
}
+110
View File
@@ -8,6 +8,7 @@ import {
isRelativeSafePath,
type LegacyBundleManifest as BundleManifest,
type BundleProvenance,
type BundleCompatibility,
} from "../src/domain/bundle-types.js";
const VALID_RAW = {
@@ -251,6 +252,115 @@ describe("Bundle types", () => {
const normalized = normalizeBundleManifest(parsed);
expect(normalized.provenance).toBeUndefined();
});
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
// C1: missing compatibility is valid (backward compat)
it("missing compatibility block passes validation (backward compat)", () => {
const raw = { ...VALID_RAW };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// C2: full compatibility block passes validation
it("full compatibility block passes validation", () => {
const raw = {
...VALID_RAW,
compatibility: {
min_daemon_version: "0.3.2",
min_cli_version: "0.3.2",
schema_version: 1,
},
};
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// C3: partial compatibility (only min_daemon_version) passes — all fields optional
it("partial compatibility block (only min_daemon_version) passes validation", () => {
const raw = { ...VALID_RAW, compatibility: { min_daemon_version: "0.3.2" } };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// C4: compatibility present but not an object rejected
it("compatibility present but not an object rejected", () => {
const raw1 = { ...VALID_RAW, compatibility: "0.3.2" };
const r1 = validateBundleManifest(raw1);
expect(r1.valid).toBe(false);
expect(r1.errors.some((e) => e.includes("compatibility"))).toBe(true);
const raw2 = { ...VALID_RAW, compatibility: [] };
const r2 = validateBundleManifest(raw2);
expect(r2.valid).toBe(false);
expect(r2.errors.some((e) => e.includes("compatibility"))).toBe(true);
});
// C5: compatibility field with wrong type rejected
it("compatibility field with wrong type rejected", () => {
const raw1 = { ...VALID_RAW, compatibility: { min_daemon_version: 0.3 } };
const r1 = validateBundleManifest(raw1);
expect(r1.valid).toBe(false);
expect(r1.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
const raw2 = { ...VALID_RAW, compatibility: { schema_version: "1" } };
const r2 = validateBundleManifest(raw2);
expect(r2.valid).toBe(false);
expect(r2.errors.some((e) => e.includes("compatibility.schema_version"))).toBe(true);
});
// C6: round-trip preserves compatibility through serialize → parse → normalize
it("round-trip preserves compatibility through serialize → parse → normalize", () => {
const compatibility: BundleCompatibility = {
minDaemonVersion: "0.3.2",
minCliVersion: "0.3.2",
schemaVersion: 1,
};
const manifest: BundleManifest = {
schemaVersion: 1,
name: "rt-compat",
version: "1.0.0",
createdAt: "2026-05-18T12:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
integrity: {
algorithm: "sha256",
files: { "rig.yaml": "1".repeat(64), "packages/pkg/package.yaml": "2".repeat(64) },
},
compatibility,
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).toContain("compatibility:");
expect(yaml).toContain("min_daemon_version: 0.3.2");
const parsed = parseBundleManifest(yaml);
const validation = validateBundleManifest(parsed);
expect(validation.valid).toBe(true);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.compatibility).toBeDefined();
expect(normalized.compatibility?.minDaemonVersion).toBe("0.3.2");
expect(normalized.compatibility?.minCliVersion).toBe("0.3.2");
expect(normalized.compatibility?.schemaVersion).toBe(1);
});
// C7: missing compatibility round-trips cleanly (no field emitted in YAML)
it("missing compatibility round-trips cleanly (no field emitted in YAML)", () => {
const manifest: BundleManifest = {
schemaVersion: 1,
name: "no-compat",
version: "1.0.0",
createdAt: "2026-05-18T00:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).not.toContain("compatibility:");
const parsed = parseBundleManifest(yaml);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.compatibility).toBeUndefined();
});
});
describe("isRelativeSafePath", () => {
@@ -700,6 +700,81 @@ describe("PodBundleManifest validation", () => {
expect(result.errors.some((e) => e.includes("provenance.source_host"))).toBe(true);
});
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
it("v2: missing compatibility block passes validation (backward compat)", () => {
const raw = {
schema_version: 2, name: "no-compat", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: full compatibility block passes validation", () => {
const raw = {
schema_version: 2, name: "with-compat", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
compatibility: { min_daemon_version: "0.3.2", min_cli_version: "0.3.2", schema_version: 2 },
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: compatibility present but not an object rejected", () => {
const raw = {
schema_version: 2, name: "bad-compat", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
compatibility: "0.3.2",
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("compatibility"))).toBe(true);
});
it("v2: compatibility field with wrong type rejected", () => {
const raw = {
schema_version: 2, name: "bad-compat-field", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
compatibility: { min_daemon_version: 0.3 },
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
});
it("v2: round-trip preserves compatibility through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-compat", version: "2.0",
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
agents: [{
name: "impl", version: "1.0", path: "agents/impl",
originalRef: "local:agents/impl", hash: "def",
importEntries: [],
}],
compatibility: {
minDaemonVersion: "0.3.2",
minCliVersion: "0.3.2",
schemaVersion: 2,
},
};
const yaml = serializePodBundleManifest(manifest);
expect(yaml).toContain("compatibility:");
expect(yaml).toContain("min_daemon_version: 0.3.2");
const parsed = parsePodBundleManifest(yaml);
const validation = validatePodBundleManifest(parsed);
expect(validation.valid).toBe(true);
const m = parsed as Record<string, unknown>;
const compat = m["compatibility"] as Record<string, unknown>;
expect(compat["min_daemon_version"]).toBe("0.3.2");
expect(compat["min_cli_version"]).toBe("0.3.2");
expect(compat["schema_version"]).toBe(2);
});
it("v2: round-trip preserves provenance through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-prov", version: "2.0",