Skip to content

feat(loopspecs): unify the terminal-state vocabulary behind one owned mapping - #60

Open
sbracewell64 wants to merge 2 commits into
mainfrom
fm/cfvc-11-terminal-vocabulary
Open

feat(loopspecs): unify the terminal-state vocabulary behind one owned mapping#60
sbracewell64 wants to merge 2 commits into
mainfrom
fm/cfvc-11-terminal-vocabulary

Conversation

@sbracewell64

@sbracewell64 sbracewell64 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

What this does

CFVC-11: one terminal-state vocabulary, owned once, consumed by both primitives.

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 (NOOP, COMPLETE, REFUSED, FAILED, EXHAUSTED, STALLED).

loopspecs/terminal-states.json now owns a unified vocabulary of nine members plus the total mapping onto it from both source vocabularies.

unified from LoopSpec from execution node
no_delta no_delta -
goal_met delta_emitted, confirmed_work_found finish-signal
budget_exhausted budget_exhausted context-ceiling, budget-finish, timeout, iteration-cap-clean
no_progress_stalled no_progress_stalled iteration-cap-failed
verification_failed verification_failed -
blocked_by_evidence_integrity blocked_by_evidence_integrity -
needs_ruling needs_ruling -
cancelled - stop-signal
unclassified_failure - unclassified

16 source names resolve to 9 unified names. Every unified name is reachable.

Scope boundary

Only this repo changes. The platform's FINALIZE_MATRIX was read at platform commit 5d86b7e and no platform file is edited; the platform-side rename is a follow-on task there. This is deliberate, not an omission.

Two disclosed judgment calls

The reduction is 7 names, not the planned 8. The one merge declined is needs_ruling with cancelled: a ruling is owed in the first and is not owed in the second, so they are different facts with different next actions. Collapsing them would have hit the planned number by losing exactly the distinction the certification clause forbids losing. The binding criterion is "strictly fewer members than the sum", which holds.

Four platform bounded stops merge onto one budget_exhausted. This follows the platform's own stated rationale that a deadline is a bound like any other and that the exit code, not the name, carries clean versus unclean. timeout is the only one of the four whose recorded consequence differs (EXHAUSTED/1 rather than EXHAUSTED/0), so it carries an explicit declared distinction and the validator refuses the map if that declaration is removed. That is how "no terminal state may lose its distinction in the merge" was made machine-checkable rather than left as prose.

A genuine cross-system disagreement is recorded rather than papered over: budget_exhausted is kind failure on the LoopSpec side, while the platform books context-ceiling, budget-finish and iteration-cap-clean as EXHAUSTED with a clean exit code. Each platform row records its status and exit code so the follow-on rename task can settle it.

Enforcement, not documentation

bin/fm-loopspec.sh checks the map 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 terminal_states[].maps_to required and typed enum:unified_terminal, resolved against the map through a new external_enums pointer rather than restating the member list. A spec terminal state that is unmapped, invented, disagreeing with the map, or contradicting its unified kind is refused rather than defaulted.

New subcommand fm-loopspec.sh terminal-map with --unified, --source, --resolve and --json. An unmapped state or an undeclared source vocabulary refuses with the new stable token refuse_unmapped_terminal.

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 be kind neutral so reaching it can never demand a verifier verdict.

Check state - read this before merging

CI is 12 of 14 green. Both failures are accounted for below, and neither is caused by this change.

check result why
Behavior portable serial 2 fail Pre-existing, unrelated, and proven so - see below
PR must be raised via no-mistakes fail Expected: this PR was opened by hand, see below
the other 12 pass Lint shell scripts, Repo invariants, Test coverage guard, Windows launcher bridge, Stock macOS Bash snapshot compatibility, Behavior tests (Herdr), Behavior timing aggregate, Behavior portable parallel 1-2, Behavior portable serial 1, 3, 4

Behavior portable serial 2 is pre-existing and not mine. The failing assertion is first inheritance transaction never reached its blocked write in tests/fm-remote-secondmate-lifecycle-e2e.test.sh:732. It is a timing wait - 250 iterations of sleep 0.02, so a 5 second ceiling - on a backgrounded remote config push reaching its blocked write. It concerns remote secondmate inheritance and touches nothing this branch changes.

I proved it rather than assuming it: the same test was run on the base commit ed376cf in a throwaway clone with loopspecs/terminal-states.json absent, and it failed identically - 14 pass, 1 fail, same assertion. It fails the same way at this branch's head. The base is red here on its own.

PR must be raised via no-mistakes is an expected artifact of how this PR had to be opened. The pipeline ran in full, but it opened its pull request at the upstream venue (kunchenguid/firstmate), where this branch appeared as 48 commits and 204 files and reported mergeable: false. That PR was withdrawn and this fork PR opened by hand in its place, which is what the check is objecting to. Against this fork base the branch is what it should be: 2 commits, 7 files, mergeable: true.

The no-mistakes pipeline returned passed, but two of its nine steps did not run normally, and both were deliberate:

  • rebase was skipped. This branch sits on the fork trunk, which is 46 commits ahead of upstream. Rebasing onto upstream would have bundled that entire fork landing queue into this PR's contribution history.
  • ci was skipped. Its only finding was the merge conflict against that upstream base, which was an artifact of the wrong venue rather than of the change. The checks that matter ran here instead, on this fork PR, and are tabulated above.

Also worth stating plainly: all three of the pipeline's CI auto-fix attempts failed on an agent session limit rather than on conflict logic, so "max auto-fix attempts reached" overstates what was actually tried.

What did run and pass: intent, review, test, document, lint, push, pr.

Test evidence

Verified locally at 83c426c, working tree clean:

  • tests/fm-loopspec.test.sh - 31 of 31 pass, 0 failures
  • bin/fm-lint.sh - clean, ShellCheck 0.11.0 (the pinned version CI and the pre-push gate both use)
  • bin/fm-doc-audience-check.sh - clean, 72 surfaces, 213 local links
  • tests/fm-documentation-audiences.test.sh - 4 of 4 pass
  • tests/fm-research-scan.test.sh - 13 of 13 pass

Every new assertion drives bin/fm-loopspec.sh through its public interface - exit status and stable refusal tokens - never by reading the script's source, and each witnesses its negative control failing before the positive result is trusted.

The map itself was mutation-tested three ways, with each mutation watched failing before the passes were trusted: dropping a source row, inventing a platform outcome the matrix does not declare, and collapsing the stall distinction onto budget_exhausted.

The platform's eight finalize outcomes are pinned as literal names in the test rather than read from the platform repository, which is not present in CI. The test asserts both directions - every pinned outcome resolves, and the map records no outcome the platform does not declare - so a platform-side vocabulary change surfaces as a failing test here rather than as silent drift.

Review findings this branch carries

The review step caught two real regressions in the first commit, both fixed in 83c426c:

  • Missing jq string-type guards meant a spec terminal state without a name, a unified entry without a name, or a source row without a state aborted the validator and suppressed every precise diagnostic for that file. Behaviour stayed fail-closed throughout - nothing unsound would have passed - but where the base cleanly reported missing required field: terminal_states[].name, the first commit printed only a generic "the validator could not run against this file". The precise diagnostics are restored and regression-tested.
  • terminal-map --source <typo> printed nothing and exited 0, so a misspelled value read as an empty mapping. It now refuses, consistent with --resolve and with the principle cmd_select already states: a typo must never read as "there is no work to do".

One review finding was deliberately not acted on: the contract-file guard covers show only, so state terminal-states still prints {} and exits 0. That behaviour is pre-existing for the schema and triggers ids as well - confirmed against the base - it already fails closed, and extending it into load_spec_or_refuse widens scope beyond this change. It is filed as a follow-up.

Later consumers

CFVC-13's attempt-budget terminal state and CFVC-12's outcome enum will consume this vocabulary. Neither is implemented here.

… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant