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

Adds optional BundleProvenance block to both LegacyBundleManifest and
PodBundleManifest. Backward compat preserved: bundles without
provenance validate and install unchanged; new fields are ignored
gracefully by older daemons (existing validators only fail on
unrecognized type shape, not on presence of additional fields).

This commit ships the manifest-side type layer only:
- BundleProvenance interface: createdAt / sourceHost / authorSession /
  sourceRigId / sourceRigName / daemonVersion / cliVersion / notes —
  all fields optional
- Shared validateProvenanceBlock helper (rejects non-object provenance
  and per-field type-mismatch when present; missing block is valid)
- v1 + v2 manifest interfaces extended with provenance
- v1 + v2 validators check provenance when present
- v1 + v2 serializers emit snake_case provenance block when present
- v1 normalizer surfaces camelCase BundleProvenance when present (v2
  has no normalizer surface today; raw round-trip path preserved)
- 12 new tests across v1 + v2 covering accept / reject / round-trip /
  missing-is-valid paths
- 3477 prior tests preserved; no regressions

Deferred to next commit on this branch (still Item 1):
- bundle-assembler.ts + pod-bundle-assembler.ts capture at create time
- CLI bundle.ts --notes flag wire-through
- bundles route accepts provenance fields in /api/bundles/create body
- Inspect output surfaces provenance (CLI text + JSON paths)

Honors PRD section v0 Out: no schema bump (v1+v2 extended with
optional fields only); no migrations; no event types; no core-routing
or bootstrap mutation. scope-and-versioning sections 1 and 3 coherence
preserved (pre-commit re-audit clean).

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:19:42 -07:00
1 parent 7f4085e684
commit 82d397156f
3 files changed
+306

No files matched your search

@@ -1,5 +1,88 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
// -- Shared types --
/**
* Provenance block — attribution metadata for a bundle artifact. All fields
* optional for backward compat; bundles without provenance install unchanged.
* Captured by the bundle-assembler at create time; surfaced in inspect output
* and audit-trail records. Not cryptographically signed at this stage.
*/
export interface BundleProvenance {
/** ISO timestamp; mirrors root createdAt at create time. */
createdAt?: string;
/** os.hostname() of the host that ran rig bundle create. */
sourceHost?: string;
/** Canonical session name of the creator (e.g. velocity-driver@openrig-velocity). */
authorSession?: string;
/** ULID of the source rig, if creating from a live rig. */
sourceRigId?: string;
/** Name of the source rig, if creating from a live rig. */
sourceRigName?: string;
/** Daemon version at create time (e.g. 0.3.2). */
daemonVersion?: string;
/** CLI version at create time (e.g. 0.3.2). */
cliVersion?: string;
/** Operator-authored notes from the --notes flag on rig bundle create. */
notes?: string;
}
const PROVENANCE_STRING_FIELDS = [
"created_at",
"source_host",
"author_session",
"source_rig_id",
"source_rig_name",
"daemon_version",
"cli_version",
"notes",
] as const;
/** Validate optional provenance block. Appends to errors if present-but-malformed. */
function validateProvenanceBlock(raw: unknown, errors: string[]): void {
if (raw === undefined) return;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
errors.push("provenance must be an object");
return;
}
const p = raw as Record<string, unknown>;
for (const field of PROVENANCE_STRING_FIELDS) {
if (field in p && typeof p[field] !== "string") {
errors.push(`provenance.${field} must be a string`);
}
}
}
/** Serialize a typed BundleProvenance to the snake_case YAML record shape. */
function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (p.createdAt !== undefined) out["created_at"] = p.createdAt;
if (p.sourceHost !== undefined) out["source_host"] = p.sourceHost;
if (p.authorSession !== undefined) out["author_session"] = p.authorSession;
if (p.sourceRigId !== undefined) out["source_rig_id"] = p.sourceRigId;
if (p.sourceRigName !== undefined) out["source_rig_name"] = p.sourceRigName;
if (p.daemonVersion !== undefined) out["daemon_version"] = p.daemonVersion;
if (p.cliVersion !== undefined) out["cli_version"] = p.cliVersion;
if (p.notes !== undefined) out["notes"] = p.notes;
return out;
}
/** Normalize raw provenance to typed BundleProvenance. Returns undefined when absent or empty. */
function normalizeProvenanceBlock(raw: unknown): BundleProvenance | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const p = raw as Record<string, unknown>;
const result: BundleProvenance = {};
if (typeof p["created_at"] === "string") result.createdAt = p["created_at"];
if (typeof p["source_host"] === "string") result.sourceHost = p["source_host"];
if (typeof p["author_session"] === "string") result.authorSession = p["author_session"];
if (typeof p["source_rig_id"] === "string") result.sourceRigId = p["source_rig_id"];
if (typeof p["source_rig_name"] === "string") result.sourceRigName = p["source_rig_name"];
if (typeof p["daemon_version"] === "string") result.daemonVersion = p["daemon_version"];
if (typeof p["cli_version"] === "string") result.cliVersion = p["cli_version"];
if (typeof p["notes"] === "string") result.notes = p["notes"];
return Object.keys(result).length > 0 ? result : undefined;
}
// -- Pod-aware bundle types (AgentSpec reboot) --
export interface PodBundleAgentImportEntry {
@@ -28,6 +111,7 @@ export interface PodBundleManifest {
agents: PodBundleAgentEntry[];
cultureFile?: string;
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
}
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
@@ -54,6 +138,8 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
}
}
validateProvenanceBlock(m["provenance"], errors);
return { valid: errors.length === 0, errors };
}
@@ -81,6 +167,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);
return stringifyYaml(doc);
}
@@ -116,6 +203,7 @@ export interface LegacyBundleManifest {
rigSpec: string;
packages: LegacyBundlePackageEntry[];
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
}
/** Validation options */
@@ -206,6 +294,8 @@ export function validateLegacyBundleManifest(
}
}
validateProvenanceBlock(m["provenance"], errors);
return { valid: errors.length === 0, errors };
}
@@ -247,6 +337,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
};
}
const provenance = normalizeProvenanceBlock(m["provenance"]);
if (provenance) result.provenance = provenance;
return result;
}
@@ -274,5 +367,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
};
}
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
return stringifyYaml(doc);
}
+116
View File
@@ -7,6 +7,7 @@ import {
serializeLegacyBundleManifest as serializeBundleManifest,
isRelativeSafePath,
type LegacyBundleManifest as BundleManifest,
type BundleProvenance,
} from "../src/domain/bundle-types.js";
const VALID_RAW = {
@@ -135,6 +136,121 @@ describe("Bundle types", () => {
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("safe relative path"))).toBe(true);
});
// T12: missing provenance is valid (backward compat — pre-Item-1 bundles install)
it("missing provenance block passes validation (backward compat)", () => {
const raw = { ...VALID_RAW };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// T13: full provenance block passes validation
it("full provenance block passes validation", () => {
const raw = {
...VALID_RAW,
provenance: {
created_at: "2026-05-18T00:00:00Z",
source_host: "test-host.local",
author_session: "velocity-driver@openrig-velocity",
source_rig_id: "01KQEQPN4MQJN0DHBM5CQ0N8D7",
source_rig_name: "openrig-velocity",
daemon_version: "0.3.2",
cli_version: "0.3.2",
notes: "Test bundle for Item 1",
},
};
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// T14: partial provenance (only notes) passes validation — all fields optional
it("partial provenance block (only notes) passes validation", () => {
const raw = { ...VALID_RAW, provenance: { notes: "ad-hoc" } };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
// T15: provenance must be an object when present (not null, not array, not string)
it("provenance present but not an object rejected", () => {
const raw1 = { ...VALID_RAW, provenance: "not-an-object" };
const result1 = validateBundleManifest(raw1);
expect(result1.valid).toBe(false);
expect(result1.errors.some((e) => e.includes("provenance"))).toBe(true);
const raw2 = { ...VALID_RAW, provenance: [] };
const result2 = validateBundleManifest(raw2);
expect(result2.valid).toBe(false);
expect(result2.errors.some((e) => e.includes("provenance"))).toBe(true);
});
// T16: malformed provenance field type rejected (numeric created_at)
it("provenance field with wrong type rejected", () => {
const raw = { ...VALID_RAW, provenance: { created_at: 12345, source_host: "h" } };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("provenance.created_at"))).toBe(true);
});
// T17: round-trip preserves provenance through serialize → parse → normalize
it("round-trip preserves provenance through serialize → parse → normalize", () => {
const provenance: BundleProvenance = {
createdAt: "2026-05-18T12:00:00Z",
sourceHost: "rt-host",
authorSession: "velocity-driver@openrig-velocity",
daemonVersion: "0.3.2",
cliVersion: "0.3.2",
notes: "round-trip fixture",
};
const manifest: BundleManifest = {
schemaVersion: 1,
name: "rt-bundle",
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": "e".repeat(64), "packages/pkg/package.yaml": "f".repeat(64) },
},
provenance,
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).toContain("provenance:");
expect(yaml).toContain("source_host: rt-host");
expect(yaml).toContain("notes: round-trip fixture");
const parsed = parseBundleManifest(yaml);
const validation = validateBundleManifest(parsed);
expect(validation.valid).toBe(true);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.provenance).toBeDefined();
expect(normalized.provenance?.sourceHost).toBe("rt-host");
expect(normalized.provenance?.authorSession).toBe("velocity-driver@openrig-velocity");
expect(normalized.provenance?.daemonVersion).toBe("0.3.2");
expect(normalized.provenance?.cliVersion).toBe("0.3.2");
expect(normalized.provenance?.notes).toBe("round-trip fixture");
});
// T18: missing provenance round-trips as undefined (no field in YAML)
it("missing provenance round-trips cleanly (no field emitted in YAML)", () => {
const manifest: BundleManifest = {
schemaVersion: 1,
name: "no-prov",
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("provenance:");
const parsed = parseBundleManifest(yaml);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.provenance).toBeUndefined();
});
});
describe("isRelativeSafePath", () => {
@@ -561,6 +561,101 @@ describe("PodBundleManifest validation", () => {
expect(imports).toHaveLength(1);
expect(imports[0]!["name"]).toBe("lib");
});
// Item 1 — provenance block (slice-05): backward-compat + presence + round-trip
it("v2: missing provenance block passes validation (backward compat)", () => {
const raw = {
schema_version: 2, name: "no-prov", 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 provenance block passes validation", () => {
const raw = {
schema_version: 2, name: "with-prov", 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: [],
}],
provenance: {
created_at: "2026-05-18T00:00:00Z",
source_host: "test-host.local",
author_session: "velocity-driver@openrig-velocity",
source_rig_id: "01KQEQPN4MQJN0DHBM5CQ0N8D7",
source_rig_name: "openrig-velocity",
daemon_version: "0.3.2",
cli_version: "0.3.2",
notes: "Test bundle for Item 1",
},
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: provenance present but not an object rejected", () => {
const raw = {
schema_version: 2, name: "bad-prov", 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: [] }],
provenance: "string-not-object",
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("provenance"))).toBe(true);
});
it("v2: provenance field with wrong type rejected", () => {
const raw = {
schema_version: 2, name: "bad-prov-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: [] }],
provenance: { source_host: 12345 },
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("provenance.source_host"))).toBe(true);
});
it("v2: round-trip preserves provenance through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-prov", 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: [],
}],
provenance: {
createdAt: "2026-05-18T00:00:00Z",
sourceHost: "rt-host",
authorSession: "velocity-driver@openrig-velocity",
daemonVersion: "0.3.2",
cliVersion: "0.3.2",
notes: "v2 round-trip fixture",
},
};
const yaml = serializePodBundleManifest(manifest);
expect(yaml).toContain("provenance:");
expect(yaml).toContain("source_host: rt-host");
const parsed = parsePodBundleManifest(yaml);
const validation = validatePodBundleManifest(parsed);
expect(validation.valid).toBe(true);
const m = parsed as Record<string, unknown>;
const prov = m["provenance"] as Record<string, unknown>;
expect(prov["source_host"]).toBe("rt-host");
expect(prov["author_session"]).toBe("velocity-driver@openrig-velocity");
expect(prov["daemon_version"]).toBe("0.3.2");
expect(prov["notes"]).toBe("v2 round-trip fixture");
});
});
describe("PodBundleSourceResolver", () => {