From 534cad6028db04dfbbee1bbe97e5a5ccf510639f Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Fri, 7 Aug 2026 18:15:18 -0400 Subject: [PATCH 1/3] feat(loopspecs): unify the terminal-state vocabulary behind one owned mapping Two vocabularies named the same terminal facts twice. A LoopSpec finalising on no_progress_stalled and the platform execution node finalising on iteration-cap-failed are one fact under two names, and the LoopSpec side's maps_to was a free-form string carrying a third, unvalidated set of names. loopspecs/terminal-states.json now owns a single unified terminal-state vocabulary of nine members and the total mapping onto it from both source vocabularies: eight LoopSpec terminal states and the eight FINALIZE_MATRIX outcomes of the platform's scripts/runtime_execution_node.py, read at platform commit 5d86b7e. Sixteen source names resolve to nine unified names, every unified name is reachable, and no platform file is changed by this record - the platform-side rename is a follow-on task there. The map is enforced rather than documented. bin/fm-loopspec.sh checks it before any spec is read against it and refuses a map that is not total, that leaves a unified state unreachable, that is not a reduction, or that collapses two source names of differing consequence without declaring where the difference still lives. schema.json makes maps_to required and resolves it against the map through an external_enums pointer, so a spec terminal state that is unmapped, invented, disagreeing with the map or contradicting its unified kind is refused rather than defaulted onto whatever looks closest. no_delta's certification survives as a machine-checked property: exactly one unified state may be reached without spending a model turn, it must be no_delta, and it must stay neutral so reaching it can never demand a verifier verdict. The test proves that behaviourally as well as declaratively, with a success terminal under the same conditions as the negative control. New subcommand: fm-loopspec.sh terminal-map, with --unified, --source, --resolve and --json. Resolving an unmapped state refuses with the new stable token refuse_unmapped_terminal. --- .agents/skills/loopspec/SKILL.md | 3 + AGENTS.md | 2 +- bin/fm-loopspec.sh | 212 +++++++++++++- loopspecs/approved-work-reconciliation.json | 16 +- loopspecs/schema.json | 10 +- loopspecs/terminal-states.json | 177 ++++++++++++ tests/fm-loopspec.test.sh | 295 ++++++++++++++++++++ 7 files changed, 697 insertions(+), 18 deletions(-) create mode 100644 loopspecs/terminal-states.json diff --git a/.agents/skills/loopspec/SKILL.md b/.agents/skills/loopspec/SKILL.md index 442ecc5d377..83bcb711452 100644 --- a/.agents/skills/loopspec/SKILL.md +++ b/.agents/skills/loopspec/SKILL.md @@ -45,6 +45,9 @@ The second is the work inside a claimed iteration, done under the spec's permitt 4. Let the bound verifier produce the verdict. Never report one yourself: a success terminal requires a recorded run bound to that iteration, and the party doing the work never certifies the work. 5. Read the resulting terminal state, then follow the spec's escalation entry for it. +Terminal states are not a per-spec invention: `loopspecs/terminal-states.json` owns the unified terminal-state vocabulary and the total mapping onto it from every source vocabulary that names the same facts, and `fm-loopspec.sh terminal-map` reads it. +A state with no row there is refused rather than mapped to whatever looks closest, so add the row before adding the state. + ## A refusal is a stop Every refusal token is a fail-closed result, never an obstacle to route around. diff --git a/AGENTS.md b/AGENTS.md index 700428a2da2..225eec9db2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ README.md public overview and development notes .agents/skills/ firstmate-loaded internal skills, committed; each carries metadata.internal=true for installers .claude/skills symlink to .agents/skills for claude compatibility skills/ standalone public installer-facing skills, committed; not loaded by firstmate -loopspecs/ canonical LoopSpec registry, committed: schema.json (field contract), triggers.json (the sixteen-trigger register), and one .json per loop; bin/fm-loopspec.sh is their only interpreter (section 13) +loopspecs/ canonical LoopSpec registry, committed: schema.json (field contract), triggers.json (the sixteen-trigger register), terminal-states.json (the unified terminal-state vocabulary and its total mapping from every source vocabulary), and one .json per loop; bin/fm-loopspec.sh is their only interpreter (section 13) firstmate.bat Windows-to-WSL launcher bridge, committed; docs/windows-launcher.md owns setup bin/ helper scripts, committed; read each script's header before first use .env optional X-mode pairing token; LOCAL, gitignored; presence-gates section 14 diff --git a/bin/fm-loopspec.sh b/bin/fm-loopspec.sh index 624e69501b2..45ee9e89ffa 100755 --- a/bin/fm-loopspec.sh +++ b/bin/fm-loopspec.sh @@ -38,6 +38,12 @@ # fm-loopspec.sh list id, version and status per spec # fm-loopspec.sh show print one spec # fm-loopspec.sh triggers [--summary] the sixteen-trigger register +# fm-loopspec.sh terminal-map [--source ] every source terminal state and +# the unified state it maps to +# fm-loopspec.sh terminal-map --unified the unified vocabulary alone +# fm-loopspec.sh terminal-map --resolve +# resolve exactly one source state +# fm-loopspec.sh terminal-map --json the whole mapping document # fm-loopspec.sh select --trigger [--scope ] # choose exactly one eligible spec # fm-loopspec.sh candidates --trigger [--scope ] @@ -82,11 +88,16 @@ # refuse_iteration_open another event key already holds the open iteration # refuse_no_open_iteration nothing to finish # refuse_unknown_terminal the terminal is not declared by this spec +# refuse_unmapped_terminal no unified terminal state is mapped for that +# source state, and one is never defaulted # refuse_state_unreadable persistent state exists but cannot be read truthfully # refuse_state_unwritable persistent state cannot be written truthfully # -# The schema, the trigger register and each spec are data, owned by loopspecs/. -# This script is their only interpreter; it never restates their content. +# The schema, the trigger register, the terminal-state map and each spec are +# data, owned by loopspecs/. This script is their only interpreter; it never +# restates their content. In particular the unified terminal-state vocabulary +# lives in terminal-states.json and is injected into schema validation as the +# unified_terminal enum, so neither file can drift away from the other. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -120,15 +131,16 @@ need_jq() { schema_path() { printf '%s/schema.json' "$SPEC_DIR"; } triggers_path() { printf '%s/triggers.json' "$SPEC_DIR"; } +terminals_path() { printf '%s/terminal-states.json' "$SPEC_DIR"; } -# Every *.json under the registry except the two contract files. +# Every *.json under the registry except the three contract files. spec_files() { local f base for f in "$SPEC_DIR"/*.json; do [ -f "$f" ] || continue base=$(basename "$f") case "$base" in - schema.json|triggers.json) continue ;; + schema.json|triggers.json|terminal-states.json) continue ;; esac printf '%s\n' "$f" done @@ -158,6 +170,7 @@ validate_structure() { local spec_file=$1 file_id=$2 skills_json=$3 out rc out=$(jq -r --slurpfile schema "$(schema_path)" \ --slurpfile triggers "$(triggers_path)" \ + --slurpfile terminals "$(terminals_path)" \ --arg file_id "$file_id" \ --argjson skills "$skills_json" ' # Identifiers are kebab-case; terminal-state names are snake_case tokens. @@ -195,7 +208,14 @@ validate_structure() { . as $spec | $schema[0] as $S | $triggers[0] as $T - | ($S.enums) as $enums + | $terminals[0] as $TM + # The unified vocabulary is owned by terminal-states.json and injected here + # as an ordinary enum, so schema.json never carries a second copy of it. + | (((try ($TM.unified) catch null)) // []) as $unified + | ($S.enums + {unified_terminal: ($unified | map(.name))}) as $enums + | ($unified | map({key: .name, value: .kind}) | from_entries) as $unified_kind + | ((((try ($TM.sources[] | select(.source == "loopspec") | .map) catch null)) // []) + | map({key: .state, value: .unified}) | from_entries) as $loopspec_map | ((try ($spec.trigger.id) catch null)) as $trigger_id | ((try ($spec.no_progress.terminal) catch null)) as $np_terminal | (((try ($spec.escalation.on) catch null)) // []) as $esc_on @@ -236,6 +256,29 @@ validate_structure() { ( (($esc_on - $declared)[]) | "escalation.on names an undeclared terminal state: \(.)" ), ( ($declared | group_by(.) | map(select(length > 1) | .[0]))[] | "duplicate terminal state: \(.)" ), + # A terminal state with no row in the map is refused outright rather + # than defaulted onto some plausible unified state, and a row that + # disagrees with the spec is refused rather than silently preferred. + ( (((try ($spec.terminal_states) catch null)) // [])[] + | select(type == "object") + | . as $ts + | ( + ( select(($loopspec_map | has($ts.name)) | not) + | "terminal state \"\($ts.name)\" has no loopspec row in terminal-states.json, so no unified state is mapped for it" ), + # Both comparisons stand down when maps_to is absent, so the + # missing required field is reported as itself rather than as a + # mismatch against a value that was never declared. + ( select((($ts.maps_to | type) == "string") + and ($loopspec_map | has($ts.name)) + and ($ts.maps_to != $loopspec_map[$ts.name])) + | "terminal state \"\($ts.name)\" maps_to \"\($ts.maps_to)\" but terminal-states.json maps it to \"\($loopspec_map[$ts.name])\"" ), + ( select((($ts.maps_to | type) == "string") + and ($unified_kind | has($ts.maps_to)) + and ($ts.kind != $unified_kind[$ts.maps_to])) + | "terminal state \"\($ts.name)\" declares kind \"\($ts.kind)\" but unified state \"\($ts.maps_to)\" declares kind \"\($unified_kind[$ts.maps_to])\"" ) + ) + ), + ( select($spec.status == "enabled") | ( ( ($T.triggers[] | select(.id == $trigger_id)) as $t @@ -263,10 +306,72 @@ validate_structure() { return 0 } +# Integrity of the terminal-state map itself, checked before any spec is read +# against it. A map that is not total, not reachable or not a reduction is not a +# map anything may be validated against. +# Prints one line per problem, nothing when sound, non-zero if it could not run. +terminal_map_problems() { + local out rc + out=$(jq -r ' + def consequence($row): ($row.consequence // ""); + + . as $TM + | ((try ($TM.unified) catch null)) as $unified + | ((try ($TM.sources) catch null)) as $sources + | if ($unified | type) != "array" or ($unified | length) == 0 then + ["unified must be a non-empty array of terminal states"] + elif ($sources | type) != "array" or ($sources | length) == 0 then + ["sources must be a non-empty array of source vocabularies"] + else + ($unified | map(.name)) as $names + | ($unified | map({key: .name, value: .}) | from_entries) as $by_name + | [ $sources[] | .source as $s | (.map // [])[] | . + {source: $s} ] as $rows + | ($rows | map(.unified)) as $targets + | [ + ( ($unified[] | select((.name | type) != "string" or (.kind | type) != "string" + or (.costs_model_turn | type) != "boolean" + or (.description | type) != "string")) + | "unified state \(.name // "?") is missing name, kind, costs_model_turn or description" ), + ( ($names | group_by(.) | map(select(length > 1) | .[0]))[] + | "duplicate unified terminal state: \(.)" ), + ( ($sources[] | .source as $s | (.map // []) | map(.state) | group_by(.) + | map(select(length > 1) | .[0])[] | "duplicate \($s) source state: \(.)") ), + ( $rows[] | . as $r | select(($names | index($r.unified)) == null) + | "\($r.source) state \"\($r.state)\" maps to \"\($r.unified)\", which is not a declared unified state" ), + ( $names[] as $n | select(($targets | index($n)) == null) + | "unified state \"\($n)\" is unreachable: no source state maps to it" ), + ( select(($unified | length) >= ($rows | length)) + | "the unified vocabulary has \($unified | length) members against \($rows | length) source states, so the merge is not a reduction" ), + ( $sources[] | .source as $s + | (.map // []) | group_by(.unified)[] + | select((map(consequence(.)) | unique | length) > 1) + | . as $group + | $group[] | select((.distinction // "") == "") + | "\($s) state \"\(.state)\" shares unified state \"\(.unified)\" with a state of different consequence and does not declare how the distinction is preserved" ), + ( ($unified | map(select(.costs_model_turn == false))) as $free + | ( select(($free | length) != 1) + | "exactly one unified state may cost no model turn, found \($free | length)" ), + ( select(($free | length) == 1 and ($free[0].name != "no_delta")) + | "the zero-model-turn unified state must be no_delta, found \"\($free[0].name)\"" ) ), + ( select(($by_name | has("no_delta")) and ($by_name["no_delta"].kind != "neutral")) + | "no_delta must be neutral so reaching it can never demand a verifier verdict, found \"\($by_name["no_delta"].kind)\"" ) + ] + end + | .[] + ' "$(terminals_path)") + rc=$? + if [ "$rc" -ne 0 ]; then + printf 'the terminal-state map could not be read\n' + return 1 + fi + [ -z "$out" ] || printf '%s\n' "$out" + return 0 +} + # Registry-wide validation. Returns 0 only when every spec is sound. validate_registry() { local -a files=() - local f base file_id problems skills_json rc=0 count=0 dupes vproblem + local f base file_id problems skills_json rc=0 count=0 dupes vproblem map_problems if [ "$#" -gt 0 ]; then files=("$@") @@ -279,8 +384,22 @@ validate_registry() { [ -f "$(schema_path)" ] || { printf 'refuse_invalid_spec registry schema is missing: %s\n' "$(schema_path)" >&2; return 1; } [ -f "$(triggers_path)" ] || { printf 'refuse_invalid_spec trigger register is missing: %s\n' "$(triggers_path)" >&2; return 1; } + [ -f "$(terminals_path)" ] || { printf 'refuse_invalid_spec terminal-state map is missing: %s\n' "$(terminals_path)" >&2; return 1; } jq -e . "$(schema_path)" >/dev/null 2>&1 || { printf 'refuse_invalid_spec registry schema is not readable JSON\n' >&2; return 1; } jq -e . "$(triggers_path)" >/dev/null 2>&1 || { printf 'refuse_invalid_spec trigger register is not readable JSON\n' >&2; return 1; } + jq -e . "$(terminals_path)" >/dev/null 2>&1 || { printf 'refuse_invalid_spec terminal-state map is not readable JSON\n' >&2; return 1; } + + # The map is checked before any spec, because a spec is only ever validated + # against it and an unsound map would let an unsound spec read as sound. + map_problems=$(terminal_map_problems) || rc=1 + if [ -n "$map_problems" ]; then + while IFS= read -r line; do + [ -n "$line" ] || continue + printf 'refuse_invalid_spec terminal-states.json: %s\n' "$line" >&2 + done <<<"$map_problems" + return 1 + fi + [ "$rc" -eq 0 ] || return 1 skills_json=$(installed_skills_json) @@ -457,6 +576,12 @@ cmd_show() { need_jq [ "$#" -eq 1 ] || die_usage "show requires exactly one spec id" local path + # The contract files share the registry directory but are not specs, so they + # are unknown here exactly as they are absent from list and from the count. + case "$1" in + schema|triggers|terminal-states) + refuse refuse_unknown_spec "\"$1\" is a registry contract file, not a spec" ;; + esac path=$(spec_path_for "$1") [ -f "$path" ] || refuse refuse_unknown_spec "no spec with id \"$1\" in $SPEC_DIR" cat "$path" @@ -496,6 +621,80 @@ cmd_triggers() { ' "$(triggers_path)" } +cmd_terminal_map() { + need_jq + local mode="rows" source="" resolve_source="" resolve_state="" + while [ "$#" -gt 0 ]; do + case "$1" in + --source) [ "$#" -gt 1 ] || die_usage "--source requires a value"; source=$2; shift 2 ;; + --unified) mode="unified"; shift ;; + --json) mode="json"; shift ;; + --resolve) + [ "$#" -gt 2 ] || die_usage "--resolve requires " + mode="resolve"; resolve_source=$2; resolve_state=$3; shift 3 ;; + *) die_usage "unknown option for terminal-map: $1" ;; + esac + done + [ -f "$(terminals_path)" ] || refuse refuse_invalid_spec "terminal-state map is missing" + jq -e . "$(terminals_path)" >/dev/null 2>&1 \ + || refuse refuse_invalid_spec "terminal-state map is not readable JSON" + + # Nothing may be resolved out of a map that does not hold together, because a + # broken map would answer confidently and wrongly. + local map_problems + map_problems=$(terminal_map_problems) \ + || refuse refuse_invalid_spec "the terminal-state map could not be read" + [ -z "$map_problems" ] \ + || refuse refuse_invalid_spec "terminal-state map: $(printf '%s' "$map_problems" | head -1)" + + case "$mode" in + json) + cat "$(terminals_path)" + ;; + unified) + # The unified state is bound to $u before counting, so the comparison + # cannot shadow itself against the row it is filtering. + jq -r ' + . as $TM + | [ $TM.sources[] | (.map // [])[] | .unified ] as $targets + | $TM.unified[] + | . as $u + | "\($u.name)\t\($u.kind)\tcosts_model_turn=\($u.costs_model_turn)\tsources=\([$targets[] | select(. == $u.name)] | length)" + ' "$(terminals_path)" + ;; + rows) + jq -r --arg src "$source" ' + . as $TM + | ($TM.unified | map({key: .name, value: .}) | from_entries) as $by_name + | $TM.sources[] + | select($src == "" or .source == $src) + | .source as $s + | (.map // [])[] + | "\($s)\t\(.state)\t\(.unified)\t\($by_name[.unified].kind)\tcosts_model_turn=\($by_name[.unified].costs_model_turn)" + ' "$(terminals_path)" + ;; + resolve) + local resolved + resolved=$(jq -r --arg src "$resolve_source" --arg st "$resolve_state" ' + . as $TM + | ($TM.unified | map({key: .name, value: .}) | from_entries) as $by_name + | [ $TM.sources[] | select(.source == $src) | (.map // [])[] | select(.state == $st) ] + | if length == 1 then + .[0] as $r | "\($r.unified)\t\($by_name[$r.unified].kind)\t\($by_name[$r.unified].costs_model_turn)" + else empty + end + ' "$(terminals_path)") + [ -n "$resolved" ] || refuse refuse_unmapped_terminal \ + "no unified terminal state is mapped for \"$resolve_state\" in source \"$resolve_source\"" + printf 'LOOPSPEC_TERMINAL_MAP %s %s -> %s kind=%s costs_model_turn=%s\n' \ + "$resolve_source" "$resolve_state" \ + "$(printf '%s' "$resolved" | cut -f1)" \ + "$(printf '%s' "$resolved" | cut -f2)" \ + "$(printf '%s' "$resolved" | cut -f3)" + ;; + esac +} + cmd_select() { need_jq local trigger="" scope="" @@ -898,6 +1097,7 @@ case "$COMMAND" in list) cmd_list "$@" ;; show) cmd_show "$@" ;; triggers) cmd_triggers "$@" ;; + terminal-map) cmd_terminal_map "$@" ;; select) cmd_select "$@" ;; candidates) cmd_candidates "$@" ;; claim) cmd_claim "$@" ;; diff --git a/loopspecs/approved-work-reconciliation.json b/loopspecs/approved-work-reconciliation.json index f2de451848d..a6bbb7879ab 100644 --- a/loopspecs/approved-work-reconciliation.json +++ b/loopspecs/approved-work-reconciliation.json @@ -121,49 +121,49 @@ { "name": "no_delta", "kind": "neutral", - "maps_to": "NOOP", + "maps_to": "no_delta", "description": "The corpus is unchanged since the last accepted iteration. Reaching this must cost no model turn." }, { "name": "delta_emitted", "kind": "success", - "maps_to": "COMPLETE", + "maps_to": "goal_met", "description": "The corpus changed and a cited delta was emitted for firstmate to relay." }, { "name": "confirmed_work_found", "kind": "success", - "maps_to": "COMPLETE", + "maps_to": "goal_met", "description": "At least one item is confirmed approved and still unimplemented, with approval and implementation proven separately." }, { "name": "needs_ruling", "kind": "refusal", - "maps_to": "REFUSED", + "maps_to": "needs_ruling", "description": "An item's approval or supersession cannot be settled from evidence and requires a captain ruling. The loop stops rather than guessing." }, { "name": "blocked_by_evidence_integrity", "kind": "failure", - "maps_to": "FAILED", + "maps_to": "blocked_by_evidence_integrity", "description": "A corpus member or decision source was unreadable, so absence of evidence could not be distinguished from evidence of absence." }, { "name": "budget_exhausted", "kind": "failure", - "maps_to": "EXHAUSTED", + "maps_to": "budget_exhausted", "description": "An iteration, wall-clock or capacity budget was reached before the success condition was met." }, { "name": "verification_failed", "kind": "failure", - "maps_to": "FAILED", + "maps_to": "verification_failed", "description": "The verifier ran and rejected the iteration, or was unavailable. An unavailable verifier lands here and can never become a pass." }, { "name": "no_progress_stalled", "kind": "failure", - "maps_to": "STALLED", + "maps_to": "no_progress_stalled", "description": "Consecutive iterations produced no verifier-declared progress, so the loop stopped instead of repeating forever." } ], diff --git a/loopspecs/schema.json b/loopspecs/schema.json index ffdcf28a1eb..2e92ecfd3a3 100644 --- a/loopspecs/schema.json +++ b/loopspecs/schema.json @@ -48,6 +48,9 @@ "declared_by_verifier" ] }, + "external_enums": { + "unified_terminal": "loopspecs/terminal-states.json unified[].name - the unified terminal-state vocabulary is owned there and deliberately not restated here, so the two can never disagree" + }, "objects": { "": { "required": { @@ -183,11 +186,10 @@ "required": { "name": "token", "kind": "enum:terminal_kind", + "maps_to": "enum:unified_terminal", "description": "string" }, - "optional": { - "maps_to": "string" - } + "optional": {} }, "escalation": { "required": { @@ -237,6 +239,8 @@ "trigger_registered: trigger.id must appear in triggers.json", "required_terminals_present: terminal_states must define every name in required_terminal_states - the universal safety stops only, never one domain's vocabulary, so a second spec is authorable", "required_terminal_kinds_present: terminal_states must cover every kind in required_terminal_kinds, so every loop declares how it succeeds, how it no-ops, how it fails and how it refuses", + "terminal_mapped: every terminal_states[].name must have a loopspec row in terminal-states.json, and maps_to must equal the unified state that row records", + "terminal_kind_agrees: every terminal_states[].kind must equal the kind its unified state declares", "no_progress_terminal_defined: no_progress.terminal must name a declared terminal state", "escalation_terminals_defined: every escalation.on entry must name a declared terminal state", "unique_selection: no two specs may share (trigger.id, selection.scope, selection.priority)", diff --git a/loopspecs/terminal-states.json b/loopspecs/terminal-states.json new file mode 100644 index 00000000000..10b900ce160 --- /dev/null +++ b/loopspecs/terminal-states.json @@ -0,0 +1,177 @@ +{ + "loopspec_schema_version": 1, + "description": "The single owner of firstmate's unified terminal-state vocabulary and of the total mapping onto it from every source vocabulary that names the same facts. A terminal state answers one question: why did this stop. Two systems previously answered it in two vocabularies - a LoopSpec finalising on no_progress_stalled and an execution node finalising on iteration-cap-failed are the same fact named twice. This file names each fact once and records where every source name lands. bin/fm-loopspec.sh is its only interpreter; schema.json consumes the unified names through its external_enums pointer and never restates them.", + "unified": [ + { + "name": "no_delta", + "kind": "neutral", + "costs_model_turn": false, + "description": "Nothing to do: the watched input is unchanged since the last accepted iteration. Reaching this must cost no model turn, which is why it is the one unified state declaring costs_model_turn false." + }, + { + "name": "goal_met", + "kind": "success", + "costs_model_turn": true, + "description": "The work declared its goal met and verification did not reject it." + }, + { + "name": "budget_exhausted", + "kind": "failure", + "costs_model_turn": true, + "description": "A declared bound - iterations, wall clock, context, capacity or cost - was reached before the goal was met. Which bound was reached is evidence recorded by the run, not a separate name." + }, + { + "name": "no_progress_stalled", + "kind": "failure", + "costs_model_turn": true, + "description": "The repetition bound was reached without declared progress, so iterating again would repeat the same non-result." + }, + { + "name": "verification_failed", + "kind": "failure", + "costs_model_turn": true, + "description": "The verifier ran and rejected the iteration, or was unavailable. An unavailable verifier lands here and can never become a pass." + }, + { + "name": "blocked_by_evidence_integrity", + "kind": "failure", + "costs_model_turn": true, + "description": "A required source was unreadable, so absence of evidence could not be distinguished from evidence of absence." + }, + { + "name": "needs_ruling", + "kind": "refusal", + "costs_model_turn": true, + "description": "The work stopped because an authority must rule before it may continue. A ruling is owed." + }, + { + "name": "cancelled", + "kind": "neutral", + "costs_model_turn": true, + "description": "An authority stopped the run deliberately. Nothing failed and nothing is owed, which is what separates this from needs_ruling." + }, + { + "name": "unclassified_failure", + "kind": "failure", + "costs_model_turn": true, + "description": "The run stopped and no classified reason held. Kept as a named state rather than folded into another failure, because a stop nobody can explain is a different fact from one that is explained." + } + ], + "sources": [ + { + "source": "loopspec", + "owner": "loopspecs/.json terminal_states[].name", + "description": "The terminal-state names a LoopSpec declares. Every declared name must appear here, and a spec's terminal_states[].maps_to must equal the unified name recorded for it - an unmapped name is refused, never defaulted.", + "map": [ + { + "state": "no_delta", + "unified": "no_delta", + "note": "Carried through unchanged. Commission section 18's zero-model-turn rule is already encoded in this state and survives the merge intact." + }, + { + "state": "delta_emitted", + "unified": "goal_met", + "note": "One of two goal-met successes; what the success produced is the spec's own business, not a vocabulary distinction." + }, + { + "state": "confirmed_work_found", + "unified": "goal_met", + "note": "The second goal-met success. It already shared a class with delta_emitted before the merge." + }, + { + "state": "needs_ruling", + "unified": "needs_ruling", + "note": "Carried through unchanged. Deliberately not merged with cancelled: a ruling is owed here and is not owed there." + }, + { + "state": "blocked_by_evidence_integrity", + "unified": "blocked_by_evidence_integrity", + "note": "Carried through unchanged. Distinct from verification_failed: the inputs were unreadable, rather than the verifier rejecting." + }, + { + "state": "budget_exhausted", + "unified": "budget_exhausted", + "note": "Carried through unchanged, and now the single name for every bounded stop on both sides." + }, + { + "state": "verification_failed", + "unified": "verification_failed", + "note": "Carried through unchanged." + }, + { + "state": "no_progress_stalled", + "unified": "no_progress_stalled", + "note": "Carried through unchanged, and the merge target for the execution node's iteration-cap-failed." + } + ] + }, + { + "source": "execution-node", + "owner": "the platform's scripts/runtime_execution_node.py FINALIZE_MATRIX", + "description": "The finalize outcomes of the platform's execution-node runtime, read at platform commit 5d86b7e. Each row also records the status and exit code that matrix books, so a merge that collapses two names is auditable against the consequence each name actually carried. This side is mapped only; the platform-side rename is a follow-on platform task and no platform file is changed by this record.", + "map": [ + { + "state": "context-ceiling", + "unified": "budget_exhausted", + "consequence": "EXHAUSTED/0", + "distinction": "The hardest structural bound the platform has, and the first row of the finalize matrix so it outranks every other outcome. That precedence lives in the matrix ordering, which the merge does not touch.", + "note": "A node stopped at the context ceiling obeyed its governor, so the platform books a clean exit." + }, + { + "state": "budget-finish", + "unified": "budget_exhausted", + "consequence": "EXHAUSTED/0", + "distinction": "Ranked above the finish row so a budget-tripped run cannot book COMPLETE. That ordering is the mechanism by which bounded beats done, and it survives the merge unchanged.", + "note": "A budget trip asks the node to wind down, so a finish declaration is usually present alongside it." + }, + { + "state": "timeout", + "unified": "budget_exhausted", + "consequence": "EXHAUSTED/1", + "distinction": "The only bounded stop the platform books unclean. The exit code carries clean against unclean and the status carries the reason, so merging the name loses nothing the record still holds.", + "note": "The platform's own rationale: a deadline is a bound, and a run stopped by one did not fail any more than a run stopped by its iteration cap did." + }, + { + "state": "iteration-cap-clean", + "unified": "budget_exhausted", + "consequence": "EXHAUSTED/0", + "distinction": "Separated from iteration-cap-failed by whether the last iteration was clean; that split is preserved by mapping the two rows to different unified states.", + "note": "The iteration cap was reached and the final iteration completed cleanly." + }, + { + "state": "finish-signal", + "unified": "goal_met", + "consequence": "COMPLETE/0", + "note": "The node declared its goal met and no higher-ranked bound held." + }, + { + "state": "stop-signal", + "unified": "cancelled", + "consequence": "CANCELLED/0", + "note": "An operator stopped the run. Mapped to cancelled rather than needs_ruling because the authority already decided; nothing is owed back." + }, + { + "state": "iteration-cap-failed", + "unified": "no_progress_stalled", + "consequence": "EXHAUSTED/1", + "note": "The fact this increment exists to name once: a node finalising here and a loop finalising on no_progress_stalled are the same fact - the repetition bound was reached and the work is no further forward." + }, + { + "state": "unclassified", + "unified": "unclassified_failure", + "consequence": "FAILED/1", + "note": "The matrix's always-holding backstop, so a matrix carrying its row always terminates." + } + ] + } + ], + "invariants": [ + "unified_names_unique: no two unified entries may share a name", + "source_names_unique: no two rows within one source may name the same state", + "mapping_total: every source row must name a unified state declared here", + "no_unreachable_unified: every unified state must be the target of at least one source row", + "strict_reduction: the unified vocabulary must have strictly fewer members than the total number of source rows", + "collapse_declares_distinction: when rows within one source share a unified state but differ in recorded consequence, every row in that group must declare how the distinction is preserved", + "zero_model_turn_preserved: exactly one unified state may declare costs_model_turn false, it must be no_delta, and its kind must be neutral" + ] +} diff --git a/tests/fm-loopspec.test.sh b/tests/fm-loopspec.test.sh index 72adf35148c..a65689283cf 100755 --- a/tests/fm-loopspec.test.sh +++ b/tests/fm-loopspec.test.sh @@ -37,6 +37,7 @@ new_case() { local d="$TMP_ROOT/$1" mkdir -p "$d/registry" "$d/state" cp "$ROOT/loopspecs/schema.json" "$d/registry/schema.json" + cp "$ROOT/loopspecs/terminal-states.json" "$d/registry/terminal-states.json" cat >"$d/registry/triggers.json" <<'JSON' { "loopspec_schema_version": 1, @@ -765,6 +766,294 @@ test_shipped_instance_references_rather_than_duplicates_its_skill() { pass "the shipped instance names the research-approved-work skill, and the contract has nowhere to duplicate it" } +# --- the unified terminal-state vocabulary ---------------------------------- +# +# Two systems previously named the same terminal facts twice: a LoopSpec +# finalising on no_progress_stalled and the platform execution node finalising +# on iteration-cap-failed are one fact under two names. terminal-states.json +# now names each fact once and records where every source name lands. +# +# The execution-node side is pinned here as literal names rather than read from +# the platform, because that repository is not present in CI. These are the +# eight FINALIZE_MATRIX outcomes of scripts/runtime_execution_node.py at +# platform commit 5d86b7e; the pin is what makes a platform-side change to the +# vocabulary show up as a failing test here rather than as silent drift. +NODE_FINALIZE_OUTCOMES="context-ceiling budget-finish finish-signal timeout stop-signal iteration-cap-clean iteration-cap-failed unclassified" + +# map_case - a registry whose terminal-state map is the real +# one put through ; echoes " ". +map_case() { + local d="$TMP_ROOT/$1" filter=$2 + mkdir -p "$d/registry" "$d/state" + cp "$ROOT/loopspecs/schema.json" "$d/registry/schema.json" + cp "$ROOT/loopspecs/triggers.json" "$d/registry/triggers.json" + cp "$ROOT/loopspecs/approved-work-reconciliation.json" "$d/registry/" + jq "$filter" "$ROOT/loopspecs/terminal-states.json" >"$d/registry/terminal-states.json" \ + || fail "could not build terminal-state map fixture $1" + printf '%s %s\n' "$d/registry" "$d/state" +} + +test_an_unmapped_terminal_state_is_refused_not_defaulted() { + local reg st + read -r reg st < <(new_case unmapped-refused) + write_spec "$reg" good "$READY" + + # The negative controls come first. A resolver that answers everything + # confidently proves nothing by answering a real state correctly. + ls_run "$reg" "$st" terminal-map --resolve loopspec not_a_terminal_state + expect_code 1 "$CODE" "an unmapped loopspec state resolved instead of refusing" + assert_contains "$OUT" "refuse_unmapped_terminal" "an unmapped state did not refuse" + + ls_run "$reg" "$st" terminal-map --resolve execution-node not-a-finalize-outcome + expect_code 1 "$CODE" "an unmapped execution-node state resolved instead of refusing" + assert_contains "$OUT" "refuse_unmapped_terminal" "an unmapped node outcome did not refuse" + + ls_run "$reg" "$st" terminal-map --resolve not-a-source no_delta + expect_code 1 "$CODE" "an unknown source vocabulary resolved instead of refusing" + assert_contains "$OUT" "refuse_unmapped_terminal" "an unknown source did not refuse" + + # A state one of the two sides does declare is still refused on the other, + # so the two vocabularies cannot leak into each other through the resolver. + ls_run "$reg" "$st" terminal-map --resolve loopspec timeout + expect_code 1 "$CODE" "a node outcome resolved against the loopspec vocabulary" + ls_run "$reg" "$st" terminal-map --resolve execution-node no_delta + expect_code 1 "$CODE" "a loopspec state resolved against the node vocabulary" + + ls_run "$reg" "$st" terminal-map --resolve loopspec no_delta + expect_code 0 "$CODE" "a mapped state refused: $OUT" + assert_contains "$OUT" "LOOPSPEC_TERMINAL_MAP loopspec no_delta -> no_delta" \ + "the mapped state did not resolve to its unified state" + + # The map is a registry contract file, not a spec: it is never listed, + # never shown and never counted, so it can never be selected or claimed. + ls_run "$reg" "$st" list + assert_not_contains "$OUT" "terminal-states" "the terminal-state map was listed as a spec" + ls_run "$reg" "$st" show terminal-states + expect_code 1 "$CODE" "the terminal-state map was shown as a spec" + assert_contains "$OUT" "refuse_unknown_spec" "showing a contract file did not refuse" + ls_run "$reg" "$st" validate + expect_code 0 "$CODE" "the fixture registry did not validate: $OUT" + assert_contains "$OUT" "specs=1" "the terminal-state map was counted as a spec" + + pass "an unmapped terminal state refuses on both sides rather than defaulting to a plausible unified state" +} + +test_the_mapping_is_total_in_both_directions() { + local reg st name unified declared rows + read -r reg st < <(new_case mapping-total) + write_spec "$reg" good "$READY" + + # Direction one: every terminal state the shipped spec declares resolves to + # exactly one unified state. + declared=$(jq -r '.terminal_states[].name' "$SPEC_SOURCE") + [ -n "$declared" ] || fail "the shipped instance declares no terminal states" + while IFS= read -r name; do + [ -n "$name" ] || continue + ls_run "$reg" "$st" terminal-map --resolve loopspec "$name" + expect_code 0 "$CODE" "declared terminal state $name does not map: $OUT" + unified=$(printf '%s\n' "$OUT" | awk '/LOOPSPEC_TERMINAL_MAP/ {print $5}') + [ -n "$unified" ] || fail "terminal state $name resolved to nothing" + [ "$(printf '%s\n' "$OUT" | grep -c 'LOOPSPEC_TERMINAL_MAP')" -eq 1 ] \ + || fail "terminal state $name resolved to more than one unified state" + done <<<"$declared" + + # Direction two: every finalize outcome the platform declares resolves too. + for name in $NODE_FINALIZE_OUTCOMES; do + ls_run "$reg" "$st" terminal-map --resolve execution-node "$name" + expect_code 0 "$CODE" "node outcome $name does not map: $OUT" + [ "$(printf '%s\n' "$OUT" | grep -c 'LOOPSPEC_TERMINAL_MAP')" -eq 1 ] \ + || fail "node outcome $name resolved to more than one unified state" + done + + # And the map carries no node row the platform does not actually have, so the + # pin above cannot pass by being a subset of an invented vocabulary. + rows=$(ls_run "$reg" "$st" terminal-map --source execution-node; printf '%s' "$OUT") + while IFS= read -r name; do + [ -n "$name" ] || continue + case " $NODE_FINALIZE_OUTCOMES " in + *" $name "*) ;; + *) fail "the map records node outcome $name, which the platform matrix does not declare" ;; + esac + done < <(printf '%s\n' "$rows" | awk -F'\t' 'NF > 1 {print $2}') + + pass "every terminal state in both vocabularies maps to exactly one unified state, and the map invents none" +} + +test_the_merge_is_a_reduction_with_nothing_unreachable() { + local reg st unified_count source_count reachable + + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map --unified + expect_code 0 "$CODE" "the unified vocabulary could not be read: $OUT" + unified_count=$(printf '%s\n' "$OUT" | grep -c 'costs_model_turn=') + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map + expect_code 0 "$CODE" "the mapping rows could not be read: $OUT" + source_count=$(printf '%s\n' "$OUT" | grep -c 'costs_model_turn=') + + [ "$unified_count" -lt "$source_count" ] \ + || fail "the merged vocabulary has $unified_count members against $source_count source states, so it is not a reduction" + + # No unified state may exist that nothing can reach, because an unreachable + # state is a claim about behaviour that cannot happen. + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map --unified + reachable=$(printf '%s\n' "$OUT" | awk -F'sources=' 'NF > 1 && $2 + 0 == 0 {print}') + [ -z "$reachable" ] || fail "unified states no source state reaches: $reachable" + + # The reduction claim is only meaningful if the validator can reject its + # opposite, so watch it reject a map that is not one. + # One unified state per source state: everything is still reachable and every + # other invariant still holds, so only the reduction claim can fail here. + read -r reg st < <(map_case not-a-reduction ' + .unified = ([ .sources[] | .map[] | (.state | gsub("-"; "_")) + | {name: ., kind: "failure", costs_model_turn: true, description: "one name per source state"} ] + | map(if .name == "no_delta" then .kind = "neutral" | .costs_model_turn = false else . end)) + | .sources = (.sources | map(.map = (.map | map(.unified = (.state | gsub("-"; "_"))))))') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a map that renames rather than reduces was accepted" + assert_contains "$OUT" "so the merge is not a reduction" "the missing reduction was not named" + + read -r reg st < <(map_case unreachable-unified \ + '.unified += [{"name": "orphan_state", "kind": "failure", "costs_model_turn": true, "description": "nothing maps here"}]') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "an unreachable unified state was accepted" + assert_contains "$OUT" "is unreachable" "the unreachable unified state was not named" + + read -r reg st < <(map_case undeclared-target '.sources[0].map[0].unified = "invented_state"') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a row mapping to an undeclared unified state was accepted" + assert_contains "$OUT" "not a declared unified state" "the undeclared target was not named" + + pass "the merged vocabulary is strictly smaller than the sum, nothing is unreachable, and the validator rejects the opposite of each claim" +} + +test_no_terminal_state_loses_its_distinction_in_the_merge() { + local reg st collapsed + + # Where the merge does collapse two source names onto one unified state, the + # map must say where the difference they used to carry still lives. This is + # the certification clause made mechanical rather than left as prose. + read -r reg st < <(map_case undeclared-collapse \ + 'del(.sources[] | select(.source == "execution-node") | .map[] | select(.state == "timeout") | .distinction)') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a collapse across differing consequences was accepted without a declared distinction" + assert_contains "$OUT" "does not declare how the distinction is preserved" \ + "the undeclared collapse was not named" + + # Only now does the shipped map's silence mean something. + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" validate + expect_code 0 "$CODE" "the shipped registry no longer validates: $OUT" + + # Every collapse in the shipped map is declared, and the states that carry + # different consequences are not collapsed at all: the node's clean and + # failed iteration caps land on different unified states. + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map --resolve execution-node iteration-cap-clean + expect_code 0 "$CODE" "iteration-cap-clean does not map: $OUT" + collapsed=$(printf '%s\n' "$OUT" | awk '/LOOPSPEC_TERMINAL_MAP/ {print $5}') + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map --resolve execution-node iteration-cap-failed + expect_code 0 "$CODE" "iteration-cap-failed does not map: $OUT" + [ "$collapsed" != "$(printf '%s\n' "$OUT" | awk '/LOOPSPEC_TERMINAL_MAP/ {print $5}')" ] \ + || fail "a clean and a failed iteration cap collapsed onto the same unified state" + + # The one fact this increment exists to name once: the node's failed + # iteration cap and the loop's stall are the same terminal. + assert_contains "$OUT" "-> no_progress_stalled" \ + "iteration-cap-failed no longer unifies with the loop's no_progress_stalled" + + pass "a collapse that would drop a distinction is refused, and the two vocabularies' matching facts unify onto one name" +} + +test_no_delta_still_costs_no_model_turn() { + local reg st free kind + + # The property, as the vocabulary declares it: exactly one unified state may + # be reached without spending a model turn, and it is no_delta. + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" terminal-map --unified + expect_code 0 "$CODE" "the unified vocabulary could not be read: $OUT" + free=$(printf '%s\n' "$OUT" | awk -F'\t' '$3 == "costs_model_turn=false" {print $1}') + [ "$free" = "no_delta" ] \ + || fail "the zero-model-turn state should be exactly no_delta, got: ${free:-none}" + kind=$(printf '%s\n' "$OUT" | awk -F'\t' '$1 == "no_delta" {print $2}') + [ "$kind" = "neutral" ] \ + || fail "no_delta must stay neutral so reaching it can never demand a verifier verdict, got: $kind" + + # Dropping or moving the property is refused, so its presence is a fact the + # validator holds rather than a comment someone remembered to keep. + read -r reg st < <(map_case zero-turn-dropped \ + '(.unified[] | select(.name == "no_delta")).costs_model_turn = true') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a vocabulary with no zero-model-turn state was accepted" + + read -r reg st < <(map_case zero-turn-moved \ + '(.unified[] | select(.name == "no_delta")).costs_model_turn = true + | (.unified[] | select(.name == "cancelled")).costs_model_turn = false') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "the zero-model-turn property was allowed to move off no_delta" + assert_contains "$OUT" "must be no_delta" "the moved property was not named" + + read -r reg st < <(map_case zero-turn-not-neutral \ + '(.unified[] | select(.name == "no_delta")).kind = "failure"') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "no_delta was allowed to stop being neutral" + + # And the property behaviourally: reaching no_delta demands no verifier + # verdict and no evidence, which is what "costs no model turn" means in + # practice. The negative control is the same call against a success terminal. + read -r reg st < <(new_case zero-turn-behaviour) + write_spec "$reg" loop "$READY" + ls_run "$reg" "$st" claim loop --event-key sha-1 --spec-version 1 --headroom 90 + expect_code 0 "$CODE" "the iteration could not be claimed: $OUT" + ls_run "$reg" "$st" finish loop --event-key sha-1 --terminal delta_emitted --verifier-result unavailable + expect_code 1 "$CODE" "a success terminal was reachable without a verifier verdict" + ls_run "$reg" "$st" finish loop --event-key sha-1 --terminal no_delta --verifier-result unavailable + expect_code 0 "$CODE" "no_delta demanded a verifier verdict it must never need: $OUT" + assert_contains "$OUT" "terminal=no_delta kind=neutral" "no_delta was not recorded as the neutral terminal" + + pass "no_delta survives the merge as the one terminal reachable without spending a model turn" +} + +test_a_spec_terminal_cannot_drift_from_the_map() { + local reg st + + read -r reg st < <(new_case terminal-drift) + + write_spec "$reg" no-mapping "$READY | del(.terminal_states[0].maps_to)" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state with no unified mapping was accepted" + assert_contains "$OUT" "missing required field: terminal_states[].maps_to" \ + "the missing mapping was not named" + rm -f "$reg/no-mapping.json" + + write_spec "$reg" invented-mapping "$READY | .terminal_states[0].maps_to = \"PROBABLY_FINE\"" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state mapping outside the unified vocabulary was accepted" + rm -f "$reg/invented-mapping.json" + + write_spec "$reg" wrong-mapping "$READY | .terminal_states[0].maps_to = \"cancelled\"" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state disagreeing with the map was accepted" + assert_contains "$OUT" "but terminal-states.json maps it to" "the disagreement was not named" + rm -f "$reg/wrong-mapping.json" + + write_spec "$reg" unmapped-name \ + "$READY | .terminal_states[0].name = \"invented_terminal\" | .no_progress.terminal = \"no_progress_stalled\"" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state absent from the map was accepted" + assert_contains "$OUT" "has no loopspec row in terminal-states.json" \ + "the unmapped terminal state was not named" + rm -f "$reg/unmapped-name.json" + + write_spec "$reg" wrong-kind "$READY | .terminal_states[1].kind = \"failure\"" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state contradicting its unified kind was accepted" + assert_contains "$OUT" "declares kind" "the kind disagreement was not named" + rm -f "$reg/wrong-kind.json" + + write_spec "$reg" good "$READY" + ls_run "$reg" "$st" validate + expect_code 0 "$CODE" "a correctly mapped spec was refused: $OUT" + + pass "a spec terminal state that is unmapped, invented, disagreeing or miskinded is refused rather than defaulted" +} + test_trigger_register_reports_one_of_sixteen() { ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" triggers --summary expect_code 0 "$CODE" "the trigger summary failed: $OUT" @@ -814,4 +1103,10 @@ test_an_enabled_spec_cannot_name_an_unreachable_verifier test_shipped_registry_is_valid_and_inert test_the_production_loop_is_bound_and_stops_before_the_merge test_shipped_instance_references_rather_than_duplicates_its_skill +test_an_unmapped_terminal_state_is_refused_not_defaulted +test_the_mapping_is_total_in_both_directions +test_the_merge_is_a_reduction_with_nothing_unreachable +test_no_terminal_state_loses_its_distinction_in_the_merge +test_no_delta_still_costs_no_model_turn +test_a_spec_terminal_cannot_drift_from_the_map test_trigger_register_reports_one_of_sixteen From 32c4e93e2e75088a9a2bab647d7096063126203e Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Fri, 7 Aug 2026 18:38:04 -0400 Subject: [PATCH 2/3] no-mistakes(review): guard jq null keys and refuse unknown terminal-map source --- bin/fm-loopspec.sh | 28 ++++++++++++----- tests/fm-loopspec.test.sh | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/bin/fm-loopspec.sh b/bin/fm-loopspec.sh index 45ee9e89ffa..f46f98f0980 100755 --- a/bin/fm-loopspec.sh +++ b/bin/fm-loopspec.sh @@ -213,9 +213,11 @@ validate_structure() { # as an ordinary enum, so schema.json never carries a second copy of it. | (((try ($TM.unified) catch null)) // []) as $unified | ($S.enums + {unified_terminal: ($unified | map(.name))}) as $enums - | ($unified | map({key: .name, value: .kind}) | from_entries) as $unified_kind + # Lookup tables are built only from string-keyed entries: a missing name or + # state is reported by its own field check, never by a crashed from_entries. + | ($unified | map(select((.name | type) == "string") | {key: .name, value: .kind}) | from_entries) as $unified_kind | ((((try ($TM.sources[] | select(.source == "loopspec") | .map) catch null)) // []) - | map({key: .state, value: .unified}) | from_entries) as $loopspec_map + | map(select((.state | type) == "string") | {key: .state, value: .unified}) | from_entries) as $loopspec_map | ((try ($spec.trigger.id) catch null)) as $trigger_id | ((try ($spec.no_progress.terminal) catch null)) as $np_terminal | (((try ($spec.escalation.on) catch null)) // []) as $esc_on @@ -263,12 +265,14 @@ validate_structure() { | select(type == "object") | . as $ts | ( - ( select(($loopspec_map | has($ts.name)) | not) + ( select((($ts.name | type) == "string") + and (($loopspec_map | has($ts.name)) | not)) | "terminal state \"\($ts.name)\" has no loopspec row in terminal-states.json, so no unified state is mapped for it" ), - # Both comparisons stand down when maps_to is absent, so the - # missing required field is reported as itself rather than as a - # mismatch against a value that was never declared. - ( select((($ts.maps_to | type) == "string") + # Every comparison stands down when name or maps_to is absent, so + # the missing required field is reported as itself rather than as + # a mismatch against a value that was never declared. + ( select((($ts.name | type) == "string") + and (($ts.maps_to | type) == "string") and ($loopspec_map | has($ts.name)) and ($ts.maps_to != $loopspec_map[$ts.name])) | "terminal state \"\($ts.name)\" maps_to \"\($ts.maps_to)\" but terminal-states.json maps it to \"\($loopspec_map[$ts.name])\"" ), @@ -324,7 +328,7 @@ terminal_map_problems() { ["sources must be a non-empty array of source vocabularies"] else ($unified | map(.name)) as $names - | ($unified | map({key: .name, value: .}) | from_entries) as $by_name + | ($unified | map(select((.name | type) == "string") | {key: .name, value: .}) | from_entries) as $by_name | [ $sources[] | .source as $s | (.map // [])[] | . + {source: $s} ] as $rows | ($rows | map(.unified)) as $targets | [ @@ -332,6 +336,8 @@ terminal_map_problems() { or (.costs_model_turn | type) != "boolean" or (.description | type) != "string")) | "unified state \(.name // "?") is missing name, kind, costs_model_turn or description" ), + ( ($rows[] | select((.state | type) != "string" or (.unified | type) != "string")) + | "\(.source) source row \(.state // "?") is missing state or unified" ), ( ($names | group_by(.) | map(select(length > 1) | .[0]))[] | "duplicate unified terminal state: \(.)" ), ( ($sources[] | .source as $s | (.map // []) | map(.state) | group_by(.) @@ -663,6 +669,12 @@ cmd_terminal_map() { ' "$(terminals_path)" ;; rows) + # An unknown source vocabulary refuses rather than printing no rows, so a + # typo can never read as "there is no mapping". + if [ -n "$source" ]; then + jq -e --arg src "$source" '(.sources | map(.source) | index($src)) != null' "$(terminals_path)" >/dev/null 2>&1 \ + || refuse refuse_unmapped_terminal "the terminal-state map declares no source vocabulary \"$source\"" + fi jq -r --arg src "$source" ' . as $TM | ($TM.unified | map({key: .name, value: .}) | from_entries) as $by_name diff --git a/tests/fm-loopspec.test.sh b/tests/fm-loopspec.test.sh index a65689283cf..eccf85ade55 100755 --- a/tests/fm-loopspec.test.sh +++ b/tests/fm-loopspec.test.sh @@ -1054,6 +1054,67 @@ test_a_spec_terminal_cannot_drift_from_the_map() { pass "a spec terminal state that is unmapped, invented, disagreeing or miskinded is refused rather than defaulted" } +test_a_malformed_field_is_named_rather_than_crashing_the_validator() { + local reg st + + # Control first: the shipped registry validates, so every refusal below is + # attributable to its one mutation and not to a broken fixture pipeline. + ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" validate + expect_code 0 "$CODE" "the shipped registry does not validate: $OUT" + + # A spec terminal state with no name is reported as its own missing field, + # never as a validator that could not run. + read -r reg st < <(new_case nameless-terminal) + write_spec "$reg" nameless "$READY | del(.terminal_states[0].name)" + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a terminal state with no name was accepted" + assert_contains "$OUT" "missing required field: terminal_states[].name" \ + "the missing name was not reported as its own field" + assert_not_contains "$OUT" "could not run against this file" \ + "a nameless terminal state crashed the validator instead of being named" + + # A unified map entry with no name is caught by the map's own field check. + read -r reg st < <(map_case nameless-unified 'del(.unified[0].name)') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a unified state with no name was accepted" + assert_contains "$OUT" "is missing name, kind, costs_model_turn or description" \ + "the nameless unified state was not reported by its own field check" + assert_not_contains "$OUT" "could not be read" \ + "a nameless unified state crashed the map check instead of being named" + + # A source row with no state is the map's defect and is attributed to the + # map, never to whichever spec would have been validated against it. + read -r reg st < <(map_case stateless-row \ + 'del((.sources[] | select(.source == "loopspec") | .map[0]).state)') + ls_run "$reg" "$st" validate + expect_code 1 "$CODE" "a source row with no state was accepted" + assert_contains "$OUT" "terminal-states.json: loopspec source row" \ + "the stateless row was not attributed to the map" + assert_contains "$OUT" "is missing state or unified" "the stateless row was not named" + assert_not_contains "$OUT" "could not run against this file" \ + "a stateless source row was blamed on a spec instead of the map" + + pass "a malformed spec or map field is reported by its own precise diagnostic instead of crashing the validator" +} + +test_a_source_typo_never_reads_as_an_empty_mapping() { + local reg st + read -r reg st < <(new_case source-typo) + + # The negative control comes first: a command that printed rows for every + # source would prove nothing by printing them for a declared one. + ls_run "$reg" "$st" terminal-map --source execution-nod + expect_code 1 "$CODE" "a misspelled source vocabulary read as an empty mapping" + assert_contains "$OUT" "refuse_unmapped_terminal" "an unknown source vocabulary did not refuse" + assert_contains "$OUT" "execution-nod" "the refusal did not name the unknown source" + + ls_run "$reg" "$st" terminal-map --source execution-node + expect_code 0 "$CODE" "a declared source vocabulary was refused: $OUT" + assert_contains "$OUT" "execution-node" "the declared source printed no rows" + + pass "an unknown source vocabulary refuses rather than reading as an empty mapping" +} + test_trigger_register_reports_one_of_sixteen() { ls_run "$ROOT/loopspecs" "$TMP_ROOT/shipped-state" triggers --summary expect_code 0 "$CODE" "the trigger summary failed: $OUT" @@ -1109,4 +1170,6 @@ test_the_merge_is_a_reduction_with_nothing_unreachable test_no_terminal_state_loses_its_distinction_in_the_merge test_no_delta_still_costs_no_model_turn test_a_spec_terminal_cannot_drift_from_the_map +test_a_malformed_field_is_named_rather_than_crashing_the_validator +test_a_source_typo_never_reads_as_an_empty_mapping test_trigger_register_reports_one_of_sixteen From 73de0aee8aab1c32eaa6d372037468e7d848e031 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 9 Aug 2026 12:33:18 -0400 Subject: [PATCH 3/3] fix(loopspecs): carry the fork-landing spec onto the unified vocabulary The fork trunk gained loopspecs/fork-landing.json after this branch was cut. It still declared the free-form maps_to names this change retires, and two of its terminal states had no row in the unified map at all, so the new terminal_mapped invariant refused the whole registry. Record already_carried and carried_and_checks_resolved as loopspec source rows - no_delta for the one whose description already forbids a model turn, goal_met for the carry success - and move the spec's own maps_to values onto the unified names. tests/fm-loop-actuate.test.sh builds its own registry directory, so it now copies terminal-states.json alongside schema.json; without it every fixture registry is missing a file the interpreter requires. --- loopspecs/fork-landing.json | 12 ++++++------ loopspecs/terminal-states.json | 10 ++++++++++ tests/fm-loop-actuate.test.sh | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/loopspecs/fork-landing.json b/loopspecs/fork-landing.json index 2eb5f4e932a..393563c1cc2 100644 --- a/loopspecs/fork-landing.json +++ b/loopspecs/fork-landing.json @@ -111,37 +111,37 @@ { "name": "already_carried", "kind": "neutral", - "maps_to": "NOOP", + "maps_to": "no_delta", "description": "The fork already carries this contribution and its checks are resolved. Reaching this must cost no model turn." }, { "name": "carried_and_checks_resolved", "kind": "success", - "maps_to": "COMPLETE", + "maps_to": "goal_met", "description": "The contribution reached the fork as an open pull request and that pull request's checks finished running. This asserts the carry and nothing more: it does not assert the checks are green, and the failed count is recorded alongside it so the record can never be read as a green light. Whether the pull request may land is the merge decision, which this loop deliberately never makes." }, { "name": "needs_ruling", "kind": "refusal", - "maps_to": "REFUSED", + "maps_to": "needs_ruling", "description": "Whether this contribution should be carried at all cannot be settled from evidence and requires a captain ruling. The loop stops rather than guessing." }, { "name": "budget_exhausted", "kind": "failure", - "maps_to": "EXHAUSTED", + "maps_to": "budget_exhausted", "description": "The iteration budget was spent before the carry could be verified. Exhaustion is a failure and is never reported as success." }, { "name": "verification_failed", "kind": "failure", - "maps_to": "FAILED", + "maps_to": "verification_failed", "description": "The verifier ran and rejected the iteration, or could not establish the evidence at all. An unavailable verifier lands here and can never become a pass." }, { "name": "no_progress_stalled", "kind": "failure", - "maps_to": "STALLED", + "maps_to": "no_progress_stalled", "description": "Consecutive iterations produced no verifier-declared progress, so the loop stopped instead of repeating forever." } ], diff --git a/loopspecs/terminal-states.json b/loopspecs/terminal-states.json index 10b900ce160..ca59a25ccca 100644 --- a/loopspecs/terminal-states.json +++ b/loopspecs/terminal-states.json @@ -78,6 +78,16 @@ "unified": "goal_met", "note": "The second goal-met success. It already shared a class with delta_emitted before the merge." }, + { + "state": "already_carried", + "unified": "no_delta", + "note": "The fork already carries the contribution, so there is nothing to do. It shares no_delta's zero-model-turn rule, which is why it lands here rather than on a success." + }, + { + "state": "carried_and_checks_resolved", + "unified": "goal_met", + "note": "A third goal-met success. What the success produced - here, a carried contribution whose checks finished - is the spec's own business, not a vocabulary distinction." + }, { "state": "needs_ruling", "unified": "needs_ruling", diff --git a/tests/fm-loop-actuate.test.sh b/tests/fm-loop-actuate.test.sh index 053a47f9eae..4f74810b08c 100755 --- a/tests/fm-loop-actuate.test.sh +++ b/tests/fm-loop-actuate.test.sh @@ -43,6 +43,7 @@ new_case() { local d="$TMP_ROOT/$1" mkdir -p "$d/registry" "$d/state" cp "$ROOT/loopspecs/schema.json" "$d/registry/schema.json" + cp "$ROOT/loopspecs/terminal-states.json" "$d/registry/terminal-states.json" cat >"$d/registry/triggers.json" <<'JSON' { "loopspec_schema_version": 1,