Skip to content

[Spec 1286] consult: configurable per-lane models and per-review-type lane selection - #1341

Open
waleedkadous wants to merge 100 commits into
mainfrom
builder/aspir-1286
Open

[Spec 1286] consult: configurable per-lane models and per-review-type lane selection#1341
waleedkadous wants to merge 100 commits into
mainfrom
builder/aspir-1286

Conversation

@waleedkadous

@waleedkadous waleedkadous commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #1286.

Lets a workspace choose which model each consult lane runs, and which lanes run at all — per
protocol and per review type — from .codev/config.json.

This removes the incentive behind the issue: the requesting workspace had been tier-2-shadowing
spir/aspir/pir protocol.json copies to change lane composition, recreating exactly the
stale-shadow-copy rot class PR #1281 had just cleaned up (17 drifted files).

Absent config, nothing changes — ids included.

{
  "consult": { "models": { "claude": "claude-opus-5", "codex": "gpt-5.6-sol" } },
  "porch": {
    "consultation": {
      "models": ["gemini", "codex", "claude"],
      "byProtocol": { "pir": { "models": ["gemini", "codex"] } }
    }
  }
}

⚠️ Behavior change (one, deliberate) — blast radius

porch done no longer swallows config errors. It previously wrapped config loading in a bare
catch that turned any error into a silent fall-back to protocol defaults.

Who is affected: only a workspace whose porch.consultation config is already malformed. That
workspace currently limps along on protocol defaults. After this change, porch done fails with:

Invalid consultation model "codexx" in porch.consultation.byProtocol.pir.models.
Valid models: "gemini", "codex", "claude", "hermes". Special modes: "none", "parent".

The error names the offending key, the value, and the valid alternatives, so the fix is the next
thing the user does. A workspace with valid or absent config sees no change.

Worth noting the previous state was worse than either alternative: porch next already failed this
way, so a typo made next refuse to run while done quietly demanded a different lane set — with
neither command printing the set it derived. Fail-fast, no fallbacks.

📋 Review history — what was and was not reviewer-approved

Stated in the PR, not only in the review file, so the gate reader can see it without opening
anything.

Phase Outcome
1–5 Unanimous codex + claude APPROVE
6 (docs) Force-advanced at the iteration cap — NOT approved
Spec Force-advanced at the iteration cap — NOT approved

Phase 6 ended iteration 3 with codex REQUEST_CHANGES / claude APPROVE, hit
max_iterations: 3, and porch force-advanced (force_advanced record in status.yaml). The fixes
for codex's iter3 finding were committed but never re-reviewed. They are docs-only — JSON examples
made parseable, real pricing rates, PIR example consistency (2cb1e2f7).

The spec was likewise force-advanced at iteration 3; codex requested changes on all three passes,
each time on a genuinely different and valid defect, and its iter3 fix was committed post-review. An
architect-required 4th codex pass was run before planning (2 of its 4 findings were factually false
and are rebutted on file).

Force-advance is not approval, and my own verification does not close out a standing
REQUEST_CHANGES.
So at the architect's direction a scoped confirming codex pass was run over
exactly the un-re-reviewed surface — phase 6's iter3 docs fixes (2cb1e2f7) plus the three PR-gate
CMAP fixes (251c867f, including the reverse exhaustiveness assertion).

Confirming verdict: codex APPROVE (HIGH)"Both commits correctly resolve the outstanding
findings without introducing defects."
KEY_ISSUES: None.

Codex independently confirmed the strict-JSON examples parse, the two doc trees are byte-identical,
documented values match the implementation, the SDK exhaustiveness guard is bidirectional, and the
replacement tests are non-circular. The verdict is reproduced verbatim at
codev/projects/1286-consult-configurable-per-lane-/1286-confirming-codex-scoped.md (run with an
explicit --output outside the porch project dir so it could not auto-persist and be miscounted as
a phase review; raw consult .txt outputs are gitignored repo-wide).

Net: every change on this branch is now reviewer-approved, either within its phase or by this
confirming pass.

My first draft of the review said "6 phases, all approved unanimously, 93 commits". That was wrong
on both counts and codex caught it at the gate; corrected in the review, the thread, and here.

🐛 Two real concurrency bugs found and fixed in metrics.ts

Both produced the same invisible symptom — a silently missing metrics row — because
recordMetrics swallows errors by design (a metrics failure must never take down a consultation).
Both trigger on the normal path: a CMAP opens one MetricsDB connection per lane, in parallel.

1. The migration was check-then-act. PRAGMA table_info then ALTER TABLE, unserialized. On
the first consultation after upgrading, all three lanes can see the column as absent; one adds it,
the others fail duplicate column name and lose their rows. Fixed with a fast path, BEGIN IMMEDIATE + re-check inside the lock, and duplicate tolerance.

2. journal_mode = WAL threw SQLITE_BUSY. busy_timeout was set after the WAL pragma — and
does not protect a journal-mode switch anyway, since that needs an exclusive lock no busy-handler
waits for. So concurrent opens of a non-WAL database threw straight out of the MetricsDB
constructor. stats.ts and analytics.ts construct it unguarded, where the throw propagates.
Fixed in enableWal(): timeout first, skip the switch when already WAL, treat SQLITE_BUSY as
success-by-someone-else.

Neither was introduced by this spec; the second surfaced only once reviewers forced the concurrency
test to stop running against a stale dist/. Both fixes are mutation-verified (migration 5/6 runs,
WAL 5/5 deterministic).

What's in it

Phase Delivered
1 Config schema, validators, resolvers (lib/consult-lanes.ts, lib/skeleton.ts helpers)
2 claude + codex lane model wiring; --model-id flag
3 agy --model passthrough; skip-vs-hard-failure split
4 model_id metrics column + guarded migration; codex costs null rather than wrong
5 One lane-selection resolver shared by porch next and porch done
6 Config reference, precedence ladder, fail-fast contract; skeleton parity

Precedence (highest first; first level present wins, levels do not merge):
byProtocol[P].modelsByType[T]byProtocol[P].modelsmodelsByType[T]models
the protocol's verify.models.

Design notes

  • No allowlist of model ids anywhere — a hard spec constraint. Syntax is validated; existence is
    the provider's call, so a new model works the day it ships. Reasoning effort is the deliberate
    opposite: a closed enum bound to the Codex SDK type via satisfies, so SDK drift breaks the build.
  • consultation_metrics.model still holds the lane name (consult stats groups on it); the
    resolved id went into a new model_id column.
  • consult.models rejects hermes (no model selector → inert), while porch.consultation lane
    lists still accept it.
  • Unknown byProtocol / modelsByType keys are errors, not warnings — a warning would leave
    you silently on the defaults you were trying to override.
  • PIR's CMAP-2 cost invariant is documented with a worked example, since config outranking protocol
    is exactly how a SPIR-tuned 3-lane default silently inflates PIR.

Testing

642 tests across the consult, porch, and lane suites; full unit suite, build, and tsc all green.
All 453 pre-existing porch tests pass unmodified — the plan named them as the regression net for
the resolver substitution.

Review with lessons learned: codev/reviews/1286-consult-configurable-per-lane-.md.

Note for the reviewer

Three assertions in this branch were caught by CMAP as structurally unable to fail (a
.rejects.toThrow() against a process.exit, a tautology, a file-existence check standing in for
"the row landed"), and the docs phase needed four iterations, mostly for examples that didn't run.
Both are written up in the review. Every end-to-end assertion here is now mutation-verified.

…sm to plan; record architect note

Also adds the initial implementation plan.
Addresses codex iter1: validation invocation point (loadConfig), new
listProtocolNames() cross-tier enumeration API, Phase 4 depends on Phase 3.
Folds in #1288 two-layer default-preservation tests.
Adds canonicalProtocolName (alias-aware byProtocol lookup), findConfigSource
(names the supplying config layer), and the three-part provider-rejection
error contract incl. agy output capture.
… resolvers

Adds consult.models / reasoningEffort / pricing and porch.consultation
modelsByType / byProtocol to CodevConfig, with all validation invoked from
loadConfig() so malformed config fails at load time (matching the existing
harness-validation precedent).

Model ids are validated for syntax only — the provider is the sole authority
on existence, so no local id allowlist exists anywhere. Reasoning effort IS
validated locally because it is a closed union; REASONING_EFFORTS is bound to
the SDK's ModelReasoningEffort via 'satisfies' so an SDK change breaks the
build rather than drifting silently (verified: adding a bogus member fails tsc).

Adds cross-tier protocol enumeration to skeleton.ts (listProtocolNames,
canonicalProtocolName, listReviewTypes). byProtocol keys accept aliases and are
canonicalized on both sides, so byProtocol.spider applies to a project running
as spir instead of silently no-opping. Review types come from the resolved
protocol.json only, so a shadowed skeleton copy's types cannot leak in.

Adds findConfigSource() to name which of the five config layers supplied a key,
without threading provenance through deepMerge.

65 tests.
…our-tier discovery tests

byProtocol.<name>.modelsByType: null passed the object guard (typeof null ===
'object') and reached Object.entries(), raising a bare TypeError instead of a
keyed config error — defeating the point of load-time validation. The same
guard was written correctly one level up; the nested copy omitted the clause.

Test gaps closed: discovery is claimed over four tiers but was only exercised
over two — added cache-tier and skeleton-tier coverage plus cross-tier
shadowing, and a table-driven suite asserting every null position raises a
keyed Error rather than a TypeError. 65 -> 75 tests.
… porch next and done

porch next and porch done each carried their own copy of the lane precedence logic, and the
copies had drifted three ways: done did no lane-name validation, did not normalize a
single-string value into a list, and wrapped config loading in a bare catch that turned any
config error into a silent fall-back to protocol defaults. That drift is not cosmetic — next
emitting one lane set while done demands another is a deadlock the user cannot debug, since
neither command prints the set it derived.

Both now call one exported resolveConsultationModels in porch/config.ts, which delegates to
phase 1's resolveLaneComposition. Placed there rather than in next.ts because both commands
already import ./config.js, so consolidating adds no new coupling between them.

Removing done's catch is a deliberate behavior change and this phase's only regression risk:
a workspace whose porch.consultation config is malformed today limps along on protocol
defaults and will now fail loudly. That is the spec's fail-fast rule applied to an existing
latent bug.

All 453 pre-existing porch tests pass UNMODIFIED — the plan named them as the regression net
and warned that needing to change them would be a signal to re-examine the code, not the test.

Scenario 8 gets real next()/done() integration rather than the tautology it invited: asserting
a shared function equals itself proves nothing. The tests drive both commands end-to-end, so
they still fail if a private copy is ever reintroduced — mutation-verified by making done
ignore config again, which fails the narrowing test. The paired unconfigured case (done must
REJECT two of three review files) exists so the narrowing assertion cannot pass via a done
that simply accepts anything.
…ort real lane count

codex found that the end-to-end test could not fail: done() reports a missing review via
process.exit(1), not a throw, so .rejects.toThrow() had nothing to intercept and the worker
just exited. The test passed anyway — a green tick I had cited as evidence. Mocked
process.exit following done-verification.test.ts, and made the assertion specific rather
than a bare toThrow(), since 'some error' would also match a crash during fixture setup.
Mutation-verified after the fix: disabling done's refusal now fails the test; it did not before.

claude's two minor points, both taken. The hardcoded '3-way review' string is output this
phase made wrong — a 2-lane PIR was told to expect three, with no way to tell a failed lane
from one never asked for; it now derives the count from the same resolver. And the deleted
catch, this phase's one real behavior change, is now pinned through done() end-to-end rather
than only at the resolver, which could not prove the catch was gone from the call site.
…tated test comment

claude's two minor iter2 points, both APPROVE-with-notes.

The comment fix matters more than the code one. My test claimed to pin the deleted catch,
but done() reaches loadCheckOverrides -> loadConfig before lane resolution, so the validator
throws at the earlier call either way — the test proves the acceptance criterion (malformed
config fails done) without proving anything about the catch site. The narrowing test is what
does that. Recorded the distinction in the comment rather than deleting the test, since a
comment that overstates a test's reach is exactly how false coverage survives review.

Also drops a duplicate getVerifyConfig call; the function-scoped verifyConfig was already
in scope.
…, fail-fast contract

Documents both config blocks (consult.models / reasoningEffort / pricing, and
porch.consultation.modelsByType / byProtocol), the five-level precedence ladder as an
ordered list matching resolveLaneComposition's candidate order, and a worked byProtocol
example keeping PIR at its CMAP-2 footprint under a widened workspace default — the case
where config silently outranks a protocol that was deliberately designed to be cheap.

States the asymmetry the plan called out, because it is the thing a reader will otherwise
get wrong: reasoningEffort is a closed enum Codev validates locally at config load, while
model ids are provider-authoritative and checked only for syntax. Someone who reads 'fails
fast on invalid values' will reasonably expect a bad model id to be rejected at config time,
and it will not be — what they get instead is a hard lane failure with the provider's error
text, the config key named, and no review file written, so porch cannot advance on a lane
that never ran. There is no model-id allowlist anywhere in Codev by design.

Verified rather than asserted: every constant in the docs (lane names, effort values, pricing
keys, id syntax, the medium default) was read from consult-lanes.ts and the codex dispatch
site, and the documented example config was run through the real loadConfig and resolver in a
temp workspace — PIR resolved to two lanes and SPIR to three, and injecting a typo'd lane name
produced exactly the documented load-time error. Skeleton parity is diff-verified empty.
…-id, tighten pricing and gemini fail-fast

Both reviewers independently named the same two defects.

The config-layer list was wrong: no env layer exists and I had dropped the framework cache.
I wrote that line from memory of how config stacks usually look rather than from config.ts,
in a document whose whole value is being trusted instead of the code. Every other constant
here was read out of the source; the one that wasn't is the one that was wrong.

--model-id ships, parses, and appears in --help but was missing from the reference — the same
'registered, documented, inert' failure its own code comment cites as the thing it was built
to avoid, one layer out. Now documented with the distinction users trip on (-m picks the lane,
--model-id picks the model), precedence, supported lanes, and the hermes hard error.

claude's catch on the gemini path was the sharpest, because my text was wrong rather than
merely thin: the hard-failure gate keys on the RESOLVED id, not on config, so --model-id arms
it too — a reader would have believed the flag left the lane in skip mode. Rewritten around
the resolved id, and it now names what still skips even with an id (agy absent, unauthed,
timed out, signal-killed), since those are environment causes rather than the model's fault.

Also: pricing accepts only the codex lane and requires three finite non-negative rates, and
the override outranks the shipped table for every model, not just unknown ones.

Skeleton parity diff-verified empty.
…lete the agy skip list

claude caught that the new section's only example does not run: cli.ts declares an optional
positional subcommand, so a bare prompt string binds to it and anything that isn't 'stats' is
rejected. Confirmed by running the command, not just reading the parser.

A wrong example is worse than a missing one — the reader has no reason to doubt it and
concludes the tool is broken. I had verified this document's config JSON by loading it for
real and every constant against source, but never ran the one shell command I wrote. Swept
the whole file rather than fixing the reported line: every other example already used
--prompt/--prompt-file/--type/stats, so the new section was the lone departure from the
file's own convention.

Also completes the list of causes that still skip non-blockingly with a model id resolved:
an agy run that exits 0 having produced no output skips too, which is the least intuitive
member of that set and the one a reader would assume must be a hard failure.
…correct pricing and PIR examples

codex found // comments in a .codev/config.json example; config.ts uses a bare JSON.parse,
so copying it fails. Same class as iter2's non-running shell command, one format over — an
example that can't be copy-pasted is worse than none, because the failure reads as 'Codev's
parser is broken'. Annotations moved to prose, fences relabelled json.

Swept instead of spot-fixing and found one codex didn't report: a pre-existing
integrationBranch example (#1113) with the same defect. Then automated the check rather than
re-reading — all six json blocks now verified through json.loads. Eyeballing is what let the
first one through.

claude's pricing nit is sharper than its label: my invented rates were ~4x below the shipped
gpt-5.6-sol ones and unlabelled, so a copy-paste yields a confidently wrong cost — exactly
what this key exists to prevent. The doc was teaching the bug it documents. Now uses the real
shipped rates with an explicit 'take these from the provider' warning.

The two PIR examples disagreed, and the worked one silently changed PIR's composition while
its prose claimed to preserve it. PIR's shipped verify pair is [gemini, codex]; both examples
now say so.
codex's three PR-review findings.

The satisfies binding caught removals and renames but not ADDITIONS — and the comment above
it claimed all three. An SDK-added effort value would compile fine while Codev hard-rejected
it as invalid, failing open in the direction the spec specifically required to break the
build. Added an Exclude<...> extends never assertion for the other direction, and
mutation-verified it: dropping a covered member now produces TS2322, which a type-level
guard needs or it is indistinguishable from no guard.

Its test was circular — it iterated the very list it validated, so it passed for any
contents. Values are now pinned as literals at runtime; SDK drift is carried by the
compile-time check, since no runtime test can enumerate a compile-time union.

And the review's own header was wrong: I wrote 'all 6 phases approved unanimously, 93
commits' when status.yaml records phase_6 force-advanced at the iteration cap with codex
still at REQUEST_CHANGES, over 96 commits. I summarized my own project from memory instead
of reading the state file — the same habit that caused every phase_6 docs defect, which I
had already written up as a lesson in this project's own thread. Force-advance is not
approval; the review now says exactly which changes went unreviewed and how I verified them.

Also records claude's three non-blocking findings as known limitations rather than fixing
them at the PR gate, since each would override a spec requirement or widen scope.
@waleedkadous

Copy link
Copy Markdown
Contributor Author

Post-review update — CMAP-2 at the PR gate ran codex REQUEST_CHANGES / claude APPROVE; all three codex findings are fixed and pushed (251c867f).

One code fix, and it's a real gap rather than polish: the reasoning-effort list was bound to the Codex SDK union with satisfies, which catches values the SDK removes or renames but not ones it adds. An added value would compile clean while Codev hard-rejected it as invalid — failing open, in the direction the spec specifically required to break the build. The comment above it claimed all three cases were covered. Now paired with an Exclude<...> extends never assertion for the other direction, mutation-verified (dropping a covered member produces TS2322, since a type-level guard that never fires is indistinguishable from no guard). Its test was also circular — it iterated the list it was validating — so values are now pinned as literals and drift is carried at compile time.

Two corrections to my own review, which codex caught and which matter for judging this at the gate:

  • Phase 6 was force-advanced at the iteration cap, not unanimously approved. It ended iteration 3 with codex REQUEST_CHANGES / claude APPROVE; my fixes for those findings were committed but never re-reviewed. Those unreviewed changes are docs-only (JSON examples made parseable, real pricing rates, PIR example consistency) and I verified them myself — all six JSON blocks through json.loads, skeleton diff empty — but no reviewer did. Phases 1–5 did end unanimous.
  • Commit count is 96, not 93.

My review header had claimed "6 phases, all approved unanimously". I'd summarized my own project from memory instead of reading status.yaml — the same habit that caused every docs defect in phase 6. Corrected in the review and thread, and the review now states exactly which changes went unreviewed.

Claude's three non-blocking findings are recorded under Known limitations and follow-up candidates in the review rather than fixed here, since each would mean overriding a spec requirement or widening scope at the gate: byProtocol name validation being workspace-scoped while config can be global; model_id being write-only until consult stats surfaces it; and two cosmetic recomputations off the hot path.

No behavior change beyond what the PR body already describes.

…PROVE)

Force-advance is not approval and self-verification does not close out a standing
REQUEST_CHANGES, so a scoped codex pass covering exactly the un-re-reviewed surface —
phase_6's iter3 docs fixes (2cb1e2f) and the three PR-gate CMAP fixes (251c867) — was run
at the architect's direction.

codex APPROVE (HIGH), no issues: strict-JSON examples parse, doc trees byte-identical,
documented values match the implementation, the SDK exhaustiveness guard is bidirectional,
replacement tests non-circular.

Run with an explicit --output outside the porch project dir so it could not auto-persist and
be miscounted as a phase review. Consult .txt outputs are gitignored repo-wide
(.gitignore:59), so the verdict is preserved verbatim in a tracked .md rather than
force-added as a raw artifact — same convention every other consult output on this project
follows. Recorded in the review's per-phase history and in the PR body, where the gate reader
sees it without opening the review.
@waleedkadous

Copy link
Copy Markdown
Contributor Author

Confirming pass: codex APPROVE (HIGH), KEY_ISSUES: None.

"Both commits correctly resolve the outstanding findings without introducing defects."

Run at the architect's direction because force-advance is not approval and my own verification does not close out a reviewer's standing REQUEST_CHANGES. Scoped to exactly the surface that was never re-reviewed:

Commit What Why it needed confirming
2cb1e2f7 phase_6 iter3 docs fixes phase_6 force-advanced at max_iterations: 3 with codex still at REQUEST_CHANGES; these landed after that verdict
251c867f the three PR-gate CMAP fixes, incl. the reverse exhaustiveness assertion made in response to codex's gate REQUEST_CHANGES, never re-reviewed

Codex independently confirmed: strict-JSON examples parse, the two doc trees are byte-identical, documented values match the implementation, the SDK exhaustiveness guard is bidirectional, the replacement tests are non-circular, and the corrected review metadata matches status.yaml.

Run with an explicit --output outside the porch project directory so it could not auto-persist as 1286-review-iter1-codex.txt and be miscounted as a phase review. Verdict preserved verbatim at codev/projects/1286-consult-configurable-per-lane-/1286-confirming-codex-scoped.md (raw consult .txt outputs are gitignored repo-wide, so no phase review is committed as a raw file either).

The PR body now carries the full review history up front — a table of what was and wasn't reviewer-approved, which specific changes went unreviewed, and this verdict closing them out.

Net: every change on this branch is now reviewer-approved, either within its phase or by this pass. Nothing further from me — the pr gate is Waleed's.

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.

consult: configurable per-lane models and per-review-type lane selection

1 participant