mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
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);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user