Skip to content

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

Merged
sbracewell64 merged 3 commits into
mainfrom
fm/cfvc-11-terminal-vocabulary
Aug 9, 2026
Merged

sbracewell64 merged 3 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.

@sbracewell64
sbracewell64 force-pushed the fm/cfvc-11-terminal-vocabulary branch from 83c426c to 3b16a33 Compare August 9, 2026 14:41
… 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.
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.
@sbracewell64
sbracewell64 force-pushed the fm/cfvc-11-terminal-vocabulary branch from 3b16a33 to 73de0ae Compare August 9, 2026 16:33
@sbracewell64

Copy link
Copy Markdown
Owner Author

Rebased onto the current fork trunk

Rebased onto 6e8bc24 (fork main). Pre-rewrite head 3b16a33 is preserved at archive/prerebase-2026-08-09/cfvc-11-terminal-vocabulary; the head branch is fork-only and is not shared with any open upstream PR.

Conflicts and how they were resolved

Both conflicts were additive on each side, and both resolved as a union:

  • loopspecs/schema.json - trunk added the required_terminal_kinds_present invariant and widened the wording of required_terminals_present; this branch added terminal_mapped and terminal_kind_agrees. All four invariants are kept.
  • bin/fm-loopspec.sh - validate_registry's local declaration gained vproblem on trunk and map_problems here. Both are still read in the merged body, so both are declared.

One integration fix the rebase exposed

Trunk gained loopspecs/fork-landing.json after this branch was cut. It still used the free-form maps_to names this change retires, and already_carried / carried_and_checks_resolved had no row in the unified map, so the new terminal_mapped invariant refused the whole registry. Added as loopspec source rows - already_carried to no_delta (its own description already forbids a model turn) and carried_and_checks_resolved to goal_met - and moved the spec's maps_to values onto the unified names. tests/fm-loop-actuate.test.sh builds its own registry directory and now copies terminal-states.json beside schema.json.

Test results, disclosed in full

bin/fm-test-run.sh --changed --base fork/main selected 54 suites; 4 failed on the first pass.

  • tests/fm-loopspec.test.sh and tests/fm-loop-actuate.test.sh - failed for the integration gap above. Both pass after the fix (total=2 failed=0).
  • tests/fm-calm-pi-extension.test.sh and tests/fm-busy-adapter-wiring.test.sh - pre-existing and unrelated. Both fail identically on unmodified fork/main in a clean checkout, with a local Node ESM loader error (node:internal/modules/esm/get_format:219), not a branch defect.

bin/fm-lint.sh is clean, and bin/fm-loopspec.sh validate reports LOOPSPEC_VALIDATE ok specs=2.

The failing-suite reporting was not trusted on absence: this run produced real not ok lines and a non-zero exit from the same runner, and the two fixed suites went from failing to passing on the same command.

@sbracewell64
sbracewell64 merged commit 5004452 into main Aug 9, 2026
13 of 14 checks passed
sbracewell64 added a commit that referenced this pull request Aug 9, 2026
… mapping (#60)

* 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.

* no-mistakes(review): guard jq null keys and refuse unknown terminal-map source

* 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.
sbracewell64 added a commit that referenced this pull request Aug 10, 2026
… mapping (#60)

* 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.

* no-mistakes(review): guard jq null keys and refuse unknown terminal-map source

* 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.
sbracewell64 added a commit that referenced this pull request Aug 11, 2026
… mapping (#60)

* 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.

* no-mistakes(review): guard jq null keys and refuse unknown terminal-map source

* 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.
sbracewell64 added a commit that referenced this pull request Aug 11, 2026
… mapping (#60)

* 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.

* no-mistakes(review): guard jq null keys and refuse unknown terminal-map source

* 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.
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