Skip to content

rust-rulebook application - #62

Merged
vinniefalco merged 39 commits into
cppalliance:masterfrom
vinniefalco:master
Sep 21, 2026
Merged

vinniefalco merged 39 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

Copy link
Copy Markdown
Member

No description provided.

The cancel-wait test that parks a child on a store write raced the blocking pool: whether the child's write finished before the owner's cancel landed was the pool's choice, so the outcome depended on how late that thread started. The test now parks the first backend write behind a one-shot gate that opens only when the child's cancellation is observed, so the run-end drain completes regardless of scheduling. The gate and its observer, previously private to the arm-conflict tests, are shared with the sibling test module, and the observer now opens on a task cancellation as well as on a write or append failure.

- `StoreGate`, `GateObserver`, and `gated_store` widen from private to `pub(super)` so the cancel-wait suite reuses the same one-shot gate instead of growing its own.
- `GateObserver` opens the gate from `observe` on `Observation::TaskCancelled { .. }` in addition to `Observation::StoreWriteFailed` and `Observation::StoreAppendFailed`; every observation still forwards to `inner`.
- `cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once` builds its context on `gated_store(&gate)` and wraps the recorder in `GateObserver::new`. The owner's yield before the cancel changes from `store.write('park', 'x')` to `store.exists('park')` so the owner never takes the gate meant for the child's write.
- `crates/promptforge-api-runtime/src/execute/tests/scheduler.rs` and `crates/promptforge-api-runtime/src/execute/tests/waits.rs` are the only code touched; no production module changes and no new test is added.

Design: new shared-mutable-state @ crates/promptforge-api-runtime/src/execute/tests/waits.rs::cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Every async workspace operation that stats, canonicalizes, reads, copies, or writes a path now hands that work to the tokio blocking pool and awaits it, so an executor thread never waits on the disk. The harness launch path does the same for its agent directory walk and source read, through the harness's single instrumented spawn site. A worker that cannot report back surfaces as an I/O error in the caller's existing error shape, or, where the call already tolerated failure, is logged and degraded the same way. Two public summary types move to the crate root, and the sibling-copy cleanup of a duplicate becomes one synchronous helper so it runs whole on a worker.

- `crates/workshop/workspace/src/blocking.rs` is the crate's one seam onto `tokio::task::spawn_blocking`: `blocking` folds a join failure into `io::Error`, and `try_blocking` folds it into the caller's error type through a `join_failed` closure, so each call site sees one error type. Turso's database I/O does not pass through it.
- `spawn_blocking_launch` joins `spawn_tagged`, `spawn_blocking_tagged`, and `spawn_session` as a permitted caller of the raw tokio method under `#[allow(clippy::disallowed_methods)]`; its span is named `launch` and carries the agent name, since no run or session exists yet to tag.
- `GrantEntry` and `WorkspaceSummary` move from `workspace-backing.rs` into `workspace.rs`, and the `pub use backing::{GrantEntry, WorkspaceSummary}` re-export goes away; the backing module now imports them from its parent.
- `ui_state` becomes `pub(crate) mod ui_state`, and the two test modules import `UI_STATE_VALUE_CAP` through it; the `#[cfg(test)]` re-export in `workspace_file.rs` is removed.
- `copy_siblings_or_clean_up` owns the copy-then-undo sequence of a duplicate, and `copy_siblings` and `remove_sibling` drop to private; `already_taken` moves beside it as `pub(super)` since both create and duplicate use it.
- `names_same_file` replaces the inline pair of `canonicalize_simplified` calls in `open_file`, so the whole same-file comparison runs in one blocking closure.
- `handlers.rs` `tree`, `read_file`, and `write_file` run the confinement check and the I/O inside one closure, so the check-then-use window is no wider than before; a join failure maps to `ListDirectory`, `ReadFile`, and `WriteFile` respectively.
- `discover_agents` and `agent_source` run on the blocking pool during a launch, each under `spawn_blocking_launch`; either join failure becomes `LaunchError::SessionState` with an `io::Error::other` source.
- `Workspace::current` stats every granted root in one blocking trip; when the worker cannot report, it warns and lists no grants rather than failing the call.
- `remember` is now `async` and awaited, so the last-workspace pointer is on disk when `swap_backing` and `reload_current` return; a write or join failure is still only logged.
- `close_database` and `snapshot` in the actor await their blocking work, so the actor handles no other command while a sidecar check or file copy is in flight.
- `spawn_blocking_launch_runs_a_closure_to_completion` is the only new test; the workspace crate's tests change only in the two `UI_STATE_VALUE_CAP` import paths, so `blocking` and `try_blocking` have no direct test of their join-failure arms.

Design: new surface-growth @ crates/harness/runner/src/spawn.rs::spawn_blocking_launch deps: F,str boundary: pub
Design: replaces value-object @ crates/workshop/workspace/src/workspace.rs::GrantEntry boundary: pub was: crates/workshop/workspace/src/workspace-backing.rs::GrantEntry
Design: new pure-function @ crates/workshop/workspace/src/workspace_file.rs::io_failure deps: Error
Design: replaces pure-function @ crates/workshop/workspace/src/workspace_file-siblings.rs::already_taken deps: str was: crates/workshop/workspace/src/workspace_file.rs::already_taken
Design: new swallowed-exception @ crates/workshop/workspace/src/workspace-backing.rs::Workspace::current
Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace-pointer.rs::Workspace::reopen_last
Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace-pointer.rs::Workspace::remember
Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace_file-actor.rs::close_database
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Replace the two boolean parameters on the engine emitter with typed enums. The debug capture flag becomes a two-variant mode enum that defaults to off, and the tool-result trust flag reuses the output trust enum the tools module already defines, so a caller names what it means instead of passing a bare true or false. The event stream does not change: the tool result event still carries a boolean trusted field, and the debug accessor still answers with a boolean. Every constructor and reporting call site across the runtime, Lua, parser, and bench crates passes the enums.

- `DebugMode` is a new public `Copy` and `Eq` enum with `Off` as its `#[default]`; `Emitter::new`, `Emitter::root`, and the emitter's `debug` field hold it where a bool sat.
- `tool_result` takes `OutputTrust` and derives the event's boolean with `trust == OutputTrust::Trusted`; the `Event::ToolResult` shape keeps `trusted: bool`, so nothing downstream of the sink changes.
- `ToolDispatch` replaces its public `trusted()` accessor with `trust()` returning `OutputTrust`; `BuiltinAnswer` and `RunContext` retype their fields the same way, and `report_debug` takes the mode.
- `Emitter::root` retyping fans out as one-literal edits to seven files outside the emitter, two of them the `null_emitter` helpers that already centralize a silent emitter.
- `captures_debug` still returns a bool by comparing against `DebugMode::On`, so the enum is write-side only.
- `an_untrusted_tool_result_reports_the_event_with_trusted_false` pins that `OutputTrust::Untrusted` lands on the wire as `trusted: false`; the two dispatch tests compare `trust()` against the enum instead of negating a bool.

Design: new value-object @ crates/promptforge-api-types/src/emitter.rs::DebugMode boundary: pub
Design: removes flag-parameter @ crates/promptforge-api-types/src/emitter.rs::Emitter::new
Design: removes flag-parameter @ crates/promptforge-api-types/src/emitter.rs::Emitter::root
Design: removes flag-parameter @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::report_debug
Design: new shotgun-surgery @ crates/promptforge-api-types/src/emitter.rs::Emitter::root
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Error variants that carry a source no longer repeat that source's text in their own message, so a rendered cause chain names each cause once. Where the text leaves the program - the run log's failed outcome, a failure pushed to a session client, the gateway's wire warning, the provider-sheet tool's stderr note, a refused agent launch's error frame - the renderer now walks the source chain instead of reading one message. Public error enums that exposed the database, JSON, and HTTP client libraries' error types now wrap them in crate-owned transparent newtypes with hand-written conversions, so every existing question-mark site still compiles. Thirteen messages drop "failed to" prefixes and acronym-led phrasing.

- `display_chain`: one renderer in the harness runner joins an error and its causes with `: ` and skips a cause whose text the accumulated rendering already contains; `harness-api` re-exports it for clients.
- `error_chain` in the cloud-providers crate joins with `; ` and does not deduplicate, and the gateway app's fence peel reuses its existing `crate::config_write::error_chain`. The harness-side and gateway-side renderers differ in separator and dedup rule.
- `DatabaseSource`, `PayloadSource`, `HttpSource`, `JsonSource`: private-field `#[error(transparent)]` wrappers over the turso, serde_json, and reqwest error types. `LogError`, `FetchError`, and `WorkspaceFileError` gain hand-written `From` impls where `#[from]` was removed; `SidecarError` and `LocalError` call sites convert with `.into()`.
- `RunFailure::Prepare`, `RunFailure::Drive`, and the api runtime's `Error::Substitution` become `#[error(transparent)]`, so the inner error is the whole message and chain.
- `failed_outcome`: the run log's `runs.error_message` now stores the rendered chain rather than the outermost message; the parse-failure row written by `prepare_source` and its integration test follow.
- `refusal_text`: a refused agent launch's error frame carries the refusal's cause text; the socket test builds a session-state refusal over an I/O error and checks the cause reaches the frame.
- `StateError`: the `Composition` variant no longer names the missing contribution in its own text; the boot test reads it from `source()`.
- `ToolCallRejection`: the `ArgumentsNotJson` message drops the decode error text; a test asserts the wire warning still carries that text through the chain.
- `reqwest::Error`, `serde_json::Error`, and `turso::Error` fields on public variants change type to the newtypes; a downstream that matched on those fields must update.

Design: new pure-function @ crates/harness/runner/src/display_chain.rs::display_chain deps: &dyn Error boundary: pub
Design: extends facade @ crates/harness-api/src/lib.rs boundary: pub
Design: new pure-function @ crates/gateway/cloud-providers/src/lib.rs::error_chain deps: &dyn std::error::Error boundary: pub
Design: new pure-function @ crates/workshop/server/src/agents/socket.rs::refusal_text deps: &LaunchRefusal
Design: new newtype @ crates/harness/log/src/error.rs::DatabaseSource boundary: pub
Design: new newtype @ crates/harness/log/src/error.rs::PayloadSource boundary: pub
Design: new newtype @ crates/workshop/workspace/src/workspace_file.rs::DatabaseSource boundary: pub
Design: new newtype @ crates/gateway-api-discovery/src/error.rs::JsonSource boundary: pub
Design: new newtype @ crates/gateway/local/src/error.rs::HttpSource boundary: pub
Design: new newtype @ crates/gateway/local/src/error.rs::JsonSource boundary: pub
Design: new newtype @ crates/gateway/cloud-providers/src/lib.rs::HttpSource boundary: pub
Repairs: error frame text carries the launch refusal's cause chain @ crates/workshop/server/src/agents/socket.rs::refusal_text - a refused launch reached the client as the bare outer message with its I/O cause dropped
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Public error enums, serde wire enums, and response-shaped wire structs across the gateway, harness, engine, and workshop crates now carry the non-exhaustive attribute, so a later variant or field is not a breaking change for dependent crates. Downstream matches that used to name every variant close with a wildcard arm instead, each commented with the enum's owning crate. Where a wildcard has to produce a value the choice is conservative: refuse a malformed request, label unknown audio as opaque bytes, drop a delta the wire cannot frame, or panic inside test support so an unclaimed event cannot pass a suite silently. No tests are added or changed.

- `events!` emits `#[non_exhaustive]` on every enum it declares, so `Event` receives the attribute at its one definition site rather than per downstream match.
- `ReplayError`, `CurrentModelError`, `DriveError`, `PrepareError`, `LogError`, `DialectResolveError`, and `FetchError` gain `#[non_exhaustive]` at the enum level.
- `EnvRole`, `Tier`, `SliceStatus`, `EmbeddingInput`, `SpeechVoice`, `SpeechResponseFormat`, `SpeechStreamFormat`, `LlamaBackend`, `DeltaKind`, `InputFrame`, `AgentEventKind`, `AgentDeltaKind`, `TaskOrigin`, `AbandonReason`, and `StoreOp` gain `#[non_exhaustive]` as serde-derived wire enums.
- `EmbeddingResponse`, `RerankResponse`, `ModelsResponse`, `SpeechRequest`, `StoredRecord`, `RunRow`, `Delta`, `AgentEvent`, and `SwitchProfileFrame` gain `#[non_exhaustive]` as structs, which confines struct-literal construction to the owning crate. `SpeechRequest` and `SwitchProfileFrame` are inbound request shapes, not responses.
- `GatewayError` takes the attribute on its `Protocol` variant only, and `UserStateError` and `WorkspaceError` take it on single variants. `GatewayError` is `pub(crate)`, so the variant-level attribute there has no cross-crate effect.
- `delta_frame` returns `Option<AgentDeltaFrame>` and yields `None` for a `DeltaKind` the wire has no label for; `run_socket` sends only when a frame is present, so such a delta is dropped like a lagged one.
- `forward_one` closes with `_ => unreachable!("Event variant no group claims: {event:?}")`, and `forward_lifecycle`, `forward_content`, and `forward_debug` close with `_ => {}`. The `unit_lifecycle_variants!` and `other_groups!` macros and the three `#[expect(clippy::unnested_or_patterns)]` attributes that guarded them are removed, so the compile-time check that every `Event` variant is claimed by a group becomes a runtime panic in test support.
- `audio_speech` refuses a `SpeechVoice` form it cannot name with `GatewayError::MalformedRequest`; `speech_fallback_mime` labels an unknown `SpeechResponseFormat` as `application/octet-stream`.
- `opened_run`, `store_observations`, and `on_delta` fold their former named arms into `_` with the same result; the `TaskOrigin` match in `Scheduler` moves `Author` into the wildcard, so an origin the engine does not yet name is pushed to `leaked` like the author's.

Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Several failure paths threw away the underlying error while converting it into a domain error, leaving callers and logs with only a generic message. This change keeps the cause. Where the target variant can hold a source, the variant now carries it and the error chain exposes it; where the variant holds only a message string, the cause is appended to that string. Two workshop crates wrap the JSON parser's error in a crate-owned transparent type so their public error enums name no third-party library. HTTP statuses, wire codes, and top-level display strings are unchanged.

- `JsonSource`: both `crates/workshop/user-state` and `crates/workshop/workspace` define an identical `#[error(transparent)]` newtype over `serde_json::Error` with a hand-written `From`, and re-export it from their crate root. Neither crate reuses the other's.
- `WorkspaceError::NotUtf8`, `WorkspaceError::UiStateNotJson`, `UserStateError::NotJson`: unit variants become `#[non_exhaustive]` struct variants holding a `#[source]`; every match arm in the touched files widens to `{ .. }` and the `#[error(...)]` strings are untouched.
- `ConfigError::UnresolvedVar`: gains a second field, `std::env::VarError`, marked `#[source]`; the display string still prints only `{0}`. The enum is `pub(crate)`.
- `WhisperError::InteriorNull`: gains `source: std::ffi::NulError`; the four `CString::new` sites in `context.rs` and `params.rs` pass it through.
- `render_message(&not_json, true)` now starts with `ui-state value is not JSON: ` followed by the parse refusal, asserted by a new check in `ui_state_refusals_map_to_client_errors`. The status and code assertions around it are unchanged.
- `VfsError::Backend`, `GatewayError::MalformedRequest`, and `HeaderReader::malformed` take a `String`, so at those four sites the cause is interpolated into the message text rather than chained as a source.
- `c_int::try_from(max)` in `tokenize` and the length-cap `map_err(|_|` in `gguf.rs` still discard their error; both sit beside converted sites in the same hunks.
- `std::ffi::NulError`, `std::env::VarError`, `std::string::FromUtf8Error`: no test exercises these new sources; existing tests were updated for variant shape only.

Design: new newtype @ crates/workshop/user-state/src/error.rs::JsonSource boundary: pub
Design: new newtype @ crates/workshop/workspace/src/error.rs::JsonSource boundary: pub
Design: new parallel-abstraction @ crates/workshop/workspace/src/error.rs::JsonSource
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Move every module root that lived as a directory-level file up to a sibling file named after the module, so the module name appears in its own filename. Where a directory is left with fewer than three files, those files are flattened into hyphenated siblings and the parent declares their locations with path attributes. File contents are otherwise unchanged; every move is a pure rename.

- `crates/promptforge/lua/src/models.rs` Declares `userdata` and `tests` through `#[path = "models-userdata.rs"]` and `#[path = "models-tests.rs"]`, flattening the two-file directory.
- `crates/promptforge-api-runtime/src/model/tests.rs` Points `always` and `integration` at `tests-always.rs` and `tests-integration.rs` in the same directory as the parent.
- `crates/gateway/app/src/cloud_models/tests.rs` Points `refresh` and `version_gate` at `tests-refresh.rs` and `tests-version-gate.rs`.
- `crates/promptforge-api-runtime/src/fanout.rs` Points the `#[cfg(test)]` `mod tests;` at `fanout-tests.rs`.
- `crates/promptforge/lua/src/tools.rs`, `crates/gateway/cloud-providers/src/providers.rs`, `crates/gateway/stt/api/src/realtime.rs`, and the remaining `tests.rs` roots are 100% renames with no content change.
- `#[cfg(test)]` No test body changes and no new tests; the only added lines are path attributes.

Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Lint suppressions across the harness spawn wrappers, the gateway model sheet, and two cloud providers now state why they exist and fail the build when the lint stops firing. Test-only re-exports leave the production modules that hosted them: the test suites import the store extension trait and the tool output kind from their upstream crates, and the scheduler's task state reaches tests only through the test hooks module. Doc examples in the gateway and the Lua VM propagate errors instead of unwrapping, and the five VM examples that were plain text now compile as doctests. Eleven constructors returning their own type gain a must-use warning, the user-state store's getter loses its get prefix, and a doubled feature gate is removed.

- `#[expect(` replaces `#[allow(` at seven suppression sites in `crates/harness/runner/src/spawn.rs`, `crates/gateway-api/src/lib.rs`, `crates/gateway/cloud-providers/src/providers/cohere.rs`, and `crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs`, each carrying a `reason`. The comment in `crates/harness/runner/clippy.toml` names the new attribute.
- `test_hooks` becomes a `pub(crate) mod` under `#[cfg(test)]` and re-exports `tasks::TaskState`; the re-export in `scheduler.rs` is removed and seven test consumers spell `crate::execute::scheduler::test_hooks::TaskState` in full rather than receiving it from a shared test prelude.
- `StoreExt` and `ToolOutputKind` are imported by `execute/tests.rs` and `execute/tests/scheduler.rs` from `promptforge_store` and `promptforge_lua` directly; the `#[cfg(test)]` re-exports in `store.rs` and `lua.rs` are gone.
- `get_all` on `UserStateStore` is renamed to `all`; the `get_state` axum handler and nine store tests follow. The method stays `pub`.
- `no_run` replaces `text` on the five `SectionVm` examples, which now compile against the crate API but never execute. Each ends in `Box<dyn std::error::Error>` so `?` covers both the VFS acquire and the VM errors, and the two `.expect("the stock backend acquires")` calls become `?`.
- `#[must_use]` lands on eleven `Self`-returning constructors: `VfsRef::new` and `VfsRef::with_policy`, `MenuBus::new`, `ShutdownHandle::new` and six adapter `new` functions in `crates/workshop/registry/src/traits.rs`, and `CommandRequest::new`.
- `crates/gateway/app/src/model_info.rs` drops one of two adjacent `#[cfg(feature = "local")]` lines on the `axum::Json` import.
- `vm.rs` examples are compiled, not run, so their setup is checked by the type system only. No test in the diff adds an assertion; the rename and the attribute additions are covered by existing tests unchanged.

Design: new shotgun-surgery @ crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs::TaskState
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
The parser and loopback crate roots each held their whole implementation beside the module list, so neither root read as an index of the crate. Each root now keeps only crate documentation, module declarations, and re-exports, with the logic moved into sibling modules named for the concept they hold and unit tests moved beside the code they cover. Two catch-all modules in the virtual filesystem and realtime transcription crates are renamed for what they contain, and the grep exchange types get a module of their own. The moved code is unchanged apart from imports, module docs, and one visibility widening, and every previously public name is still reachable from its crate root.

- `crates/promptforge/parser/src/lib.rs` shrinks from 676 lines to 44: crate docs, `mod` lines, and `pub use` only. The error substrate and its classification move to `error.rs`; the `Prompt::parse` entry point with frontmatter decoding and body assembly moves to `parse.rs`; the `Prompt`, `Section`, and `Block` types with their accessors and `strip_h1_prose` move to `prompt.rs`.
- `crates/shared-loopback/src/lib.rs` shrinks from 548 lines to a 26-line re-export root. `peer.rs` holds `require_loopback` and `is_loopback_peer`, `host.rs` holds `require_loopback_host`, and `origin.rs` holds the two WebSocket Origin predicates. The 19 tests that lived in the root move with their modules: 4, 10, and 5.
- `crates/shared-vfs/src/types.rs` is renamed `stat.rs` and keeps `FileType`, `Stat`, and `Entry`; `GrepQuery`, `GrepMatch`, and `GrepResults` move to a new `grep.rs`. Five importing files switch paths and the crate root re-exports the same six names.
- `crates/gateway/stt/api/src/realtime/wire/shared.rs` is renamed `vocabulary.rs` and gains a module doc naming what it holds. The `mod` line, the root re-export, and three import paths follow.
- `with_prompt_context` widens from private to `pub(crate)` because its only caller now lives in `parse.rs`; `Result` is re-exported `pub(crate)` from `error.rs` for the same reason. No other moved item changes visibility, signature, or body.
- `mod tests;` in the parser root stays in place; that test module is not in this change and still reaches every name it uses through the root's re-exports.

Design: extends facade @ crates/promptforge/parser/src/lib.rs
Design: new facade @ crates/shared-loopback/src/lib.rs
Design: replaces value-object @ crates/promptforge/parser/src/error.rs::ParseErrorKind boundary: pub was: crates/promptforge/parser/src/lib.rs::ParseErrorKind
Design: replaces pure-function @ crates/promptforge/parser/src/error.rs::body_line_column deps: &str,u32,usize was: crates/promptforge/parser/src/lib.rs::body_line_column
Design: replaces pure-function @ crates/promptforge/parser/src/error.rs::classify_parse_error deps: &Error was: crates/promptforge/parser/src/lib.rs::classify_parse_error
Design: replaces pure-function @ crates/shared-loopback/src/peer.rs::is_loopback_peer deps: Option<SocketAddr> boundary: pub was: crates/shared-loopback/src/lib.rs::is_loopback_peer
Design: replaces pure-function @ crates/shared-loopback/src/origin.rs::gateway_loopback_origin_allowed deps: Option<&str> boundary: pub was: crates/shared-loopback/src/lib.rs::gateway_loopback_origin_allowed
Design: replaces pure-function @ crates/shared-loopback/src/origin.rs::workshop_same_origin_authority_allowed deps: Option<&str>,Option<&str> boundary: pub was: crates/shared-loopback/src/lib.rs::workshop_same_origin_authority_allowed
Design: replaces bag-of-state @ crates/shared-vfs/src/grep.rs::GrepQuery boundary: pub was: crates/shared-vfs/src/types.rs::GrepQuery
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Tests that needed a request or tool call to never finish used a thirty second sleep as the stall, which tied their meaning to wall time and would jump straight to completion under a paused runtime clock. Those stalls now use a future that never resolves, so the timeout or cancellation under test is the only thing that can end them. The workspace's grant timestamps now come from a clock function stored on the workspace, defaulting to the wall clock, so a test can hand consecutive grants distinct stamps instead of spinning until the real second changes. One runner integration test moves to paused time so its bounded wait for task teardown costs no wall time.

- `Workspace` gains a private `now: fn() -> String` field that a new grant's `added_at` reads through `(self.now)()`. `Default` is written by hand to seed it with `now_rfc3339`, and `with_clock_for_test` swaps it in test builds only, so the public constructor surface is unchanged.
- `TICKS` is a process-wide `AtomicU32` behind `ticking_clock`, so every test in the process draws from one counter and two grants in one test never share a stamp however the tests interleave.
- `std::future::pending()` replaces the five `tokio::time::sleep(Duration::from_secs(30))` stall arms in the transport limits, web search, and two runtime tool tests. Each stall can no longer complete on its own, so only the timeout or cancellation under test ends it.
- `a_refused_log_write_returns_the_log_error_and_aborts_the_parked_performers` runs with `start_paused = true`; each sleep inside `await_raised` yields to the teardown and then advances the clock.
- `wait_past` is removed along with the `now_rfc3339` import from the grants test module; the two save-as tests build on `ticking_workspace()` instead.

Design: new strategy @ crates/workshop/workspace/src/workspace.rs::Workspace::now
Design: new global-state @ crates/workshop/workspace/src/workspace-tests-grants.rs::TICKS
Deferred: The paused-time conversions of the remaining runtime, performer, gateway progress, and workspace mutation tests are not in this commit.
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Documentation comments across the gateway family of crates now describe what each item does in the third person instead of issuing a command, so generated docs and editor hover text read consistently. Every speech-to-text production module and every gateway test module that opened without a module-level summary now begins with one sentence naming its purpose. The commit touches comment text only: no code line, attribute, signature, or test assertion changes.

- `crates/gateway/protocol/src/upstream.rs` The `Upstream` trait's method summaries now read as descriptions (forwards, opens, explicitly releases) rather than instructions; the doc text sits on a public trait, and the verb form is the only change.
- `crates/gateway/cloud-providers/src/providers/openai.rs` and every sibling provider module have their `fetch`, `normalize_model`, and `apply_taxonomy` summaries reworded on the same pattern, so the provider set documents itself uniformly.
- `crates/gateway/stt/api/src/realtime.rs` and the other speech-to-text production modules under `take/`, `realtime/`, and `wire/` each gain a one-sentence `//!` header where the file formerly opened directly on a `use` line or an item.
- `crates/gateway/config/src/config/tests.rs` and the other gateway test modules gain a `//!` header stating what the file tests.
- `crates/gateway/app/src/dialect.rs` Reworded summaries keep every doctest fence and intra-doc link such as `ParsedCall` intact; no comment changes meaning, only its grammatical mood.

Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Rewords the first line of 56 doc comments across the promptforge and harness crate families from the imperative to the third-person indicative, so each summary reads as a statement of what the item does rather than an instruction. Adds a one-sentence crate-level or module-level doc header to 44 source files that had none: seven production modules in the Lua crate and 37 test modules spread over the runtime, API types, model client, parser, runner, sessions, and web search crates. No code, signature, attribute, or test body changes; every source hunk is a comment line.

- `crates/promptforge/lua/src/vm.rs`, `hardening.rs`, `host.rs`, `handles.rs`, `program.rs`, `scope.rs`, and `sys.rs` each gain a `//!` header naming the module's role: section VM lifecycle, sandbox hardening, host callbacks, tool bindings, compiled chunks, per-VM tool scope, and the sealed `sys` table.
- `crates/harness/sessions/src/transition.rs` and `crates/harness/webfetch/src/error.rs` reword enum variant summaries on `SupervisorEffect`, `HistoryEffect`, and `Disposition` from commands to descriptions, so variant docs read as what selecting the variant does.
- `crates/promptforge-api-runtime/src/execute/scheduler/step.rs` rewords the `Advance` variant summaries the same way; `subst.rs`, `execute/tests.rs`, and `model-client/src/client/wire.rs` reword free-function and constructor summaries.
- `crates/promptforge-api-types/src/cancel-tests.rs` and the eight sibling `*-tests.rs` and `tests.rs` files in that crate gain headers; the same applies to ten runtime test modules, four model-client test modules, five Lua test modules, eight harness test modules, and the parser `tests.rs`.

Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Doc comment summary lines in the build and workshop crates now use third-person indicative verbs, so a function's first sentence describes what it does instead of instructing the reader. Every test module file in the workshop crates that lacked a leading module doc line gains one sentence naming what the file covers. The workshop shell binary's crate-level docs now open the file, ahead of the release-build subsystem attribute and its comment. Nothing compiled changes.

- `crates/workshop/shell/src/main.rs`: the `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` line and its four-line comment now follow the `//!` crate docs instead of preceding them. Both are inner attributes, so the reorder leaves the compiled crate unchanged.
- `crates/build-user-guide/src/main.rs` and the 13 touched `crates/build-xtask/src/` files: 37 `///` summary lines switch from imperative to third-person indicative (`Run` -> `Runs`, `Check` -> `Checks`); the wording after the verb is untouched.
- `crates/workshop/workspace/src/workspace_file-actor.rs`: the seven `Command` variant docs get the same verb change (`Read` -> `Reads`, `Insert or replace` -> `Inserts or replaces`).
- `crates/build-llama-cuda/src/bundle.rs` and `crates/build-ui/src/lib.rs`: one field doc each (`smoke`, `define_app_version`) gets the verb change.
- `//!` first lines land on 27 test files across `crates/workshop/gateway`, `crates/workshop/menu`, `crates/workshop/server-api`, `crates/workshop/server`, `crates/workshop/status`, and `crates/workshop/workspace`, including the two `tests/it/heartbeat_loop` integration files.

Deferred: The 11 test files under `crates/workshop/server/tests/it/chat_gate/` and `crates/workshop/server/tests/it/realtime_relay/` keep no `//!` line because `include!` splices them into a parent module that already owns the module docs.
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md
Rename the gateway's public vocabulary crate so its name states what it holds, and add a progress snapshot to it: a busy flag and a producer-owned text, with no fractions, weights, or hierarchy on the wire. Every gateway crate that named the old crate now names the new one, and the boundary checker's public pair follows. The metadata-driven dependency boundary test and its CI step are removed, since the build task's checker already enforces the same matrix.

- `Progress` is a two-field struct with public `busy` and `text` fields, deriving `Default`, `PartialEq`, `Eq`, `Serialize`, and `Deserialize`. It has no schema version; the doc commits future fields to `#[serde(default)]` so a lagging reader survives them.
- `PUBLIC_GATEWAY` in `crates/build-xtask/src/product.rs` becomes `["gateway-api-types", "gateway-api-discovery"]`, and its violation message names the new crate; this matrix is the one left standing once the duplicate test is gone.
- `crates/gateway/stt/api/tests/it/architecture.rs` is deleted along with its `mod architecture;` line and the `cargo test -p gateway-stt --test it architecture` CI step. Its `PRODUCT_DEPENDENCY_RULES` restated the family rules the build task already checks.
- `progress-tests.rs` asserts the busy snapshot round-trips through JSON byte-for-byte and that the default serializes as `{"busy":false,"text":""}`.
- `gateway-api-types` depends on `serde`, `serde_json`, `time`, and `workspace-hack` only; no other workspace crate enters its dependency list.

Design: new surface-growth @ crates/gateway-api-types/src/progress.rs::Progress boundary: wire
Design: new shotgun-surgery @ crates/gateway-api-types
Design: removes parallel-abstraction @ crates/gateway/stt/api/tests/it/architecture.rs
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
The shared status bar shell no longer carries a determinate progress bar that displaces the LED indicators while work runs. In its place an animated indeterminate barberpole stands to the left of the indicators, shows while work is in flight, and hides otherwise; the LEDs stay visible the whole time. The shell's consumer API shrinks to a single busy toggle, so a fraction can no longer reach the bar. Both UIs derive that toggle from what their current status data already carries, and the tests that pinned the old swap are rewritten or replaced to pin the side-by-side layout.

- `setBusy(busy: boolean)` replaces `renderSlot(progress: SlotProgress | null)` on `StatusBarShell`; the `SlotProgress` type and the `progress: HTMLProgressElement` field are removed, so a consumer can only signal in-flight or idle.
- `.status-bar__barberpole` is a `span` with `role="progressbar"` and no `aria-valuenow`, appended to `.status-bar__right` immediately before the slot that holds the indicators; the `<progress>` element and its webkit pseudoelement rules are gone.
- `--progress-width` grows from 96px to 144px and the slot's `min-width: var(--progress-width)` is removed, so the indicators group no longer reserves the bar's width.
- `.status-bar__indicators` never receives `hidden`; `setBusy` flips `barberpole.hidden` alone and leaves the group and its LEDs untouched.
- `@keyframes status-bar-barberpole` slides `background-position` one period per 0.8s loop over a `repeating-linear-gradient(-45deg, ...)` whose period is twice the bar height; `prefers-reduced-motion: reduce` stops the animation and leaves the stripes static.
- `shell.setBusy(true)` in the gateway config UI fires on the presence of an active queue command while the text still carries the rounded percent; `this.shell.setBusy(frame.progress !== null)` in the workshop fires on a non-null progress frame. Both call sites are commented as interim.
- `progress-swap-indicators.mjs` is deleted and `barberpole-beside-indicators.mjs` asserts the inverse contract: a progress frame leaves the recording and activity LED group and both LEDs visible, and the barberpole precedes the group in DOM order.
- `shared-status-bar.mjs` asserts `typeof shell.renderSlot === "undefined"` and that no `progress` element remains, but no test covers the animation, the reduced-motion rule, or the 144px width.

Design: replaces surface-growth @ crates/shared-ui/status-bar.ts::StatusBarShell.setBusy boundary: pub was: crates/shared-ui/status-bar.ts::StatusBarShell.renderSlot
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
The workshop stops importing the gateway's progress machinery and reads only its public wire type: each snapshot on the admin progress stream is a busy flag plus text. The status bar's determinate progress slot becomes a busy flag on the wire and the push facade gains a busy intent to match. The anti-flicker policy that decides when the bar shows moves from the status subsystem's hub renderer into the gateway subscriber, where it runs as a state machine over explicit instants. The shared progress hub, its renderer, and the profile switch's step ladder are gone; a switch holds the bar busy once until its own terminal frame ends it.

- `StatusBarUpdate` carries `busy: bool` in place of `progress: Option<Progress>`, and the protocol crate's `Progress` struct is deleted. The serialized frame has a `busy` boolean and no `progress` key; `StatusFrame` in the SPA mirrors it.
- `push_busy` replaces `push_progress` on the push facade with `(label, description, activity)`; `emit` takes `busy: bool` and every other intent passes `false`. `StatusBus::progress` is deleted.
- `Presenter` is a new state machine in the gateway crate over `Instant` values: a busy snapshot starts the show delay, an idle one ends the run, `settle` pushes `push_busy` once `SHOW_DELAY` (1s) lapses and `push_idle` no sooner than `MIN_VISIBLE` (500ms) after showing. Text changes republish while shown; a repeated text does not.
- `Timing` bundles `resubscribe_delay` and the presenter `Policy` (`show_delay`, `min_visible`); `spawn` forwards `Timing::DEFAULT` to `spawn_with_timing`, which the tests call with millisecond values.
- `until` wraps every wait in `run`: the stop, reachability, and rebind signals in `Signals` end the wait with an `Ended` variant, and the presenter's `next_wake` deadline ticks it in place, so a minimum-visible hold lapses on time between subscriptions.
- `ProgressStream` renames `ProgressEventStream` and yields `gateway_api_types::Progress`; the `subscribe`, `decode`, `next_buffered_event`, and `parse_event_block` signatures follow. `gateway-api-types` replaces `shared-progress` in the gateway manifest.
- `ProgressHub` leaves `compose`: `register_tasks` in the gateway crate drops its `Arc<ProgressHub>` parameter and hands the subscriber `registry.push()`; the status crate's `register_tasks`, `progress.rs`, and `progress-tests.rs` are deleted and `shared-progress` leaves the gateway, server, and status manifests.
- `parse_event_block` reports a payload without `busy` and `text` as `GatewayError::Malformed`; a new test feeds the retired operation-event shape and asserts the error. The subscriber warns and keeps the stream on a malformed snapshot.
- `detach` treats a lost subscription or a rebind as idle: a pending show is forgotten, a shown bar rests once its hold lapses. The recovery test asserts the idle frame lands before the replacement's first busy frame.
- `run_switch` pushes one `push_busy` under `SWITCHING_LABEL` at the start; `drive_switch` no longer takes `push`, and `push_step` and `SWITCH_STEPS` are removed. No `push_idle` is added: the settled arm's status update, deferred notice, or failure is the non-busy frame, and the restart tests assert exactly one busy frame per switch.
- `setBusy` in the SPA status bar reads `frame.busy`; the interim check on `frame.progress` is gone.
- `run` in the subscriber is 95 lines, down from the removed version, and the `match until(tokio::time::sleep(timing.resubscribe_delay), ...)` block appears twice with only the arm body differing.

Design: new schema-change @ crates/workshop/protocol/src/status.rs::StatusBarUpdate boundary: wire
Design: new parameter-object @ crates/workshop/gateway/src/gateway_progress.rs::Timing
Design: removes shared-mutable-state @ crates/workshop/server/src/app.rs::compose
Design: new clone-block @ crates/workshop/gateway/src/gateway_progress.rs::run
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
Progress reporting in the gateway is reduced to one primitive: a producer begins an activity with a short user-facing line, may replace that line as work moves, and ends it by dropping a guard. The hub publishes a single snapshot, busy while any activity is live and carrying the newest activity's text, through a watch channel that keeps only the latest state, so subscribers see the current snapshot and every later change with no replay, fractions, weights, or hierarchy. Every gateway producer, the admin progress stream, the admin status document, the tray label, and the config UI move onto this shape, and the log bridge that rendered the old event stream is removed because producers now write their own log lines. The blob cache route keeps its byte-count stream, publishing raw counts on its own channel while formatting a percent into the activity text for the status consumers.

- `crates/shared-progress/src/lib.rs`: `ProgressHub` holds `Arc<Inner>` with `live: Mutex<Vec<(u64, String)>>`, `next_id: AtomicU64`, and `tx: watch::Sender<Progress>`; every `Activity` clones the `Arc` and removes its own entry on `Drop`. `Inner::publish` recomputes `busy = !live.is_empty()` and `text = last live text` and uses `send_if_modified`, so an unchanged snapshot wakes no subscriber.
- `crates/shared-progress/Cargo.toml`: the crate now depends on `gateway-api-types` and `tokio` `sync` only; the `serde` feature and the `time` and `tracing` dependencies are gone, and `event.rs`, `handle.rs`, `hub.rs`, `remote.rs`, `render.rs`, and `tree.rs` are deleted.
- `crates/gateway/app/src/admin/progress.rs`: `progress_sse_response` subscribes, calls `rx.mark_changed()` so the first `data:` line is the current snapshot, then emits one line per change; the broadcast lag arm is gone because the watch cannot fall behind. `snapshot_line` serializes `Progress` instead of `ProgressEvent`.
- `crates/gateway/app/src/admin/status.rs`: `admin_status` adds a top-level `"progress"` object from `state.hub.current()` and drops `"fraction"` from `queue.active`; `status-tests.rs` asserts both.
- `crates/gateway/app/src/commands.rs`: `begin_next` calls `self.hub.begin(entry.label.clone())` so the hub is busy from the instant the worker owns a command, and the `Activity` is passed into the `Executor` body, whose return drops it. `CommandStatus.progress` and `operation_fraction` are removed; `Enqueued.operation` becomes `Enqueued.entry: u64`.
- `crates/gateway/app/src/cache.rs`: `ChannelProgress` owns the download's `Activity`, a `PercentText`, and a `watch::Sender<Sample>`; `sse_response` reads byte samples off the watch and, when the reporter dropped before the first poll, still emits the latest unemitted sample ahead of the terminal line.
- `crates/gateway/local/src/artifacts/download.rs`: `PercentText::report` republishes only on a whole-percent change and clamps at 100; a zero total publishes the bare label once. `DownloadProgress` loses `finish` and `abandon`; `download` takes `name` and `Option<&Activity>` instead of a tree handle.
- `crates/gateway/local/src/artifacts/archive.rs`: `Extracting` pairs an `&Activity` with a `PercentText` exactly as `ActivityProgress` does in `download.rs`, and `digest.rs` builds the same pair as a tuple; `prewarm` in `crates/gateway/stt/backend-whisper/src/model.rs` formats its own whole-percent line inline rather than using `PercentText`.
- `crates/gateway/stt/backend-whisper/src/config.rs`: `WhisperConfig.progress` is `Option<Weak<Activity>>` and `live_progress` upgrades it, so a decoder built after the load's guard dropped reports nothing and cannot keep the hub busy.
- `crates/gateway/app/src/render.rs` and `crates/gateway/local/src/artifacts/progress.rs` are deleted; `serve_thread` no longer starts a renderer, and producers emit `tracing::info!` start and finish lines and `tracing::warn!`/`tracing::error!` on failure at the sites that formerly called `fail()`.
- `crates/gateway/app/src/tray/logic.rs`: `status_label` takes `busy_text: Option<&str>` and renders `"Running - {text}"`; the three tray backends read it through the new `AppState::tray_busy_text`.
- `crates/gateway/local/src/server.rs`: `ServerGuard::start` and `start_with` lose the `ready` parameter; readiness is no longer reported as progress.
- `crates/gateway/config-ui/ui/src/components/apply-overlay.ts`: the `KNOWN_STAGES` list and download detail row are replaced by one activity row that shows `progress.text` while busy and `WAITING_TEXT` otherwise; `status-bar.ts` drives `shell.setBusy` and `shell.setText` from `status.progress`, and `gateway-api.ts` adds `parseProgress`, which reads any malformed value as idle.
- `crates/gateway/app/src/cache.rs`: `ChannelProgress::sample` is `#[cfg(test)]`; `crates/gateway/app/src/commands.rs`: `Enqueued.entry` is read only by tests under `expect(dead_code)`.
- `Activity` carries no failure state: no producer writes an error into the text; the error propagates and the caller logs it.

Design: replaces shared-mutable-state @ crates/shared-progress/src/lib.rs::Inner was: crates/shared-progress/src/hub.rs::ProgressHub
Design: new schema-change @ crates/gateway/app/src/admin/status.rs::admin_status deps: AuthedCaller,State<AppState> boundary: wire
Design: new schema-change @ crates/gateway/app/src/admin/progress.rs::snapshot_line deps: Progress boundary: wire
Design: new parallel-abstraction @ crates/gateway/local/src/artifacts/archive.rs::Extracting
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
The progress hub leaves the shared layer and becomes a private crate inside the gateway family container, so only gateway crates can depend on it and the container privacy rule enforces that without new checking code. Every gateway producer, manifest, and lock entry follows the new name; the hub's behavior is unchanged. Policy text across the workspace catches up with the earlier renames: the public gateway pair is named by its new name, the progress reporting rule names the private crate and its producer contract, the retired metadata boundary test is no longer cited, and per-crate guidance that repeated the invariants block or the root rules is trimmed to what those do not already say.

- `crates/gateway/progress` is the moved `crates/shared-progress` with package name `gateway-progress`; the root `members` list names it explicitly because family containers sit outside the `crates/*` glob, and `[workspace.dependencies]` swaps the old entry for the new path.
- `gateway-progress` depends on `gateway-api-types`, `tokio`, and `workspace-hack` only, per the lock entry that replaces `shared-progress`; `gateway`, `gateway-local`, `gateway-stt`, and `gateway-stt-backend-whisper` are its four dependents.
- `crates/gateway/progress/src/lib.rs` keeps the hub's shape: one `Arc<Inner>` held by `ProgressHub` and cloned into every `Activity`, with `live: Mutex<Vec<(u64, String)>>` behind it. The move changes one doctest path and nothing else in the body; `lib-tests.rs` moves byte for byte.
- `use gateway_progress::` replaces `use shared_progress::` in 22 Rust files across `crates/gateway/app`, `crates/gateway/local`, `crates/gateway/stt/api`, and `crates/gateway/stt/backend-whisper`, plus the `Arc<gateway_progress::ProgressHub>` parameters on `new_with_hub` and `from_config_with_hub` in `runner.rs`; four `Cargo.toml` files swap the dependency line.
- `AGENTS.md` names `gateway-api-types` as half of the public pair and in the harness may-depend list, states that the types crate carries wire vocabulary only, and rewrites the reporting rule: long-running gateway work reports through `gateway-progress`, consumers outside the family read only `Progress` from `gateway-api-types`.
- `AGENTS.md` drops the `cargo test -p gateway-stt --test it architecture` clause from the enforcement sentence, drops the SPA `index.ts` half of the Invariants rule, and corrects the CSS example to `parts/agent/agent-session.css`.
- `crates/gateway/README.md` gains a `gateway-progress` entry and lists `progress` among the family crates the gateway depends on; `crates/README.md` renames the `gateway-api` entry, adds `Progress` to its description, and removes the `shared-progress` entry. `crates/gateway/stt/README.md` lists `gateway-progress` for the api and whisper crates.
- `## Invariants` blocks in `crates/harness-api/src/lib.rs` and the `capabilities`, `log`, `models`, `runner`, and `sessions` crates name `gateway-api-types` in place of `gateway-api`.
- `crates/harness/models/AGENTS.md`, `crates/harness/sessions/AGENTS.md`, and `crates/promptforge-api-runtime/AGENTS.md` lose the family-rules and one-door bullets restated from their `lib.rs` or the root file and point readers at the `## Invariants` block; "Core" becomes `promptforge-api-runtime` in `crates/promptforge/parser/AGENTS.md`, `crates/harness/webfetch/AGENTS.md`, and `crates/harness/web-search/AGENTS.md`.
- `lib-tests.rs` is the only test file in the change and it moves without edits; no test is added, and no Rust logic outside import paths and the two `runner.rs` parameter types is touched.

Design: replaces shared-mutable-state @ crates/gateway/progress/src/lib.rs::Inner was: crates/shared-progress/src/lib.rs::Inner
Design: new shotgun-surgery @ crates/gateway/progress
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
Add an invariants section to the three harness web crate roots and split the oversized webfetch and web-search source files into sibling files so that every file in those crates stays under the 500-line ceiling. The configuration error type, its validators, the search request type, and the test suites move verbatim into the new files; the public error path is preserved through a re-export, and no runtime behavior changes.

- `crates/harness/webfetch/src/config-validate.rs` now holds `ConfigError`, `ConfigErrorRepr`, and the seven `validate_*` functions. `config.rs` attaches it with `#[path = "config-validate.rs"]` and keeps the crate's public path alive with `pub use validate::ConfigError`, so `lib.rs` still exports `ConfigError` from `crate::config` unchanged.
- `crates/harness/webfetch/src/tool-tests.rs` keeps the shared fixtures (`ARTICLE_HTML`, `MapLookup`, `spawn_server`, `spawn_recording_server`, `loopback_builder`, `split_header`) and the two descriptor tests; `tool-tests-policy.rs` and `tool-tests-body.rs` are child modules that reach the fixtures through `use super::*`.
- `crates/harness/web-search/src/web_search-request.rs` takes `Freshness`, `SafeSearch`, and `SearchRequest`; `SearchRequest` and `from_args` widen from private to `pub(super)` so `web_search.rs` can still construct it, and `web_search-tests-responses.rs` takes the gateway response tests.
- `ConfigErrorRepr` widens from private to `pub(super)` so the validators can name it from the child module; it remains unreachable outside `config`.
- `crates/harness/webfetch/src/lib.rs`, `crates/harness/web-search/src/lib.rs`, and `crates/harness/web/src/lib.rs` each gain a `## Invariants` doc block naming the allowed dependency set, the 500-line file ceiling, and the no-direct-spawn rule, plus the crate-specific invariants (redirect revalidation and no ambient identity for webfetch, the bearer-token rule for web-search, construction-time tool building for web).
- `#[path = "tool-tests.rs"]` and its siblings are the only additions to `tool.rs`, `config.rs`, `web_search.rs`, and `web_search-tests.rs` beyond import trims; the removed and added function sets match name for name (89 each), and no test is added, removed, or reasserted.
- `## Invariants` blocks are documentation only; nothing in this diff reads or enforces them.

Design: replaces newtype @ crates/harness/webfetch/src/config-validate.rs::ConfigError boundary: pub was: crates/harness/webfetch/src/config.rs::ConfigError
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_user_agent deps: String was: crates/harness/webfetch/src/config.rs::validate_user_agent
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_limit deps: &'static str,usize,usize was: crates/harness/webfetch/src/config.rs::validate_limit
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_redirects deps: usize was: crates/harness/webfetch/src/config.rs::validate_redirects
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_timeout deps: &'static str,Duration,Duration was: crates/harness/webfetch/src/config.rs::validate_timeout
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_deny_cidrs deps: Vec<String> was: crates/harness/webfetch/src/config.rs::validate_deny_cidrs
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_host deps: &str was: crates/harness/webfetch/src/config.rs::validate_host
Design: replaces pure-function @ crates/harness/webfetch/src/config-validate.rs::validate_allow_hosts deps: Vec<(String, IpAddr)> was: crates/harness/webfetch/src/config.rs::validate_allow_hosts
Design: replaces encapsulated-invariant @ crates/harness/web-search/src/web_search-request.rs::SearchRequest boundary: wire was: crates/harness/web-search/src/web_search.rs::SearchRequest
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
Every workshop and harness crate must now open its crate docs with the Invariants marker; a family crate without it is an architecture violation rather than a crate that quietly sits outside the checks. Family membership is decided by package name instead of by the marker's presence, so the file ceiling and lint inheritance checks bind those crates whether or not they carry it, while any other crate can still opt in by carrying the marker. The tidy test module moves to a sibling file, and the repository rules describe the marker as mandatory and scope the line ceiling to marked crates.

- `family_requires_marker` decides membership from the manifest package name: any `workshop-` or `harness-` prefix, with the `workshop` shell exempt. Membership no longer depends on the marker being present.
- `participating_crates` is now the union of family crates and marker-carrying crates over a new `workspace_crates` walk that returns every crate directory, so an unmarked family crate stays under the ceiling and lint checks while a marked outsider keeps opting in.
- `tidy-tests.rs` holds the test module, wired from `tidy.rs` through a `#[path = "tidy-tests.rs"]` attribute; `write_crate` gains a package name parameter so fixtures can exercise the name rule.
- `marker_violations` reports each family crate whose `src/lib.rs` lacks the marker, naming the crate and the marker text, and `all_violations` runs it between the tier and ceiling checks.
- `a_harness_crate_without_the_marker_is_a_violation_and_still_held_to_the_ceiling` replaces the test that asserted the opposite: an unmarked harness crate used to fall outside the ceiling and is now both a marker violation and a ceiling participant. Companion tests cover the exempt shell, a marked non-family crate, and an unmarked non-family crate.
- `AGENTS.md` states that the marker is mandatory for the two families by package name and scopes the 500-line sentence to crates carrying the marker.
- `package_name` returns None when a manifest cannot be read or parsed, and `marker_violations` skips such a crate without reporting it, so a family crate with a malformed manifest passes the marker check and drops out of the ceiling and lint checks.

Design: new pure-function @ crates/build-xtask/src/tidy.rs::family_requires_marker deps: str
Design: new swallowed-exception @ crates/build-xtask/src/tidy.rs::package_name deps: Path
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
Plan: vibe/2026-09-20-2-gateway-api-types-progress.md
A blocking-pool join failure is a panicked or drained task, never
something the caller sent, so every route now dispatches through one
`blocking()` helper in `error.rs` that maps the join to a single
`GatewayError::BlockingTask` (500, `blocking_task_failed`). The same
file gains `WireQuery` and `WirePath` beside `WireJson`, so a
malformed query string or path capture lands in the OpenAI error
envelope without a per-handler `map_err`.

- `blocking()` replaces the join mapping at every `spawn_blocking`
  site in the admin and cache routes; the closures keep their own
  domain error mapping via `??` or `.map_err(..)`.
- `SystemMetrics` and `system_metrics()` are removed: the join was
  their only producer.
- The `cloud_models.rs` sheet-cache write previously mapped a join to
  `CloudModelsUnavailable` (502); it is now the shared 500.
- `admin_model_info`, `admin_hf_model`, and `admin_hf_readme` list
  the fallible extractor after `AuthedCaller`, so an unauthenticated
  caller earns 401 before a malformed input earns 400.
- The two `spawn_blocking` sites in `commands.rs` are unchanged; they
  handle the join inside a match that also drives progress leaves.
- No test exercises a join failure; the classify table gains the new
  variant's row and loses the removed one.
Each route area now owns its mounts through `pub(crate) fn routes()
-> Router<AppState>`, and `build_router` in `lib.rs` only merges the
areas and applies the walls. The admin surface splits by tier into
`admin/open/` and `admin/walled/`, and the walled router is merged
once behind `shared_loopback::require_loopback`, so a module's path
states whether a LAN peer may ever reach it.

- The 13 flat walled modules move under `admin/walled/`; the former
  `admin/{profiles,progress,queue,status}` move under `admin/open/`.
  `admin/config.rs` folds into `config_write.rs`, renamed
  `admin/walled/config.rs`, so `/admin/config` has one owning module.
- New `LoopbackCaller` extractor in `auth.rs`, taken by every
  bearer-authed walled handler in place of `AuthedCaller`. Its
  `Rejection = Response`: a non-loopback or peerless caller earns the
  wall's bare 403 before auth runs; an auth failure earns the usual
  `GatewayError` envelope. Five tests in `auth-loopback-tests.rs`
  drive it through a router with no wall in front.
- `CatalogModelsResponse`, `CatalogModelInfo`, and
  `SpeechCatalogModelInfo` move from `model_info.rs` to `models.rs`
  with their test in `models-tests.rs`, so `model_info.rs` is only the
  walled GGUF route and sits behind `#[cfg(feature = "local")]`.
- `health`, `web_search`, and `config_ui_redirect` leave `lib.rs`:
  the first two as one-file area modules, the redirect into
  `handoff.rs` beside `/auth`. The two `handoff` routes take no
  caller extractor; the handoff is how a browser earns its credential.
- The `clippy::too_many_lines` waiver on `build_router` is gone.
- The set of mounted paths is unchanged: 35 before, 35 after. No
  route, method, or wall moved tiers.
Every mounted route is now data as well as code: each area module
declares one `RouteInfo` constant per route (path template, methods,
and `Tier::Open` or `Tier::Walled`) and binds `INFO.path` in its
`routes()`, and `registry::all()` collects every area's `ROUTES`
under the feature gates `build_router` merges them under. The
registry replaces the crate-root doc paragraph that enumerated every
route in prose, and it drives the wall tests instead of a hand list.

- `registry-tests.rs` sweeps the assembled router from the registry:
  every walled route earns 403 from a LAN peer and a peerless caller
  and passes the wall from loopback; every open route answers a LAN
  peer with something other than 403, 404, or 405. A route declared
  in the wrong tier, or declared but not mounted, fails the sweep.
- The hand-listed `walled_requests()` and bearer-only sweeps in
  `loopback-tests.rs` are removed; that list had no entry for
  `/shutdown`. Its tempdir fixture moves to
  `test_support::walled_fixture`, which points `HfProxy` at a dead
  loopback port so a sweep that reaches the HF routes never calls
  the real hub.
- `build_router` logs each registry entry at debug level as
  "route mounted", the registry's one production consumer.
- The `lib.rs` crate doc drops its 73-line route enumeration for a
  description of the two surfaces and their tiers.
- No route path, method, or tier changes; 475 tests pass, 474 before
  plus 5 registry sweeps minus 4 hand-listed sweeps.
The admin routes' reply bodies were `serde_json::json!` literals whose
shape only the integration tests pinned. Each is now a `Serialize`
struct declared in the module that produces it, with one doc line per
field, so the config UI's contract is a type the compiler checks and
the serialized JSON is unchanged.

- `ProfilesReply` and `SwitchProfileReply` in `profiles.rs`;
  `ShadowReply` in `config.rs`, shared by `admin_put_config` and
  `admin_put_env`; `ApplyReply` and `RevertReply` in
  `config_apply.rs`; `PendingReply` and `DirtyReply` in
  `config_pending.rs`; `EnvReply` and `EnvSection` in `env_file.rs`;
  `CancelReply` in `queue.rs`; `OrphansReply` in `orphans.rs`.
- `PendingReply.profile` stays a `serde_json::Value`: it is the config
  document itself with `active_profile` inserted.
- `env_section` no longer takes an `Option`; the one caller always
  had a path, and `EnvReply.profile` is `None` directly.
- `dirty_reply` returns `DirtyReply`; its unit test asserts on the
  struct's fields instead of indexing JSON.
- `admin_status` keeps its `json!` body; it is not part of this change.
- No integration test changes; every reply parses to the same JSON.
The `ApplyConfig` command's body lived in the route module that
enqueues it. It now lives in `commands-apply.rs` as `commands::apply`,
with the snapshot vocabulary the route and the command share, so
`admin/walled/config_apply.rs` holds the two handlers, their replies,
and the revert body that runs inline under the route's lock.

- Moved: `ShadowCapture`, `ApplySnapshot`, `ApplyPlan`,
  `RESTART_SECTIONS`, `capture_apply`, `promote_captures`,
  `apply_config`, `apply_cancelled`, `apply_snapshot`. `ApplyPlan` and
  `capture_apply` become `pub(crate)` for the route and its test.
- `delete_all_shadows` stays with `admin_config_revert`; it is not a
  command.
- The apply mutex is taken in the same places as before: the route's
  capture, the command's commit, and the revert.
- `provision_model` and `unload_model` in `commands.rs` go through
  `blocking()`; a join failure is `GatewayError::BlockingTask`
  where it was `cache(join)` and `switch_failed("unload-model", join)`.
- `admin_status` returns a typed `StatusReply` (with `QueueReply`,
  `ActiveCommandReply`, `PendingCommandReply`); `EndpointStatus` in
  `models.rs` derives `Serialize`. The JSON is unchanged, `progress`
  and the absent `active.fraction` included.
- No test assertion changes; the route tests import `ApplyPlan` and
  `capture_apply` from their new path.
`error_chain` flattens an error's `source()` chain into the one line a
wire message carries. It lived in the config-write module that first
needed it, but two of its five callers sit outside the walled admin
tier: `dialect.rs` on the relay path and a boot test. It moves to
`error.rs`, beside the `GatewayError` variants that carry its output.

- Callers repoint from `admin::walled::config::error_chain` to
  `crate::error::error_chain`; `config.rs` keeps using it for
  `ConfigWriteRejected` through an import.
- The doc names the multi-line renderers in `main.rs` as a separate
  rendering, so the three chain walks in the crate are distinguishable
  by purpose rather than by accident of location.
- Three module docs said their blocking work "runs inside
  `tokio::task::spawn_blocking`"; those modules now call
  `crate::error::blocking`, so the docs name that instead.
- No behavior change: same function body, same output, same call
  sites.
Every route module's tests now live in a `<parent>-tests.rs` sibling
wired with `#[path]`, the convention the crate states and the rest of
the tree follows. `admin/open/` already did; `admin/walled/` did so in
none of its twelve modules, and neither did `cache.rs`, so one
directory carried two conventions split along the tier boundary.

- Thirteen files lose their inline `#[cfg(test)] mod tests { .. }`
  block to a sibling; `handoff.rs` had two blocks under different
  feature gates and yields `handoff-tests.rs` and
  `handoff-cookie-tests.rs`.
- Each sibling keeps its parent's cfg attribute on the `mod`
  declaration, so `handoff-tests.rs` stays behind
  `all(test, feature = "config-ui")` and the rest behind `test`.
- Module-level `#![expect(clippy::expect_used)]` attributes move with
  their bodies and apply to the same module, now as file-level inner
  attributes.
- Production code shrinks to the route and its types: `config_apply.rs`
  893 to 192 lines, `hf.rs` 816 to 344, `handoff.rs` 625 to 240,
  `reveal.rs` 573 to 229, `cache.rs` 533 to 186.
- Pure code motion. No test is added, removed, renamed, or re-asserted;
  the suite runs 470 before and after.
- Non-route modules keep their inline blocks and are untouched:
  `runner.rs`, `commands.rs`, `dialect.rs`, `boot.rs`, `error.rs`,
  `routing.rs`, `main.rs`, `api_error.rs`, `diagnostics.rs`,
  `relaunch.rs`, `test_support.rs`, `tray/logic.rs`. `auth.rs` still
  carries both forms.
The build tool's architecture checks enumerated the workspace crates twice, through two walks that had drifted apart. One descended the crate tree to any depth, the other stopped one level down, so a crate parked deeper than that slipped past the file ceiling unnoticed. Both checks now share a single enumeration, which also records the directories whose manifests it could not read, parse, or find a package name in. A crate whose name cannot be read is no longer treated as exempt: it is reported and held to the same limits as the crates around it. Those read failures have a single owner among the checks, so a broken manifest is named once however many checks share the walk.

- `CrateWalk` holds the crates the walk named, the directories it could not name, and the failures in walk order. Its two consumers thread one value instead of a pair of out-parameters.
- `workspace_crates` in the product module is now the one enumeration and is visible to the tidy checks; the tidy-side copy that stopped one level below `crates/` is gone.
- `package_name` goes with it. The three manifest failures it collapsed into a single `None` - unreadable, unparseable, and unnamed - now reach the report as distinct messages from the shared reader.
- `participating_crates` chains the unread directories onto the named crates, so a crate whose manifest cannot be read is still bound by the file ceiling and the lint inheritance check.
- `marker_violations` owns the walk's read failures and `product_boundary_violations` leaves them to it, so sharing one walk does not name a broken manifest twice.

Design: removes parallel-abstraction @ crates/build-xtask/src/tidy.rs::workspace_crates deps: Path
Design: removes parallel-abstraction @ crates/build-xtask/src/tidy.rs::package_name deps: Path
Design: new parameter-object @ crates/build-xtask/src/product.rs::CrateWalk
Repairs: the tidy checks enumerate every crate under crates/ @ crates/build-xtask/src/tidy.rs::participating_crates - a crate nested under a subsystem container escaped the file ceiling
Repairs: only a manifest that names its package can show a crate exempt @ crates/build-xtask/src/tidy.rs::marker_violations - a family crate whose manifest declared no package name went unreported and unchecked
Plan: vibe/2026-09-20-4-debt-removal.md
Both gateway error exits turn a failure into a person-facing string by walking the chain of causes and joining their messages with a separator. Some failures copy their source's text into their own message, so the walk printed that text twice, and a cause that renders as nothing left a dangling separator at the end. Both exits now skip a cause whose text the accumulated string already contains and a cause that renders as empty. Neither rendering was pinned by a test before; each is now.

- `error_chain` keeps one copy per crate. The two copies were patched identically rather than folded into a shared renderer, because the crates that hold them share no dependency edge and neither can call the other's.
- `!cause_text.is_empty() && !text.contains(&cause_text)` is the whole predicate. It is a plain substring test against the text rendered so far, not an identity or structural comparison, so an unrelated cause that happens to be a substring of the text already rendered is dropped too.
- `pub fn error_chain` is public in the cloud-providers crate, so the changed string reaches every caller of that crate.
- `a_cause_the_outer_message_already_carries_renders_once` is the one new test in each crate and pins both cases: the cause whose text the outer message already carries, and the cause that renders as nothing.
- `"; "` is still the separator at both exits. Only the choice of which causes to append changed.

Design: new clone-block @ crates/gateway/app/src/error.rs::error_chain deps: &dyn std::error::Error
  instead-of: pure-function: a single shared renderer would need a new dependency edge
Design: new clone-block @ crates/gateway/cloud-providers/src/lib.rs::error_chain deps: &dyn std::error::Error
  boundary: pub
Repairs: each cause contributes its text at most once @ crates/gateway/app/src/error.rs::error_chain - a cause the outer message already carried printed twice, and an empty cause left a dangling separator
Repairs: each cause contributes its text at most once @ crates/gateway/cloud-providers/src/lib.rs::error_chain - a cause the outer message already carried printed twice, and an empty cause left a dangling separator
Plan: vibe/2026-09-20-4-debt-removal.md
The recorder's event routing and the scheduler's owner teardown both turn on matches that cannot be exhaustive, since the enums they read are owned by another crate and declared open to new variants. Two tests now stand in for the compiler check that is unavailable: one drives a value of every known event variant through the recorder and asserts each reaches exactly one sink and no other, and the other settles a single owner holding one task of each origin and asserts only the task the prompt author spawned is reported as still live. The comments over both matches claimed a guarantee the code does not make; they now state the conditional one and name the test that backs it.

- `one_of_every_event_variant` is one flat hand-written table of event values, deliberately not derived from the forwarder's own or-patterns, and carries a lint allowance recording that splitting it would hide the coverage it exists to show.
- `Seam` names the three sinks an event can land in as an index into the recorder's per-sink counts, which lets each case assert both the sink it must reach and the emptiness of the other two.
- `unit_events!` collapses the forty-eight payload-free lifecycle variants to a bare name list, leaving only the payload-carrying variants written out in full.
- `every_event_variant_reaches_exactly_one_seam` drives each value through `forward_one` with both an observer and a debug capture attached, so a variant no group claims panics naming itself and a variant a group claims but does not destructure records nothing and fails.
- `an_ending_owner_leaks_its_author_task_and_never_its_model_task` ends one owner holding both origins at a single chain end, asserting the author's id is the whole of the live-task error, both slots end abandoned, and both emitted their terminal observation past the effect-backed branch that would otherwise skip the origin arm.
- `Recorder` gains `on_thinking`, `on_assistant_tool_calls`, `on_tool_result`, and `on_task_notice`, each pushing a formatted line to the content seam so the variants routed there are counted rather than passing vacuously.
- `forward_one`'s doc comment no longer claims the recorder observes every event; the guarantee holds only for variants someone has added to both a group's or-pattern and the test's list, by hand.
- `TaskOrigin` is non-exhaustive to this crate, so a variant added to it lands in the leaked arm silently until someone extends the test; the inline comment now says so instead of implying the arm is total.

Design: new oversized-unit @ crates/promptforge-api-runtime/src/test_support/recording-forward-tests.rs::one_of_every_event_variant
Design: new oversized-unit @ crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs::an_ending_owner_leaks_its_author_task_and_never_its_model_task
Deferred: no compiler check binds the hand-maintained variant lists to their enums
Plan: vibe/2026-09-20-4-debt-removal.md
The boundary rule planned for the gateway app would have fired on three
files the relocation step never touches, making its exit check
unsatisfiable. The plan now moves those references out of the tier and
allowlists only the sites that assemble state, and it splits the
error-source collapse so a red gate has two bisect points.

- The tier keeps three outside references after the relocation, not
  two: the cloud-models boot constants read at startup, the pending
  config path helpers used by the apply command, and the fixture that
  builds the proxy. The first two move out; the third is allowlisted.
- Items leaving the tier go to homes named for what they are. Only the
  error goes to the error module; the state snapshot, the shutdown
  signal, the boot constants, and the path helpers each get a home that
  matches their subject.
- The rule's reach is now stated rather than implied. It matches text,
  so it misses aliases, relative paths, re-exports, and generated
  paths, and it must skip comments because module docs name modules in
  prose.
The walled tier is the set of admin routes that read secrets, write files, or launch processes. Several things with nothing to do with those routes had accumulated inside it: the ambient credential primitives the authentication rules read in every build, the process shutdown signal the serve loop and the tray watch, a speech status snapshot, an error mapper, the config shadow bookkeeping, and three boot constants. Each was reached from outside the tier, so core code, the open admin tier, and the tray all imported route modules to get at it. Each now lives in a module named for what it is, and the browser handoff that fronts the config surface is gated as a whole rather than item by item. Nothing changes at runtime.

- `crates/gateway/app/src/auth-primitives.rs` holds the cookie name, the session proof, the Fetch Metadata gates, the hex decoder, and the handoff URL builder, wired from `auth.rs` as a path module. The authentication rules read them in every build; only the routes that mint the cookie stay behind the config surface.
- `crates/gateway/app/src/shutdown.rs` makes the process shutdown signal a crate-root concern rather than a route's export, which is what the serve loop, the open-ended response streams, and the tray status tick already treated it as.
- `crates/gateway/app/src/config_shadow.rs` gathers the shadow census and its path rendering for the three readers that share them: the dirty report, the apply route, and the apply command.
- `crate::boot::CACHE_FILE_NAME` puts the cloud model sheet constants in the boot module because the process runner is what reads them. The sheet route and its tests now reach inward for them, which is the permitted direction.
- `hex_encode` stays with the config surface, so the cookie tests that run in every build keep a hand-written copy of it. The round-trip test that pins the pair now compiles only where the encoder does, and nothing pins the duplicate to either side.

Design: removes layer-violation @ crates/gateway/app/src/admin/walled/handoff.rs
Design: removes layer-violation @ crates/gateway/app/src/admin/walled/shutdown.rs
Design: removes layer-violation @ crates/gateway/app/src/admin/walled/system.rs
Design: removes layer-violation @ crates/gateway/app/src/admin/walled/config.rs
Design: removes layer-violation @ crates/gateway/app/src/admin/walled/config_pending.rs
Design: removes layer-violation @ crates/gateway/app/src/admin/walled/cloud_models.rs
Plan: vibe/2026-09-20-4-debt-removal.md
The walled admin tier is a directory reserved for routes that read secrets, write files, or launch processes. Nothing outside it should reach in, but nothing checked that, so a module no walled route needs could drift into the directory and dilute what being there means. The architecture checker now reports any source file outside the tier that spells one of the tier's module paths, sparing only the three assembly sites that build the application state and enumerate its routes and therefore must name them. The check is a textual tripwire for the ordinary case rather than a proof of the boundary; the wall itself stays enforced structurally, where the routers merge.

- `walled_tier_violations` walks the gateway app's source tree, skips the tier's own modules, and joins the aggregate check, so the rule runs wherever the other architecture checks run.
- `WALLED_ALLOWLIST` is keyed on file paths rather than line numbers, which move with any edit. It holds exactly three entries: the state field types and router merge, the route enumeration, and the test fixture that assembles the same state. The fixture is listed by name even though it is test-gated, because the rule reads source text and never sees a cfg attribute.
- `slash_path` renders a relative path with forward separators so the allowlist and the reports read the same on every platform. A component that is not valid UTF-8 is rendered lossily rather than dropped, since dropping one would shorten the path and could collide it with an allowlist entry.
- `WALLED_PATH` is matched as text, so a line that begins as a comment is skipped. Prose naming a route module says where the other half of a feature lives; failing the build on documentation would only teach authors to stop writing it.
- `unreadable source file` is reported as a violation rather than skipped, because a file that was never scanned cannot be shown clean.
- `no_file_outside_the_walled_tier_names_its_modules` runs the rule against the real workspace, so the tree is held to the rule it gains.
- `crate::admin::walled::` is matched literally and only literally. The rule misses an alias, a `super::` path, a re-export, and any path a macro generates.

Design: extends facade @ crates/build-xtask/src/tidy.rs::all_violations
Design: new hidden-dependency @ crates/build-xtask/src/tidy.rs::WALLED_ALLOWLIST
Design: new pure-function @ crates/build-xtask/src/tidy.rs::slash_path deps: Path,Path
Plan: vibe/2026-09-20-4-debt-removal.md
Every product family had grown its own copy of the same error-source wrapper, so the same type existed under the same name in crates that could not see each other. A new shared crate now owns one wrapper per third-party error that a public error surface would otherwise name, and the gateway crates wrap their causes through it instead of through private copies. Each wrapper renders and sources exactly as the error it holds, and its accessors give callers back the ability to branch on that error, which transparent delegation otherwise takes away. Every wrapper sits behind its own feature, so a consumer takes on only the third-party dependency it already has.

- `shared-error-source` depends on no workspace crate; that independence is what keeps a shared wrapper off the cross-family edge.
- `JsonSource`, `HttpSource`, and `DatabaseSource` are each a transparent newtype over one third-party error, with `as_inner` and `into_inner`. Transparent delegation puts the wrapped value out of reach by type, so the accessors are what restore branching on it.
- `SidecarError`, `LocalError`, and `FetchError` name the shared wrappers, and none of the three crates re-exports them; a caller that needs the underlying error names `shared_error_source` directly.
- `crates/gateway/local/src/lib.rs` and `crates/gateway-api-discovery/src/lib.rs` drop the wrappers from their public re-exports, narrowing what each crate's surface names.
- `downcast_ref` tests in all three repointed crates pin that each variant's cause is the shared wrapper and that the third-party error stays reachable through it.
- `DatabaseSource` has no consumer: no crate enables the `database` feature, and no error enum holds it.

Design: new newtype @ crates/shared-error-source/src/lib.rs::JsonSource
  boundary: pub
Design: new newtype @ crates/shared-error-source/src/lib.rs::HttpSource
  boundary: pub
Design: new newtype @ crates/shared-error-source/src/lib.rs::DatabaseSource
  boundary: pub
Design: removes newtype @ crates/gateway-api-discovery/src/error.rs::JsonSource
  boundary: pub
Design: removes newtype @ crates/gateway/local/src/error.rs::HttpSource
  boundary: pub
Design: removes newtype @ crates/gateway/local/src/error.rs::JsonSource
  boundary: pub
Design: removes newtype @ crates/gateway/cloud-providers/src/lib.rs::HttpSource
  boundary: pub
Plan: vibe/2026-09-20-4-debt-removal.md
Three crates each defined their own private wrapper around the same two third-party failures, a JSON parse error and a database engine error, leaving five definitions in the workspace for two causes. Those wrappers now come from the shared substrate instead, so one name covers each cause and three public interfaces stop exporting a local one. Every repointed error type gains a case proving its third-party cause is still reachable behind the shared wrapper, which transparent delegation would otherwise hide. A gateway import and a lint expectation are narrowed to the features that need them, so builds with those features off stay quiet.

- `shared_error_source` is imported directly wherever a cause is named, and no crate re-exports the wrappers. A caller that needs the underlying error depends on the shared crate itself.
- `PayloadSource`, the run log's separate serde wrapper, folds onto the shared JSON type, so a serde cause has one name rather than one per crate.
- `LogError`, `WorkspaceFileError`, `WorkspaceError`, and `UserStateError` name the shared wrapper in their source positions. This changes their public variant shapes for every consumer in this workspace.
- `is_not_a_database` reads the engine variant through `as_inner()` rather than by pattern, because the wrapper's field is private to the crate that defines it. A case asserts the refusal still fires on a corrupt file.
- `registry` carries an `unused_mut` expectation that applies only when both gated route groups are off, which is the one configuration where nothing extends the list.
- `blocking` is imported under the local feature alone, since only the local-inference command bodies leave the async executor.

Design: removes parallel-abstraction @ crates/harness/log/src/error.rs::DatabaseSource
  boundary: pub
Design: removes parallel-abstraction @ crates/harness/log/src/error.rs::PayloadSource
  boundary: pub
Design: removes parallel-abstraction @ crates/workshop/user-state/src/error.rs::JsonSource
  boundary: pub
Design: removes parallel-abstraction @ crates/workshop/workspace/src/error.rs::JsonSource
  boundary: pub
Design: removes parallel-abstraction @ crates/workshop/workspace/src/workspace_file.rs::DatabaseSource
  boundary: pub
Plan: vibe/2026-09-20-4-debt-removal.md
Plan: vibe/2026-09-20-4-debt-removal.md
@vinniefalco
vinniefalco merged commit e337a76 into cppalliance:master Sep 21, 2026
17 checks passed

This branch was successfully deployed

1 active deployment
github-pages e337a76d Deployed Sep 21, 2026 by vinniefalco via deploy #74
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