Skip to content

feat(control-plane): honor the expanded Admin model waterfall (composed model:effort:fast ids) - #391

Merged
lopugit merged 15 commits into
github-actionsfrom
claude/widen-model-waterfall-grammar
Aug 27, 2026
Merged

feat(control-plane): honor the expanded Admin model waterfall (composed model:effort:fast ids)#391
lopugit merged 15 commits into
github-actionsfrom
claude/widen-model-waterfall-grammar

Conversation

@lopugit

@lopugit lopugit commented Aug 25, 2026

Copy link
Copy Markdown
Owner

What

PR #388 (develop) expanded the Admin AI workflow model waterfall from a closed 3-id list to unlimited composed option ids — <model>[:<effort>][:fast] over a 33-model Claude+OpenAI catalog. Until now, every control-plane loader on this branch failed closed to ["default"] for any such config, so the admin's expanded preference was safely ignored. This PR teaches the control plane the widened grammar without weakening its injection-safety posture.

Grammar (widened but still closed)

  • jq validation (all three loaders — resolver model_config, the rebase workflow's copy, the all-branch build doctor): unique 1..256 string array, every entry matching ^[a-z0-9][a-z0-9.:-]{0,63}$. An entry can never lead with - or contain a space, so a compromised response still cannot smuggle a CLI flag. Malformed JSON / wrong key / unavailable endpoint fail closed to ["default"] with the existing ::warning:: lines, unchanged.
  • bash mapping: each entry is re-validated against the same charset, then parsed into base / effort (none|minimal|low|medium|high|xhigh|max|ultra) / fast segments. Duplicate segments, unknown segments, or empty segments (model::x, trailing :) fail the whole mapping closed to [default].
  • Claude-capable filter: default plus bases re-matched against ^claude-[a-z0-9-]{1,48}$ — the chain is rebuilt from the regex match, never from raw API strings. Other providers' entries (OpenAI ids) are skipped with a log line: they configure those providers' own consumers (e.g. the Codex review backend's repo vars). default is still appended defensively; variants of one base collapse into its single CLI slot; a list with nothing Claude-capable falls back to [default].

Effort now applies (fast is logged)

Claude Code headless supports --effort (verified against the CLI, and this control plane already passed --effort max everywhere). So:

  • model_args now carries a mandatory --effort <low|medium|high|xhigh|max> — the primary entry's explicit effort, defaulting to max (today's behavior) when the primary has none. Non-CLI tiers on a Claude entry keep max with a note.
  • The eight fixed --effort max lines (claude_args blocks + exact-session continuations in resolve-pr-conflicts.yml, all-branch.yml, and rebase-conflict-round/action.yml) are removed; the action falls back to --effort max only when its model-args input is empty.
  • All four downstream MODEL_ARGS regex gates enforce the same widened-but-closed grammar including the --effort tail: ^--model (default|claude-[a-z0-9-]{1,48})( --fallback-model …)? --effort (low|medium|high|xhigh|max)$.
  • fast has no headless Claude Code flag → logged as recorded-but-not-applied, per the product-side docs.

Graphify primaries

The three PREFERRED_MODEL cases (resolver job, rebase workflow, refresh-promotion-graphify.sh) accept any charset-valid claude-* primary instead of the two hard-coded literals, so a future Claude model flows through with no control-plane edit.

Contracts

workflow-control-plane-contract.mjs and resolve-pr-conflicts-routing-contract.mjs replace the ALLOWED_MODELS includes-loop with pins on the new invariants (charset, effort segment set, Claude base pattern, defensive . + ["default"], --effort $claude_effort in model args). The existing negative pins (no hard-coded model literals, no hard-coded --model, turn budgets) still pass — this change adds no model-name literals anywhere.

Verification

  • 36-case behavioral harness executing the three loaders' run: blocks extracted verbatim from the edited YAML, with a stubbed curl: legacy trio maps unchanged (--model claude-opus-5 --fallback-model claude-fable-5,default --effort max); the real PR feat(admin): unlimited AI model waterfall with full Claude + OpenAI catalog, per-entry effort and fast mode #388 composed config maps to --model claude-opus-5 --fallback-model default,claude-haiku-4-5 --effort high (OpenAI entries skipped, primary effort honored, fast noted); flag injection (--allowedTools=Bash entry), unknown segments, trailing colon, oversized (257) arrays, wrong key, and endpoint-down all fail closed to --model default --effort max; variant dedupe collapses claude-opus-5:xhigh + claude-opus-5:max into one slot keeping xhigh; a future claude-omega-6:low flows through; every emitted model_args passes the downstream gate regex.
  • Live run of the resolver block against the production endpoint: the currently saved order maps byte-identically to today plus the explicit --effort max.
  • node .github/scripts/workflow-control-plane-contract.mjs --self-test ✓ and node .github/scripts/resolve-pr-conflicts-routing-contract.mjs --self-test ✓; all four edited YAML files parse.

Rollout notes

  • Zero behavior change for the currently saved production waterfall.
  • Backward-compatible in both directions: this control plane accepts legacy bare ids, and the develop app's reads already drop ids it doesn't know.
  • The product-side TESTING.md bullet describing the old fail-closed behavior flips in a companion develop PR once this merges.

🤖 Generated with Claude Code

PR #388 on develop expanded the Admin AI workflow model waterfall from
a closed 3-id list to unlimited composed option ids
(`<model>[:<effort>][:fast]`) over a 33-model Claude+OpenAI catalog.
Until now every control-plane loader failed closed to ["default"] for
any such config, silently ignoring the admin's preference.

All three Admin-waterfall loaders (resolver model_config, the rebase
workflow's copy, and the all-branch build doctor) now validate the
widened-but-still-closed grammar:

- jq accepts a unique 1..256 string array whose entries match the
  closed charset ^[a-z0-9][a-z0-9.:-]{0,63}$ — an entry can never lead
  with `-` or contain a space, so no CLI flag can be smuggled.
- Each entry is re-validated and parsed in bash into base model,
  effort (none|minimal|low|medium|high|xhigh|max|ultra), and fast
  segments; duplicate or unknown segments and empty segments fail the
  whole mapping closed to [default] with the existing warnings.
- Claude Code runs Claude-capable entries only: `default` plus bases
  re-matched against ^claude-[a-z0-9-]{1,48}$ (rebuilt from the match,
  never interpolated raw). Other providers' entries (OpenAI ids) are
  skipped with a log line — they configure those providers' own
  consumers. `default` is still appended defensively, variants of one
  base collapse into its single CLI slot, and an all-skipped list
  falls back to [default].
- The primary entry's effort now steers the session: model_args gains
  a mandatory `--effort <low|medium|high|xhigh|max>` (default max,
  preserving today's behavior), and the eight fixed `--effort max`
  lines in claude_args blocks and exact-session continuations are
  gone. The rebase-conflict-round action falls back to `--effort max`
  only when no model-args input is supplied. Fast mode has no headless
  Claude Code flag; it is logged as recorded-but-not-applied.
- All four downstream MODEL_ARGS regex gates (three continuation
  gates in resolve-pr-conflicts.yml plus the action's) enforce the
  same widened-but-closed grammar including the --effort tail.
- Graphify PREFERRED_MODEL cases (resolver, rebase, promotion script)
  accept any charset-valid claude-* primary instead of two literals.
- Both contract self-tests now pin the new invariants (charset, effort
  segment set, Claude base pattern, defensive default, effort in
  model_args) and pass.

Verified: 36-case behavioral harness executing the extracted loader
blocks verbatim (legacy trio unchanged; the real PR #388 composed
config maps to `--model claude-opus-5 --fallback-model
default,claude-haiku-4-5 --effort high`; injection, malformed-segment,
trailing-colon, oversized, wrong-key, and endpoint-down inputs all
fail closed; every emitted model_args passes the downstream gate), a
live run against the production endpoint (current saved order maps
identically plus explicit `--effort max`), YAML parse checks, and both
routing/control-plane contract self-tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lopugit added a commit that referenced this pull request Aug 25, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review

Lopu reviewed this PR against github-actions as Thingtime's principal PR and repository manager. Using Claude Opus 5.

Lopu found no justified local change to publish from this review pass.

Lopu review — round 4, at the new merged head

Reviewed 2d9a397a against github-actions @ b84a9fc6.

Woken by a conversation comment
(round-3 ack). Since round 3 the head gained two commits — e8bfff82 (my
round-3 assertAdminTransportCap push, which the ack confirms was pulled) and
the merge 2d9a397a, which absorbed +289 lines into
resolve-pr-conflicts.yml
from #435's content-addressed Graphify work. The
base also advanced to #437. That merge is the only genuinely new surface on
this head, so it is where I spent the review.

What I compared

  • b84a9fc6...2d9a397a (merge-base e11ef299) — the PR's own delta: 7 files,
    +470/−88, unchanged in shape from what round 3 reviewed.
  • e8bfff82..2d9a397a — what the merge itself dragged in, to see whether
    upstream landed anything the PR's rewrite of effort/model routing had to
    absorb.
  • Live check state via gh pr checks, and the CodeQL snapshot for this head.

Findings

No defects. No changes made.

Checks: all green. 41 checks — 21 pass, 20 skipped, none failing,
cancelled, or timed out. Nothing to diagnose or repair.

CodeQL: zero open alerts on this head (trusted snapshot is []). Nothing
to fix and nothing to dispose; the disposition file is left as [].

The merge is clean, verified mechanically rather than by eye. The
head→base diff is exactly the file-for-file inverse of the three-dot diff
(resolve-pr-conflicts.yml 165/165, all-branch.yml 112/112,
rebase-pr-stacks.yml 110/110, workflow-control-plane-contract.mjs 133/133,
routing contract 11/11, refresh-promotion-graphify.sh 15/15), plus #437's
base-only content. Nothing from either side was dropped in the auto-resolution.

The two ways this merge could have silently broken the PR — both checked,
both clean:

  1. A fifth MODEL_ARGS gate arriving on the old closed grammar. There are
    exactly four gates, all carrying the widened pattern including the
    --effort tail. The only surviving claude-fable-5/claude-opus-5
    literals in the whole of .github/ are two backend_label display arms
    with a generic *) fallback — not grammar.
  2. model_config outputs drifting out of sync. The job declares 9 outputs;
    the loader emits exactly those 9 keys.

No new or duplicated --effort site appeared: every occurrence in the tree is
either --effort $claude_effort (the three loaders), the action's
empty-input fallback, or a gate regex.

Validation run

All executed in the worktree at 2d9a397a.

Differential harness, 3 loaders × 34 cases, executed not read. The three
run: blocks were extracted verbatim from the edited YAML by indentation
(no ${{ }} interpolation in any of them, so they are pure bash) and run
against a stubbed curl. For every case I checked all three loaders agree and
that the emitted model_args is accepted by the downstream gate regex.

group result
legacy trio --model claude-opus-5 --fallback-model claude-fable-5,default --effort max — byte-identical to today plus the explicit effort
real #388 composed config --model claude-opus-5 --fallback-model default,claude-haiku-4-5 --effort high (OpenAI skipped, primary effort honored)
future claude-omega-6:low flows through, no control-plane edit
variant dedupe :xhigh + :max collapse to one slot, keeping xhigh
non-CLI tiers (none/minimal/ultra) keep the model, pin --effort max — documented intent
injection (flag, space, newline, uppercase) all fail closed to --model default --effort max
malformed grammar (::, trailing :, unknown/duplicate segments) all fail closed
transport failures (endpoint down, wrong key, ok:false, truncated JSON, two top-level values, non-array) all fail closed
size (256 at the jq cap, 257 over it) both fail closed, by different gates

Transport-cap boundary is exact in all three copies. Constructed chains
landing on the precise byte:

constructed model_args resolver rebase doctor
exactly 2048 chars passes through, gate accepts same same
exactly 2049 chars collapses to --model default --effort max + ::warning:: same same

No off-by-one in either direction, and the loaders stay consistent with the
audit gate's <= 2048.

Mutation-tested the pin the ack just pulled — each applied alone, then
reverted:

mutation result
delete the guard from all-branch.yml caught
widen the rebase copy's cap to 4096 caught
drop claude_effort="max" from the resolver fallback body caught

Repository contract suite: 13/13 pass, including both self-tests the ack
cites. All four edited YAML files parse; bash -n clean on all three loaders
and refresh-promotion-graphify.sh. Worktree verified clean after mutation
testing (git status --porcelain empty).

Answering the open question

The ack asks whether the source= alignment is worth tracking as a tiny
follow-up. It is, and I measured the real scope so it can be written once
rather than twice: it is three stale-on-collapse log signals across two
paths
, not two.

Executed evidence:

  • no-Claude-entries path — logs Model waterfall source: Thingtime admin setting beside a default chain.
  • transport-cap path — same stale source, and additionally still emits
    The primary entry requests fast mode…, because primary_fast is logged
    above the cap check and the cap fallback (unlike the other two) does not
    reset it.
  • mapping_failed path — correct (built-in default); useful as the control.

All three are log-only. No output is affected: on every collapse path
model_args, primary_model, and backend_label are all correctly
default, and the ::warning:: makes the collapse discoverable. The
assessment that this is cosmetic holds — and so does the judgment that
half-fixing it would preserve the inconsistency.

I made no change for this, deliberately. It was explicitly ruled out of
this PR by the author, and it is out of scope for a fix pushed to this head.

Verdict

Ready to merge. No defect found on this head, no worktree change made, no
CodeQL disposition written.

View Lopu workflow run

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Could not auto-resolve every conflict with github-actions — manual resolution is needed for the residual paths below. See the workflow run.

Residual conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged github-actions into claude/widen-model-waterfall-grammar — conflicts auto-resolved by Lopu's PR manager.

Conflicted files:

  • .github/actions/rebase-conflict-round/action.yml
  • .github/scripts/rebase-stack/refresh-promotion-graphify.sh

Rebuilt the merged code graph first with graphify update ., then ran LLM semantic extraction (graphify extract ., claude-cli backend) and committed the result.

Please review the merge commit before relying on it.

github-actions Bot and others added 2 commits August 26, 2026 04:01
…solved conflicts)

Conflicted paths: .github/actions/rebase-conflict-round/action.yml, .github/scripts/rebase-stack/refresh-promotion-graphify.sh

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32899512380

Co-Authored-By: Lopu <github-actions[bot]@users.noreply.github.com>
Structural `graphify update` followed by LLM semantic `graphify extract` (graphify 0.9.4, claude-cli backend); unchanged content served from the tracked semantic cache.

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32899512380
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged github-actions into claude/widen-model-waterfall-grammar — conflicts auto-resolved by Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

graphify-out/ was reset wholesale to the github-actions side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI).

Rebuilt the merged code graph first with graphify update ., then ran LLM semantic extraction (graphify extract ., claude-cli backend) and committed the result.

Please review the merge commit before relying on it.

# Conflicts:
#	graphify-out/GRAPH_REPORT.md
#	graphify-out/graph.json
#	graphify-out/manifest.json
Structural `graphify update` followed by LLM semantic `graphify extract` (graphify 0.9.4, claude-cli backend); unchanged content served from the tracked semantic cache.

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32945995253
@github-actions

Copy link
Copy Markdown
Contributor

Lopu review — composed model waterfall grammar

Reviewed 677712002 against github-actions @ 1cdf9900. I read the three loader
copies line by line, then executed them (the run: blocks extracted verbatim from
the edited YAML, with a stubbed curl) rather than reasoning about the bash from the
diff. Summary: the security posture holds up, one real defect fixed, and the red check
is not yours to fix in code.

✅ The failing check is a manual cancellation, not a defect

control-plane / Lopu builds and repairs the all branch
(job)
shows fail, but:

  • conclusion is cancelled, with 0 steps executed and 0 ms billable runner time
  • the check annotation reads "The run was canceled by @lopugit."

It sat queued 27m16s in the repo-wide lopu-agent-fleet group and was cancelled by
hand as part of a batch — the same workflow's runs on lopu/workflow-check-fix-…,
codex/batch-pr-conflict-resolutions, codex/account-hint-environment-labels,
claude/hidden-links-get-bridge, promote/pr-221-… and two scheduled main runs were
all cancelled within the same ~15s window (09:50:39–09:50:53Z). Nothing to repair; a
re-run is the remedy. I made no control-plane change for it.

🔧 Fixed: the three loader copies disagreed on one input

The PR states duplicate segments fail the mapping closed. The resolver does that; the
build-doctor and rebase-stack copies used fast) : ;; and silently accepted a repeat.
On ["claude-opus-5:fast:fast"]:

Loader Before
resolve-pr-conflicts.yml --model default --effort max ✔ fails closed
all-branch.yml --model claude-opus-5 --fallback-model default --effort max
rebase-pr-stacks.yml --model claude-opus-5 --fallback-model default --effort max

One admin dial drives all three lanes, so a malformed id would have produced a silent
split brain — resolver on default, doctor and rebase on Opus. I made the two
divergent copies count fast and reject a repeat, matching the resolver exactly
(+16/−2, no other edits). All three now agree on every input tested; single fast and
fast:high still work.

🔒 I tried to break the widened grammar and could not

Every one of these fails closed to --model default --effort max on all three loaders:
flag entry --allowedTools=Bash(*), space smuggling, uppercase ids, claude-,
unknown segment, trailing :, empty ::, duplicate effort, ok:false,
waterfall:null, wrong key, two top-level JSON docs, 257-entry array, endpoint down.

I specifically checked the one bypass I thought might exist: jq's Oniguruma test()
treating ^/$ as line anchors, which would let "claude-opus-5\n--allowedTools=Bash"
satisfy the pattern on its first line. It does not — and independently mapfile splits
the newline into a second id that the bash re-validation rejects. Both layers hold.

🔍 Removing the eight fixed --effort max lines is safe

This was my main worry — those were the backstop for an empty model_args. Traced
every consumer: the resolve-pr-conflicts.yml jobs all gate on
needs.model_config.result == 'success', all-branch.yml's doctor gates on
steps.models.outcome == 'success', and the three continuation steps now require the
--effort tail in an unconditional gate regex, so an empty value hard-fails rather than
silently dropping effort. The optional-input path in rebase-conflict-round/action.yml
is correctly covered on both branches. Not reachable.

⚠️ Unrelated: this head carries a degraded Graphify index

graphify-out/graph.json drops 577 → 365 nodes and 1234 → 649 links. No file
left the index, but symbol granularity collapsed for key sources —
promote-features-to-main.mjs 128→1, promotion-pr-changelog.mjs 37→1,
build-all-branch.mjs 32→1, prepare-round.sh 14→1, CLAUDE.md 13→1.

manifest.json shows it is a tooling bug, not a content change: for every one of those
files ast_hash and semantic_hash are identical to base — only mtime moved. The
incremental refresh invalidated on mtime, skipped deep extraction because the content
hash matched, then failed to carry the cached symbol nodes forward.

It is recurring and self-healing rather than a property of this branch: across the last
15 commits touching the file the graph sits at 546–595 nodes, with one prior identical
dip at f639ef06 (460/904) that the next full run repaired. I deliberately did not
touch it
— hand-editing generated output is churn the next refresh overwrites. Worth
its own issue against the incremental cache.

💬 Two judgement calls I left to you

  1. none/minimal effort rounds up to max. They parse as valid segments but
    match no CLI tier, so the primary keeps --effort max. For ultra that is
    conservative; for none/minimal it inverts intent — the cheapest tier becomes the
    most expensive. none|minimal → low would honour the direction. Your documented
    call, so I raised it rather than changing it.
  2. Fleet queue saturation. Every model-backed lane serialises through one
    repository-wide group, so ~15 open PRs each firing an all-branch rebuild saturates it
    and needs manual draining — which is exactly what produced the red check above. Five
    more runs were already queued at review time. Deliberate design on the base branch
    and out of scope here, but the backlog is a real operational cost.

Validation

42-case behavioural harness (14 × 3 loaders) 42/42 pass, every emitted model_args
accepted by the downstream gate regex; 42-case adversarial suite 42/42 fail closed or
gate-valid
; 11/11 repository contract self-tests pass (including both contracts
this PR edits); all four edited YAML files parse; bash -n clean on all three loaders.

Today's saved production waterfall still maps byte-identically to current behaviour plus
the explicit --effort max. CodeQL: zero open alerts on this head — nothing to fix
or dispose.

The grammar is widened but still genuinely closed. Looks good to me once the cancelled
check is re-run.

— Lopu

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged github-actions into claude/widen-model-waterfall-grammar — conflicts auto-resolved by Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

graphify-out/ was reset wholesale to the github-actions side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI).

Rebuilt the merged code graph first with graphify update ., then ran LLM semantic extraction (graphify extract ., claude-cli backend) and committed the result.

Please review the merge commit before relying on it.

# Conflicts:
#	graphify-out/GRAPH_REPORT.md
#	graphify-out/graph.json
#	graphify-out/manifest.json
Structural `graphify update` followed by LLM semantic `graphify extract` (graphify 0.9.4, claude-cli backend); unchanged content served from the tracked semantic cache.

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32980929603
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged github-actions into claude/widen-model-waterfall-grammar — conflicts auto-resolved by Lopu's PR manager.

No AI resolution was needed by merge time; the branch was updated with a plain merge commit.

graphify-out/ was reset wholesale to the github-actions side (repo rule: one side, never mixed — the graph merge driver is unavailable in CI).

Rebuilt the merged code graph first with graphify update ., then ran LLM semantic extraction (graphify extract ., claude-cli backend) and committed the result.

Please review the merge commit before relying on it.

# Conflicts:
#	graphify-out/GRAPH_REPORT.md
#	graphify-out/graph.json
#	graphify-out/manifest.json
Structural `graphify update` followed by LLM semantic `graphify extract` (graphify 0.9.4, claude-cli backend); unchanged content served from the tracked semantic cache.

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/33017060489
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Lopu review — round 2, at the new base

Re-reviewed ffedf4e3 against github-actions @ b19f8acc. The base moved 14
commits since my last round and the branch absorbed two more merges, so I
re-ran the verification from scratch rather than re-reading the same diff.
All 41 checks are green (21 pass, 20 skipped, none failing) and CodeQL has
zero open alerts on this head
— nothing to fix or dispose there.

✅ The grammar still holds, and last round's fix survived the merges

I re-extracted all three loaders' run: blocks verbatim from the edited YAML
and executed them: 36 inputs × 3 loaders = 108 runs, all three agree on every
input, every emitted model_args accepted by the downstream gate regex.

["claude-opus-5:fast:fast"] still fails closed to --model default --effort max
on all three, so the duplicate-fast fix from last round came through both
merges intact.

I also stopped assuming the CLI contract and checked it: claude --help
(2.1.224) documents --effort <level> with levels (low, medium, high, xhigh, max) — exactly your gate-regex tier set — and --fallback-model taking a
comma-separated list.

🔧 Fixed: one of the three loaders had no contract coverage at all

assertAdminLoader is applied to the resolver and the rebase loader. The
build-doctor loader in all-branch.yml is pinned by neither contract — it's
deliberately excluded from AI_RUNTIME_YAML, and the routing contract only pins
that the workflow consumes steps.models.outputs.model_args, never what
produced it.

That's precisely the gap the duplicate-fast divergence came through last
round, in precisely that file. So the contracts couldn't have caught it.

Split the grammar pins into assertAdminWaterfallGrammar (charset, effort
segments, Claude base pattern, defensive . + ["default"], --effort $claude_effort, model_args output) and applied it to all three copies.
assertAdminLoader now calls it plus the primary_model pin that only the two
waterfall-owning loaders emit, so existing coverage is untouched. Added two pins
for the invariant that actually regressed: reject a repeated fast, reject a
repeated effort.

Mutation-tested, because a green assertion proves nothing until it can fail —
each applied to all-branch.yml alone, then reverted:

mutation caught by
restore the old fast) : ;; rejects a repeated fast segment
absorb a repeated effort segment rejects a repeated effort segment
widen the composed-id charset validates the closed composed-id charset
drop --effort from model_args appends the validated session effort
(control) remove resolver primary_model still caught — refactor didn't weaken existing pins

+33/−3, test-only, no runtime behaviour change. 4/4 contract self-tests pass.

⚠️ Two latent findings I raised rather than changed

1. The 1..256 widening made a downstream 2048-char cap reachable.
resolve-pr-conflicts.yml:4142 hard-fails the promotion publish step when
model_args is over 2048 characters. Measured: today's saved order 74 chars, a
realistic 33-model catalog 803, 256 short ids 3007, 256 max-length ids 14381. So
it needs ~70 distinct Claude bases — unreachable with the real catalog — but
when it does trip it's an exit 1 after the resolution work is finished, not
the fail-closed posture used everywhere else. A four-line loader-side length
guard closes it; choosing between failing closed to [default] and truncating
the chain is your policy call, so I left it.

2. A dotted Claude id is silently classed as another provider's. The
composed-id charset permits . (OpenAI ids need it), but the Claude base
pattern is ^claude-[a-z0-9-]{1,48}$. claude-haiku-4.5 gets dropped with the
log line "Skipping non-Claude waterfall entry … it configures that provider's
own consumers"
— which is simply false about a Claude model. Safe today (falls
to default, and the catalog is all-dashed), but fixing it means widening seven
regexes across five files on a naming convention I can't verify. Raising, not
churning.

none/minimal still rounding up to max is unchanged from last round and
still your call.

🔍 The Graphify degradation: root cause found — and I was wrong about it

Correcting my last comment: I called this "self-healing" and blamed the
incremental path in graphify update. Both are wrong.

Reproducing on the exact merge parent 9352ccdd with the CI-pinned
graphifyy==0.9.4, graphify update . alone gives 574 → 573 nodes with
zero poisoned files. Phase 1 is healthy. The loss is entirely phase 2
(graphify extract + cluster-only), which rebuilds the graph from changed
files plus the tracked semantic cache — so a file that is unchanged and not in
that cache comes back as a bare file node with every symbol dropped, while its
ast_hash and semantic_hash stay byte-identical because the content genuinely
never moved. The extract log shows the mechanism directly: 27 unchanged,
semantic cache: 2 hit / 7 miss.

And it isn't self-healing. The guard only refuses a >50% total shrink, while
this loss is per file — across the last 40 base commits the invariant is
violated 11 times, 490 symbol nodes lost, and the guard refused none of
them. In one case (bc2f2fdcf) the total node count rose while four files
were being stripped, which is why a total-count guard structurally cannot see it.

Since the root cause is in the protected control plane and unrelated to this PR,
the fix isn't on this branch. I added a graph_pair_not_poisoned check beside
the existing graph_not_collapsed in all three refresh implementations, wired
into the chains that already have a correct rollback — a refused refresh simply
takes the existing "preserve the verified structural graph" path, no new
machinery. It's queued as its own PR against github-actions.

Validated against real pairs: it flags the exact pair CI committed here (naming
all five collapsed files, ignoring three benign −1 jitters), passes the
legitimate 574→573 refresh, and replays 41 commits to 11 poisoned / 28 healthy,
matching an independent implementation of the invariant exactly. The ≥2-node
threshold is measured, not guessed: per-file losses in that window are either
exactly 1 (13 occurrences, clustering jitter) or ≥2 (36 occurrences, every real
collapse).


The grammar is widened but genuinely still closed — I tried to break it 36 ways
and couldn't. Removing the eight fixed --effort max lines is safe on every
path I traced. Findings 1–2 are latent, none blocking. Ready to merge from my
side.

— Lopu

lopugit and others added 2 commits August 27, 2026 16:27
…n-model-waterfall-grammar

# Conflicts:
#	graphify-out/GRAPH_REPORT.md
#	graphify-out/cache/semantic/337100725a28b633408a6858e9a54adfb464b90aed3591875ed1d01fb071f697.json
#	graphify-out/cache/semantic/7c2d0a03f92fdb76691c98c37c0062f234440478de562ac867a717c945cbfeeb.json
#	graphify-out/cache/semantic/93258826c8c86af77bb10853c4732c4110af620b658ed9ab5ed685e37d153b60.json
#	graphify-out/cache/semantic/ad6637f804333a1b6d93bff06b1533daf4b1d672a4511c2d9811861b100e3c47.json
#	graphify-out/cache/semantic/aecbd9a9111cd08ad8974a7e83ee681caf0efa5162bdb1b7c25e8ee0f3993515.json
#	graphify-out/manifest.json
…skip log

Review round-2 policy calls: an oversized (yet valid) model chain now
fails closed to [default] AT the loader, instead of tripping the
promotion publish step's 2048-character cap after resolution work is
done; the provider-skip log line no longer claims a skipped entry
belongs to another provider (a dotted claude typo is skipped too, and
the old wording was false for it). Guard composes its fallback from
variables so the no-hardcoded-model contract pin holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Round-2 follow-ups applied in 9bc7f35:

  • 2048-char cap: chose fail-closed over truncation — a valid-but-oversized chain now collapses to [default] at the loader in all three copies, with a ::warning::, instead of erroring in the promotion publish step after resolution work. Harness case added (39/39 pass).
  • Dotted Claude id: agreed — not widening seven regexes on an unverifiable naming convention. Reworded the skip log to stop claiming the entry belongs to another provider (it was false for a dotted-claude typo).
  • none/minimal → max: keeping the round-up. The app-side catalog never emits those tiers on Anthropic entries, so this path only sees malformed/future input, and max matches the fleet's historical posture.

Both contract self-tests and the 3-loader harness pass on the head. Thanks for the assertAdminWaterfallGrammar split + mutation testing — the repeated-fast divergence was mine, good catch.

@github-actions

Copy link
Copy Markdown
Contributor

Lopu review — round 3, on the round-2 follow-ups

Re-reviewed 9bc7f35f against github-actions @ cb7e6f2a. All 41 checks are
green (21 pass, 20 skipped, none failing or cancelled) and CodeQL has zero open
alerts on this head
— nothing to fix or dispose. I took your three follow-ups
one at a time and tried to break each rather than reading the diff.

✅ The 2048 guard is exact at the boundary

Agreed on fail-closed over truncation. I checked the threshold rather than
trusting it, because a guard that front-runs another gate is only useful while
the two agree. The audit gate is (( ${#MODEL_ARGS} <= 2048 )) and the
attestation replay is value.model_args.length <= 2048; the loaders reject
-gt 2048. Constructed chains landing on the exact boundary:

constructed chain result
37 ids, model_args = 2048 chars passes through, accepted by the gate
37 ids, model_args = 2049 chars fails closed to --model default --effort max
255 short claude ids + default (3105) fails closed

No off-by-one in either direction.

The part I most expected to be wrong was output completeness — rewriting
model_args isn't enough on its own, since every output derived from the
collapsed chain has to collapse with it. It's right in all three:
resolve-pr-conflicts.yml resets backend_label before agent_backend_label
and review_backend_label are computed from it, so a collapsed run can't report
"Claude Opus 5 (high effort)" while default actually ran; the rebase copy
resets primary, so primary_model and the Graphify PREFERRED_MODEL follow;
the build doctor emits only model_args. Nice.

🔧 Fixed: the new guard had no contract pin

Same gap class as last round, in the same three files. The guard now lives in
three copies of a loader driven by one Admin dial, and nothing pins it — so
a future edit to one copy diverges silently, which is precisely how the
duplicate-fast split brain happened.

Added assertAdminTransportCap, called from assertAdminWaterfallGrammar so it
covers all three copies including the build doctor's, plus a cross-file pin that
the loader cap and the audit gate share one constant. It pins the cap value, and
that the fallback body warns, resets the session effort, and rebuilds from
validated variables — and that the guard sits after assembly and before
export, since measuring a partial chain would defeat it entirely.

Mutation-tested, each applied alone then reverted:

mutation caught by
delete the guard from all-branch.yml caps the assembled model args at the 2048-character transport limit
widen the rebase copy's cap to 4096 caps the assembled model args …
hard-code --model default in the fallback rebuilds the chain from validated variables
drop claude_effort="max" from the body transport-cap fallback resets the session effort
move the guard above the assembly line measures the finished chain after assembly and before export
drift the audit gate to 4096 promotion audit gate: enforces the same 2048-character cap as the loaders
(control) remove resolver primary_model still caught — no existing pin weakened

I also drafted and then dropped an eighth assertion (a doesNotMatch on a
model literal in the guard body). I couldn't construct a realistic defect only it
catches, and it would misfire if someone reworded the warning text. By the same
standard I applied last round, a pin that can't fail usefully is noise.

+51/−0, one file, test-only, no runtime behaviour change.

👍 The other two calls

Dotted Claude id — the reworded log line is accurate for every entry that
reaches it. Worth noting the surrounding block comments are phrased generally
("an entry for another provider … is skipped here"), which stays true, so I left
them alone.

none/minimalmax — your call, and the reasoning holds: the app-side
catalog never emits those tiers on Anthropic entries, so it's malformed/future
input only. Not raising it a third time.

⚠️ One nit, raised not changed

The mapping_failed path sets source="built-in default"; the transport-cap
path doesn't. A collapsed run logs Model waterfall source: Thingtime admin setting beside a default chain. Cosmetic, the ::warning:: makes it
discoverable, and the pre-existing "No Claude-capable entries" path has the same
gap — so fixing only the new one leaves the inconsistency and fixing both is
outside this PR. Your call.

🔍 The Graphify degradation is gone on this head

Re-ran the per-file invariant on this pair: 603 → 642 nodes, zero files losing
≥2 symbols
, 6 gaining. Healthy. The graph_pair_not_poisoned guard is carried
in #424 against github-actions, correctly not on this branch.

Validation

30-case × 3-loader harness, 90 runs, 30/30 passrun: blocks extracted
verbatim from the edited YAML and executed against a stubbed curl; all three
loaders agree on every input and every emitted model_args is accepted by the
downstream gate regex. Covers the legacy trio, the real #388 composed config,
flag/space/newline injection, duplicate segments, ::, trailing colon, 257
entries, endpoint down, variant dedupe, a future claude-omega-6:low, dotted
Claude, OpenAI-only, and the three new boundary cases. Repository contract
suite 8/8, mutations 7/7 caught, all four edited YAML files parse.

Ready to merge from my side.

— Lopu

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Round-3 ack: pulled e8bfff8 (assertAdminTransportCap + cross-file cap pin) — both contract self-tests pass locally on the merged head. On the source-label nit: agreed it's cosmetic and half-fixing would preserve the inconsistency, so leaving both paths as they are; happy for a tiny follow-up PR to align source= on the transport-cap and no-Claude-entries paths together if you want it tracked.

…n-model-waterfall-grammar

# Conflicts:
#	graphify-out/cache/semantic-cas/v1/3aee611299d7dbf873292a7bde825412cebd80d72aefe814095a5470230e9d4c/1b8a32a8e38e4b51ac8179c84abf8d5232bdef2b90a5206374b78ecbd5da7d59.json
#	graphify-out/cache/semantic-cas/v1/788e65310b84ed8e0872d8a32971f3dc8ba7fdb6f6a42a1050339f6ec64e2b2a/678bfef7062180b47a56fb1d3b143d4cc087150ec0fa875615639d305f73c92e.json
#	graphify-out/snapshots/v1/fb533b7ffb7dfb984073eaa5e9122eedfaa8e2a86b09dcded475c48d188fce1b/a46a66a779cff71e12336a6de500883fc583b76e0a03de5967aa27d30f7f9289/graph.json
@lopugit
lopugit merged commit 3688aac into github-actions Aug 27, 2026
61 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Lopu review — round 4, at the new merged head

Ack received, and confirmed from my side: e8bfff826 is on the branch and both
contract self-tests pass here too. Since your ack the head moved again —
2d9a397a merged +289 lines into resolve-pr-conflicts.yml from #435's
content-addressed Graphify work, and the base advanced to #437. That merge is
the only genuinely new surface, so I re-verified 2d9a397a against
github-actions @ b84a9fc6 rather than re-reading what round 3 already
covered.

No defects. No changes made. All 41 checks green (21 pass, 20 skipped),
CodeQL zero open alerts on this head — nothing to fix or dispose.

The merge didn't quietly eat either side

Checked mechanically, not by eye: the head→base diff is exactly the
file-for-file inverse of the three-dot diff (165/165, 112/112, 110/110,
133/133, 11/11, 15/15) plus #437's base-only content.

The two ways this particular merge could have broken the PR, both clean:

  • A fifth MODEL_ARGS gate arriving on the old closed grammar. Exactly four
    gates exist, all on the widened pattern with the --effort tail. The only
    surviving claude-fable-5/claude-opus-5 literals anywhere in .github/ are
    two backend_label display arms behind a generic *) — not grammar.
  • model_config outputs drifting. Job declares 9; loader emits exactly
    those 9.

No new or duplicated --effort site appeared anywhere in the tree.

Validation

3 loaders × 34 cases, executedrun: blocks extracted verbatim from the
edited YAML against a stubbed curl; all three agree on every input and every
emitted model_args is accepted by the downstream gate. Legacy trio maps
byte-identically to today plus the explicit --effort max; the real #388
config maps to --model claude-opus-5 --fallback-model default,claude-haiku-4-5 --effort high; injection/malformed/transport/size cases all fail closed.

Cap boundary re-confirmed exact on this head, in all three copies: a
constructed chain at 2048 passes and is accepted by the gate, 2049
collapses with the ::warning::.

Mutations, each alone then reverted: deleting the all-branch.yml guard,
widening the rebase copy to 4096, and dropping claude_effort="max" from the
resolver fallback — 3/3 caught by the pin you pulled.

Contract suite 13/13, four YAML files parse, bash -n clean.

On the follow-up you offered to track

Worth tracking — and I measured it so it can be written once instead of twice.
The scope is three stale-on-collapse signals across two paths, not two:

  • no-Claude-entries — logs Model waterfall source: Thingtime admin setting
    beside a default chain.
  • transport-cap — same stale source, plus it still emits The primary entry requests fast mode…, because primary_fast is logged above the cap
    check and that fallback (unlike the other two) doesn't reset it.
  • mapping_failed — correct (built-in default); the control.

All three are log-only — on every collapse path model_args, primary_model,
and backend_label are correctly default, and the ::warning:: makes it
discoverable. So your read stands: cosmetic, and half-fixing preserves the
inconsistency. I changed nothing for it here.

Ready to merge from my side.

— Lopu

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