Skip to content

feat(bin): enforce the zero-budget model rule with a model registry, spawn gate, and probe verification - #1267

Open
sbracewell64 wants to merge 3 commits into
kunchenguid:mainfrom
sbracewell64:fm/model-onboarding-implement
Open

feat(bin): enforce the zero-budget model rule with a model registry, spawn gate, and probe verification#1267
sbracewell64 wants to merge 3 commits into
kunchenguid:mainfrom
sbracewell64:fm/model-onboarding-implement

Conversation

@sbracewell64

Copy link
Copy Markdown

Intent

Implement the model-onboarding framework per the accepted design at data/model-onboarding-policy/report.md: make the fleet's zero-budget safety rule enforceable by code instead of prose, and add the registry, validation, quota guards, policy docs, tests and fixtures around it.

WHY THIS EXISTS. The rule 'the budget for every API-key provider is ZERO ... this is a safety rule, not a preference' lived only inside a JSON comment blob in config/crew-dispatch.json that no script read. The hazard is that one API key commonly reaches both free and metered models on the same provider, rendered identically in catalogue listings that have no cost column and no entitlement column, so one plausible model name is a charge. A prior incident already proved the fleet routes from a plausible name without checking: a model was configured from a catalogue listing, never probed, and every dispatch to that tier failed at launch until an investigation found it.

DELIBERATE DECISIONS A REVIEWER SHOULD NOT FLAG AS MISTAKES. All eight were reviewed and approved by the supervising coordinator before implementation began; the staging plan is at data/model-onboarding-implement/design.md.

  1. Enforcement is ASYMMETRIC about config/models.json being absent, and this is the single most likely thing to look like a fail-open bug. With no registry the spawn check is intentionally INERT so behavior stays byte-identical for homes that never opted in (an additive-compatibility guarantee the design promised so the change stays upstreamable); bootstrap then prints MODEL_REGISTRY reporting the unenforced state so it is never silent. With the file present every unclear answer refuses: malformed JSON, unsupported schema version, unclassified provider, stale-evidence allowlist entry, and missing jq all refuse. This was explicitly ruled, not overlooked.

  2. THREE SEPARATE AXES are kept deliberately un-unified, which may look like duplicated logic: cost (fm_model_zero_budget_decision), routability (fm_model_routable_decision), availability (state/model-health.json). Folding any two together is itself the failure mode. The model that broke a whole tier sat on a FLAT SUBSCRIPTION, so the cost rule correctly allows it and only its recorded status refuses it. And a rate-limited model must be unavailable rather than demoted, or every transient outage would permanently degrade the routing table. Availability lives in state/ and routing status in config/, written by different code, so the separation is structural.

  3. Allowlist evidence requires a PRICE-BEARING source (provider-doc or harness-static-catalogue); a probe deliberately does NOT satisfy it. This is intentionally STRICTER than the report's own recommendation. A probe proves the account gets an answer and says nothing about what the answer costs, so treating 'it responded' as 'it is free' is the shape of the billing incident rather than a defence against it. Consequence: two models the report suggested allowlisting on probe evidence are recorded blocked instead.

  4. The promotion system ships DORMANT and has NO ledger reader, which may look unfinished. That is required: the wake-outcome-ledger's terminal-line format is being defined in parallel by another worker, and writing a parser against a guessed format is exactly the 'configured from a plausible name, never verified' failure this whole change exists to prevent. Implemented instead: schema, validation, the authority ceiling, and the dormancy predicate. Activation is a config plus data condition, never a code change.

  5. Promotion authority is validated as a CEILING in each direction, so a registry may be more conservative but never more permissive: Tier 4 to Tier 3 may be automatic, Tier 3 to Tier 2 requires captain confirmation, Tier 1 and Tier 0 are never entered by accumulated evidence. Tier 1 is triggered by risk rather than capability rank.

  6. The spawn check is placed at exactly one line (after the secondmate model token can still overwrite MODEL, before any worktree/endpoint/metadata creation) so a refusal leaves nothing to clean up. The design report suggested two other line numbers; both were wrong on inspection and the header explains why.

  7. AGENTS.md grew by exactly THREE lines by design. It is loaded by every session of every fleet member, so the repo's own coding-guidelines skill mandates routing conditional detail into a skill plus a one-line trigger. The full policy is in .agents/skills/model-onboarding/SKILL.md and the schema in docs/configuration.md, each a single owner.

  8. config/models.json is added to FM_INHERITABLE_CONFIG next to crew-dispatch.json and must not be separated from it: inheriting dispatch rules without the registry would leave a secondmate's own crewmates outside enforcement AND make every inherited model read as unregistered there.

TEST CHANGES THAT ARE INTENTIONAL, NOT COLLATERAL. tests/fm-bootstrap.test.sh fixtures gained covering registries because they route provider-prefixed models with no cost evidence, which is precisely the state that must not be silent; the contract is 'a FULLY CONFIGURED home is silent', and the registry-absence case is owned by the new suite instead. Both new suites also recreate their temp dir and own their EXIT trap, because tests/lib.sh's fm_test_tmproot runs its cleanup inside the command substitution so the path it returns is already deleted; existing suites survive only because they mkdir subdirs. SC2016 is disabled file-wide in the registry suite because every single-quoted dollar-name there is a jq variable bound by --arg.

TWO BUGS FOUND AND FIXED DURING SELF-VERIFICATION, both with regression tests: the spawn gate originally checked only cost, so an explicit --model naming a rejected model would still have launched; and the probe sweep's due-selection query indexed the status array instead of the entry, and the failure was swallowed, so the sweep would have probed nothing forever while appearing healthy.

DELIBERATELY OUT OF SCOPE. Local config is NOT applied by this change: the primary home's config/models.json and config/crew-dispatch.json edits ship as reviewed artifacts under data/model-onboarding-implement/ for the coordinator to apply, because writing another home's config would cross the project-write boundary. No metered or Copilot model was probed (billable, and Copilot is dropped by captain decision). The evaluation suite, capability scores, provider-health abstraction and shadow dual-dispatch are all explicitly rejected or dormant in the accepted design.

What Changed

  • Added a model registry framework (bin/fm-model-registry-lib.sh, docs/examples/models.json schema) that makes the fleet's zero-budget API-key rule enforceable by code: bin/fm-spawn.sh now refuses to launch a metered or unroutable model at a single gate point before any worktree or state is created, and bin/fm-bootstrap.sh validates the registry at session start and config-edit time — loud when malformed or unenforced, byte-identical behavior for homes with no config/models.json. Cost, routability, and availability are kept as three deliberately separate decision axes, and the promotion system ships dormant with its authority ceiling validated.
  • Added bin/fm-model-verify.sh for health probes with a due-selection sweep wired into bootstrap; review-driven fixes cost-gate the probe paths, surface sweep stderr instead of swallowing it, and repair the due-selection query that previously indexed the status array and would have silently probed nothing.
  • Added config/models.json to the secondmate inheritance allowlist alongside crew-dispatch.json, documented the full policy in .agents/skills/model-onboarding/SKILL.md and docs/configuration.md, and covered the change with three new test suites (fm-model-registry, fm-model-zero-budget, fm-bootstrap fixtures) — all passing in the pipeline along with adjacent spawn/inheritance regression suites.

Risk Assessment

✅ Low: All three supervisor-ruled fixes are correctly implemented with regression tests that cover the previously untested bootstrap path, no live-probe can be triggered from tests, and the only remaining findings are informational tradeoffs the supervisor has already ruled on.

Testing

Ran the two new suites (64 checks) plus the modified bootstrap suite and two adjacent regression suites, all passing, then demonstrated the end-user surfaces live: fm-spawn refusing metered and rejected models with actionable errors and zero leftover state, admitting an allowlisted free model, bootstrap printing the inert-but-never-silent notice and fail-closed diagnostics, and models.json inheriting into a secondmate home; transcripts captured as evidence (CLI-only change, no rendered UI).

Evidence: fm-spawn zero-budget gate transcript (refusals, admission, clean-state proof)

$ fm-spawn zb-demo ~/projects/demo --harness pi --model opencode/deepseek-v4-metered --effort low error: zero-budget rule refuses opencode/deepseek-v4-metered: opencode is an API-key provider and "opencode/deepseek-v4-metered" is not on the verified-free allowlist (allowlist: .../config/models.json -> zero_budget.allowlist) exit: 1 $ fm-spawn ... --model openai-codex/withdrawn-model error: the model registry records openai-codex/withdrawn-model as rejected: live probe returned a server-side entitlement refusal for this account exit: 1 $ fm-spawn ... --model google/gemini-2.5-flash # allowlisted verified-free error: no brief at .../data/zb-demo/brief.md # past all model gates $ ls ~/state ~/data # refusals left nothing behind (empty)

### 1. Metered sibling on an API-key provider: refused by the zero-budget rule
$ fm-spawn zb-demo ~/projects/demo --harness pi --model opencode/deepseek-v4-metered --effort low
NOTICE: auto-detected herdr runtime (HERDR_ENV=1) - spawning into the EXPERIMENTAL herdr backend. Set config/backend or pass --backend tmux to opt out.
error: zero-budget rule refuses opencode/deepseek-v4-metered: opencode is an API-key provider and "opencode/deepseek-v4-metered" is not on the verified-free allowlist (allowlist: /tmp/fm-zb-demo.eSzJkq/config/models.json -> zero_budget.allowlist)
exit: 1

### 2. Model the registry records as rejected (entitlement refusal): refused on routability
$ fm-spawn zb-demo ~/projects/demo --harness pi --model openai-codex/withdrawn-model --effort low
NOTICE: auto-detected herdr runtime (HERDR_ENV=1) - spawning into the EXPERIMENTAL herdr backend. Set config/backend or pass --backend tmux to opt out.
error: the model registry records openai-codex/withdrawn-model as rejected: live probe returned a server-side entitlement refusal for this account (registry: /tmp/fm-zb-demo.eSzJkq/config/models.json)
exit: 1

### 3. Allowlisted verified-free model: passes the zero-budget gate
$ fm-spawn zb-demo ~/projects/demo --harness pi --model google/gemini-2.5-flash --effort low
NOTICE: auto-detected herdr runtime (HERDR_ENV=1) - spawning into the EXPERIMENTAL herdr backend. Set config/backend or pass --backend tmux to opt out.
error: no brief at /tmp/fm-zb-demo.eSzJkq/data/zb-demo/brief.md
exit: 1

### 4. Nothing left behind by the refusals
$ ls ~/state ~/data
/tmp/fm-zb-demo.eSzJkq/data:

/tmp/fm-zb-demo.eSzJkq/state:
Evidence: fm-bootstrap model-registry diagnostics transcript (4 scenarios)

### A. Registry ABSENT + provider model routed MODEL_REGISTRY: no config/models.json, so the zero-budget rule is not enforced for routed provider models: opencode/deepseek-v4-flash-free ### B. Registry PRESENT and consistent (no model-registry diagnostics - silent) ### C. Malformed registry MODEL_REGISTRY: invalid config/models.json - malformed JSON ### D. Dispatch routes an unregistered model MODEL_REGISTRY: opencode/unprobed-model is named in config/crew-dispatch.json but absent from config/models.json

### A. Registry ABSENT + dispatch routes a provider-prefixed model: inert but never silent
$ mv config/models.json config/models.json.bak && fm-bootstrap (detect-only)
MODEL_REGISTRY: no config/models.json, so the zero-budget rule is not enforced for routed provider models: opencode/deepseek-v4-flash-free

### B. Registry PRESENT and consistent: silent (a fully configured home is silent)
$ mv config/models.json.bak config/models.json ; add routed model to registry ; fm-bootstrap
(no model-registry diagnostics - silent)

### C. Registry PRESENT but malformed: refuses loudly, never reads as absent
$ echo "{ broken" > config/models.json && fm-bootstrap
MODEL_REGISTRY: invalid config/models.json - malformed JSON

### D. Dispatch routes a model the registry does not know: caught at config-edit time
$ add rule routing opencode/unprobed-model && fm-bootstrap
MODEL_REGISTRY: opencode/unprobed-model is named in config/crew-dispatch.json but absent from config/models.json
Evidence: models.json secondmate inheritance transcript

$ propagate_secondmate_inheritance <primary> <secondmate> $ ls secondmate/config/ crew-dispatch.json models.json $ cmp primary/config/models.json secondmate/config/models.json && echo identical identical

### models.json travels with crew-dispatch.json into a secondmate home
$ propagate_secondmate_inheritance <primary> <secondmate>
$ ls secondmate/config/
crew-dispatch.json
models.json
$ cmp primary/config/models.json secondmate/config/models.json && echo identical
identical

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-model-verify.sh:144 - The due-selection failure diagnostic ('MODEL_VERIFY: could not determine which models are due for a probe') is printed to stderr only, and the sole automated caller, model_probe_sweep at bin/fm-bootstrap.sh:768, invokes the script as out=$(fm-model-verify.sh 2&gt;/dev/null || true), discarding both stderr and the exit code. If the select_due jq program ever fails at runtime, the session-start sweep is silent while appearing healthy - the exact swallowed-failure mode the change's self-verification fix was meant to close (the regression test only calls the script directly with 2>&1). Emit this diagnostic on stdout so bootstrap forwards it like every other MODEL_VERIFY line.
  • ⚠️ bin/fm-model-verify.sh:181 - Neither the automatic sweep nor the explicit --model path consults fm_model_zero_budget_decision before issuing a live request: --model probes any registered model regardless of recorded cost class (the zero-budget test fixture itself registers a metered blocked model that --model would probe), and select_due probes any approved-* model even when it sits on an api-key provider with no allowlist entry, a combination the validator never cross-checks. The skill states a probe 'is itself a billable act on a metered provider', yet the G2-before-G3 probe-authorization ordering is enforced only by prose - the pattern this change exists to replace. If --model is intended as the captain's explicit authorization this may be deliberate, but at minimum the unattended sweep path could apply the cost decision before probing.
  • ℹ️ tests/fm-model-registry.test.sh:327 - test_registered_probed_model_passes falls back to date -u +%s (current time) when GNU date -d is unavailable (macOS/BSD). Once wall-clock time passes ~2026-10-25, the fixture's 2026-07-27 probe exceeds the 90-day O4 window and the test starts failing on those platforms. The adjacent test_stale_probe_evidence_reported already uses a fixed-epoch fallback (echo 1780000000); use a fixed epoch here too.
  • ℹ️ bin/fm-model-registry-lib.sh:209 - fm_model_registry_validate, fm_model_registry_integrity, and the drift scan all use $(jq ... 2&gt;/dev/null || true) and treat empty output as 'no findings', so a jq invocation that fails outright (e.g. an old jq that cannot compile the program) reads as a clean registry at bootstrap. Exposure is limited to the detect-only reporting path: the spawn-side decision functions independently fail closed because an empty verdict refuses. Checking jq's exit status separately from its output would keep the detect layer honest.
  • ℹ️ bin/fm-bootstrap.sh:730 - The routed-model extraction jq (the profiles/named-provider-models snippet) is duplicated in three places: fm_model_registry_integrity in bin/fm-model-registry-lib.sh, the no-registry branch of model_registry_validate in bin/fm-bootstrap.sh, and write_covering_registry in tests/fm-bootstrap.test.sh. Exporting one jq def string from the registry lib would keep the three copies from drifting.
  • ℹ️ bin/fm-model-registry-lib.sh:379 - fm_model_concurrency_decision counts state/*.meta at check time, but the spawn lock is per-task-id, so two concurrent spawns of different tasks on the same model (or pool) can both pass the cap check before either writes its meta, breaching the cap by one. The guard deliberately errs toward refusing on stale metas but under-counts this race; likely acceptable given firstmate serializes dispatches, noted as a known tradeoff.

🔧 Fix: cost-gate probe paths, surface sweep stderr, fix test epoch
1 info still open:

  • ℹ️ bin/fm-model-verify.sh:197 - Cost-refusal and --force-probe announcement lines print on stdout before the health record, so fm-model-verify.sh --json emits non-JSON prefix lines whenever a due model is cost-refused or force-probed. No automated caller uses --json (bootstrap invokes the script bare), and stdout visibility for these lines is mandated by the supervisor ruling, so this is a noted tradeoff of the ruling rather than a defect; if a --json consumer ever appears, the announcements could be emitted only in non-JSON mode while keeping the refusal itself intact.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-model-registry.test.sh — 37 checks, all pass (schema validation, freshness, integrity, price drift, probe classifier, promotion dormancy, sweep due-selection regression)
  • bash tests/fm-model-zero-budget.test.sh — 27 checks, all pass (asymmetric enforcement, axis separation, explicit --model regression, concurrency caps, bootstrap loudness)
  • bash tests/fm-bootstrap.test.sh — all pass with the new covering-registry fixtures
  • bash tests/fm-spawn-dispatch-profile.test.sh and bash tests/fm-shared-captain-inheritance.test.sh — adjacent regression suites, all pass
  • Manual: drove real bin/fm-spawn.sh against a temp home — metered model refused naming the allowlist path, rejected model refused on routability with recorded reason, allowlisted free model cleared the gate, refusals left state/ and data/ empty
  • Manual: drove real bin/fm-bootstrap.sh (detect-only) — MODEL_REGISTRY unenforced notice with registry absent, silence when fully configured, loud refusal on malformed registry, config-edit-time catch of an unregistered routed model
  • Manual: drove propagate_secondmate_inheritance — models.json copied byte-identically alongside crew-dispatch.json
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@kunchenguid

kunchenguid commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch.

When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again.

Noted for firstmate#1267 at db0b2d59.

…t time

The fleet's stated safety rule - "the budget for every API-key provider is
ZERO ... this is a safety rule, not a preference" - was implemented as prose
inside a JSON comment blob that no code read. It relied on the coordinator
recalling it correctly at every intake and every failover, forever.

That is load-bearing because one API key commonly reaches both free and
metered models on the same provider, rendered identically in every catalogue
listing (six columns, no cost column, no entitlement column). A single
mistyped or well-meant model name is a charge. A separate incident had
already shown the fleet will route from a plausible name without checking:
a model was configured from a catalogue listing, never probed, and every
dispatch to that tier failed at launch until an investigation found it.

Add config/models.json (local, gitignored) as the enforced copy, plus the
checks that read it:

- fm-spawn refuses a model whose API-key provider is not on the verified-free
  allowlist, whose provider cost posture is unclassified, whose registry
  status is rejected or blocked, or whose concurrency cap is already met.
  The check sits at the first point where harness and model are both final
  and the last point before any mutation, so a refusal creates nothing. It is
  also the only gate that sees an explicit --model that bypassed the dispatch
  config, which bootstrap validation structurally cannot see.
- bootstrap binds config/crew-dispatch.json to the registry, so a rule naming
  an unregistered, non-approved, or unprobed model fails at config-edit time.
- fm-model-verify runs the entitlement probe and the price-drift comparison,
  interval-gated by observation level so the steady-state cost is one file
  read. Probes close stdin and run under a timeout; pi -p can otherwise hang
  unbounded, and a wedged probe on the session-start path would present to
  supervision as a stale session.

Three axes are kept deliberately separate, because conflating any two of them
is itself a failure mode: cost (can this call be billed), routability (is the
account entitled to it), and availability (is it answering right now). A
rate-limited model is unavailable, not demoted, so a transient outage cannot
permanently degrade the routing table; availability lives in state/ and
routing status in config/, written by different code.

Enforcement is asymmetric about the registry's absence, by design. With no
config/models.json the spawn check is inert and behavior is byte-identical to
before, so nothing is forced on a home that never opted in; bootstrap then
reports the unenforced state rather than leaving it silent. With the file
present every unclear answer refuses - malformed JSON, an unsupported schema,
an unclassified provider, a missing jq - because a broken safety file must
never read as an absent one.

The allowlist stores each price numerically rather than only a cost class,
which is what makes a repricing detectable at all: a name-based allowlist is
structurally blind to one, since the thing that makes a name safe is a number
living in a catalogue the provider rewrites. Allowlist evidence must include
a genuinely price-bearing source; a probe is deliberately not enough, because
it proves the account gets an answer and says nothing about what that answer
costs.

The promotion system ships dormant behind a config flag and a named evidence
instrument, so activation is a configuration and data change rather than a
code change. Its authority is validated as a ceiling in each direction:
Tier 4 to Tier 3 may be automatic, Tier 3 to Tier 2 needs captain
confirmation, and Tier 1 and Tier 0 are never entered by accumulated
evidence - Tier 1 is triggered by risk, not capability rank, and a spotless
Tier 2 record demonstrates nothing about credential or destructive-operation
judgment.

config/models.json is inherited by secondmate homes alongside
config/crew-dispatch.json and must not be separated from it: inheriting the
rules without the registry would leave a secondmate's own crewmates outside
enforcement and make every inherited model read as unregistered there.
@sbracewell64
sbracewell64 force-pushed the fm/model-onboarding-implement branch from db0b2d5 to 2782d78 Compare July 30, 2026 20:39
@kunchenguid kunchenguid removed the wheelhouse:pending-contributor-action Managed by Wheelhouse label Jul 30, 2026
@sbracewell64

Copy link
Copy Markdown
Author

Rebased and conflict-free at 2782d78; fork-side CI 10/10 green (mirror: sbracewell64#10); upstream workflow runs awaiting approval.

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.

2 participants