Files
stack/extensions/goal/lib/executive-update.ts
T

186 lines
8.5 KiB
TypeScript

// lib/executive-update.ts — parser for the cited ms-executive-update Machine contract.
//
// The contract bytes are pinned and verified by Mosaic Core before this parser
// is reachable for a version-4 role. This module accepts no alternate format,
// heading, whitespace form, or identifier spelling.
import {
GOAL_REPORT_CONTRACT_BLOB,
GOAL_REPORT_CONTRACT_PATH,
GOAL_REPORT_CONTRACT_SECTION,
GOAL_REPORT_CONTRACT_SHA256,
GOAL_REPORT_FORMAT,
type GoalItemResolutionV1,
type GoalPolicyPublication,
type GoalTrackedItem,
validateGoalItemResolution,
} from "../../mosaic-core/lib/goal-policy.ts";
export {
GOAL_REPORT_FORMAT,
GOAL_REPORT_CONTRACT_PATH,
GOAL_REPORT_CONTRACT_SECTION,
GOAL_REPORT_CONTRACT_BLOB,
GOAL_REPORT_CONTRACT_SHA256,
};
export type ExecutiveUpdateSection = GoalTrackedItem["section"];
export type ExecutiveUpdateParseResult =
| Readonly<{ ok: true; kind: "update" | "no-change"; items: readonly GoalTrackedItem[] }>
| Readonly<{ ok: false; reason: string }>;
const SECTIONS: readonly ExecutiveUpdateSection[] = ["Just Completed", "Next Step", "Blocked"];
const CONTROL_RE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/;
const TASK_RE = /^T[0-9]+$/;
const PR_RE = /^#[0-9]+$/;
const ROW_RE = /^[A-Z]{1,4}\.?[0-9]+(?:\.[0-9]+)*$/;
const MARKER_RE = /^[A-Z0-9]+(?:-[A-Z0-9]+)*-Q[0-9]+$/;
const SHA_RE = /^[0-9a-f]{40}$/;
const PATH_RE = /^`[^`\s]+`$/;
function invalid(reason: string): ExecutiveUpdateParseResult {
return Object.freeze({ ok: false, reason });
}
function valid(items: GoalTrackedItem[], kind: "update" | "no-change" = "update"): ExecutiveUpdateParseResult {
return Object.freeze({ ok: true, kind, items: Object.freeze(items) });
}
function isContractItemToken(token: string): boolean {
if (TASK_RE.test(token) || PR_RE.test(token) || ROW_RE.test(token) || MARKER_RE.test(token) || SHA_RE.test(token)) return true;
if (!PATH_RE.test(token)) return false;
const path = token.slice(1, -1);
return !path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !/(?:^|\/)\.\.(?:\/|$)/.test(path);
}
function hasValidBlockedSuffix(text: string): boolean {
return text.endsWith("\u2014 nothing from you")
|| /\u2014 Decision needed: \(1\) .+ \(2\) .+; recommend [1-9][0-9]*$/.test(text);
}
/** The contract's distinct single-line update form. */
function parseNoChange(raw: string): ExecutiveUpdateParseResult | undefined {
const match = /^No change since ([^;\n]+); still waiting on (.+)\.$/.exec(raw);
if (!match) return undefined;
const [, since, waiting] = match;
if (!isContractItemToken(since) || !isContractItemToken(waiting)) return invalid("No change: invalid item identifier");
// The trusted resolver's public input is deliberately the existing closed
// GoalTrackedItem shape. Both references are waiting-state identifiers; the
// no-change kind below supplies the stricter unchanged-state rule.
return valid([
Object.freeze({ token: since, section: "Next Step" }),
Object.freeze({ token: waiting, section: "Next Step" }),
], "no-change");
}
function parseSection(section: ExecutiveUpdateSection, body: string): ExecutiveUpdateParseResult | GoalTrackedItem[] {
if (body === "* none\n") return [];
if (!body.endsWith("\n")) return invalid(`${section}: final bullet must end with LF`);
const lines = body.slice(0, -1).split("\n");
if (lines.length < 1 || lines.length > 5) return invalid(`${section}: requires one to five bullets or * none`);
const items: GoalTrackedItem[] = [];
for (const line of lines) {
if (line === "" || /[ \t]$/.test(line)) return invalid(`${section}: blank or trailing-whitespace line`);
const match = /^\* ([^:\n]+): ([^\n]+)$/.exec(line);
if (!match) return invalid(`${section}: invalid bullet grammar`);
const [, token, text] = match;
if (!isContractItemToken(token)) return invalid(`${section}: invalid item identifier`);
if (text.startsWith("-")) return invalid(`${section}: bullet text may not start with -`);
if (section === "Blocked" && !hasValidBlockedSuffix(text)) return invalid("Blocked: required terminal disposition missing");
items.push(Object.freeze({ token, section }));
}
return items;
}
/** Parse the exact three-section Machine contract before any Goal state mutation. */
export function parseExecutiveUpdate(raw: unknown): ExecutiveUpdateParseResult {
if (typeof raw !== "string") return invalid("payload must be a string");
if (raw.length === 0) return invalid("payload is empty");
if (raw !== raw.normalize("NFC")) return invalid("payload is not NFC-normalized");
if (CONTROL_RE.test(raw)) return invalid("payload contains control characters or non-LF line endings");
if (/(^|\n)[^\n]*[ \t](?=\n|$)/.test(raw)) return invalid("payload contains trailing whitespace");
const noChange = parseNoChange(raw);
if (noChange) return noChange;
const first = "Just Completed:\n\n";
const second = "\nNext Step:\n\n";
const third = "\nBlocked:\n\n";
if (!raw.startsWith(first)) return invalid("first heading must be Just Completed");
const nextIndex = raw.indexOf(second, first.length);
if (nextIndex < 0 || raw.indexOf(second, nextIndex + 1) !== -1) return invalid("Next Step heading must appear exactly once after Just Completed");
const blockedIndex = raw.indexOf(third, nextIndex + second.length);
if (blockedIndex < 0 || raw.indexOf(third, blockedIndex + 1) !== -1) return invalid("Blocked heading must appear exactly once after Next Step");
if (raw.includes("\nJust Completed:\n", first.length) || raw.includes("\nNext Step:\n", 0) && raw.indexOf(second) !== nextIndex) return invalid("heading duplication or order violation");
const bodies = [
raw.slice(first.length, nextIndex),
raw.slice(nextIndex + second.length, blockedIndex),
raw.slice(blockedIndex + third.length),
];
const parsed: GoalTrackedItem[] = [];
for (let index = 0; index < SECTIONS.length; index++) {
const sectionItems = parseSection(SECTIONS[index], bodies[index]);
if (!Array.isArray(sectionItems)) return sectionItems;
parsed.push(...sectionItems);
}
return valid(parsed);
}
export type AttestedGoalReportResult =
| Readonly<{ ok: true; items: readonly GoalTrackedItem[] }>
| Readonly<{ ok: false; reason: string }>;
function resolutionError(item: GoalTrackedItem, result: GoalItemResolutionV1): AttestedGoalReportResult | undefined {
if (result.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${result.outcome}` });
if (item.section === "Just Completed" && !result.changedSincePreviousAcceptedReport) {
return Object.freeze({ ok: false, reason: "unchanged-completion" });
}
if (item.section === "Just Completed" && result.completionEvidenceId === null) {
return Object.freeze({ ok: false, reason: "completion-evidence-missing" });
}
return undefined;
}
export type AttestedGoalReportStatus = "satisfied" | "blocked" | "in_progress";
/** Resolve each parser-produced item through the attested, read-only resolver. */
export async function validateAttestedGoalReport(
raw: unknown,
publication: GoalPolicyPublication,
status: AttestedGoalReportStatus,
): Promise<AttestedGoalReportResult> {
const parsed = parseExecutiveUpdate(raw);
if (!parsed.ok) return parsed;
if (parsed.kind === "no-change" && status !== "in_progress") {
return Object.freeze({ ok: false, reason: "no-change-status" });
}
for (const item of parsed.items) {
let resolution: unknown;
try {
resolution = await publication.resolver(item);
} catch {
return Object.freeze({ ok: false, reason: "resolver-unavailable" });
}
if (!validateGoalItemResolution(resolution)) {
return Object.freeze({ ok: false, reason: "resolver-malformed" });
}
if (parsed.kind === "no-change") {
if (resolution.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${resolution.outcome}` });
if (resolution.changedSincePreviousAcceptedReport) return Object.freeze({ ok: false, reason: "no-change-state-changed" });
continue;
}
const error = resolutionError(item, resolution);
if (error) return error;
}
return Object.freeze({ ok: true, items: parsed.items });
}
/** Stable code for Core's payload-free denial journal. */
export function goalPolicyDenialCode(reason: string): string {
return ["resolver-zero", "resolver-multiple", "resolver-unavailable", "resolver-stale", "resolver-malformed", "unchanged-completion", "completion-evidence-missing", "no-change-status", "no-change-state-changed"].includes(reason)
? reason
: "parser-rejected";
}