Assert ParallelBranchStarted seeds started_at (the live-timer half of
the fix) and cover the failed-status fold to a Failed terminal state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the billed_model_usage_from_llm_with_cost wrapper with a
with_reported_cost method on BilledModelUsage and BilledTokenCounts, and
centralize the optional-cost fold as UsdMicros::accumulate so fabro-agent
and fabro-workflow share one implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add Poolside as a built-in OpenAI-compatible provider and expose Laguna S 2.1 and XS 2.1 both directly and through OpenRouter. Include vault/env credential registration, secret redaction, live coverage, catalog tests, and user documentation.
Consolidate duplicated logic from the SQLite runs read model review:
- Derive the status sort CASE and board-column filter from a new
RunStatusKind::board_rank(), replacing three hand-maintained copies
of the status/column mapping; add a test upserting every status
variant so the migration CHECK can't silently drift
- Share RunSize bucket thresholds between from_total_usd_micros and
the generated size-sort CASE via RunSize::BUCKET_MAX_USD_MICROS
- Resolve run selectors from a lean identity query instead of
decoding every stored summary per request
- Delete the RunsSortKey/RunsSortDirection adapter enums; the store
sort enums now carry the wire serde names
- Consolidate the workflow display-name fallback chain into
WorkflowRef::display_name() (store, CLI, run lookup)
- Share pagination clamping and the paginated list envelope across
handlers
- Reconcile now skips rows whose source seq is unchanged and
batch-deletes stale rows; drop the two indexes no query can use
- Hold the summary store OnceLock cell in RunDatabaseInner instead of
a snapshot so late attachment reaches already-open writers
- Misc: expect() on COUNT(*) sign, %err logging, shared wall-time
helper, shared SQLite test fixture, dead billing fallback removed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consolidate the legacy-import helpers (backup path naming, RFC 3339
timestamp parsing, import report) into fabro-db and reuse them from the
vault, automation, variable, and environment stores. Add
SecretStore::open_snapshot to collapse the repeated
open/snapshot/into_vault chain. Let automation trigger canonicalization
live solely in normalize_replace, replace its redundant second full
validation with a targeted manual-id collision check, single-source the
automation SELECT projection, and gate list_automation_runs on a
lightweight existence query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Delete the dead test-only Vault-based env-secrets migration and point
the startup migration tests at the production migrate_to_store path
over a real SQLite-backed SecretStore
- Extract shared legacy-import helpers (timestamped backup rename,
is_toml_file) into fabro_db::legacy and parse_rfc3339_utc into
fabro-db, replacing four per-crate copies
- Take one secrets snapshot in migrate_to_store instead of per-name
queries
- Share one bind order between the MCP store INSERT and UPDATE
statements
- Return SecretEntry directly from entry_from_row
- Unify the environment/MCP store blocking loaders into a generic
load_store_blocking helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The automations legacy import declares REMOVAL_DEADLINE per
docs/internal/migrations-strategy.md; the secrets JSON import predates it
and never did. Add the same constant and log field so the temporary
migration's lifespan is visible in code and in startup logs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review pass over the secrets-to-SQLite migration:
- Add SecretStore::open() consolidating the connect/migrate/import-legacy
sequence repeated at five call sites; fabro-agent and fabro-cli drop
their fabro-db dependency
- Restore process-env LLM credential lookup in the standalone CLI/agent
sources via SqlVaultCredentialSource::new (regression: vault_only
dropped the env fallback that VaultCredentialSource::new provided)
- Fix five install tests that still asserted against the legacy
secrets.json, which the importer renames to .bak
- Make AppStateConfig.preloaded_vault required, deleting the fallback
that re-read the already-renamed legacy file; drop the now-unused
vault_path field and demote load_startup_vault to test-only
- Skip the snapshot clones and CAS retry in resolve() when the vault
holds no OAuth secrets (per-request hot path)
- Remove dead persist_with_secret_store, the VaultSecretWrite alias,
the secret_type_string one-liner (now SecretType::as_str), the
impossible RowCountOverflow error, and duplicated row parsing
- Run check_crypto concurrently with the other diagnostics checks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Detect applied migrations via sqlx's Migrate trait
(ensure_migrations_table + list_applied_migrations) instead of
hand-querying the _sqlx_migrations bookkeeping table, so the check
cannot drift from what Migrator::run actually applies.
- Write the snapshot to a staging file and rename it into place, so a
failure mid-copy never leaves a partial file at the snapshot path.
- Derive the database path from the pool's connect options instead of
storing a duplicate copy on Database.
- Drop the invented "fabro.sqlite3" fallback filename from
pre_migration_snapshot_path; append the suffix to the path directly.
- Deduplicate the snapshot-inspection blocks in the test behind small
connect_read_only/table_exists helpers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A binary downgrade after new SQLite migrations have been applied fails
sqlx's startup validation ("migration was previously applied but is
missing in the resolved migrations") and previously left the operator
with no rollback artifact: the shared database had no backup, so
recovering meant hand-editing _sqlx_migrations and dropping tables.
Database::migrate now writes a consistent single-file snapshot to
<db>.pre-migration.bak (via VACUUM INTO, mode 0600) before applying any
migration the database has not seen. Rollback is: stop the server,
replace the database file with the snapshot, delete -wal/-shm siblings,
start the previous binary. Fresh databases and no-op migrates skip the
snapshot, so the file always preserves the state from immediately before
the most recent schema change. A snapshot failure fails the migration:
no rollback artifact, no schema change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parallel branches run in spawned tasks that bypass the engine's
StageStarted/StageCompleted lifecycle, so a branch stage was created
Running by its first branch-scoped event and never reached a terminal
state. On a successful run nothing swept it (only RunFailed does), so
the fan-out rows spun forever with a `--` duration even after the run
and its fan-in finished.
Fold ParallelBranchStarted/ParallelBranchCompleted in the projection:
seed started_at for the live timer, then set the terminal state and
wall-time from the branch's own completion event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exact-value secret registry (`SecretRedactor`) was built as
infrastructure ahead of its wiring, but the wiring was never merged —
the team settled on content-based redaction (entropy analysis + gitleaks
patterns) as the sole mechanism. The type had zero consumers outside its
own crate. This PR removes it and corrects two doc comments that
described the abandoned design as pending.
**What changed:**
1. `fabro-redact/src/secret_registry.rs` deleted in full (~217 lines),
with its `mod` declaration and `pub use` re-export removed from
`lib.rs`. `Region`, `redact_string`, `redact_json_value`,
`DisplaySafeUrl`, and everything else in the crate are untouched.
2. The `resolve_extra_headers` doc in `fabro-auth` no longer promises
future exact-match registration. It now honestly states that low-entropy
header values not shaped like credentials are not caught by
content-based redaction.
3. The `InterpString` module doc in `fabro-types` no longer describes a
pending per-run registry. It states the real architecture: resolved
secret values are plain strings, and redaction is content-based applied
at output serialization.
**Known limitation (pre-existing, not introduced here):** a declared
secret whose value is a low-entropy ordinary word (e.g. an environment
name) is not caught by content-based detection. This was the gap
`SecretRedactor` was meant to fill; it is an accepted trade-off, not a
regression from this PR.
### Fabro Details
<details>
<summary>Ran 8 stages in 26m 21s for $2.27</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 34s | – | 0 |
| preflight_lint | 2m 45s | – | 0 |
| implement | 0s | – | 0 |
| simplify_fable | 9m 44s | $2.27 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 10m 50s | – | 0 |
| **Total** | **26m 21s** | **$2.27** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
GitHub App installation tokens expire ~60 min after minting. On a long run,
the origin token baked into the sandbox clone at clone time is stale by the
time a late ACP node (e.g. the PR node) runs `git push`, causing an
`Invalid username or token` failure.
Two host-driven mechanisms, both using the existing
`Sandbox::refresh_push_credentials()` (re-mint + `git remote set-url origin`)
over the exec channel — no new inbound surface:
1. Turn-entry re-mint at each ACP node entry, so a push early in the turn uses
a fresh token.
2. A background refresh-ahead loop, scoped to the turn via a drop-guard, that
re-mints every 45 min so a single push-bearing turn that itself exceeds the
TTL stays fresh. A normal sub-interval turn never ticks; a failed/timed-out
tick retries sooner so a transient error cannot leave a longer-than-interval
expired-token window.
Both refresh calls are timeout-bounded (30s) so a stalled GitHub API cannot
hang node entry. FABRO_PUSH_CRED_REFRESH_AHEAD (default on; falsy = empty/0/
false/off/no, case-insensitive) disables the whole feature — turn-entry and
loop — for operators who manage `origin` themselves;
FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS overrides the interval (0 disables
just the loop). Both are added to the worker env allowlist.
refresh_push_credentials now returns RefreshOutcome (Refreshed vs Skipped) so
callers log accurately: Refreshed only when a GitHub App installation token was
actually re-minted; a static PAT or pre-minted Installation token (nothing to
re-mint) short-circuits to Skipped before the set-url exec.
Known follow-ups documented in-code: (a) resumed runs reconnect without App
creds, so refresh no-ops until they are threaded through the reconnect path;
(b) no freshness check on the per-entry mint; (c) the background set-url can
contend with the agent's own git on .git/config.lock; (d) parallel ACP branches
each run their own loop; (e) the refresh lives in the ACP handler only though
the stale-origin problem is stage-agnostic (native/command stages are not
covered); (f) refresh failures are logged via tracing but not surfaced as a
RunNotice event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- add GPT-5.6 Sol, Terra, and Luna to the built-in OpenAI model catalog
with current capabilities, limits, aliases, and pricing
- route all three models through the OpenAI Responses API and keep the
Codex-safe 272K context policy
- make GPT-5.6 Sol the OpenAI default and update the model docs and Ask
Fabro API contract
## Testing
- cargo nextest run -p fabro-model
- cargo nextest run -p fabro-llm builtin_catalog_route_equivalence_table
- cargo nextest run -p fabro-server list_models
- cargo nextest run -p fabro-server --features test-support
run_responses_include_ask_fabro_affordance
- cargo +nightly-2026-04-14 fmt --check --all
- cargo +nightly-2026-04-14 clippy -p fabro-model -p fabro-llm
--all-targets -- -D warnings
- git diff --check
The `server.integrations.slack.default_channel` field was typed
`Option<InterpString>` but was never documented as interpolable — every
doc example uses a plain channel name like `#releases`. It resolved only
env vars, only once at server startup, and that capability was inherited
from a uniform schema-staging design, not a deliberate feature. This
brings it in line with every other server-scope config field, which were
already demoted to plain literals under the project rule that
interpolation belongs to fields resolved with run context.
## What changed
- **Type** (`fabro-types`, `fabro-config` layers):
`Option<InterpString>` → `Option<String>` in `SlackIntegrationSettings`
and `SlackIntegrationLayer`.
- **Demotion warning** (`resolve/server.rs`): calls
`warn_if_demoted_template` at resolve time with the field path
`server.integrations.slack.default_channel`, matching the pattern used
for earlier server-field demotions. A value still containing a `{{ env.*
}}`-shaped token is stored verbatim and triggers a startup warning — no
resolution, no error.
- **Startup wiring** (`server.rs`): the `value.resolve(process_env_var)`
call and its error mapping are deleted; the literal string is passed
directly to `SlackService::new`, which already accepts `Option<String>`.
- **System status handler** (`handler/system.rs`): removed the
now-unnecessary `display_interp` helper that called `resolve_or_source`;
the field is cloned directly into the metadata map.
- **Wire shape**: unchanged. `InterpString` serialized as its raw source
string, so stored/wire JSON is identical before and after. The OpenAPI
spec is untouched.
## What is not changing
Per-run Slack channels — `run.notifications.<route>.slack.channel` and
`run.interviews.slack.channel` — remain `InterpString` with variable
substitution at run creation. Those are the intended interpolating
surface and are correct as-is.
## Migration signal
Anyone who placed a `{{ env.NAME }}` token in
`server.integrations.slack.default_channel` (only possible during ~3
months of nightly builds) will see a startup warning naming the field.
The value is treated as a literal; no data is lost and startup does not
fail.
## Interview-prompt routing observation (step 4)
The interview-prompt posting path checks `run.interviews.slack.channel`
first and falls back to the server default only when the run-scope field
is absent — the preference already exists. No routing change is needed
or made here.
### Fabro Details
<details>
<summary>Ran 8 stages in 41m 23s for $10.10</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 41s | – | 0 |
| preflight_lint | 2m 42s | – | 0 |
| implement | 0s | – | 0 |
| simplify_fable | 27m 43s | $10.10 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 7m 44s | – | 0 |
| **Total** | **41m 23s** | **$10.10** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
## Summary
Provider `extra_headers` previously required values to be typed TOML
tables (`{ env = "X" }`, `{ literal = "Y" }`, `{ vault = "Z" }`). This
PR migrates them to the project's standard interpolation string format:
plain text for literals, `{{ env.NAME }}` tokens for environment
variables, and `{{ secrets.NAME }}` tokens for vault secrets. This
brings `extra_headers` in line with the rest of the interpolation system
and unlocks mixed-segment values like `Bearer {{ secrets.GATEWAY_TOKEN
}}`.
### What changed and why
**Config authoring surface (`fabro-config`):**
`ProviderSettings.extra_headers` changes from `Option<HashMap<String,
HeaderValueRef>>` to `Option<HashMap<String, InterpString>>`. The
`Combine` impl and all re-exports are updated accordingly.
**Catalog layer (`fabro-model`):**
`ProviderCatalogSettings.extra_headers` and
`CatalogProvider.extra_headers` become `HashMap<String, String>` — raw
interpolation source strings. This is required by the crate dependency
direction: `fabro-types` (which owns `InterpString`) depends on
`fabro-model`, so `fabro-model` cannot hold `InterpString` without
creating a cycle. The source string is re-parsed and resolved in
`fabro-auth` at credential-build time.
**Credential resolution (`fabro-auth`):** Both `CredentialResolver`
(vault-backed) and `EnvCredentialSource` (env-only) are rewritten to
parse each header source string as an `InterpString` and resolve it with
a `ResolveCtx` scoped to `env` + `secrets` only. A new
`resolve_extra_headers` helper is shared between the two paths. Vault
resolution uses `vault_token_lookup`, which wraps `vault_get_token` and
maps any non-Token vault entry to `None` — so file and OAuth vault
entries fail closed rather than resolving incorrectly. `vars.*` and
`inputs.*` tokens are not in scope and produce `Unavailable` errors
automatically.
**New error variant:** `ResolveError::Interpolation { provider, source
}` surfaces header resolution failures as diagnosable auth issues. The
inner `source` (an `InterpResolveError`) names only the token namespace
and name — never a resolved value.
**`{ literal = "..." }` guardrail removed:** `HeaderValueRef`
deliberately rejected bare string header values to discourage pasting
credentials. `InterpString` accepts any string. This is an intentional
change; the mitigation is documentation — use `{{ secrets.NAME }}` for
credential-shaped values, not bare literals.
**Redactor registration gap (noted, not fixed here):** Secrets resolved
into provider headers at the credential boundary do not flow through the
run boundary's exact-match redaction registry. Exposure is low (headers
are host-side and outbound-only, never logged), but a follow-up should
thread a registering lookup through `VaultCredentialSource`. A code
comment at the resolution site marks the gap.
### Breaking change
Existing `extra_headers` config using `{ env = "X" }`, `{ literal = "Y"
}`, or `{ vault = "Z" }` table syntax **will fail to parse** after this
change. Users must migrate to the token form: plain strings for
literals, `{{ env.X }}` for env vars, `{{ secrets.X }}` for vault
secrets. A changelog entry is included.
### Plan Summary
- Update `ProviderSettings.extra_headers` → `InterpString` in
`fabro-config`
- Collapse authoring `InterpString` → source `String` in
`provider_settings_to_catalog` (allowlisted `as_source()` call)
- Delete `HeaderValueRef` and its serde/display/parse machinery from
`fabro-model`
- Rewrite both auth resolution paths to use `InterpString::parse +
resolve_with`; add `Interpolation` error variant
- Add `vault_token_lookup` helper for token-only fail-closed vault
resolution
- Update test TOML in `fabro-llm`, builtin catalog comment in
`openrouter.toml`, and all hand-written + generated docs
### Fabro Details
<details>
<summary>Ran 8 stages in 108m 43s for $34.44</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 5m 33s | – | 0 |
| preflight_lint | 5m 59s | – | 0 |
| implement | 43m 23s | $17.34 | 0 |
| simplify_fable | 32m 49s | $13.09 | 0 |
| simplify_gpt | 6m 25s | $4.01 | 0 |
| verify | 13m 58s | – | 0 |
| **Total** | **108m 43s** | **$34.44** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
## Problem
The post-node run-branch checkpoint commit runs repository commit hooks
unless `skip_git_hooks` is enabled, but its sandbox command timeout was
hardcoded to 30 seconds. Consumers whose hooks run a multi-minute gate
cannot complete a checkpoint.
## Change
Adds `commit_timeout_ms` to the existing `[run.checkpoint]` table.
- Defaults to `30000`, preserving existing behavior.
- Threads the value through config raw layer -> merge -> resolve ->
resolved settings -> `RunOptions` -> `GitState` -> both checkpoint call
sites.
- Applies the configured timeout to checkpoint `git add -A` and `git
commit`.
- Keeps old serialized run manifests compatible via serde default.
## Testing
- `cargo +nightly-2026-04-14 fmt --all`
- `cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets
-- -D warnings`
- `cargo nextest run --locked -p fabro-config -p fabro-types -p
fabro-workflow`
- 1795 passed, 31 skipped
- `cargo nextest run --locked -p fabro-cli
attach_json_errors_without_prompting_for_human_input`
- `cargo nextest run --locked --workspace --status-level slow --profile
ci --no-fail-fast`
- 6951 passed, 3 timed out, 187 skipped
- The 3 timeouts are preexisting on clean `upstream/main`: verified by
running `CARGO_TARGET_DIR=/data/projects/fabro/target cargo nextest run
--locked -p fabro-cli --profile ci --no-fail-fast workflow::acp::acp`
from a detached worktree at `upstream/main` (`8c7d5dc7d`), which timed
out the same three tests:
-
`workflow::acp::acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start`
-
`workflow::acp::acp_backend_does_not_inject_registered_provider_credentials`
- `workflow::acp::acp_backend_workflow`
## Compatibility
No behavior change without explicit opt-in. Omitted config resolves to
the existing 30 second timeout, and old serialized run manifests
deserialize unchanged.
---------
Co-authored-by: thewoolleyman <chad@thewoolleyman.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Runs created before #530 disappear from the run list after upgrading,
because
their persisted `run.created` event can no longer be deserialized.
#530 renamed `RunPrepareSettings`'s field from `commands: Vec<String>`
to
`steps: Vec<PreparedStep>`. That struct is persisted inside the
`run.created`
event (`WorkflowSettings.run.prepare`). Events written by older versions
carry a
`prepare` object with a `commands` key and **no** `steps` key. Because
`steps`
had no serde default, deserializing such an event fails with:
```
Serialization error: missing field `steps`
```
`warm_projection_cache` catches that error per-run and **skips** the run
(`fabro_store::slate: Skipping run during projection cache warmup`), so
every
pre-#530 run silently vanishes from the run list. The event data is
intact on
disk — it just can't be read back.
This is an event-schema back-compat break: any type persisted in an
event must
stay readable across the field renames/additions that happen after it
was
written.
## Fix
Add `#[serde(default)]` at the container level on `RunPrepareSettings`,
so a
`prepare` object missing `steps` (and/or `timeout_ms`) falls back to the
existing `Default` impl (empty steps, product-default timeout) instead
of
failing the whole run. The unknown legacy `commands` key is ignored (the
struct
has no `deny_unknown_fields`).
- New runs always serialize explicit `steps`, so nothing changes for
them — the
#530 feature is unaffected.
- Pre-#530 runs load again with an empty prepare phase, which is
faithful: those
runs already executed; this only rebuilds a read model for display.
`#[serde(default)]` is already the evolution idiom in this same struct
tree
(e.g. `RunModelSettings.controls`).
## Test plan
- [x] `cargo test -p fabro-types` — added two regression tests that
deserialize
the exact pre-#530 event shape (`{ commands, timeout_ms }`, no `steps`)
and an empty object, asserting both load instead of erroring.
- [x] Built the patched server and pointed it at a real
`~/.fabro/storage` that
had 119 pre-#530 runs being skipped. After the fix, 0 runs are skipped
and
all 119 appear in `GET /api/v1/runs`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
## Problem
Loading the web UI from a remote server took **~11 seconds to first
render on every refresh**. A HAR capture against a remote deployment
showed the page downloading **13.5 MB of JavaScript across 356 files,
uncompressed, on every single page load** — even though the assets are
content-hashed and served with `Cache-Control: immutable`.
Four compounding causes:
1. **`Pragma: no-cache` defeated the browser cache.** The
security-headers middleware stamped `Pragma: no-cache` onto every
response, including hashed assets that set a year-long immutable
`Cache-Control`. Browsers treat a response `Pragma: no-cache` as
`Cache-Control: no-cache` and check it *before* `max-age` (Chromium
zeroes freshness on it), and since assets carried no validators,
"revalidate" degraded into a full re-download. Empirically visible in
the HAR: Google-Fonts woff2s served from cache (`transfer = 0`) during
the same page load where all 356 of our assets re-downloaded in full.
2. **No response compression.** The server had no compression layer;
13.5 MB of JS compresses to ~2.5 MB with brotli.
3. **The HTML force-loaded every chunk.** `writeIndexHtml` emitted a
`<script type="module">` tag for all 356 outputs. Only 2.9 MB is
statically reachable from the entry; the other ~10.7 MB is
dynamic-import-only code (syntax grammars, Graphviz WASM, xterm, diff
file tree) that was being downloaded eagerly at high priority.
4. **The immutable heuristic over-matched.** Any dash in a filename
counted as a content hash, so stable-named files
(`pierre-diffs-worker/worker-portable.js`, `apple-touch-icon.png`) would
be pinned in browser caches for a year across deploys once fix 1 made
immutable caching effective.
## Changes
- **`security_headers`**: apply the `no-store`/`Pragma: no-cache`
defaults only when the handler didn't set its own `Cache-Control`. API
responses keep the conservative defaults.
- **Compression**: `tower-http` `CompressionLayer` (brotli + gzip) on
both the main router and the install-mode router (install mode serves
the same SPA bundle through a separate router). Default predicate keeps
SSE (`text/event-stream`), gRPC, images, and tiny bodies
identity-encoded. Quality pinned to `Precise(4)` — tower-http's default
defers to the codec default, and brotli's default is quality 11 (seconds
of CPU per multi-megabyte asset).
- **Entry-only HTML**: `writeIndexHtml` emits script tags only for `kind
=== "entry-point"` outputs. The module graph pulls static imports (depth
1, so no waterfall); dynamic `import()` chunks load on demand.
- **Cache-control classifier + validators**: only files matching the
bundler's actual output shape (`assets/<stem>-<hash8>.js|css`, lowercase
base-36) get `immutable`. Everything else is `no-cache` **with a strong
ETag** and `If-None-Match` → `304` support, so index.html / app.css /
the pierre worker revalidate in one cheap conditional request instead of
a full re-download.
## Impact (measured on the built bundle)
| | Before | After |
|---|---|---|
| Cold load, ~1 MB/s link | 13.5 MB raw ≈ **11–14 s** | ~0.8 MB
compressed eager payload ≈ **~1 s** |
| Refresh | full re-download, same 11–14 s | served from cache + one 304
≈ **instant** |
| Eager JS on first render | 13.56 MB / 356 files | 2.88 MB raw (0.79 MB
gzip) / 6 files |
## Verification
- 959 fabro-server tests pass (incl. new coverage); fmt + clippy clean;
`bun run typecheck` passes (the 5 pre-existing bun test failures
reproduce identically on `main` — missing `@pierre/diffs/dist/worker`
fixture + flaky InstallApp timing tests).
- New integration tests pin compression through **both** serving shapes
that matter: regular routes and the SPA fallback service, each via tower
`oneshot` **and** over a real TCP connection through hyper (raw-socket
assertions, so no client auto-decompression can mask a regression).
- Live-verified against a debug server: hashed assets get `immutable` +
brotli and no `Pragma`; mutable assets get `no-cache` + ETag and answer
conditionals with `304`; API responses keep `no-store`.
- Headless Chrome boots the rebuilt SPA from the entry-only HTML and
fully renders the UI.
## Notes for reviewers
- The ETag is skipped for immutable assets deliberately — they never
revalidate, so hashing multi-MB bodies per request would be pure
overhead.
- Install mode previously had **no** compression and shares the same
bundle; it gets the same layer via a shared `compression_layer()`
helper.
- `bun test` has a pre-existing suite (`production build copies Pierre
worker assets`) that fails without `@pierre/diffs/dist/worker` present
locally; unrelated to this change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>