Fabro carried its own Sandbox trait long after every implementation
became a thin layer over the sandbox driver: one production type
implemented it, a delegation macro forwarded it, and each consumer crate
kept hand-written fakes of its thirty methods for tests. The trait
existed to be mocked, and the mocks pinned behavior that no provider
had — canned walk listings that ignored the traversal root, opaque
provider paths, activation failures with no lifecycle behind them.
There is now one sandbox type. RunSandbox keeps fabro's semantics — path
resolution against the run's working directory, the Bash exec policy,
git setup and push, credential refresh — as inherent methods over the
driver's exec, filesystem, search, and git facets, and every consumer
takes Arc<RunSandbox>. The directory, grep, and walk types are the
driver's own, re-exported from fabro-sandbox. The exec policy reports
the provider's measured duration rather than its own clock.
Tests script a sandbox through fabro-sandbox's MockSandbox: a struct of
fields (seeded files, the result every command returns, the platform,
a runtime directory) that hands out a RunSandbox over the driver's
scripted doubles and reads back what the code did — commands, timeouts,
environment, term stops, writes, deletes, existence probes. The
hand-written fakes in fabro-agent, fabro-acp, fabro-hooks,
fabro-workflow, and fabro-server are gone; the one wrapper a git
integration test still needs sits at the driver level, hiding a path
from a real Host sandbox. The refresh-ahead loop takes the refresh as a
closure so its schedule is tested without a sandbox at all.
The driver pin moves to the testing-crate stack head, which gained the
double behavior these ports needed: retention caps on scripted output,
canned walks narrowed to the requested base, upload and download on the
memory filesystem, and recorders for deletes, existence probes, and
term stops. One test that modelled a provider handing back opaque object
paths from a walk is removed: the driver contract has no such thing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sandbox-driver stack fabro pins now carries five things fabro used
to do itself, so fabro stops doing them. A tag pin is a clone option,
so the exec-based init, fetch, and checkout path for tags is gone and
every pin goes through the driver's clone; the shell command builders
and the hermetic git proofs that only served that path go with it. The
driver classifies every git failure it produces, so the retry module
keeps only the decision (a rejected credential is retried while a fresh
App token may still be replicating; an unreachable remote is retried
whatever the credential; everything else is permanent) and its hint
tables are gone. The provider runs the TERM, grace, KILL ladder for the
timeout and for the caller's cancellation, so the exec wrapper sets the
grace on the spec, passes the cancellation token as the term stop, and
reads the driver's verdict instead of racing its own timer. Daytona's
snapshot is ensured by the driver, so the list, activate, create, and
poll sequence and its back-off loop are gone. Every provider is
connected through the driver's ownership scope, narrowed to the run when
one is known, so creates carry fabro's labels and attaches to anything
else are refused by the driver; the label module keeps only the label
names, and the inventory provider reads the scope's answers instead of
checking labels itself.
The pin moves to the stack head with the driver's fix for a command
that honours the TERM inside the grace, which fabro's own tests caught
as a timeout reported as a cancellation. The Docker checkpoint
integration tests now create their container through the driver, since
the driver attaches only to containers it created; they were reaching
for a hand-run container since the Docker cutover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three copies of the same mapping turned an environment into a sandbox:
Docker had its own options type built from the environment, Daytona a
config type with its own network enum and minute-and-gigabyte units,
and plugin kinds a third options type, each with its own spec builder,
constructor, and attach function, and the run spec had a variant per
provider carrying them. The mapping now runs once. SandboxOptions is
what any environment asks of any provider; options_from_environment
builds it, and one base spec carries the source, name, labels,
variables, resources, and network policy. Docker and Daytona are
overlays on that spec: Docker fixes its working directory, supplies the
default image, and pulls; Daytona replaces the source with the snapshot
it ensures from the same image or Dockerfile, fixes its working
directory and name, and sets the timers. The snapshot's content-addressed
name is unchanged, since the identity still hashes the image or
Dockerfile and the resources in whole gigabytes.
SandboxSpec has two variants, Local and Provider, and the worker and
the server preflight build the Provider one without knowing which kind
it is; provider_sandbox and attach_provider_sandbox connect any kind
through the single construction function and lay the repository out
where that kind keeps it. The per-provider constructors, the config
module, and the environment mapping module are gone, and the SDK
reference and the integration tests use the one constructor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes the sandbox-driver adoption: any provider a sandbox-driver plugin
executable serves can now host a fabro run, and fabro's own bundled
providers can be served the same way.
- `SandboxSpec::Plugin` builds a normalized driver spec from the
environment (image or Dockerfile source, or a provider-managed
directory; resources; network policy; labels; env) and lays fabro's
repository checkout out inside the provider's working directory. The
layout is recorded on the run through the new `workspace_layout` trait
method.
- Plugin settings on a bundled kind (`[server.sandbox.providers.docker]
path = ...`) serve that kind out of process through the driver's
executable; the config layer no longer rejects them.
- `ProviderAccess` carries the server's provider settings and the vault's
Daytona credentials to every reconnect: run resume, sandbox details,
terminals, previews, and the worker's start path. The worker receives
the settings through `StartServices`. No "plugin not wired" errors
remain.
- The CLI worker requires GitHub credentials only when a repository will
be cloned; a `none` target on a clone-based provider creates an empty
workspace and needs none.
- fabro-db tracks its migrations directory so a new migration file
recompiles the crate; the environment provider migration had been
silently missing from stale builds. Environment store 500s now log
their cause.
- The CLI workflow scenarios run against `host-plugin` (the driver's
Host executable under the non-bundled `host` kind) and `docker-plugin`
(the bundled `docker` kind served over stdio), each on an isolated
server, printing the server log on failure. A live Daytona gate runs
the native git clone over the JSON-RPC wire. A new CI job runs the
plugin scenarios and the driver-backed Docker integration tests with
the plugin executables built.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The `daytona` provider kind now maps onto the sandbox-driver Daytona
provider instead of fabro's own SDK client. Fabro keeps what is its own:
the HMAC-named snapshot built from the environment's image or Dockerfile,
the explicit 120 minute auto-stop default, the managed labels that gate
destructive operations, the clone decision and layout, and push
credentials. The driver creates the sandbox, clones natively, and serves
exec, files, search, terminal, SSH, preview, and VNC through its facets.
- `daytona.rs` builds the driver `SandboxSpec` (snapshot source,
`/home/daytona/workspace`, labels, timers, network policy, run name),
ensures the snapshot through the driver `SnapshotProvider`, attaches by
persisted id with fabro's label guard, and probes credentials through
the provider health check under fabro's 20 second budget.
- `DriverSandbox` gains a create plan that settles the spec right before
the provider call, records the snapshot a sandbox came from, and
reports the provider console URL on `Ready`.
- Terminals use the driver `Pty` facet; the server's SSH, preview, and
VNC endpoints use the `SshAccess`, `PreviewUrls`, and `Vnc` facets
through the driver-typed reconnect. The preview endpoint now answers
for every provider with a preview facet, so the local sandbox returns
its loopback URL.
- Daytona credentials travel as `DaytonaCredentials` built from the
vault key plus configured URL and organization; nothing reads the
process environment implicitly. The inventory registry uses the shared
`DriverInventoryProvider`.
- The SDK-based `daytona/mod.rs`, `provider/daytona.rs`, the Daytona
terminal, the `daytona` cargo feature, and the direct daytona-sdk,
git2 (in fabro-sandbox), tungstenite, and rustls dependencies are
gone. The live Daytona tests run against the driver-backed sandbox.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fabro's Docker provider kind now maps onto the sandbox-driver Docker
provider instead of its own bollard implementation. Fabro keeps what is
its own: the clone decision and repository layout, the GitHub App push
credentials embedded in origin, git retry classification, the managed
labels that gate destructive operations, and the run-facing events.
- `clone.rs` performs fabro's clone over the driver `Git` and `Exec`
facets. An exact commit goes through the driver's pinned clone; a tag
pin runs fabro's init/fetch/attach sequence through `Exec` so the
fully qualified tag ref is the only revision consulted. Network
failures retry through `git_retry`, which now classifies driver errors
(exec output, provider retryability) and never replays an operation
whose outcome is unknown.
- `DriverSandbox` gains a pending-create state, a `RepoWorkspace` with
push-credential state, path resolution against the cloned working
directory, git lifecycle methods, image-pull progress mapped from
driver events, and an embedded terminal over the driver `Pty` facet.
- `docker.rs` builds the driver `SandboxSpec` (image, `/workspace`,
fabro labels, env, cpu/memory, network policy, run name) and attaches
by persisted container id, refusing containers without fabro's labels.
- Inventory, details, diagnostics, terminal, and reconnect run over the
driver: a `DriverInventoryProvider` lists by fabro's managed label and
projects `SandboxStatus` into fabro's inventory and details shapes.
- The bollard-based `docker.rs`, `provider/docker.rs`, Docker terminal,
Docker error variants, the `docker` cargo feature, and the bollard and
tar dependencies are gone. The Docker integration tests, the agent
shell test, the workflow artifact test, and the driver benchmark run
against the driver-backed sandbox.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SandboxSpec::Local and run reconnect now build a DriverSandbox over the
sandbox-driver Host provider instead of fabro's own LocalSandbox, which
is deleted. fabro_sandbox::local_sandbox designates the working
directory (created when missing, never removed), creates the Host handle
in a per-process registry, and learns the platform up front. A local
sandbox reports no provider id: it is its directory, which the run
record already carries, so reconnect rebuilds the handle over that
directory rather than by id.
The credential filter for explicit environment variables and the Bash
readiness probe now come from the exec layer and the driver's activate
helper. Test call sites move to the async constructor; test factories
that must stay synchronous share the parent session's sandbox handle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SandboxProviderKind is now a validated string newtype instead of a
closed enum. The bundled kinds (local, docker, daytona) keep their
constants and a BundledProvider enum for the code paths that still
dispatch on them; any other well-formed sandbox-driver kind name is
accepted and names a plugin executable. EnvironmentProvider is gone:
environment settings carry SandboxProviderKind directly, and
is_clone_based is replaced by a workspace policy where local runs in a
designated directory and every other provider clones.
Server sandbox policy is keyed by kind. [server.sandbox.providers.<kind>]
accepts the bundled kinds with `enabled` and any plugin kind with its
launch settings (path, sha256, dev, args, env, inherit_env); bundled
kinds reject the plugin keys and a kind with no entry is disabled. The
OpenAPI schema, generated Rust and TypeScript clients, web settings
pages, and docs follow. The environments table drops its provider CHECK
enumeration in favour of the kind name rules so a plugin environment
can be stored.
Bundled-only code paths (run start, preflight, reconnect, terminal,
details) now fail with an explicit message for a plugin kind until the
driver construction function lands in the next step.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`DaytonaSnapshotSettings` carried two independent `Option`s (`image` and
`dockerfile`) that every consumer had to re-validate. Replace them with a
single `source: DaytonaSnapshotSource { Image, Dockerfile }` so the
both-set and neither-set states are unrepresentable at the sandbox layer.
This removes four unreachable error arms in `canonical_manifest` and
`create_snapshot_params`, the `.filter(...)` guard in `initialize`, and
the presence guard in `daytona_config_from_environment`. The
mutual-exclusion rule now lives only in fabro-config, which owns the
`image.docker` / `image.dockerfile` keys the old messages named.
Merge `ImageSnapshotManifest` into `SnapshotManifest` via a flattened
`SourceManifest` enum. The dockerfile case serializes to the same bytes
as before, so existing snapshot names are unchanged; the pinned identity
test still passes. Pin the image-case identity as well so a future
manifest change cannot silently orphan image snapshots.
Fold `validate_daytona_image_settings` into the existing Daytona arm of
`validate_provider_capabilities`; both callers already invoke it right
after `resolve_environment_fields`, so error order is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed node with an effective `succeed` policy and no explicit recovery
route now finishes as `succeeded` and follows normal success routing. The
original failure stays on the outcome so the stage.completed event and the
checkpoint keep the diagnostic, and the outcome notes record which scope
promoted it.
- OnFailure gains a Succeed variant; Node::on_failure resolves the
deprecated auto_status=true attribute as an alias, with an explicit
on_failure winning
- The core executor applies the policy before the lifecycle observes the
result, so the recorded outcome, context keys, goal gates, events, and
routing all see the effective outcome; this replaces AutoStatusLifecycle
- Explicit routes take priority: a matching condition, preferred label,
suggested next node, or handler jump keeps the outcome failed. A failed
outcome takes an unconditional edge only under route, so under succeed
any edge selection is an explicit route
- succeed applies only to failed, matching exit; the auto_status alias no
longer promotes partially_succeeded
- Parallel branches promote after their retry loop, so a failed succeed
branch counts as succeeded in the parent aggregate
- Validation accepts succeed and adds an auto_status_deprecated warning
that suggests on_failure="succeed"
- Document the policy table, semantics, and deprecation; add a changelog
entry
Closes#807
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A node can now set its own on_failure attribute to override the
graph-level failed-node routing policy in either direction: a
best-effort node can keep route inside an exit graph, and a critical
node can exit while the rest of the graph keeps the default. An absent
node attribute inherits the graph policy.
- Node::on_failure returns Option<OnFailure> so absence means inherit
- Graph::resolve_on_failure(node_id) is the single resolution point,
returning ResolvedOnFailure { policy, scope } so the executor's
end-of-run message names the scope that stopped routing
- The core Graph trait method becomes resolve_on_failure(node_id); the
graph-scope failure message is unchanged
- The failed-human-gate fallthrough block stays independent of a
node-level route override
- Validation now accepts and value-checks node-level on_failure (it
previously warned that node placement had no effect) and keeps the
edge-placement warning with updated wording
- Document precedence in transitions, failures, and the DOT reference,
and extend today's changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Chraa21RK7i2KqHdZSJLb8
- Dedup Diagnostic construction in the on_failure_valid rule
- Use the shared node_with_attrs test helper
- Drop an executor test that duplicated existing retry-target coverage
- Build on_failure integration test graphs from DOT and share a run
harness, exercising the parser path for valid on_failure values
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J6MnJri6oSEMZaYeADY5dP
Use /tmp/fabro/runtime for both Docker and Daytona instead of
provider-specific roots. A writable /tmp inside the sandbox is already
a dependency (commit-message files, exec stop-files), it needs no
root-level mkdir for non-root container users, and it makes the two
providers uniform.
The trailing runtime path component stays load-bearing: materialized
blobs at runtime/blobs/{hash}.json are recognized as managed blob
references and normalized back to blob:// in durable context.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Kmn5jyrdpyCdvcfvmEDvA
Remote prompt-value materialization wrote demoted values to
{working_directory}/.fabro/blobs inside the repository checkout, so a
later checkpoint could commit them and leak them into the run pull
request.
Give each sandbox a run-scoped runtime directory outside the source
checkout as part of the Sandbox contract:
- Sandbox::runtime_directory() names the directory; host-local
sandboxes return None because the engine owns a host-side runtime
directory (RunScratch) for those runs.
- Docker creates /fabro/runtime at initialize with umask 077 and
uploads runtime files with mode 0600.
- Daytona creates /home/daytona/fabro/runtime with mode 0700.
- Both remote materialization paths in fabro-workflow share one
materialization-path helper built on the new contract. The paths keep
the runtime/blobs suffix, so durable context still normalizes to
blob://sha256/... references.
- Local materialization now writes owner-private directories and files
on Unix.
Regression coverage: an integration test runs remote-style prompt
demotion against a real git checkout, then a real checkpoint commit,
and asserts the checkout stays clean, the agent-facing file is
readable, and a deleted materialized file is recreated from the
durable blob store. A real-Docker test verifies the runtime directory
and blob file permissions inside a container.
Fixes#798
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Kmn5jyrdpyCdvcfvmEDvA
test_blob_store was a process-wide OnceLock singleton over one in-memory
SQLite connection, so content-addressed rows written by one test were
visible to every other test in the same process. nextest's
process-per-test model masked the bleed, but plain cargo test failed
(8/24 in fabro-workflow-version) because negative existence assertions
became order-dependent.
test_blob_store now builds a fresh isolated in-memory store per call,
and test_database gives every database its own blob authority.
Reopen-style tests that model one durable blob authority across several
store handles use the new test_blob_store_at, which keeps the blob table
in a SQLite file beside the store directory, plus
test_database_with_blobs to share it explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blob activation cleanups:
- Reuse fabro-db's append_to_path, remove_file_if_exists, and
set_private_permissions instead of local duplicates.
- Return the store directly from activate_blob_storage; the report
wrapper existed only to be logged internally and then discarded.
- Collapse compute_disk_preflight to return the required free bytes
instead of echoing its inputs back through a struct.
- Deduplicate the "exactly one ok row" PRAGMA integrity_check protocol
into one executor-generic helper used by the backup and live checks.
- Skip re-validating a freshly published backup; the staging copy was
validated immediately before the atomic rename, so only a
concurrently published file needs its own validation.
- Replace the manual anyhow wrapping plus duplicate error log in
serve.rs with a plain .context(), matching other startup errors.
- Extract the disk-candidate enumeration in resource_sampler.rs that
available_space_for_path had copy-pasted from sample_disk_resources.
Test fixture cleanups:
- Route all hand-assembled Database::new(..., test_blob_store()) test
fixtures (32 sites) through fabro_store::test_support::test_database,
and make that helper infallible instead of returning an unconditional
Ok.
- Install the test blob schema from fabro_db::BLOBS_MIGRATION_SQL via a
test-support-gated optional dependency instead of a four-level
relative include_str! into fabro-db's migrations directory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply the cleanup findings from a four-angle review (reuse,
simplification, efficiency, altitude) of the demotion change:
- Share one size gate: serialized_if_over now backs both offload_value
and demote_value_for_prompt, restoring the cheap short-string and
scalar pre-checks so per-node demotion no longer serializes every
small value just to measure it.
- Stop re-writing blobs every node: materialize_value_bytes writes the
sandbox file directly from the in-hand bytes and short-circuits on the
content-addressed file's existence, so an already-demoted value costs
one existence probe instead of a store round-trip per node visit. The
local file write is shared with materialize_blob_ref.
- Demote over the resolved snapshot map instead of re-snapshotting a
Context copy, making the context and outcome loops symmetric and
saving a full deep clone per node; the fidelity lifecycle builds the
Context after the pass.
- Skip the pass entirely for Full and Truncate fidelities (nothing
renders context values), except parallel nodes whose branch stash may
render at a richer fidelity.
- Build is_preamble_hidden_key on is_engine_internal_key instead of
restating its prefixes, and call it directly from the preamble
renderer rather than through a wrapper.
- Document that outcome updates are demoted wholesale and that
BranchWorkItem.item carries the prompt-ready (possibly demoted) item;
drop the item rebinding and redundant test assertions; restore the
local integration test's confinement assertion and make the remote
one non-vacuous.
Skipped by choice: unifying the crate's several truncation helpers and
rendering the marker through the "See:" pointer family (cross-module
coupling out of proportion to the preview cosmetics), per-branch
demotion inside parallel.results (wholesale demotion is what bounds the
total), and cross-node demotion memoization (the file-existence
short-circuit already reduces repeats to a stat).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH8Jj9Y4E4Tu5g1jwDtHAb
Compact and summary preambles render workflow context values and stage
outputs with no per-value size limit. A late-run node inherits everything
the run has accumulated, and one oversized value (a join result, a jobs
list, a single-line command emit) can push the composed prompt past the
model's context window. A security-review run failed exactly this way:
its dedupe stage assembled a ~1.8M-token prompt against a 1M-token model
limit, made almost entirely of accumulated context the agent never
needed inline.
Reuse the existing blob machinery at the last mile. Before the preamble
builders run, any resolved context or outcome value whose serialized
JSON exceeds 8KB is persisted as a content-addressed blob, materialized
as a real file in the sandbox, and replaced with a small marker holding
a preview, the byte count, and the file path. The agent reads the file
if it needs the data. for_each items get the same treatment at fan-out
with a more generous 64KB budget, since the item is the branch's work
assignment; branch labels still come from the full item. Keys the
preamble never renders are left alone, and a value that fails to demote
stays inline and is logged: demotion bounds prompt size, it does not
gate execution.
The two downstream-resolution integration tests asserted that resolving
text values writes no files; demotion now legitimately materializes the
oversized response for preamble use, so they instead pin that resolution
returned the full inline text and that nothing is written outside the
sandbox blob directory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH8Jj9Y4E4Tu5g1jwDtHAb
build_branch_plan read the runtime array before run_branches looked at
`simulated`, so every for_each workflow failed under --dry-run with
"for_each source '...' was not found in workflow context". Nothing had
populated the key yet: upstream LLM nodes take Handler::simulate, which
returns no context updates.
A dry run now stands in one placeholder item when the source is absent or
unusable, and simulates the template target once. Graph-shape mistakes
still fail, since catching those is the point of a dry run.
Also from review:
- ITEM_FENCE_PREFIX replaces the bare "untrusted-" literal that
render_item_data and its test each spelled out.
- ItemRecordingHandler no longer guesses an item label by substring
search. Nothing asserted it, and the third item's label "2" matched
stray hex from the random fence tag about two thirds of the time.
- The twin test reads keys::PARALLEL_RESULTS instead of the raw string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Simplification pass over the for_each branch. No behavior change.
Share what was duplicated:
- Node::prompt_or_label replaces the "prompt, else label" fallback that
agent, prompt, and the for_each item injector each wrote out.
- context::lookup_flat replaces the "exact key, then strip context."
lookup that condition.rs had twice and the for_each source had again.
- is_llm_handler_type replaces the inline agent/prompt match, so the
runtime and the for_each_contract rule agree by construction.
- find_join_node now takes branch ids, so a for_each fan-out passes its
template target instead of needing find_join_for_target.
- collect_events moves to test_support; parallel and integration tests
shared one copy already.
- One ScriptedHandler replaces four test handlers that differed only in
what they returned.
Straighten the branch retry loop:
- Reserve the branch scope once before the loop instead of guarding it
with an Option, which removes three expect() calls.
- acquire_branch_permit and backoff_or_cancel replace the cancel-aware
select! blocks the loop repeated verbatim.
- Keep the match arms in Executor::execute_with_retry order so the two
loops stay easy to compare.
Drop redundant state:
- BranchPlan::is_for_each derives from template_target_id.
- for_each_contract checks node type before source shape, so one
mistake reports one diagnostic.
Prove the new wire fields survive the OpenAPI boundary: the fabro-api
round-trip fixture now carries index and item_label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restore the documented SDK env credential facade without reintroducing run fallback behavior. Fail closed on GitHub permission resolution, require worker storage at the CLI boundary, and align interpolation names and generated docs.
`ModelResolutionOptions` was a field-for-field duplicate of the existing
public `ModelResolutionTransform`, down to a verbatim copy of its `new()`.
`pipeline::transform` then unpacked one to rebuild the other, cloning the
catalog Arc and the eligible-provider set on the way.
- Delete `ModelResolutionOptions`. `TransformOptions.model_resolution` now
holds an `Option<ModelResolutionTransform>` directly, so the TRANSFORM
step is `resolution.apply(graph)?` with no rebuild and no clones. This
is consistent with `custom_transforms`, which already holds transforms.
- Add `ModelResolutionTransform::catalog()` so the VALIDATE step can reach
the same catalog for its lint rules. That is the only new code needed.
- Drop `CatalogScope` from `operations::validate`, which was a third copy
of the same fields. The three entry points now hand a partially built
transform to `validate_resolving_models`, which completes it with the
workflow's default provider once the workflow is resolved.
- Extract `validate_child_workflow` in `manager_loop`, collapsing two
near-identical validate-and-unwrap blocks.
- Point the transform tests at their own `transform_options()` helper via
struct-update syntax instead of respelling all seven fields, and drop a
HashSet -> Vec -> HashSet round trip from the create test helper.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up cleanup on the catalog-free validation split. Same behavior,
fewer parallel code paths.
- Make the catalog an explicit `Option<&Catalog>` on `pipeline::validate`
instead of a `validate` / `validate_with_catalog` pair, so each call
site states whether catalog rules run.
- Collapse `preprocess_and_validate`, `preprocess_and_validate_structural`,
and `preprocess` into one function that takes `TransformOptions`. Its
`model_resolution` field is now the single source of truth for catalog
awareness, which drops a 12-argument signature and the
`too_many_arguments` allow.
- Replace the duplicated resolve-and-preprocess block in
`operations::validate` with one `validate_in_scope` helper, and drop the
HashSet -> Vec -> HashSet round trip on the catalog path.
- Extract `configured_default_provider`, previously duplicated between
`operations::create` and `operations::validate`.
- Delete `validate_manifest_with_environment_defaults`, which had no
callers outside its own module.
- Share the `server-model.fabro` fixture between the two CLI tests instead
of inlining it twice. The validate test now asserts the rendered output
through the usual snapshot helper, which also removes a hand-rolled
`std::fs::write` and its clippy allow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>