fix(daemon): tolerate missing metadata mapping in readMissionReadiness (#72) (#91)

Scaffolded mission manifests (and mission.yaml files without an explicit
metadata block) omit the top-level metadata key. Previously,
readMissionReadiness called mapping(doc.metadata, 'mission metadata')
unconditionally, throwing a JudgmentError ('mission metadata: expected a
mapping') when doc.metadata was undefined. This error was caught at the
top level of readMissionReadiness, causing it to abort composition slice
resolution, leave slices empty ([]), and set state to 'unknown'.

Safely default metadata to {} when doc.metadata is nullish, while
retaining strict mapping validation when doc.metadata is specified. Also
add unit test coverage for missions omitting metadata as well as
malformed metadata negative control.

Fixes #72.
This commit is contained in:
Shravan Sumanthanan authored and GitHub committed 2026-09-28 02:55:00 -07:00
1 parent 5fead45078
commit 4fcf2910bb
2 files changed
+33 -1

No files matched your search

+17 -1
View File
@@ -211,6 +211,15 @@ export interface JudgeInput {
subject?: Judgment["subject"]; expectedEvidence?: Evidence[]; reason: string; expectedRevision: string;
expectedPrevious: string | null; operationId?: string; replace?: boolean;
}
/**
* Records a verifiable proof judgment for a slice item into the judgments ledger.
*
* @param missionsRoot - Workspace root path for resolving scopes.
* @param input - Judgment details including scope, item, verdict, and evidence.
* @param actor - Identifier of the judging actor authorized under the proof policy.
* @param provenance - Attestation provenance string recorded in the ledger.
* @returns Recorded judgment receipt, current slice readiness, and replay status.
*/
export function recordJudgment(missionsRoot: string, input: JudgeInput, actor: string, provenance: string): { judgment: Judgment; readiness: ScopeReadiness; replayed: boolean } {
const dir = resolveProofScope(missionsRoot, input.scope), root = workspaceOf(dir, proofFs);
if (path.basename(path.dirname(dir)) !== "slices") throw new JudgmentError("slice_required", "Item judgments belong to a slice; higher outcome judgments remain workflow decisions");
@@ -264,13 +273,20 @@ export function recordJudgment(missionsRoot: string, input: JudgeInput, actor: s
return { judgment, readiness: readSliceReadiness(dir), replayed: false };
}
/**
* Evaluates readiness for a mission by reading its composition and resolving member slices.
*
* @param missionDir - Path to the mission directory containing mission.yaml.
* @param readPolicy - Reader function for determining applicable proof policy.
* @returns Evaluated mission readiness, including revision, state, slices, and any issues.
*/
export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicyRead = policyOf): MissionReadiness {
const issues: string[] = [], slices: MissionReadiness["slices"] = [];
let historicalStatus: string | null = null;
try {
const doc = manifest(proofFs, path.join(missionDir, "mission.yaml"));
if (!doc) return { revision: hash("legacy"), state: "legacy", slices, issues, historicalStatus };
const metadata = mapping(doc.metadata, "mission metadata");
const metadata = doc.metadata != null ? mapping(doc.metadata, "mission metadata") : {};
historicalStatus = typeof metadata.status === "string" ? metadata.status : null;
const members = validateMissionComposition(doc, path.join(missionDir, "mission.yaml")).filter(m => m.active);
for (const member of members) {
@@ -205,6 +205,22 @@ describe("retained authority negative controls", () => {
f.write(join(f.beta, "proof/judgments/00000001.md"), fs.readFileSync(join(f.alpha, "proof/judgments/00000001.md"), "utf8"));
expect(readSliceReadiness(f.beta).items[0]!.state).toBe("unknown");
});
/**
* Verifies that mission readiness resolves composition slices when metadata is omitted (#72),
* while rejecting malformed metadata values.
*/
it("tolerates mission without metadata and resolves composition slices (#72)", () => {
const f = fixture();
const members = ["01-alpha", "02-beta"].map((s, i) => ({ ref: `slices/${s}/slice.yaml`, order: i + 1, active: true }));
f.write(join(f.mission, "mission.yaml"), { kind: "mission", composition: { slices: members } });
const readiness = readMissionReadiness(f.mission);
expect(readiness.issues).toEqual([]);
expect(readiness.slices.map(s => s.id)).toEqual(["01-alpha", "02-beta"]);
expect(readiness.historicalStatus).toBeNull();
f.write(join(f.mission, "mission.yaml"), { kind: "mission", metadata: "not-a-mapping", composition: { slices: members } });
const invalid = readMissionReadiness(f.mission);
expect(invalid.issues).toContain("mission metadata: expected a mapping");
});
it("pushes changed source truth, keeps sibling basis, and ignores unchanged source bytes", async () => {
const f = fixture(); f.judge(); const sibling = readSliceReadiness(f.beta).revision;
const events: Array<{ type: string; revision: string }> = []; let invalidated = 0;