Skip to content

feat: per-role model binding — tiers, operator maps, --role overrides, pack-adopted collaborations - #287

Merged
saucam merged 3 commits into
mainfrom
feat/role-model-binding
Aug 6, 2026
Merged

feat: per-role model binding — tiers, operator maps, --role overrides, pack-adopted collaborations#287
saucam merged 3 commits into
mainfrom
feat/role-model-binding

Conversation

@saucam

@saucam saucam commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Implements docs/role-model-binding.md — design PR #286. Note: this branch carries the amended copy of the doc (updated during implementation + review: role-pin rung, provider-authoritative rules, §4 honesty fix), superseding #286's version. Suggest closing #286 in favor of this PR — or merge it first and let this branch's copy win.

What this is

A model binding is policy, not methodology. The pack declares what a role is (envelope) and what it needs (tier: — a semantic class like reasoning-max); which concrete model satisfies that need is an operator decision, per machine, per run. One six-rung resolution chain shared by every surface: CLI --role > config modelRoles > pack phase pin > role-YAML pin > config modelTiers > provider default — with resolvedFrom naming the winning rung everywhere it surfaces.

The three slices

  1. Resolution + pipeline surfaceresolveBinding() (pure, one function, every caller), pipeline.modelTiers/modelRoles config maps, pipeline run --role (collab grammar, *count rejected), per-phase bindings resolved once at create and persisted (deterministic resume under config change), pipeline status renders provider:model ← rung.
  2. Visibilitypack show --resolve: the pre-flight view of each role's currently-effective binding under this machine's config, with phase-level pins listed separately so stale pins stay visible.
  3. Pack-adopted collaborations + single sessions--collaborate --pack lets the pack define what each role IS (strict name binding, real envelopes from role YAML, ETHOS→goal→roster constitution composition), models via the same chain minus the phase-pin rung; --model on single sessions with provider-aware validation and the §6.2 chain for --pack-role.

Review process

Two review rounds (bench + adversarial) ran against the branch before this closeout; the third commit resolves their merged punch list.

Headline adversary finding: bindings were display-only on the pipeline surface. runPhaseOnSession deliberately dropped provider/model, so pipeline run --role adversary:claude:claude-fable-5 resolved, persisted, and displayed the binding — and then ran the phase on the session's default model anyway. Fixed by applying the bound model to the run session for each phase turn (Session.overrideModel — non-persisting, provider-aware, restore-in-finally; a user's mid-phase set_model wins over the restore) and adopting the collab-adoption rule at create: the run session's provider is authoritative — a resolved binding naming another backend is skipped with a warning naming the rung and never persisted as effective.

Other round findings fixed here: model-only pins no longer defeat the cross-vendor guard (validated per-backend on all three paths; skip-with-warning, never a hard-fail for a model the operator never typed); binding warnings now reach the creating client (warnings on response.ok/pipeline.snapshot, rendered in CLI + web) instead of dying in the daemon console; the web modal's open-order bug (pipeline open → Esc → collaborate open wiped the default role names and bricked Create); collab children resume WITH their resolved model; empty-string phase pins rejected at the schema (min(1)); one case-insensitivity rule for role names on both surfaces; doc de-overclaimed (per-phase token attribution moved to non-goals).

Tests

  • After slices 1–3: 2,264 daemon / 395 web. After this closeout: 2,275 daemon / 398 web (+11 daemon, +3 web — including the previously-missing proof that a phase turn actually runs with the bound model, asserted on the model handed to the provider stub).
  • Known pre-existing flake: ORCHESTRATOR_FLEET_TOOLS › the BUILT server registers exactly those tools in collaboration.test.ts fails under full-suite load (passes standalone, 138/138; identical behavior on clean main). The single failure in the full run is exactly this test.
  • bun run typecheck (root + protocol + core + web) and bun run lint clean.

Web build stamp / stale dist

Slice 3 added a build stamp to the web UI (vite define, shown in the Help modal) after a review round was nearly derailed by a stale web/dist — the daemon serves dist/ live per-request, so an un-rebuilt dist silently demos old code while the source reads fixed. web/dist is rebuilt as part of this branch's verification (it is gitignored; deployers run bun run build:web).

🤖 Generated with Claude Code

@saucam
saucam force-pushed the feat/role-model-binding branch from fc84970 to 0475d02 Compare August 6, 2026 05:52
@saucam

saucam commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Auto-maintained by pr-shepherd (iteration 1):

No content changes; commits are identical apart from the new base. CI is re-running on the rebased head.

@saucam

saucam commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on 0475d02 (rebased head).

  • daemon: SUCCESS
  • web: SUCCESS
  • Socket Security (Project Report + Pull Request Alerts): SUCCESS

The known ORCHESTRATOR_FLEET_TOOLS flake did not reproduce in CI. No further shepherd action pending — PR is ready for human review/merge.

saucam and others added 3 commits August 6, 2026 08:07
…k show --resolve

Slices 1–2 of docs/role-model-binding.md (brought onto this branch from
docs/role-model-binding and amended): a model binding is policy, not
methodology — the pack declares what a role needs (a semantic tier), the
operator's config maps tiers to concrete models, and the invocation can
override per run. One pure resolution chain shared by every surface.

- src/daemon/pipeline/binding.ts (new): resolveBinding() — the six-rung
  precedence chain (cli > config-role > phase-pin > role-pin > config-tier
  > default) with resolvedFrom naming the winning rung; plus
  roleBindingsFromSpecs() compiling collab-grammar --role specs (rejecting
  *count — pipelines run one session per phase).
- pack.ts: roles gain optional tier (non-empty, ≤64 chars). The role-level
  provider/model pins (collab fleet path) join the chain as rung 4 — a doc
  amendment: the design predates their existence.
- config.ts: pipeline.modelTiers / pipeline.modelRoles operator maps.
- manager.ts: CreatePipelineOpts.roleBindings + modelConfig; create()
  resolves once per phase and persists {provider, model, resolvedFrom} on
  CLONED phase defs (a pack's pipeline array is shared across runs), so
  resume/retry replay the persisted binding — deterministic under config
  change. Unknown binding role fails create listing the declared roles.
- wire: pipeline.create gains roleBindings (validated against the provider
  registry in the handler); PipelinePhaseWire carries provider/model/
  resolvedFrom; PackWire carries resolvedRoles + phasePins.
- CLI: pipeline run --role (repeatable, reuses parseRoleSpec); pipeline
  status renders ⟨provider:model ← rung⟩; pack show --resolve is the
  pre-flight view — per-role effective bindings with phase pins listed
  separately so stale pins stay visible.
- runner.ts: a failed phase turn names its binding + rung, so a bad model
  id points at the layer to fix.

Tests: precedence table per rung, create-time persistence + config-change
survival, validation errors, tier schema bounds, resolve-view shape, wire
round-trip, formatter rendering. Full suite: 2242 pass / 1 pre-existing
flake (ORCHESTRATOR_FLEET_TOOLS — fails identically on origin/main).

Slice 3 (pack-adopted collaborations, single-session --model) follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…option UI + build stamp

Slice 3 of docs/role-model-binding.md (§6 + §6.2), on top of slices 1-2's
resolution chain (binding.ts).

A. Collab/pack exclusivity lifted (daemon). session.create with both
   collaboration + pack now ADOPTS the pack: adoptPackRoles() (collaboration.ts)
   binds the roster strictly to the pack's declared roles (unbound name errors
   listing them), takes write authority from the role YAML (a conflicting spec
   is an error, not an override), defaults purpose to the role summary, and
   resolves each role's model through the §6.1 chain (cli spec model >
   modelRoles > role-YAML pin > modelTiers[tier] > backend default) via the
   shared resolveBinding(). Recorded clarification: the roster's provider is
   authoritative — a winning rung on a different backend is skipped with a
   warning rather than silently moving the role (a model id doesn't transfer
   across vendors, and collab's standing rule is no silent backend collapse).
   roleChildPosture passes the role YAML's real write/network/envelope/
   exceptions to the canUseTool fence; compileGoalPack composes pack ETHOS →
   goal → roster under the real pack id (children get ETHOS → goal, no roster —
   independence); the orchestrator gets the pack's subagents like a --pack
   session. packRole with collaboration is rejected (category error). *count
   fan-out stays valid. Free-form collabs are byte-identical to before.

B. --model on single sessions (§6.2). session.create gains `model` (wire +
   CLI + terminal client): validated provider-aware at create (Claude alias
   can't ride onto another vendor; past that the backend validates), placed as
   the Session's defaultModel — the same slot the collab fleet path already
   uses. With --pack --pack-role and no --model, the chain minus phase-pin/cli
   rungs resolves it; the winning rung's provider fails closed if unregistered
   and yields to an explicit conflicting --provider with a warning.

C. Web collab dialog: the pack select now serves collaborate mode (\"\" =
   free-form). Choosing a pack turns worker role names into a strict <select>
   over the pack's declared roles, hides the write toggle (the role YAML's
   call), shows Slice 2's resolvedRoles as effective-model placeholders, and
   sends the pack on the wire. Rows stay in the <Index> — the swap is a
   signal-driven element change in the same slot, no remount, focus preserved.

D. Build stamp: vite bakes the short git commit in (define __BUILD_COMMIT__),
   surfaced in the help modal footer/title — served-dist drift is now
   diagnosable at a glance.

Tests: adoptPackRoles precedence per rung + cross-backend skip + write
conflict, posture/constitution composition units, end-to-end adoption create
(real pack dir; profiles, models, write flags on children), §6.2 model
resolution (explicit/pin/tier rungs, wrong-vendor rejection, collab
rejection), web adoption UI (strict select, placeholders, payload, free-form
restore). Full daemon suite: 2264 pass / 1 pre-existing flake
(ORCHESTRATOR_FLEET_TOOLS — identical on the pre-slice baseline). Web: 395
pass, typecheck + lint clean, dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s 1-2

Closes the merged punch list from the two review rounds (bench + adversarial)
on the per-role model binding feature (docs/role-model-binding.md). The
headline adversary finding: pipeline-surface bindings were resolved,
persisted, and DISPLAYED but never used — runPhaseOnSession deliberately
ignored provider/model, so `pipeline run --role` was inert.

- Bindings now APPLY on the pipeline surface. runPhaseOnSession sets the
  bound session's model for the phase turn via Session.overrideModel — a
  non-persisting, provider-aware override — and restores the previous model
  in a finally (reliable on phase failure; a user's mid-phase set_model wins
  over the restore; a mid-phase daemon crash resumes on the pre-override
  model and the re-driven phase re-applies it). The run session's provider
  is authoritative (same rule as collab adoption): a resolved binding naming
  a different backend is skipped at CREATE with a warning naming the rung
  and is never persisted as effective — status/CLI never render a binding
  that won't apply, and the runner's failure message only names bindings
  actually in effect.
- Model-only rungs no longer defeat the cross-vendor guard: adoptPackRoles,
  the §6.2 single-session chain, and pipeline #bindModels validate
  chain-resolved models provider-aware (resolveModelIdForProvider) and skip
  with a warning — never a hard-fail for a model the operator never typed,
  never a silent vendor transfer. Only explicitly typed models
  (--role name:provider:model, --model) may hard-fail create validation.
- Create replies carry the collected warnings (warnings on response.ok /
  pipeline.snapshot); the CLI prints them and the web renders them (modal
  warning block with explicit dismiss; PipelineRunner warn strip).
- Web modal open-order bug: a leftover pipeline-mode packId's transition to
  "" wiped the freshly-seeded collab role names on reopen (open pipeline
  modal → Esc → open collaborate → blank names, Create disabled). The
  clearing is now gated on the →pack direction. The effective-binding
  placeholder also respects the row's chosen backend — it no longer
  advertises a model the daemon would skip.
- Resume restores a collab child's resolved model: #resumeRoleChild threads
  the roster model (same source as spawn — plannedChildFor) into the child's
  options, and the resume constructor no longer clobbers it with the
  conductor-only default.
- Wire bounds: phaseDefSchema provider/model gain min(1) (an empty string
  persisted as a phantom phase-pin); config ModelBindingSchema reuses
  LIMITS.MODEL_MAX instead of a second literal 256.
- One case rule everywhere: role-name matching is case-insensitive on the
  pipeline surface too (#bindModels lookup + declared-role check,
  roleBindingsFromSpecs duplicate detection), matching adoptPackRoles and
  the collab validator. Doc §5 states it; the cli.ts comment no longer
  claims the wire path rejects *count (the wire shape has no count field).
- Doc honesty: §4's "actual token usage per phase" claim moved to §7
  non-goals (not implemented); §3 now states the provider-authoritative +
  apply/restore semantics precisely; header status reconciled.

Tests: +11 daemon, +3 web. New coverage: the phase turn actually runs with
the bound model (asserted on the model handed to the provider stub) and the
session's model is restored after; cross-provider bindings are skipped at
create with the warning on the snapshot; a skipped binding strips the
phase's own pin fields; model-only pins validate per-backend on both the
pipeline and adoption paths (and sail through validateCollaboration instead
of hard-failing); a model-bound collab child resumes WITH its model;
session.create surfaces tier warnings on the reply; case-insensitive
matching on both surfaces; empty-string phase pins rejected at the schema.
Full daemon suite: 2275 pass / 1 pre-existing flake (ORCHESTRATOR_FLEET_TOOLS
— identical on the pre-fix baseline). Web: 398 pass, typecheck + lint clean,
dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saucam

saucam commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main after #286 merged — the only conflict was docs/role-model-binding.md (main took #286's original; this branch carries the amended version with the 6-rung precedence, implementation-reality corrections, and closeout updates — the amended version wins). Typecheck + lint clean post-rebase; CI re-running.

@saucam
saucam force-pushed the feat/role-model-binding branch from 0475d02 to 16e31ff Compare August 6, 2026 06:08
@saucam
saucam merged commit eaf5216 into main Aug 6, 2026
4 checks passed
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