fabro/docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md

1001 lines
45 KiB
Markdown

---
title: "refactor: Settings API entrypoints — owner-first context types"
type: refactor
status: active
date: 2026-04-22
---
# Settings API Entrypoints — Owner-First Context Types
## Overview
Replace the free-function settings API with two owner-first context types
that expose dense, resolved views of current config. The elegance rule:
- **`SettingsLayer`** is the sparse transport/storage form — what TOML
files parse into, what's persisted in run manifests, what merges with
precedence.
- **Context types** (`ServerSettings`, `UserSettings`) are dense,
owner-scoped, resolved views of a *current process's* config,
computed from a layer at the moment a consumer needs them.
These roles do not overlap: stored artifacts are layers; current-config
reads are views. Stored-layer readers (code that reads specific
namespaces off a persisted run's `SettingsLayer`) keep using
per-namespace resolvers — the layer is the real artifact there.
After this refactor:
- **`ServerSettings`** — `server` + `features` namespaces. Derived from
the server's **effective runtime layer** (the post-`apply_runtime_settings`
`SettingsLayer` that folds in CLI overrides like `--storage-dir` and
`--bind`) at startup and whenever hot-reload refreshes the layer.
Held in `AppState` alongside the shared layer itself.
`GET /api/v1/settings` serves the current in-memory view directly —
no per-request disk read, no view toggle, no redaction.
- **`UserSettings`** — `cli` + `features` namespaces. The CLI process
builds one from its own `~/.fabro/settings.toml`. `fabro run attach`
uses the *live* `UserSettings` at attach time.
Both context types expose `from_layer(&SettingsLayer)` (primitive) and
`resolve()` (convenience that loads the default file; used by tools
and tests, not the server startup path which composes `from_layer`
against the effective runtime layer).
The god type `Settings` goes away with no named replacement — its
former consumers either migrate to per-namespace resolvers (the
stored-layer readers) or are deleted wholesale (the view-toggle
machinery removed by Unit 7). `EffectiveSettingsMode` goes away;
`fabro settings --local` goes away; redaction machinery goes away.
Settings contain no secrets — `InterpString` templates preserve
`{{ env.NAME }}` unresolved on the wire. Actual secrets live in
`ServerSecrets` / Vault.
## Problem Frame
The settings API grew around a layered-config design that's now visibly
clunky:
- Every caller that wants a resolved value first takes a `SettingsLayer`,
then picks the right `resolve_*_from_file` helper. The layer plumbing is
on the public surface even though most callers just want "give me the
server config."
- `EffectiveSettingsMode` has three variants; production code always picks
`LocalDaemon`. The enum is dead ceremony.
- `fabro_types::settings::Settings` is a god type — server-side execution
code needing `run.*` also gets `cli.*` in the same struct.
- The CLI/server trust boundary is enforced by `fabro_cli::local_server`
convention plus `bin/dev/check-boundary.sh`. Type-level projection does
this at compile time.
The settings schema itself is fine. This plan is the programmatic API over
it.
## Requirements Trace
- **R1.** Current-config callers (the server's in-memory settings, the
CLI process's settings) use the context types. Stored-layer readers —
code that reads specific namespaces off a persisted `SettingsLayer`
(`runner.rs`, `operations/create.rs`) — continue to use per-namespace
resolvers. The layer is the real artifact there and must not be
contorted to fit the context-type API.
- **R2.** Each context type exposes namespaces owned by its consumer,
plus the cross-cutting `features.*` namespace.
- **R3.** `EffectiveSettingsMode` is deleted. One merge path remains.
- **R4.** `fabro settings --local` and its filesystem-walking assembly
are deleted.
- **R5.** `GET /api/v1/settings` returns the server's in-memory
`ServerSettings` as a typed JSON body. No `view=` query param, no
`X-Fabro-Settings-View` header, no disk re-read, no redaction.
- **R6.** Today's per-namespace resolved types (`ServerSettings`,
`CliSettings`, `ProjectSettings`, `WorkflowSettings`, `RunSettings`,
`FeaturesSettings`) rename with a `*Namespace` suffix, freeing the
short names for context types. Only `ServerSettings` and `UserSettings`
are defined as context types in this PR.
- **R7.** The `Settings` god type (and the public `fabro_config::resolve`
function that returns it, and the `load_and_resolve` helper that
wraps them) are deleted. No named replacement type is introduced;
consumers migrate to per-namespace resolvers or disappear with the
view-toggle machinery Unit 7 removes.
- **R8.** Layer-merge internals are `pub(crate)` or private.
Per-namespace resolver visibility matches reality (see Key Technical
Decisions).
- **R9.** The CLI/server trust boundary survives. `fabro_cli::local_server`
remains the only sanctioned CLI-side gateway to `[server.*]`;
`bin/dev/check-boundary.sh` updates to cover the new symbols.
## Scope Boundaries
- **Not changing** the TOML schema, namespace inventory, merge precedence,
or owner-domain stripping rules.
- **Not changing** `PreparedManifest.settings` — stays `SettingsLayer`.
Wire format for persisted run settings unchanged.
- **Not migrating** stored-layer readers. `runner.rs:507-508`
(`resolve_run_from_file` / `resolve_server_from_file` on `record.settings`)
and `operations/create.rs` (metadata aggregation from stored layers) are
legitimate consumers of the sparse layer, not candidates for
context-type migration.
- **Not adding** endpoints or CLI commands.
- **Settings contain no secrets** (invariant, with documented gaps).
Secret-bearing fields should be `InterpString`. Known gaps not addressed
here: `McpTransport::Http.headers`, `McpTransport::Stdio.env`,
`McpTransport::Sandbox.env`, `HookType::Http.headers`, `run.inputs`,
`run.metadata`. Pre-existing; deferred to a follow-up plan that retypes
the maps to `HashMap<String, InterpString>` and adds submit-time
validation. Any new field added by this refactor must satisfy the
invariant.
## Context & Research
### Relevant Code and Patterns
- `lib/crates/fabro-config/src/effective_settings.rs` — layer merge +
owner-domain stripping.
- `lib/crates/fabro-config/src/resolve/` — per-namespace resolve helpers.
- `lib/crates/fabro-config/src/user.rs` — loads
`~/.fabro/settings.toml` into a `SettingsLayer`.
- `lib/crates/fabro-types/src/settings/resolved.rs` — god type `Settings`.
- `lib/crates/fabro-types/src/settings/mod.rs` — re-exports per-namespace
types to be renamed.
- `lib/crates/fabro-cli/src/local_server.rs` — single sanctioned CLI
gateway to `[server.*]`.
- `lib/crates/fabro-cli/src/commands/config/mod.rs` — `fabro settings`
command; contains `--local` branch to delete.
- `lib/crates/fabro-server/src/run_manifest.rs` — sole production caller
of `materialize_settings_layer`.
- `lib/crates/fabro-server/src/settings_view.rs` — deleted by this
refactor.
- `lib/crates/fabro-server/src/server.rs` — `AppState` will hold the
server's `ServerSettings`; the settings endpoint handler serves it
directly.
- `docs/api-reference/fabro-api.yaml` — OpenAPI spec.
- `bin/dev/check-boundary.sh` — CLI/server boundary regression guard.
### Related Context
- `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` —
defined the six-namespace schema and owner-first trust boundaries. This
plan operationalizes those boundaries in code.
### Call-Site Inventory
- `resolve_server_from_file` outside `fabro-config`: ~20 call sites across
`fabro-cli`, `fabro-server`, `fabro-workflow`, `fabro-install`. Re-run
`grep -rn "resolve_server_from_file" lib/` before Unit 4 to confirm the
full set.
- `resolve_cli_from_file` outside `fabro-config`: `user_config.rs`,
`attach.rs` (deleted in Unit 5).
- `materialize_settings_layer` outside tests: `run_manifest.rs` only.
- Per-namespace type name references to rename: ~57.
### Institutional Learnings
- No prior `docs/solutions/` entries cover this area.
## Key Technical Decisions
- **Layer and view are distinct roles.** `SettingsLayer` is sparse
transport/storage. Context types are dense, owner-scoped, resolved views
computed from a layer (or combination) at read time. Persisted artifacts
are layers; live reads are views. These don't overlap and don't convert
in place.
- **`GET /api/v1/settings` serves `AppState` in memory.** The server
builds its `ServerSettings` at startup from the *effective runtime
layer* (post-`apply_runtime_settings`, which folds in CLI overrides
like `--storage-dir` and `--bind`) and stores it in `AppState`.
Hot-reload keeps the derived view in sync via
`state.replace_settings(...)`. The handler returns a clone of the
current value. No per-request disk read. No view toggle. No
redaction. No response header.
- **No `WorkflowSettings` in this PR.** An earlier draft introduced a
`WorkflowSettings` context type as the dense resolved view for
server-side execution, but the refactor has no production caller that
needs it: `operations/create.rs` migrates to per-namespace resolvers
(legitimate stored-layer read), `server.rs:1337` is deleted wholesale
as part of Unit 7's view-toggle removal, and `settings_view.rs`
disappears entirely. Introducing `WorkflowSettings::resolve_for_run`
with no caller is speculative abstraction. If future server code
wants a dense multi-namespace view, it can add the type then with a
real consumer. `PreparedManifest.settings` stays `SettingsLayer`
(unchanged by this refactor); server execution code reads specific
namespaces via per-namespace resolvers as it does today.
- **Attach honors live CLI settings.** `fabro run attach` calls
`UserSettings::resolve()` on the attaching process's config.
Submit-time `cli.*` on a stored run is inert — no code reads it back.
- **Two constructors per context type.**
`ServerSettings::from_layer(&SettingsLayer)` is the primitive;
`ServerSettings::resolve()` loads the default
`~/.fabro/settings.toml` and delegates. `UserSettings` has the same
pair. No `resolve_from(path)`: `--config` handling is an existing
`serve.rs` concern that produces the on-disk settings layer before
`apply_runtime_settings` runs; the new `ServerSettings::from_layer`
is invoked on the resulting *effective runtime layer*, not on a
fresh disk read.
- **Delete `EffectiveSettingsMode`.** Production always picks
`LocalDaemon`; the enum is ceremony. `materialize_settings_layer`
becomes a single straight-line function.
- **Delete redaction.** Settings contain no secrets. `settings_view.rs`,
`redact_for_api`, `redact_resolved_value`, `SettingsApiView`,
`SettingsQuery`, `X-Fabro-Settings-View` — all deleted. Both settings
endpoints serialize directly. If a future field should not be exposed,
the fix is to type it as `InterpString`, not to reintroduce redaction.
- **`fabro settings` composes local + server.** The CLI renders
`UserSettings::resolve()` (local) plus the server's `ServerSettings`
(fetched via the endpoint). Two sections.
- **Typed OpenAPI schema via `Deserialize` + `with_replacement`.**
Aligned with CLAUDE.md's API type ownership doctrine. The `ServerSettings`
OpenAPI schema describes the internal Rust type directly; progenitor
reuses it via `with_replacement`. Reachable namespace types gain
`Deserialize` derives; types with custom `Serialize` get matching custom
`Deserialize` (notably `InterpString`, which must preserve unresolved
templates).
- **`features.*` on both context types — the one carve-out to
owner-first.** Cross-cutting by design; server and CLI both gate
behavior on feature flags, and server execution code consumes them
via its per-namespace `resolve_features_from_file` reads on stored
layers.
- **Context types live in `fabro-config`.** Inherent `impl` blocks must
live with the type per Rust's orphan rules. `fabro-types` keeps only
per-namespace shape types.
- **Per-namespace resolver visibility, decided once:** all six
`resolve_*_from_file` helpers stay `pub`, because each has at least
one cross-crate consumer:
- `resolve_server_from_file` — `runner.rs:508` reads `server.*` off
stored `record.settings`.
- `resolve_run_from_file` — `runner.rs:507`, `run_manifest.rs:369`,
`operations/create.rs` read `run.*` off stored layers.
- `resolve_project_from_file`, `resolve_workflow_from_file` —
`operations/create.rs` reads their `.metadata` for label
aggregation.
- `resolve_cli_from_file` — `fabro-cli/tests/it/cmd/create.rs:364`
integration test asserts the persisted `cli.*` wire shape.
- `resolve_features_from_file` — `fabro-server/src/server.rs:1414`
reads `features.session_sandboxes`; the
`fabro-config/tests/resolve_features.rs` integration test also
depends on it.
- **Other `fabro-config` visibility:**
- `pub(crate)`: `materialize_settings_layer` — internal helper; the
merge step stays inside `run_manifest.rs`'s call site, consumed only
via the public `SettingsLayer` output.
- `pub`: `user::load_settings_config` — external caller in
`fabro-server/src/serve.rs` loads the on-disk config layer before
`apply_runtime_settings`.
- `pub(crate)`: `EffectiveSettingsLayers` — no external consumer
remains after `WorkflowSettings` is dropped (`run_manifest.rs`
builds layers internally for its own `materialize_settings_layer`
call).
## Open Questions
### Resolved During Planning
- *Introduce a `WorkflowSettings` context type?* No. The refactor has
no production caller that needs a dense multi-namespace view —
`operations/create.rs` migrates to per-namespace resolvers, the
view-toggle branches are deleted. Adding the type speculatively
violates the no-speculative-cleanup directive.
- *Context types' crate?* `fabro-config`.
- *`GET /api/v1/settings` re-read from disk per request?* No. Serves
`AppState.server_settings`.
- *`PreparedManifest.settings` retype?* No. Stays `SettingsLayer`.
- *Stored-layer readers migrate to context types?* No. They stay on
per-namespace resolvers.
- *Redaction for the new endpoints?* None. Deleted entirely.
### Deferred to Implementation
- `fabro settings` output layout (two-section render — labels, ordering,
field suppression). Snapshot tests drive.
## High-Level Technical Design
> *Directional guidance, not implementation specification.*
**Type inventory:**
```
// Per-namespace dense types (renamed from today's *Settings)
ServerNamespace, CliNamespace, ProjectNamespace,
WorkflowNamespace, RunNamespace, FeaturesNamespace
// Context types (new; all in fabro-config)
ServerSettings { server: ServerNamespace, features: FeaturesNamespace }
UserSettings { cli: CliNamespace, features: FeaturesNamespace }
```
**Public constructors:**
```
impl ServerSettings {
fn from_layer(&SettingsLayer) -> Result<Self>;
fn resolve() -> Result<Self>;
}
impl UserSettings {
fn from_layer(&SettingsLayer) -> Result<Self>;
fn resolve() -> Result<Self>;
}
```
**Role separation:**
```
SettingsLayer — sparse transport/storage. Unchanged.
ServerSettings — dense view. AppState holds one; API serves it.
UserSettings — dense view. CLI process builds one.
```
**Call-site topology:**
```
Before After
────── ─────
resolve_server_from_file(&layer) ─► ServerSettings::from_layer(&layer)
(for current-config reads) (stored-layer reads keep
resolve_*_from_file)
GET /api/v1/settings: load + resolve ─► GET /api/v1/settings: clone AppState.server_settings
+ redact + serialize per request
resolve_cli_from_file(&layer) ─► UserSettings::resolve() or ::from_layer
attach reads stored cli.* ─► attach calls UserSettings::resolve()
fabro_config::resolve(&layer) ─► per-namespace resolvers at each call site
(god-type god fn) in create.rs (project/workflow/run metadata reads)
materialize_settings_layer(...) ─► materialize_settings_layer(...)
(stays at run_manifest.rs merge site;
loses the `mode` parameter; output
still stored in PreparedManifest.settings
as SettingsLayer)
Settings god type ─► deleted; no named replacement
runner.rs / operations/create.rs: ─► unchanged (pub per-namespace resolvers)
per-namespace stored-layer reads
```
**Unit dependency graph:**
```mermaid
flowchart TB
U1[Unit 1: Delete --local]
U2[Unit 2: Delete mode enum]
U3[Unit 3: Rename to *Namespace]
U4[Unit 4: ServerSettings + AppState]
U5[Unit 5: UserSettings + live attach]
U6[Unit 6: Delete Settings god type]
U7[Unit 7: New /api/v1/settings shape]
U8[Unit 8: Privatize internals]
U1 --> U2
U2 --> U3
U3 --> U4
U3 --> U5
U3 --> U6
U4 --> U7
U6 --> U7
U4 --> U8
U5 --> U8
U6 --> U8
```
## Implementation Units
- [ ] **Unit 1: Delete `fabro settings --local` and the `LocalOnly` path**
**Goal:** Remove `--local`, its helpers, and the `LocalOnly` variant.
**Requirements:** R4.
**Dependencies:** None.
**Files:**
- Modify: `lib/crates/fabro-cli/src/args.rs` — remove `local` and
`workflow` fields from `SettingsArgs`.
- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` — delete
`local_settings_value`, `workflow_and_project_layers`, `config_layers`,
`strip_nulls`, `resolve_local_settings_value`, `render_resolve_errors`,
and the `args.local` / `args.workflow` branches.
- Modify: `lib/crates/fabro-cli/src/main.rs` — remove `args.local`
snapshot-test assertions.
- Modify: `lib/crates/fabro-config/src/effective_settings.rs` — remove
`LocalOnly` variant and its match arm.
- Modify: `lib/crates/fabro-config/tests/resolve_root.rs` and the
`effective_settings.rs` tests module — drop `LocalOnly` cases.
- Delete: `lib/crates/fabro-cli/tests/it/cmd/config.rs` tests that depend
on `--local` (e.g., `settings_local_merges_cli_and_project_defaults`,
`settings_local_workflow_name_applies_run_overlay_and_deep_merges`).
They assert filesystem-walked behavior that's being removed entirely.
**Approach:**
- `--workflow WORKFLOW` was only meaningful with `--local`. Both go.
- `fabro settings` continues to work via the server path (reshaped in
Unit 7).
- `LocalOnly` removal is dead-code deletion once the CLI stops calling it.
**Test scenarios:**
- *Contract:* `fabro settings` with a running server returns the
server's settings (shape reshaped in Unit 7).
- *Parser-level:* `fabro settings --local` fails to parse. `fabro settings
WORKFLOW_ARG` fails to parse.
**Verification:**
- `cargo build --workspace` and `cargo nextest run -p fabro-cli` pass.
- `grep -rn "LocalOnly\|args\.local" lib/` returns only deliberate doc
references.
---
- [ ] **Unit 2: Delete `EffectiveSettingsMode` entirely**
**Goal:** Kill the mode enum. `materialize_settings_layer` becomes a
single straight-line function.
**Requirements:** R3.
**Dependencies:** Unit 1.
**Files:**
- Modify: `lib/crates/fabro-config/src/effective_settings.rs` — delete
`EffectiveSettingsMode`; remove `mode` parameter from
`materialize_settings_layer`; flatten to a single path (strip owner
domains, merge, apply server authority). Rename
`apply_local_daemon_overrides` → `enforce_server_authority`. Delete
`apply_server_defaults` (no surviving caller).
- Modify: `lib/crates/fabro-config/src/lib.rs` — update internal
`load_and_resolve` to match.
- Modify: `lib/crates/fabro-server/src/run_manifest.rs` — call
`materialize_settings_layer(layers, Some(server_settings))` without a
mode.
- Modify: `lib/crates/fabro-server/src/server.rs` — remove the
`local_daemon_mode` field from `AppState` and `AppStateConfig`; update
the three handler call sites that thread it (`create_run` ~line 4104,
`run_preflight` ~line 4212, `render_graph_from_manifest` ~line 4242);
update helper signatures (~lines 2515, 2541, 2641, 2708); remove the
test helper at ~line 2575.
- Modify: `effective_settings.rs` tests module — delete the
`RemoteServer` test case (`cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode`).
Verify its unique assertions are covered by the surviving `LocalDaemon`
test at ~line 437; if any coverage is unique, fold that assertion into
the surviving test.
**Approach:**
- `apply_server_defaults` (the `RemoteServer` code path) has no surviving
caller; delete along with the enum.
- Keep `strip_owner_domains` and `enforce_server_authority` as private
helpers.
**Test scenarios:**
- *Contract:* `materialize_settings_layer` with representative layers
produces the same output as the pre-refactor `LocalDaemon` invocation.
- *Edge case:* `Some(empty_layer)` for server settings preserves client
values and doesn't panic.
**Verification:**
- `cargo build --workspace` and `cargo nextest run -p fabro-config` pass.
- `grep -rn "EffectiveSettingsMode\|RemoteServer\|LocalDaemon\|local_daemon_mode" lib/`
returns zero hits.
---
- [ ] **Unit 3: Rename per-namespace resolved types to `*Namespace`**
**Goal:** Free the short names for context types.
**Requirements:** R6.
**Dependencies:** Units 1, 2.
**Files:**
- Modify: `lib/crates/fabro-types/src/settings/{mod,server,cli,project,workflow,run,features}.rs`
— rename each per-namespace type (`ServerSettings` → `ServerNamespace`,
etc.).
- Modify: `lib/crates/fabro-types/src/settings/resolved.rs` — update the
god-type field types (the god type itself goes away in Unit 6).
- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` and siblings —
update return types.
- Modify: ~57 other references across the workspace (mechanical).
**Approach:** Type-only rename. Function names, field names, and serde
wire format unchanged. Use `cargo check --workspace` between file batches.
**Execution note:** Mechanical; suitable for `Execution target:
external-delegate`.
**Test scenarios:** All existing tests continue to pass. Compiler is the
primary witness.
**Verification:**
- `cargo build --workspace` and `cargo nextest run --workspace` pass.
- `grep -rn "fabro_types::settings::\(ServerSettings\|CliSettings\|ProjectSettings\|WorkflowSettings\|RunSettings\|FeaturesSettings\)\b" lib/`
returns zero hits.
- Clippy clean.
---
- [ ] **Unit 4: Introduce `ServerSettings`; `AppState` holds one**
**Goal:** Add `ServerSettings` with `from_layer` + `resolve`. Build one at
server startup and store in `AppState`. Migrate current-config callers.
**Requirements:** R1, R2.
**Dependencies:** Unit 3.
**Files:**
- Create: `lib/crates/fabro-config/src/context.rs` — `ServerSettings {
server: ServerNamespace, features: FeaturesNamespace }`. Inherent
`from_layer(&SettingsLayer) -> Result<Self>` and `resolve() ->
Result<Self>`.
- Modify: `lib/crates/fabro-config/src/lib.rs` — re-export `ServerSettings`.
- Modify: `lib/crates/fabro-server/src/serve.rs` (~lines 478-482) —
startup already computes
`effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir)`
(the post-runtime-override layer that folds in `--storage-dir`,
`--bind`, etc.). Add
`let server_settings = ServerSettings::from_layer(&effective_settings)?;`
alongside the existing `resolved_server_settings =
resolve_server_settings(&effective_settings)?` line, and thread
`server_settings` into `AppState`. Derive from the effective runtime
layer, not from a fresh `~/.fabro/settings.toml` read; a fresh read
would drop the CLI overrides.
- Modify: `lib/crates/fabro-server/src/serve.rs` (~line 600, the
hot-reload path) — when `apply_runtime_settings(...)` is rerun and
`state_for_poll.replace_settings(effective)` is called, also refresh
`AppState.server_settings` from the new layer so the typed view
stays in sync with the `Arc<RwLock<SettingsLayer>>`.
- Modify: `lib/crates/fabro-server/src/server.rs` — add
`server_settings: Arc<ServerSettings>` (or equivalent interior
mutability for hot-reload refresh) to `AppState`. Extend
`replace_settings(...)` to also update the derived `ServerSettings`
so both are consistent after reload.
- Modify: every current-config caller of `resolve_server_from_file` (~20
sites across `fabro-cli`, `fabro-server`, `fabro-workflow`,
`fabro-install`) — switch to `ServerSettings::from_layer(&layer)` where
a layer is already in hand, or `ServerSettings::resolve()` where
defaults apply. Stored-layer readers (`runner.rs:507-508`) stay on
`resolve_server_from_file`.
- Modify: `bin/dev/check-boundary.sh` — add grep patterns for
`ServerSettings::resolve` and `ServerSettings::from_layer` so the
regression guard covers the migration window.
- Test: `lib/crates/fabro-config/tests/resolve_server.rs` — add tests for
`ServerSettings::from_layer` and `ServerSettings::resolve`.
**Approach:**
- `from_layer` invokes the per-namespace resolver and wraps the result.
Single primitive.
- `resolve()` loads the default `~/.fabro/settings.toml` and delegates
to `from_layer`. Useful for tools (`fabro doctor`-style),
integration tests, and any process that wants "the defaults on this
machine." **Not** used by the server startup path: the server's
authoritative settings are the *effective runtime layer* produced by
`apply_runtime_settings`, which a fresh disk read doesn't see.
- Server startup: after `serve.rs` builds `effective_settings =
apply_runtime_settings(...)`, it calls
`ServerSettings::from_layer(&effective_settings)` and stores the
result in `AppState`. Hot-reload (`state.replace_settings(...)`) also
refreshes that derived view.
- `AppState.server_settings` is the server's canonical current-config
value. Handlers read it, not disk. It's always in sync with the
layer in `AppState`'s `RwLock<SettingsLayer>`.
**Test scenarios:**
- *Contract:* `from_layer(&layer)` returns the same data as the old
free function on the same layer.
- *Contract:* `resolve()` with `$FABRO_HOME` set to a temp dir loads
that directory's `settings.toml`.
- *Integration:* Server startup derives `AppState.server_settings`
from the effective runtime layer (post-`apply_runtime_settings`).
A startup with `--storage-dir /tmp/foo` produces
`AppState.server_settings.server.storage` reflecting `/tmp/foo`,
confirming CLI overrides flow through to the typed view.
- *Integration:* Hot-reload (triggered via the existing
`state.replace_settings(effective)` pathway) refreshes
`AppState.server_settings` so the typed view reflects the updated
layer.
**Verification:**
- `cargo build --workspace` and `cargo nextest run --workspace` pass.
- `grep -rn "resolve_server_from_file" lib/` returns only
`fabro-config/src/` and stored-layer reader call sites.
- `bin/dev/check-boundary.sh` still passes; verify on a throwaway branch
that it catches a deliberate unsanctioned `ServerSettings::resolve*`
import.
---
- [ ] **Unit 5: Introduce `UserSettings`; attach uses live config**
**Goal:** Add `UserSettings` with `from_layer` + `resolve`.
`fabro run attach` reads the attaching process's live `UserSettings`.
**Requirements:** R1, R2.
**Dependencies:** Unit 3.
**Files:**
- Modify: `lib/crates/fabro-config/src/context.rs` — add `UserSettings {
cli: CliNamespace, features: FeaturesNamespace }`. Inherent
`from_layer(&SettingsLayer)` and `resolve()`.
- Modify: `lib/crates/fabro-config/src/lib.rs` — re-export `UserSettings`.
- Modify: `lib/crates/fabro-cli/src/user_config.rs` — replace
`resolve_cli_from_file` usage with `UserSettings::from_layer` or
`UserSettings::resolve()`.
- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs` — delete the
stored-layer `resolve_cli_from_file(&record.settings)` read at ~line 90.
Attach reads the attaching process's `UserSettings` (already threaded
through the command context) and honors its verbosity.
- Delete: any test that asserted submit-time verbosity preservation on
attach. Add a test that attach honors the attaching CLI's live
verbosity.
- Test: `lib/crates/fabro-config/tests/resolve_cli.rs` — add
`UserSettings::from_layer` and `UserSettings::resolve` cases.
**Approach:**
- Mirror `ServerSettings`'s shape.
- Attach-verbosity behavior changes: attach honors live settings. Stored
`cli.*` on a run is inert; wire format unchanged.
**Test scenarios:**
- *Contract:* `from_layer` and `resolve` return the expected namespaces.
- *Edge case:* Missing `~/.fabro/settings.toml` returns defaults without
erroring.
- *Behavior:* `fabro run attach --verbose` against a non-verbose submitted
run prints verbose output; reversed case prints non-verbose output.
**Verification:**
- `cargo build --workspace` and `cargo nextest run --workspace` pass.
- `grep -rn "resolve_cli_from_file" lib/` returns only
`fabro-config/src/` internal references **plus**
`fabro-cli/tests/it/cmd/create.rs:364` (the integration test that
asserts the persisted `cli.*` wire shape — per the KTD visibility
policy, this test intentionally keeps `resolve_cli_from_file`
public). The `--local` call site at `commands/config/mod.rs:121`
was deleted in Unit 1; `user_config.rs` migrates to
`UserSettings::from_layer` in this unit; the `attach.rs` read is
deleted in this unit.
---
- [ ] **Unit 6: Delete the `Settings` god type and its ecosystem**
**Goal:** Remove `fabro_types::settings::Settings`, the
god-type-returning `fabro_config::resolve` function, the
`fabro_config::load_and_resolve` helper, and all their dependent code
— tests and production — without introducing a named replacement
type.
**Requirements:** R7.
**Dependencies:** Units 3 (renames) and 4 (context types exist for
migrated callers).
**Files:**
*Delete the type and wrapper functions:*
- Delete: `lib/crates/fabro-types/src/settings/resolved.rs` (the
`Settings` struct itself).
- Modify: `lib/crates/fabro-types/src/settings/mod.rs` — remove the
`Settings` re-export.
- Modify: `lib/crates/fabro-config/src/lib.rs` — delete the
`load_and_resolve` public helper (it returns `Settings`); delete the
`use fabro_types::settings::{Settings, SettingsLayer}` import (keep
the `SettingsLayer` import via its own `use`).
- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` — delete the
public `fn resolve(&SettingsLayer) -> Result<Settings>` function
(the god-type-returning one); remove it from `pub use
resolve::{...}` in `lib.rs`. Per-namespace resolvers are unaffected.
*Migrate the one production caller of `fabro_config::resolve`:*
- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` — uses
`Settings` at ~line 19 (import) and ~lines 289-298
(`resolve_settings_tree` / `combined_labels`). The caller at ~line
107 reads **both** `resolved_settings.server.storage.root` (to
compute `storage_root` for run persistence) and
`combined_labels(&resolved_settings)` (project/workflow/run
metadata). Replace `resolve_settings_tree` so it returns a small
struct (or 4-tuple) containing `ServerNamespace` +
`ProjectNamespace` + `WorkflowNamespace` + `RunNamespace`, built
from `fabro_config::resolve_server_from_file`,
`resolve_project_from_file`, `resolve_workflow_from_file`, and
`resolve_run_from_file` on the same `SettingsLayer`. Update the
call site at ~line 107 to read `.server.storage.root` off the
returned `ServerNamespace`, and `combined_labels` to read
`.metadata` off each of `ProjectNamespace` / `WorkflowNamespace` /
`RunNamespace`. All four per-namespace resolvers stay `pub` per
Unit 8.
*Migrate or delete dependent tests:*
- Modify: `lib/crates/fabro-config/tests/resolve_root.rs` — tests
(including ~line 12 imports and ~line 43 call) currently exercise
`fabro_config::resolve`. Rewrite each assertion against the
surviving per-namespace resolvers: e.g.,
`resolve_root.rs::resolves_root_settings_require_explicit_server_auth_methods`
becomes a test on `resolve_server_from_file(&SettingsLayer::default())`.
Tests that check multi-namespace behavior split into per-namespace
assertions.
- Modify: `lib/crates/fabro-config/tests/defaults.rs` — ~line 94 calls
`resolve(&SettingsLayer::default())`. Rewrite to target the specific
per-namespace resolver whose default the test is asserting (likely
`resolve_server_from_file`, from the `server.auth.methods` check
shown by grep).
- Modify: `lib/crates/fabro-cli/tests/it/cmd/config.rs` — ~line 160
`resolved_server_settings_fixture` calls `fabro_config::resolve(...)`
to produce a Settings-shaped fixture for the settings command tests.
Replace with a fixture built via
`ServerSettings::from_layer(&server_settings_layer_fixture())`
(produces only `server` + `features`, matching the new
`GET /api/v1/settings` response shape).
*Server.rs branch removed by Unit 7:*
- `lib/crates/fabro-server/src/server.rs:1337` (the
`fabro_config::resolve(&settings)` call inside the `?view=resolved`
branch of `retrieveServerSettings`) is already deleted by Unit 7's
handler simplification. No separate action needed here.
*Settings_view deletion by Unit 7:*
- `lib/crates/fabro-server/src/settings_view.rs` (the
`redact_resolved_value(&Settings)` function plus test at ~line 257)
is deleted entirely by Unit 7. No separate action needed here.
*Run_manifest.rs: mode parameter only:*
- Modify: `lib/crates/fabro-server/src/run_manifest.rs` — the existing
`materialize_settings_layer(layers, Some(server_settings), mode)`
call **stays**; this unit only removes the `mode` argument (Unit 2
deleted the enum). `materialize_settings_layer` still produces the
merged `SettingsLayer` stored in `PreparedManifest.settings`. No
`WorkflowSettings` or other context type is constructed here.
**Approach:**
- The god type has exactly one production consumer outside the
view-toggle machinery (`operations/create.rs`); migrate it. Every
other `fabro_config::resolve` call site is either a test (migrate or
delete) or inside code already being deleted by Unit 7.
- No replacement context type: stored-layer readers keep using
per-namespace resolvers per R1; current-config reads are covered by
`ServerSettings`/`UserSettings` (Units 4-5).
- `PreparedManifest.settings: SettingsLayer` is unchanged; the
run_manifest merge pipeline stays intact.
**Test scenarios:**
- *Contract:* `operations/create.rs` label aggregation produces the
same result as before — per-namespace metadata combined by the
replacement `resolve_settings_tree`.
- *Migration:* rewritten `resolve_root.rs` and `defaults.rs` tests
cover the same assertions at the per-namespace resolver level.
- *Migration:* the `fabro settings` command test in
`tests/it/cmd/config.rs` still passes with the `ServerSettings`-shaped
fixture.
**Verification:**
- `cargo build --workspace` and `cargo nextest run --workspace` pass.
- `grep -rn "fabro_types::settings::Settings\b" lib/` returns zero
hits.
- `grep -rn "fabro_config::resolve\b" lib/` returns zero hits (the
god-type-returning function is gone; per-namespace
`fabro_config::resolve_*_from_file` and the new context-type
constructors remain).
- `grep -rn "load_and_resolve" lib/` returns zero hits.
---
- [ ] **Unit 7: Typed `GET /api/v1/settings` served from `AppState`; delete redaction**
**Goal:** The handler returns a typed `ServerSettings` from `AppState` in
memory. Delete the redaction machinery. Typed OpenAPI schema via
`Deserialize` + `with_replacement`.
**Requirements:** R5.
**Dependencies:** Units 4, 6.
**Files:**
- Modify: `docs/api-reference/fabro-api.yaml`:
- Remove the `view` query parameter and `X-Fabro-Settings-View` header
from the `retrieveServerSettings` operation.
- Replace `ServerSettings`'s `additionalProperties: true` with a typed
schema (two fields: `server`, `features`) matching the Rust
`ServerSettings`.
- Rename the `RunSettings` schema to a sparse run-settings wire name and update its
description to reflect that the endpoint returns the persisted
`SettingsLayer` as-is.
- Remove all `redact`, `redaction`, `secret subtrees` language across
the YAML.
- Modify: `lib/crates/fabro-api/build.rs` — add `with_replacement(...)`
entries mapping the OpenAPI `ServerSettings` schema (and nested types)
to the internal Rust types.
- Modify: `lib/crates/fabro-types/src/settings/server.rs`, `features.rs`,
and reachable child types — add `Deserialize` derives. For types with
custom `Serialize` (`serialize_socket_addr`, `InterpString`, etc.),
implement matching custom `Deserialize`. `InterpString::Deserialize`
must preserve unresolved `{{ env.NAME }}` templates.
- Add: `lib/crates/fabro-api/tests/` type-identity + JSON-parity test
(per CLAUDE.md's `with_replacement` requirement).
- Regenerate: `cargo build -p fabro-api` (progenitor runs in build.rs).
- Regenerate: `cd lib/packages/fabro-api-client && bun run generate`.
- Modify: `lib/crates/fabro-server/src/server.rs` — the
`retrieveServerSettings` handler returns a clone of
`AppState.server_settings`. No view toggle, no response header, no
redaction. The `/runs/:id/settings` handler serializes
`run_spec.settings` (the `SettingsLayer`) directly.
- Modify: `lib/crates/fabro-client/src/client.rs` (~lines 493-513) —
simplify `retrieve_resolved_server_settings` to drop `?view=resolved`
and the header check.
- Delete: `lib/crates/fabro-server/src/settings_view.rs` (module + tests).
This removes `SettingsApiView`, `SettingsQuery`,
`RESOLVED_VIEW_HEADER_NAME`, `RESOLVED_VIEW_HEADER_VALUE`,
`redact_for_api`, `redact_resolved_value`.
- Modify: `lib/crates/fabro-server/src/demo/mod.rs` (~lines 602-621 and
~line 242) — the parallel demo settings handler imports
`settings_view::{SettingsQuery, SettingsApiView, RESOLVED_VIEW_HEADER_NAME}`.
Replace with direct serialization of the demo fixture in the new shape.
- Delete or rewrite every test still wired to the old view-toggle /
`X-Fabro-Settings-View` contract:
- `lib/crates/fabro-server/tests/it/api/settings.rs:63` and `:143`
(the entire
`retrieve_server_settings_resolved_view_returns_dense_settings_and_marker`
test goes away; layer-view test reshapes to the new single-shape
response).
- `lib/crates/fabro-server/tests/it/api/runs.rs:116` (redaction
assertion on `/runs/:id/settings` — the endpoint now returns the
layer unredacted).
- `lib/crates/fabro-server/src/server.rs:7491` (inline unit test
that issues `GET /settings?view=resolved` — rewrite to hit
`/settings` without the query parameter, or delete if it was only
covering the resolved view branch).
- `lib/crates/fabro-cli/tests/it/cmd/config.rs:923, :1008, :1011`
(CLI config-command integration tests mock
`/api/v1/settings?view=resolved` with an
`X-Fabro-Settings-View: resolved` response header; migrate the
mocks to the new single-shape contract — no `view` query param,
no custom header, body = typed `ServerSettings` fixture built via
`ServerSettings::from_layer(&server_settings_layer_fixture())`).
- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` —
`rendered_config` fetches the new `ServerSettings` from the endpoint
and merges with `UserSettings::resolve()` for a two-section display.
- Modify: `apps/fabro-web/app/routes/settings.tsx` — consume the newly
typed `ServerSettings` shape explicitly.
- Run: `scripts/refresh-fabro-spa.sh`.
**Approach:**
- The endpoint returns the in-memory `AppState.server_settings`. No
per-request disk read.
- OpenAPI conformance (`openapi_conformance.rs`) verifies the new
single-shape endpoint against the spec.
**Test scenarios (canonical only):**
- *Contract:* `GET /api/v1/settings` returns JSON with exactly two
top-level keys, `server` and `features`. The typed shape matches the
OpenAPI `ServerSettings` schema.
- *Contract:* Generated TypeScript client returns a typed object with
`server` and `features` fields.
- *Contract:* `GET /api/v1/runs/:id/settings` returns the persisted
`SettingsLayer` (renamed to the sparse run-settings wire name in the spec) directly.
- *Behavior:* `server.listen` is present and visible in the main settings
response.
- *Integration:* OpenAPI conformance passes.
- *Integration:* `fabro settings` renders two sections (user / server)
without errors.
**Verification:**
- `cargo build -p fabro-api` succeeds (progenitor codegen clean).
- `cargo nextest run -p fabro-server` passes, including conformance.
- `cd lib/packages/fabro-api-client && bun run generate && bun run
typecheck` succeeds.
- CI's SPA-drift check passes.
---
- [ ] **Unit 8: Privatize merge internals**
**Goal:** Minimize the public surface of `fabro-config`. Context types
and their constructors are the primary API; internal helpers go
`pub(crate)`.
**Requirements:** R1, R8.
**Dependencies:** Units 4, 5, 6.
**Files:**
- Modify: `lib/crates/fabro-config/src/effective_settings.rs` —
`materialize_settings_layer` → `pub(crate)`.
- `lib/crates/fabro-config/src/resolve/mod.rs` — all six
`resolve_*_from_file` functions (`project`, `workflow`, `run`, `cli`,
`server`, `features`) keep their existing `pub` visibility. Each has
at least one cross-crate consumer; see the KTD visibility decision
for the specific sites.
- Keep `pub`: `lib/crates/fabro-config/src/user.rs::load_settings_config`
— `fabro-server/src/serve.rs` loads the on-disk settings layer via
it. No visibility change.
- Modify: `lib/crates/fabro-config/src/lib.rs` — **settings-entrypoint
re-exports only**. Narrow scope:
- **Add:** `ServerSettings`, `UserSettings` (the new context types).
- **Remove:** the `Settings` type and the god-type-returning
`resolve` function from the public re-export list (both deleted in
Unit 6).
- **Leave untouched:** existing re-exports of `Error`, `Result`,
`Home`, `expand_tilde`, `apply_builtin_defaults`,
`defaults_layer`, `load_settings_*`, `parse_settings_layer`, the
`storage` module, and any other non-settings-entrypoint APIs.
These serve cross-workspace consumers (e.g.,
`fabro-workflow/src/run_lookup.rs`,
`fabro-workflow/src/operations/create.rs`) whose API surface is
out of scope for this refactor.
- Update the *settings-entrypoint* paragraph in the crate-level
doc comment to describe the two context types and their
constructors as the primary resolution API. Do **not** rewrite
the full crate doc comment.
**Approach:** Cleanup pass. Flip visibility; compile.
**Test scenarios:**
- *Integration:* Downstream crates compile after visibility narrowing.
**Verification:**
- `cargo build --workspace` and `cargo nextest run --workspace` pass.
- `grep -rn "pub fn resolve_\(project\|workflow\|run\|cli\|server\|features\)_from_file" lib/crates/fabro-config/`
returns six hits (all six per-namespace resolvers remain `pub`
because each has cross-crate consumers).
- `bin/dev/check-boundary.sh` passes.
## System-Wide Impact
- **Interaction graph:** ~20 current-config call sites migrate from
`resolve_server_from_file(&layer)` to `ServerSettings::from_layer(&layer)`
(or `::resolve()`). Stored-layer reads unchanged.
- **Error propagation:** Resolver errors flow back unchanged; constructors
wrap today's error types.
- **API surface:** `GET /api/v1/settings` shape changes (single dense
`ServerSettings` served from `AppState`). `GET /api/v1/runs/:id/settings`
shape unchanged (still the persisted `SettingsLayer`; OpenAPI schema
renamed to the sparse run-settings wire name).
- **Integration coverage:** OpenAPI conformance guards spec/router
alignment. Progenitor regen + `bun run generate` + SPA refresh is the
known hygiene.
- **Unchanged invariants:** TOML file format, namespace inventory,
layering precedence, server authority over server-owned fields,
`PreparedManifest.settings` wire format, `fabro_cli::local_server` as
the sanctioned CLI gateway to `[server.*]`.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| The ~57-reference rename (Unit 3) lands incomplete, breaking the build mid-merge | Atomic commit for Unit 3; `cargo check --workspace` between file batches |
| TypeScript regen drift fails CI's SPA-bundle check | Run `scripts/refresh-fabro-spa.sh` after `bun run generate` |
| `fabro settings` snapshot tests drift | `cargo insta pending-snapshots` + review per-snapshot before accept |
| `bin/dev/check-boundary.sh` silently passes after the rename | Script updated in Unit 4 (not Unit 8) so coverage never drops; verified on a throwaway branch |
## Documentation / Operational Notes
- OpenAPI description prose for `/api/v1/settings` updated to reflect the
single-shape response.
- `CLAUDE.md`'s "API workflow" section already describes the
OpenAPI → Rust → TypeScript pipeline; no changes needed.
- Release notes: `fabro settings --local` removed; `GET /api/v1/settings`
response shape changed; redaction removed.
## Sources & References
- Related code: `lib/crates/fabro-config/src/effective_settings.rs`,
`lib/crates/fabro-types/src/settings/resolved.rs`,
`lib/crates/fabro-cli/src/local_server.rs`,
`lib/crates/fabro-server/src/run_manifest.rs`,
`docs/api-reference/fabro-api.yaml`.
- Adjacent brainstorm:
`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`
(R16 on owner-first namespace boundaries).
- Boundary-enforcement commit: `5b1c40764` + `bin/dev/check-boundary.sh`.