Foundry

Foundry is an event-driven workflow engine for engineering automation. It replaces imperative shell scripts with composable task blocks connected by events, with throttle controlling how far each event ripples through the system.

Foundry runs as a daemon (foundryd) with a CLI controller (foundry) communicating over gRPC. Any emitter — a scheduled job, a webhook, a manual command — can fire an event and trigger the same downstream workflow.

For day-to-day engineering work, the main entry points are:

  • foundry task for one concrete objective in an isolated, reviewed worktree;
  • foundry campaign for a durable mission that derives one task at a time from live evidence;
  • foundry validate, iterate, and scout for project health and intent;
  • foundry run and sentinels for maintenance and scheduled operations.

Start with Getting Started, then read Tasks and Campaigns before authorizing autonomous coding work. The remaining chapters explain the event engine, workflow formations, operations, and wire-level reference.

How We Got Here

Foundry has been evolving for roughly four months. It started as a patchwork of shell scripts and launchd jobs — nightly maintenance automation that grew into a linear pipeline: iterate projects, audit for vulnerabilities, cut releases, install locally. Each step waited for all previous steps to complete. Projects couldn't run in parallel. An audit couldn't start until every project finished updating. A vulnerability discovered at 2pm had to wait for the 2am maintenance window.

As the scripts accumulated, the concepts started to clarify. Events, task blocks, throttle control, self-filtering — these patterns kept emerging from the scripts, so we started extracting them into something more intentional. Foundry is the result: a strongly typed Rust framework for building event-driven engineering workflows, replacing the fragile shell glue with compiler-checked event flows and composable task blocks.

Foundry decouples the work from the scheduling. The same task blocks that run during nightly maintenance can be triggered individually, at any time, with throttle controlling how deep the ripple goes.

Where We Are Today

Foundry has two layers, both defined in Rust:

  • Event library — the vocabulary of immutable facts that flow through the system. Each event type is a variant of the EventType enum with a well-defined payload structure.
  • Block library — the catalogue of reusable task blocks. Each block implements the TaskBlock trait, declaring which events it sinks on, what work it performs, and what events it emits.

All blocks are registered into a single engine at startup. Workflows are not declared anywhere — they emerge from the sink/emit relationships between blocks. The engine routes by event type; blocks self-filter by inspecting payload fields. This means a different entry event (or a different payload) activates a different subset of blocks, producing a different workflow — all from the same block library.

See Workflow Formations for the formations that exist today and the possibilities the current block library opens up.

Where We're Headed

Events and blocks will remain Rust-defined — they benefit from strong type safety and compile-time guarantees. The composition layer is what we intend to extract: allowing teams to declare which blocks participate in a formation and how events flow between them through configuration rather than code, enabling situational customisation without recompilation.

Key Ideas

  • Events are immutable facts — something happened. They carry a payload but have no opinion about what should happen next.
  • Task blocks are reusable units of work. Each block sinks on specific event types, does work, and emits new events.
  • Tasks isolate one coding objective, verify mechanical gates, obtain a skeptical typed review, and either land or preserve the result.
  • Campaigns hold a mission, evidence, context, owner policy, and a bounded cycle budget. They derive the next objective only after observing current repository state and the previous task result.
  • Throttle sits on a task block's output side. It controls whether Mutators execute: full (everything runs and propagates) or dry_run (Mutators are simulated, no side effects).
  • Workflows are compositions of task blocks wired together by events. They emerge from the emitter/sink relationships — not from a central orchestrator.

Charter

Mission

Provide a reliable, observable, event-driven engine for automating engineering workflows — from vulnerability remediation to dependency maintenance to release management.

Principles

  1. Events over orchestration. Work is triggered by events, not by position in a script. Any emitter can start any workflow.

  2. Composable task blocks. Small, reusable units of work. The same "Cut Release" block serves the vulnerability workflow and the maintenance workflow.

  3. Throttle controls depth. Every invocation declares how far the ripple should go. Audit without releasing. Release without installing. The same workflow, different throttle.

  4. Observability is paramount. Every event, every task block execution, every throttle decision is logged and traceable. If it happened, you can see it.

  5. Correctness through types. Rust's type system enforces exhaustive event handling, valid throttle states, and safe concurrency. Malformed events are compiler errors, not runtime surprises.

Scope

Foundry automates engineering workflows for a registered project portfolio:

  • Vulnerability detection and remediation
  • Dependency maintenance (iterate, maintain, commit, push)
  • Release management (tag, build, distribute)
  • Local tool installation
  • Release pipeline observation

It does not replace the existing evt-cli event logging system. Foundry emits events into the same JSONL intake files, coexisting with the current tooling. Over time, evt-cli may be rewritten in Rust to share Foundry's event type crate.

Non-Goals

  • General-purpose workflow engine (this serves specific engineering needs)
  • CI/CD replacement (Foundry orchestrates local work and observes pipelines)
  • Real-time monitoring dashboard (use Grafana, ops-visualizer, or similar)

Concepts

Events

An event records that something happened. It is an immutable fact appended to the event log. Events carry:

  • id — deterministic hash of content (same input always produces same ID)
  • event_type — what happened (e.g., vulnerability_detected)
  • project — which project this relates to
  • throttle — propagated through the chain, controls downstream behaviour
  • payload — event-type-specific data as JSON
  • occurred_at / recorded_at — timestamps

Events have no opinion about what should happen next. That's the job of task blocks.

Task Blocks

A task block is a unit of work. It has:

  • Name — human-readable identifier
  • KindObserver (reads/scans) or Mutator (writes/deploys)
  • Sinks — which event types trigger this block
  • Work — what it does when triggered
  • Emits — which events it produces on completion

Task blocks are registered with the engine at startup. When an event arrives, the engine finds all blocks that sink on that event type and executes them.

Observer vs Mutator

The distinction matters for throttle behaviour:

KindExamplesThrottle behaviour
ObserverAudit tag, audit main, validate projectAlways executes, always emits
MutatorCut release, install locally, commit+pushExecution and emission controlled by throttle

Throttle

The throttle sits on a task block's output side. After a block completes its work, it checks the throttle before emitting downstream events.

LevelObserversMutators
fullExecute + emitExecute + emit
dry_runExecute + emitSkip execution + simulate success

The throttle is set when an event is first emitted (e.g., via CLI) and propagated through the entire chain. This means a single command controls how deep the ripple goes.

Workflows

A workflow is not a first-class object in Foundry. It emerges from the emitter/sink wiring between task blocks. When you emit vulnerability_detected, the chain of task blocks that fire in response is the vulnerability remediation workflow.

This means workflows are composable by nature — adding a new task block that sinks on an existing event type automatically extends every workflow that produces that event.

Engine

The engine is the runtime that:

  1. Receives an event
  2. Finds task blocks that sink on that event type
  3. Checks throttle to decide whether to execute and/or emit
  4. Executes matching blocks
  5. Collects emitted events and feeds them back into step 2
  6. Continues until no more events are produced (the chain is complete)

The engine processes events depth-first within a single invocation.

Event Model

Foundry's event model defines the vocabulary of immutable facts that flow through the system. Events carry payloads describing what occurred but have no opinion about what should happen next — task blocks make those decisions.

Authoritative Source

The canonical event type definitions live in foundry-sdk/src/event.rs (EventType enum). The implementation roadmap for completing all task blocks and workflows is in IMPLEMENTATION_PLAN.md at the project root.

Event Categories

Run Lifecycle

EventEmitterPurpose
maintenance_run_startedOrchestrator / manualBegins a per-project maintenance chain
maintenance_run_completedOrchestrator (fan-in)All projects finished; carries aggregate results

Per-Project: Iterate / Maintain

EventEmitterPurpose
project_validation_completedValidateProjectPre-flight check (dir, branch, gates)
project_iteration_completedRouteGateResultOne structural improvement attempted
project_maintenance_completedRouteGateResultDependencies updated, gates verified
project_changes_committedCommitAndPushGit commit created
project_changes_pushedCommitAndPushPushed to remote

Per-Project: Release Audit

EventEmitterPurpose
release_tag_auditedAuditReleaseTagLatest tag scanned for vulnerabilities
main_branch_auditedAuditMainBranchMain branch checked for same vulnerability
release_requestedAudit chainIntent to cut a patch release
release_completedCutReleaseTag pushed

Vulnerability Remediation

EventEmitterPurpose
vulnerability_detectedExternal / nightly auditEntry point for remediation workflow
remediation_startedRemediateVulnerabilityFix attempt underway
remediation_completedRemediateVulnerabilityFix attempt finished (success or failure)

Distribution Pipeline

EventEmitterPurpose
release_pipeline_completedWatchPipelineGitHub Actions finished building and publishing
local_install_completedInstallLocallyTool reinstalled on local machine

Event Structure

Every event has:

  • id — Deterministic SHA256 hash of (type + project + occurred_at + payload)
  • event_type — One of the EventType enum variants
  • project — Which project this event relates to
  • occurred_at / recorded_at — When it happened vs. when it was logged
  • throttle — Propagated through the chain to control downstream behaviour
  • payload — Event-type-specific JSON data

Payload Conventions

Downstream blocks read payload fields to make routing decisions (self-filtering). The engine routes by event type only — it cannot inspect payloads.

FieldUsed ByValues
vulnerableAuditMainBranchtrue/false — whether the tag has known CVEs
dirtyRemediateVulnerability, CutReleasetrue/false — whether main still has the vulnerability
cveAll vulnerability blocksCVE identifier string
statusDownstream blocks"ok"/"error" — validation and completion status
has_changesCommitAndPushWhether there are uncommitted changes to persist

Tracing

Foundry's tracing system gives every event in the system a place inside a nested causal tree. When a maintenance cycle kicks off, every workflow it spawns, every block those workflows dispatch, and every subprocess those blocks launch can be reconstructed as a hierarchy — not just a flat list of events that share a trace ID.

The model is borrowed directly from OpenTelemetry. If you have read OTel's spec on traces, spans, and context propagation, the shapes will feel familiar. The Foundry-specific parts are which events open new spans and how the engine stamps that lineage onto every emitted event.

Why Nested Tracing

Earlier versions of Foundry tagged events with a single trace_id. That worked when each invocation produced one linear chain, but it broke down the moment a cycle fanned out across multiple projects or a workflow launched sub-workflows.

The flat model could answer "what happened in this run?" but not:

  • Which project run owns this block execution?
  • What sub-workflows did this iteration spawn?
  • If a remediation failed three levels deep, what triggered it?

The nested model adds two more identifiers — span_id and parent_span_id — on top of the existing trace ID. The same trace can now be rendered as a tree, with each branch corresponding to a logical scope of work.

The Four Span Levels

Foundry recognizes four levels of span nesting. Every event carries the identifiers of the deepest span active when it was emitted.

LevelSpan opens atSpan closes at
CycleMaintenanceCycleStarted eventMaintenanceCycleCompleted event
Project runProjectRunStarted eventProjectRunCompleted event
WorkflowA *Requested or *Started opener eventMatching completion event
BlockEngine dispatches a blockBlock returns

A typical maintenance run produces a tree shaped roughly like this:

flowchart TD
    Cycle[Cycle span<br/>MaintenanceCycleStarted]
    Cycle --> RunA[Project run span<br/>ProjectRunStarted: foundry]
    Cycle --> RunB[Project run span<br/>ProjectRunStarted: epilogue-tracker]
    RunA --> WfA1[Workflow span<br/>ProjectIterationRequested]
    RunA --> WfA2[Workflow span<br/>ProjectMaintenanceRequested]
    WfA1 --> BlockA1[Block span<br/>Assess Project]
    WfA1 --> BlockA2[Block span<br/>Execute Plan]
    BlockA2 --> WfA3[Workflow span<br/>InnerIterationStarted]

Not every Foundry invocation uses all four levels. A bare foundry emit greet_requested only produces a workflow span and the block spans beneath it — no cycle or project run wraps it.

Campaigns use the same structure with a different boundary. Each manual or automatic CampaignAdvanceRequested is a span opener and mints a fresh cycle span under the campaign run's trace. Task-side events carry a separate campaign_cycle domain field in addition to tracing context. The field makes the cycle directly queryable from persisted events even when concurrent campaigns target the same project or events arrive out of timestamp order.

Identifiers

Every event carries up to five optional identifiers that locate it in the trace tree and any fan-out coordination:

  • trace_id — 32-character lowercase hex string (OTel format). Identifies the entire causal tree. All events under one cycle share the same trace_id.
  • span_id — 16-character lowercase hex string. Identifies one specific span. Multiple events under the same workflow share a span_id.
  • parent_span_id — 16-character lowercase hex string, or None for the root span of a trace. Points at the span that contains this one.
  • causation_id — the id of the event that triggered the block which emitted this one, or None for a root event. Points at the direct causal parent in the event graph.
  • gather_id — identifies the fan-out (scatter/gather) group an event belongs to, or None when the event is not part of a fan-out. Propagates verbatim like trace_id (see below).

A canonical event therefore carries something like:

trace_id        4bf92f3577b34da6a3ce929d0e0e4736
span_id         00f067aa0ba902b7
parent_span_id  b9c7c989f97918e1
causation_id    evt_a1b2c3d4e5f6
gather_id       gth_9f8e7d6c5b4a

Spans Versus Causation

trace_id, span_id, and parent_span_id describe observability structure — how work nests for the purpose of rendering trace trees. causation_id describes domain causality — precisely which event caused which. The two often parallel each other, but they are distinct: a span groups many peer events under one workflow, whereas causation_id records the single edge from a trigger to each event a block emits in response. Coordination logic (fan-out/fan-in) relies on causation, not spans.

Legacy Trace IDs

Events emitted by Foundry prior to OTel-shaped tracing carry IDs in the trc_<uuid> form. These remain valid and queryable — Foundry continues to recognize them via foundry_sdk::is_legacy_trace_id, which returns true for any ID starting with trc_. Tooling that renders trace trees falls back to a flat chronological view when it detects a legacy trace.

The Two Stamping Rules

The engine stamps tracing fields onto every emitted event using two rules. Together they preserve the parent/child relationship without requiring task blocks to think about it.

Default rule — peers under the same workflow span

When a block emits an event whose type is not a registered span opener, the new event inherits the trigger event's (trace_id, span_id, parent_span_id) triple verbatim. Every event emitted inside one workflow is a peer under the same workflow span.

flowchart LR
    T[charter_validated<br/>span: W1] --> B([Assess Project])
    B --> E[project_assessed<br/>span: W1]

The block runs inside its own block span, but the event it emits is stamped with the workflow span (W1) so siblings stay flat under the workflow rather than burrowing into per-block sub-spans.

Span-opener rule — mint a new child workflow

When a block emits an event whose type is a registered span opener, the engine mints a fresh span_id and stamps the new event with:

  • trace_id = unchanged from the trigger
  • span_id = newly minted
  • parent_span_id = the emitting block's own span ID

The result is a new workflow span hanging off the emitting block. The parent linkage points at the block, not at the trigger event, because the block is what caused the new workflow to exist.

flowchart LR
    T[project_iteration_completed<br/>span: W1] --> B([Route Project Workflow<br/>block span: B1])
    B --> E[maintenance_requested<br/>span: W2<br/>parent: B1]

This is the only place fresh span IDs appear during execution. Trace IDs are minted only at the very top — when a brand new top-level event enters the system.

Causation stamping

Alongside the two span rules, the engine stamps causation_id on every emitted event, setting it to the id of the triggering event. This is unconditional — it does not depend on whether the emitted event is a span opener — because causation tracks the domain edge from trigger to emitted event regardless of how spans nest. Like the span fields, stamping is "set if unset": a block that emits an event with an explicit causation_id keeps it. Root events entering the engine directly carry no causation_id.

Gather-ID propagation

The engine also propagates gather_id onto every emitted event, inheriting it verbatim from the trigger — the same rule as trace_id, and deliberately unlike causation_id. A scattered child workflow may run many causal hops deep, crossing span-opener boundaries; carrying the gather_id unchanged all the way down means the child's terminal *Completed event still identifies the fan-out group it belongs to, so the engine can count it toward the gather. Stamping is "set if unset", and an event outside any fan-out simply carries None.

Span-Opener Registry

The span-opener registry is implemented as EventType::is_span_opener in foundry-sdk. The current openers are:

  • Cycle and project-run openers
    • MaintenanceCycleStarted
    • ProjectRunStarted
  • Workflow request openers
    • ProjectIterationRequested
    • ProjectMaintenanceRequested
    • ExecutionRequested
    • CampaignAdvanceRequested
    • ValidationRequested
    • DriftAssessmentRequested
    • ReleaseRequested
    • PipelineCheckRequested
    • GreetingRequested
    • MaintenanceSummaryRequested
  • Explicit lifecycle openers
    • RemediationStarted
    • StrategicCycleStarted
    • InnerIterationStarted
    • CommitDigestStarted
    • OpsDigestStarted
    • SupplyChainScanStarted

Compiler-enforced exhaustiveness

is_span_opener is an exhaustive match with no wildcard arm. Every EventType variant is classified as opener (true) or non-opener (false) at this single authoritative site. Adding a new EventType variant without classifying it here is a compile error — the compiler, not discipline, keeps the classification complete.

To add a new opener, edit the match in is_span_opener and move the new variant from the false arm group to the true arm group. No other tracing code needs to change — the engine reads the result at emit time.

[EventType::Custom] is always false here; third-party workflows that need a custom root event to open a span can register it at runtime via Engine::with_span_openers.

To decide whether a new event type belongs in the opener set, ask: does this event represent the start of a logically distinct unit of work whose internal events should be grouped together? If yes, add it to the true arm. If the event is just one step in an existing workflow, leave it in the false arm.

Subprocess Propagation

Workflows often launch external processes — shell commands and AI agent streams. To keep their work attached to the right span, Foundry injects a W3C Trace Context traceparent header as a TRACEPARENT environment variable on every spawned process.

The format is:

00-<trace_id>-<span_id>-01
  • 00 — version byte
  • <trace_id> — the current trace's 32-hex-char ID
  • <span_id> — the currently active block span's 16-hex-char ID
  • 01 — trace flags (sampled)

Injection happens transparently:

  • foundryd::shell::run injects TRACEPARENT into every spawned command if a span context is active.
  • foundryd::agent_stream injects the same variable when streaming an AI agent.

When a subprocess turns around and runs foundry emit, the CLI reads TRACEPARENT from its environment, parses it, and stamps the emitted event with parent_span_id set to the block's span. The new event arrives back at the daemon already correctly parented — no manual plumbing required from inside the subprocess.

If no span context is active (for example, the daemon is starting up), no TRACEPARENT is injected and no value leaks into spawned processes.

Querying Spans

Two gRPC RPCs on foundryd give clients access to trace data:

  • Trace — returns every event sharing a given trace_id. The whole tree, in one response.
  • Span — returns events and block executions belonging to a single span_id. Just that subtree.

foundry trace

The CLI command for inspecting a trace is foundry trace <event-id>. Given any event ID, it resolves the trace that event belongs to and renders it.

By default the output is the nested span tree — cycle at the root, project runs underneath, workflows nested inside, and block executions at the leaves. Each event is rendered under the span it belongs to.

For legacy traces, or when explicit chronology is more useful than hierarchy, pass --flat:

foundry trace <event-id> --flat

Flat mode renders events in chronological order without any span nesting, which matches the original behaviour of the command before nested tracing existed.

foundry status --span

foundry status lists active workflows. To narrow that listing to a specific span — for example, "what's currently running inside this project run?" — pass --span:

foundry status --span <span-id>

This is the fastest way to drill down from a known span without fetching the whole trace tree. If the span belongs only to a block execution and has no events of its own, the daemon still returns the owning trace_id, so foundry status --span narrows the active workflow list to that trace instead of falling back to an unfiltered listing.

Legacy Traces

Foundry retains read-side compatibility with traces emitted before the nested model existed. Specifically:

  • Events with trc_* IDs (the pre-OTel format) are still queryable. foundry_sdk::is_legacy_trace_id identifies them, and both the Trace and Span RPCs accept them.
  • foundry trace detects legacy traces — events that carry a trace_id but no span_id — and automatically falls back to the --flat chronological view, since there is no span structure to render.

A migration script lives at scripts/migrate-event-names.sh. It renames event types in older event logs to match the current taxonomy but does not backfill span_id or parent_span_id onto historical events. The information needed to reconstruct the nested structure after the fact simply is not present in old logs, and any inferred reconstruction would be guesswork.

The practical effect is that history is preserved and queryable, but only events emitted after the upgrade carry full span structure. New runs render as trees; older runs render as flat chronologies.

Worked Example

Consider a maintenance run for a single project. The cycle starts, a project run is opened, iteration is requested, the iteration completes, and the engine routes through to a maintenance request that also completes. The resulting span tree is:

flowchart TD
    M[MaintenanceCycleStarted<br/>span: C1, parent: none]
    P[ProjectRunStarted: foundry<br/>span: R1, parent: C1]
    I[ProjectIterationRequested<br/>span: W1, parent: R1]
    IC[ProjectIterationCompleted<br/>span: W1, parent: R1]
    Mreq[ProjectMaintenanceRequested<br/>span: W2, parent: B1 from W1]
    Mc[ProjectMaintenanceCompleted<br/>span: W2, parent: B1 from W1]
    PC[ProjectRunCompleted: foundry<br/>span: R1, parent: C1]
    MC[MaintenanceCycleCompleted<br/>span: C1, parent: none]

    M --> P --> I --> IC --> Mreq --> Mc --> PC --> MC

Notice that ProjectIterationCompleted sits inside the same span (W1) as ProjectIterationRequested — the default stamping rule — while ProjectMaintenanceRequested mints a new workflow span (W2) parented to whichever block emitted it, because ProjectMaintenanceRequested is a registered span opener.

That hierarchy is what foundry trace renders, and it is what makes "who triggered this?" answerable from a single command.

Task Block Library

Task blocks are the reusable processing units of Foundry. Each block is defined once and can participate in multiple workflows.

Implementing the TaskBlock Trait

Every task block implements foundry_sdk::task_block::TaskBlock:

#![allow(unused)]
fn main() {
pub trait TaskBlock: Send + Sync {
    fn name(&self) -> &'static str;
    fn kind(&self) -> BlockKind;
    fn sinks_on(&self) -> &[EventType];
    fn execute(
        &self,
        trigger: &Event,
    ) -> Pin<Box<dyn Future<Output = anyhow::Result<TaskBlockResult>> + Send + '_>>;

    // Optional — defaults to no retries
    fn retry_policy(&self) -> RetryPolicy {
        RetryPolicy::default()
    }
}
}

The trait provides default implementations for should_emit() and should_execute() based on kind() and the throttle level. Override retry_policy() to enable automatic retry of transient failures.

See the Writing Task Blocks guide for step-by-step instructions and a full example including RetryPolicy.

Foundry records block_started and block_completed observations around every block execution. These observations are persisted and streamed for operational auditability, but are not routed through sinks_on() and can never trigger another task block. The daemon applies the same durability rule to every event published on Watch, including observations emitted outside the engine.

Current Blocks

Hello-World (validates engine mechanics)

BlockKindSinks OnEmits
Compose GreetingObservergreet_requestedgreeting_composed
Deliver GreetingMutatorgreeting_composedgreeting_delivered

Vulnerability Remediation

These blocks form two paths through the vulnerability remediation workflow. Both Remediate Vulnerability and Cut Release sink on main_branch_audited and self-filter based on the dirty flag in the payload — only one path fires per event.

Audit Release Tag also sinks on project_changes_pushed to perform a post-push re-audit, confirming the fix is clean before anything downstream acts.

BlockKindSinks OnEmitsSelf-filters
Scan DependenciesObserverscan_requestedvulnerability_detected
Audit Release TagObservervulnerability_detected, project_changes_pushedrelease_tag_auditedSkips post-push when project not in registry
Audit Main BranchObserverrelease_tag_auditedmain_branch_auditedSkips when vulnerable=false
Remediate VulnerabilityMutatormain_branch_auditedremediation_completedOnly when dirty=true
Commit and PushMutatorremediation_completed, project_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushedSkips when tree is clean or changes=false
Cut ReleaseMutatormain_branch_auditedrelease_completedOnly when dirty=false
Watch PipelineMutatorrelease_completedrelease_pipeline_completed
Install LocallyMutatorproject_changes_pushed, release_pipeline_completedlocal_install_completed

Maintenance

The maintenance workflow uses an explicit routing Observer (Route Project Workflow) to delineate which sub-workflow runs. This keeps each downstream block focused on a single responsibility.

BlockKindSinks OnEmitsSelf-filters
Validate ProjectObservermaintenance_run_startedproject_validation_completedSkips projects not in active registry
Route Project WorkflowObserverproject_validation_completediteration_requested or maintenance_requestedStops when status != "ok" or no actions enabled
Commit and PushMutatorproject_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushedSkips when tree is clean
Audit Release TagObserverproject_changes_pushedrelease_tag_auditedSkips when project not in registry

The actions.maintain flag is forwarded inside the iteration_requested payload so that the gate routing can chain directly to maintenance_requested after a successful iteration without re-querying the project configuration.

Gate Orchestration

These blocks provide native gate resolution, execution, and routing for iterate, maintain, and validation workflows.

BlockKindSinks OnEmitsSelf-filters
Resolve GatesObserveriteration_requested, maintenance_requested, validation_requestedgate_resolution_completed
Run Preflight GatesObservergate_resolution_completedpreflight_completedSkips maintain workflow
Run Verify GatesObserverexecution_completedgate_verification_completed
Route Gate ResultObservergate_verification_completedproject_iteration_completed / project_maintenance_completed / retry_requestedRoutes based on pass/fail and retry count
Route Validation ResultObserverpreflight_completedvalidation_completedOnly handles validate workflow

Iterate Workflow

These blocks form the native iterate chain, running inside the gate orchestration lifecycle.

BlockKindSinks OnEmitsSelf-filters
Check CharterObserverpreflight_completedcharter_validatedOnly handles iterate workflow
Assess ProjectMutatorcharter_validatedproject_assessed
Triage AssessmentMutatorproject_assessedassessment_triaged
Create PlanMutatorassessment_triagedplan_created
Execute PlanMutatorplan_createdexecution_completed

Maintain Workflow

BlockKindSinks OnEmitsSelf-filters
Execute MaintainMutatorgate_resolution_completedexecution_completedOnly handles maintain workflow
Retry ExecutionMutatorretry_requestedexecution_completed
Summarize ResultObserverproject_iteration_completed, project_maintenance_completedgenerate_summary

Validation Workflow

A dedicated read-only workflow for checking project gate health. No Mutator blocks are involved — validation never modifies code.

validation_requested
  → Resolve Gates → gate_resolution_completed
    → Run Preflight Gates → preflight_completed
      → Route Validation Result → validation_completed

Vulnerability Workflow Chain

flowchart TD
    A[scan_requested] --> B([Scan Dependencies])
    B --> C[vulnerability_detected]
    C --> D([Audit Release Tag])
    D --> E[release_tag_audited]
    E --> F([Audit Main Branch])
    F --> G[main_branch_audited]
    G --> H{dirty?}
    H -->|dirty=true| I([Remediate Vulnerability])
    I --> J[remediation_completed]
    J --> K([Commit and Push])
    K --> L[project_changes_committed]
    K --> M[project_changes_pushed]
    M --> N([Audit Release Tag post-push])
    N --> O[release_tag_audited]
    M --> P([Install Locally])
    P --> Q[local_install_completed]
    H -->|dirty=false| R([Cut Release])
    R --> S[release_completed]
    S --> T([Watch Pipeline])
    T --> U[release_pipeline_completed]
    U --> V([Install Locally])
    V --> W[local_install_completed]

Maintenance Workflow Chain

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C([project_validation_completed])
    C --> D[[Route Project Workflow]]
    D -->|iterate=true| E([iteration_requested])
    D -->|iterate=false, maintain=true| F([maintenance_requested])
    D -->|no actions| G([end — no automation])
    E --> H[[Resolve Gates]]
    H --> I[[Run Preflight Gates]]
    I --> J[[Check Charter]]
    J --> K[[Assess Project]]
    K --> L[[Triage Assessment]]
    L --> M[[Create Plan]]
    M --> N[[Execute Plan]]
    N --> O[[Run Verify Gates]]
    O --> P[[Route Gate Result]]
    P -->|pass| Q([project_iteration_completed])
    P -->|fail, retries left| R[[Retry Execution]]
    Q -->|maintain=true| F
    F --> S[[Resolve Gates]]
    S --> T[[Execute Maintain]]
    T --> U[[Run Verify Gates]]
    U --> V[[Route Gate Result]]
    V -->|pass| W([project_maintenance_completed])
    V -->|fail, retries left| X[[Retry Execution]]

Gateway Pattern

Every block that executes an external process (a shell command or an audit tool) receives its I/O capability through a gateway trait rather than calling the implementation module directly. Two gateway traits live in gateway.rs:

  • ShellGateway — wraps crate::shell::run for arbitrary command execution.
  • ScannerGateway — wraps crate::scanner::run_audit for vulnerability scanning.

Production blocks hold an Arc<dyn ShellGateway> (and/or Arc<dyn ScannerGateway>) which is initialised to the real implementation in new(). A #[cfg(test)] constructor accepts a fake instead, enabling hermetic unit tests for every block.

This separation means:

  • The happy path and every failure/edge-case branch can be tested without spawning real processes.
  • shell.rs and scanner.rs stay untouched; the gateway is a thin adapter.
  • No async_trait macro is required — the return type uses an explicit Pin<Box<dyn Future + Send + '_>> (the same pattern as TaskBlock::execute).

See Testing with Fakes for usage examples.

RetryPolicy

Blocks can declare automatic retry behaviour by overriding retry_policy():

#![allow(unused)]
fn main() {
use std::time::Duration;
use foundry_sdk::task_block::RetryPolicy;

fn retry_policy(&self) -> RetryPolicy {
    RetryPolicy {
        max_retries: 3,
        backoff: Duration::from_secs(5),
    }
}
}

max_retries: 0 (the default) means the block runs exactly once. With max_retries: N, the engine retries up to N times after any failure (either a returned Err or a TaskBlockResult { success: false, .. }), sleeping backoff between each attempt. The final result (success or failure) is what the engine records in the BlockExecution trace.

Crate Structure

Foundry is organised as a Cargo workspace with five crates:

foundry/
├── Cargo.toml              # Workspace root
├── proto/foundry.proto     # gRPC service definition
├── crates/
│   ├── foundry-sdk/        # Stable SDK contract (library)
│   ├── foundry-engine/     # Core event processing engine (library)
│   ├── foundry-blocks/     # Built-in task block implementations (library)
│   ├── foundryd/           # Daemon (binary)
│   └── foundry-cli/        # CLI controller (binary)
└── book/                   # This documentation

foundry-sdk

Shared types used by both the daemon and CLI:

  • event.rsEvent struct, EventType enum, deterministic ID generation
  • throttle.rsThrottle enum (Full, DryRun)
  • task_block.rsTaskBlock trait, BlockKind, TaskBlockResult, RetryPolicy
  • registry.rsRegistry, ProjectEntry, ActionFlags, Stack, InstallConfig
  • trace.rsTraceIndex, BlockExecution, ProcessResult — the structured types used to persist and display execution traces. Moved here from foundryd so the CLI can deserialise on-disk traces without depending on the daemon crate.

This crate has no async runtime dependency. It defines the vocabulary that the rest of the system speaks.

foundryd

The daemon process. Listens on gRPC (127.0.0.1:50051 by default, override with FOUNDRYD_LISTEN_ADDR) and runs the workflow engine.

Core engine

  • engine.rs — event router: matches events to task blocks, executes them with retry logic, propagates emitted events respecting the throttle level. Exposes BlockExecution and ProcessResult for structured telemetry.
  • service.rs — gRPC service implementation (Emit, Status, Watch, Trace)

Daemon support modules

  • orchestrator.rs — coordinates per-project maintenance runs with concurrency control. Dispatches MaintenanceRunStarted per project, enforces max_concurrent via a semaphore, and prevents double-running via an active project set with a drop-guard cleanup.
  • event_writer.rs — appends every event to monthly JSONL files (YYYY-MM.jsonl) inside ~/.foundry/events/ (or FOUNDRY_EVENTS_DIR). Crash-safe: each write opens, flushes, and closes the file. A Mutex serializes concurrent writes.
  • trace_store.rs — in-memory store of recent ProcessResult chains, keyed by root event ID. Used for fast Trace RPC lookups of workflows still in progress or recently completed.
  • trace_writer.rs — persists completed ProcessResult objects to disk as pretty-printed JSON files under ~/.foundry/traces/YYYY-MM-DD/{event_id}.json. Traces written here survive daemon restarts indefinitely and are read by foundry history and foundry trace when the in-memory store has no match.
  • workflow_tracker.rs — tracks workflows that are currently being processed by background tasks. Thread-safe via RwLock. Each Emit RPC inserts an ActiveWorkflow entry on start; a RAII WorkflowGuard removes it on completion or panic. The Status RPC reads this tracker to show live in-flight workflows.
  • shell.rs — async shell runner used by block implementations. Runs an external command with configurable timeout (default 5 min), captures stdout and stderr, and returns a CommandResult.
  • scanner.rs — vulnerability scanner abstraction. Dispatches to the stack-appropriate tool (cargo audit, npm audit, pip-audit, mix deps.audit) and normalizes output into a Vec<Vulnerability>.
  • gateway.rs — I/O abstraction layer for task blocks. Defines ShellGateway and ScannerGateway traits with ProcessShellGateway and ProcessScannerGateway production implementations. Also provides FakeShellGateway and FakeScannerGateway test doubles (available under #[cfg(test)] only) that record invocations and return pre-configured results, enabling hermetic unit testing of every block without spawning real processes.
  • summary.rs — renders a MaintenanceRunSummary as a Markdown report (project table with success/failure/skipped, a failures section, and timing statistics).

Task block implementations (blocks/)

  • validate.rsValidateProject: pre-flight checks before a maintenance run
  • resolve_gates.rsResolveGates: reads .hone-gates.json and emits gate definitions
  • run_preflight_gates.rsRunPreflightGates: runs gates on unmodified codebase
  • run_verify_gates.rsRunVerifyGates: runs gates after code changes
  • route_gate_result.rsRouteGateResult: routes pass/fail to completion or retry
  • route_validation_result.rsRouteValidationResult: routes validation-only results
  • check_charter.rsCheckCharter: validates project charter before iteration
  • assess_project.rsAssessProject: AI-driven project assessment
  • triage_assessment.rsTriageAssessment: prioritises assessment findings
  • create_plan.rsCreatePlan: generates an execution plan from triaged findings
  • execute_plan.rsExecutePlan: executes the generated plan
  • execute_maintain.rsExecuteMaintain: runs maintenance tasks
  • retry_execution.rsRetryExecution: retries failed executions with context
  • summarize_result.rsSummarizeResult: generates workflow summary and traces
  • git_ops.rsCommitAndPush: stages, commits, and optionally pushes changes
  • audit.rsAuditReleaseTag, AuditMainBranch: vulnerability scanning
  • release.rsCutRelease, WatchPipeline: tagging and CI monitoring
  • install.rsInstallLocally: reinstalls the project locally after a fix
  • remediate.rsRemediateVulnerability: invokes the AI agent to fix a CVE
  • scan.rsScanDependencies: scans for known vulnerabilities
  • greet.rsComposeGreeting, DeliverGreeting: hello-world engine validation

foundry-cli

The CLI controller. Connects to foundryd over gRPC.

  • main.rsclap-based argument parsing; subcommands: emit, status, watch, trace, run, history, registry
  • commands.rs — async implementations of each subcommand via tonic gRPC client; also contains the history command which reads on-disk traces directly from ~/.foundry/traces/ without a daemon connection
  • registry_commands.rs — registry CLI handlers. init is explicit offline-only recovery; list, show, add, remove, and edit use typed gRPC against foundryd by default and touch ~/.foundry/registry.json only when --offline is set

proto/foundry.proto

The gRPC contract between CLI and daemon:

  • Emit — fire an event with type, project, throttle, and optional JSON payload
  • Status — query active workflow states (all or by workflow ID)
  • Watch — server-side streaming of live events, filterable by project
  • Trace — retrieve the full event chain and block execution records for a completed workflow

Getting Started

This guide takes Foundry from installation to a registered project and a first evidence-reviewed task. For the underlying event model, continue to Concepts afterward.

Install

Homebrew

brew tap svetzal/tap
brew install foundry

From source

Source builds require Rust 1.85 or newer and the Protocol Buffers compiler.

brew install protobuf
git clone https://github.com/svetzal/foundry.git
cd foundry
./install.sh

install.sh builds and installs both binaries to ~/.cargo/bin. On macOS it also gives them stable ad-hoc signing identifiers. Use the script for subsequent source upgrades so macOS privacy grants remain attached to the binaries.

The installation contains:

  • foundryd — the long-running workflow daemon;
  • foundry — the CLI controller.

Verify the CLI:

foundry --version

Start the Daemon

Run the daemon in a terminal:

foundryd

By default it listens on 127.0.0.1:50051, loads project, campaign, and sentinel state from ~/.foundry/, and registers the production task-block library.

To expose the daemon on a trusted LAN, set FOUNDRYD_LISTEN_ADDR at startup:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051 foundryd

The CLI resolves its daemon URL by precedence: explicit --addr, then FOUNDRY_DAEMON_ADDR, then http://127.0.0.1:50051. See Trusted-LAN Control Plane for the plaintext networking model and the Mac-to-mojility-ops-01 migration runbook.

The repository also includes service definitions for unattended operation:

Do not run foundryd as root. It needs the same repositories, agent credentials, GitHub credentials, and ~/.foundry state as the user who owns the workspaces.

Register a Project

Open another terminal and add a Git checkout:

foundry registry add \
  --name my-project \
  --path /absolute/path/to/my-project \
  --stack rust \
  --agent codex \
  --repo owner/my-project \
  --branch main \
  --iterate \
  --maintain \
  --push

The online command updates daemon-owned state through gRPC and persists it to ~/.foundry/registry.json. Confirm the result:

foundry registry show my-project

See The Project Registry for every field, action flag, skip reason, install strategy, and explicit offline recovery.

Establish Quality Gates

Ask Foundry to inspect the repository and write .hone-gates.json:

foundry gates --init my-project

Review that file in the project, then run the gates without changing code:

foundry validate my-project

Required gates are the mechanical safety boundary for task landing. A project should have at least one meaningful required gate before it accepts autonomous mutations.

Run One Task

Use a task for one concrete, immediately executable objective:

foundry task my-project \
  "Add a --quiet flag and prove it suppresses progress output"

Foundry creates an isolated worktree, runs the coding agent, executes the project gates, performs a separate skeptical review, and returns one typed verdict. Passing complete work lands on the registered branch. Safe, converging remainder work can also land when required gates passed; defects and blocked work are preserved without reaching trunk.

See Tasks and Campaigns for verdicts, preservation, landing rules, and broader multi-cycle missions.

Inspect the Workflow

Convenience commands stream block progress and render the completed trace. You can return to it later:

foundry history --project my-project
foundry trace <event-id>
foundry trace <event-id> --verbose

While work is running:

foundry status
foundry watch --project my-project

Traces survive daemon restarts under ~/.foundry/traces/.

Try a Dry Run

Throttle lets a workflow retain its observation and routing behaviour while simulating mutators:

foundry run --project my-project --throttle dry_run

Observers execute normally. Mutators emit their simulated success events without changing repositories or external systems. See Throttle Control for the execution rules.

Next Steps

  • Use foundry campaign when the mission is broader than one task and the next objective should be derived from current evidence.
  • Use foundry iterate for charter-driven quality improvement.
  • Use foundry scout for read-only intent-drift discovery.
  • Use foundry run for registered maintenance actions.
  • Inspect Sentinels for scheduled maintenance and digest workflows.
  • Read Workflow Formations to understand how events activate the shared task-block library.

Build Foundry Itself

Contributors can build the workspace directly:

cargo build --workspace

The repository's required gates are:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
cargo deny check
mdbook build book

Emitting Events

From the CLI

foundry emit <event_type> --project <project> [--throttle <level>] [--payload <json>]

Arguments

ArgumentRequiredDescription
event_typeYesEvent type name (e.g., greet_requested, vulnerability_detected)
--projectYesTarget project name
--throttleNofull (default) or dry_run
--payloadNoJSON string with event-specific data
--addrNoDaemon address (overrides FOUNDRY_DAEMON_ADDR, else defaults to http://127.0.0.1:50051)

Examples

# Simple event, default throttle
foundry emit greet_requested --project hello

# With payload
foundry emit greet_requested --project hello --payload '{"name": "Stacey"}'

# Dry run — observe without mutating
foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "severity": "high"}'

# Dry run — no side effects at all
foundry emit maintenance_run_started \
  --project evt-cli \
  --throttle dry_run

What Happens When You Emit

  1. The CLI sends the event to foundryd via gRPC
  2. The engine creates an Event with a deterministic ID
  3. The engine finds all task blocks that sink on the event type
  4. For each matching block:
    • Check throttle: should this block execute? Should it emit?
    • Execute the block's work
    • Collect emitted events
  5. Feed emitted events back into step 3 (the chain continues)
  6. Return the initial event ID to the CLI

The chain continues until no more events are produced.

Inspecting a Completed Chain

After emitting an event, use foundry trace with the returned event ID to see the full propagation tree and what each block did:

$ foundry emit greet_requested --project hello --payload '{"name": "Stacey"}'
Event emitted: evt_47fcb603e1b18c8435b8cc3b

$ foundry trace evt_47fcb603e1b18c8435b8cc3b
greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting: ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting: ok — delivered greeting: Hello, Stacey!
        greeting_delivered (evt_f6e5d4c3b2a1) project=hello

Pass --verbose to also show trigger payloads, emitted payloads, raw shell output, and paths to any audit artefacts produced.

Persistent Trace Storage

Every completed event chain is written to disk under ~/.foundry/traces/YYYY-MM-DD/{event_id}.json (overridable via FOUNDRY_TRACES_DIR). Traces persist indefinitely across daemon restarts — there is no expiry TTL on disk.

To browse past traces, use foundry history:

# Show the last 7 days
foundry history

# Show a specific date
foundry history 2026-03-22

# Filter by project
foundry history --project my-tool

# Explicit local-file diagnostics when the daemon is stopped
foundry --offline history 2026-03-22 --project my-tool

By default foundry history is daemon-authoritative: it asks foundryd for the durable trace store and does not inspect the client-side FOUNDRY_TRACES_DIR. Use --offline deliberately when you want direct file diagnostics.

You can retrieve the full trace for any persisted event with foundry trace, even if the daemon has been restarted since the event was processed. If the trace is missing, the CLI reports No trace found for <event-id> (expired or unknown).

The Project Registry

The registry is Foundry's source of truth for which projects exist on your machine and what automation applies to each one. Without a populated registry, the daemon starts successfully but skips all project-specific work.

Where the Registry Lives

By default: ~/.foundry/registry.json

Override the path with the environment variable:

export FOUNDRY_REGISTRY_PATH=/path/to/my-registry.json

The daemon reads the registry on startup. If the file is missing it logs a warning and continues with an empty registry (no projects will be processed).

Single Source of Truth

The running daemon holds an in-memory copy of the registry in a read-write lock. Online foundry registry list, show, add, edit, and remove commands all go through the daemon's typed gRPC API so reads and writes observe the same daemon-owned state. Direct file access is reserved for explicit recovery with --offline. Without --offline, an unreachable daemon is an error and the client-side registry file is left untouched. Online registry commands also do not create FOUNDRY_REGISTRY_PATH when it does not already exist. The one exception is foundry registry init, which is always an explicit offline-only recovery command:

foundry registry add --name my-tool …             # daemon-authoritative
foundry --offline registry add --name my-tool …   # direct registry.json recovery

Registry Format (v2)

{
  "version": 2,
  "projects": [
    {
      "name": "my-tool",
      "path": "/Users/alice/projects/my-tool",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/my-tool",
      "branch": "main",
      "skip": false,
      "actions": {
        "iterate": true,
        "maintain": true,
        "push": true,
        "audit": true,
        "release": false
      },
      "install": {
        "command": "cargo install --path ."
      }
    }
  ]
}

Top-level fields

FieldTypeDescription
versionnumberMust be 2
projectsarrayList of ProjectEntry objects

ProjectEntry fields

FieldRequiredTypeDescription
nameYesstringUnique human-readable identifier used in events and logs
pathYesstringAbsolute path to the project on your local filesystem
stackYesstringTechnology stack — see Stack values
agentYesstringAI agent name used for automation (e.g. "claude")
repoYesstringGitHub repository slug (owner/repo) used by Watch Pipeline
branchYesstringDefault branch (e.g. "main") — validation checks out this branch
skipNostring or nullAbsent or null means not skipped; a non-empty string value is the skip reason. Accepts true (treated as "skipped") and false/null for backwards compatibility
actionsNoobjectWhich automation steps are enabled; all default to false
installNoobjectHow to reinstall locally after automation — see InstallConfig
notesNostringHuman-readable notes about the project (informational only)
timeout_secsNonumberTimeout in seconds for long-running commands. Defaults to 3600 (60 minutes) when absent
audit_exceptionsNostring[] (default [])Array of CVE/advisory IDs the project has formally accepted as not-applicable. Matching vulnerabilities are suppressed from the post-push auditor. This is Foundry's own copy — independent of hone's gate config and the supply-chain .supply-chain-allow.json. Record rationale in notes; remove entries once upstream patches.

Accepted-risk CVEs (audit_exceptions)

When a project's supply-chain scan surfaces a CVE that your team has formally reviewed and accepted — for example, because the vulnerable code path is unreachable in your deployment — you can suppress it from Foundry's post-push auditor by listing it in audit_exceptions:

{
  "name": "my-service",
  "audit_exceptions": ["CVE-2026-45829"],
  "notes": "CVE-2026-45829: chromadb vector store path is not exposed; accepted risk 2026-06-27."
}

Matching is case-insensitive. Each suppressed CVE is logged at info level so suppression is never silent. The field is Foundry's own policy record — independent of hone's gate configuration and the supply-chain .supply-chain-allow.json allowlist. Remove an entry once the upstream advisory is patched.

Stack values

The stack field tells Foundry which audit tool to use and how to run stack-specific commands.

ValueAudit toolNotes
"rust"cargo audit --jsonRequires cargo-audit to be installed
"typescript"npm audit --jsonExit code 1 = vulnerabilities found (not a tool failure)
"python"pip-audit --format=jsonRequires pip-audit to be installed
"elixir"mix deps.audit --format=json
"cpp"Placeholder for C++ projects; audit tooling not yet wired

ActionFlags

The actions object controls which steps run during a maintenance run. All flags default to false when the actions key is absent.

FlagWhat it enables
iterateRuns the iterate workflow (assess, plan, execute) after project validation passes
maintainRuns the maintain workflow — either after iterate completes (when both are enabled) or directly after validation (when only maintain is enabled)
pushPushes commits to the remote via git push after a commit is made
audit(Reserved for future use — currently informational only)
release(Reserved for future use — currently informational only)

InstallConfig

The install field configures how the project is reinstalled locally after automation completes. Exactly one variant is used per entry:

Command — runs an arbitrary shell command in the project directory:

"install": { "command": "cargo install --path ." }

Brew — installs via a Homebrew formula:

"install": { "brew": "my-formula" }

Minimal Project Entry

Only the six required fields are needed. All optional fields default to safe values (no actions enabled, no install step, not skipped):

{
  "version": 2,
  "projects": [
    {
      "name": "minimal-project",
      "path": "/Users/alice/projects/minimal-project",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/minimal-project",
      "branch": "main"
    }
  ]
}

Excluding a Project Temporarily

Set skip to a string reason to pause automation without removing the entry:

{
  "name": "on-hold",
  "path": "/Users/alice/projects/on-hold",
  "stack": "typescript",
  "agent": "claude",
  "repo": "alice/on-hold",
  "branch": "main",
  "skip": "Waiting for CI to stabilise"
}

The value of skip is the human-readable reason displayed in foundry registry show. Absent, null, false, or an empty string all mean "not skipped". For backwards compatibility, true is treated as the reason string "skipped".

The Validate Project block silently acknowledges skipped projects (emits project_validation_completed with status: "skipped") so the engine trace remains complete.

Managing the Registry with the CLI

Rather than editing registry.json by hand, use the foundry registry subcommands. All commands respect FOUNDRY_REGISTRY_PATH for non-default locations.

foundry registry list, show, add, edit, and remove are daemon-authoritative in normal online use: they talk to foundryd over gRPC and operate on the daemon-owned in-memory registry state. Direct filesystem access is reserved for explicit offline recovery with --offline. Without --offline, an unreachable daemon is an error and leaves the client-side registry file untouched. If FOUNDRY_REGISTRY_PATH is absent, the online path still leaves it absent. If daemon persistence fails during an online add/edit/remove, the RPC returns a stable INTERNAL error and leaves the daemon-owned registry unchanged.

# Create an empty registry during offline recovery
foundry --offline registry init

# List all projects from the daemon-owned registry
foundry registry list

# Inspect one project from the daemon-owned registry
foundry registry show my-tool

# Add a new project through the daemon
foundry registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool \
  --iterate --maintain --push \
  --install-command "cargo install --path ." \
  --notes "Main CLI toolchain"

# Offline recovery: mutate the file directly while the daemon is stopped
foundry --offline registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool

# Edit an existing project
foundry registry edit my-tool --timeout-secs 3600

# Skip a project temporarily
foundry registry edit my-tool --skip "Waiting for CI to stabilise"

# Clear a skip (resume automation)
foundry registry edit my-tool --skip ""

# Remove a project
foundry registry remove my-tool

See CLI Commands — foundry registry for the full option reference.

Multiple Projects

A single registry file can declare any number of projects. They are processed concurrently during a maintenance run (up to max_concurrent at a time, which defaults to the number of active projects unless the orchestrator is configured otherwise):

{
  "version": 2,
  "projects": [
    {
      "name": "api-server",
      "path": "/Users/alice/projects/api-server",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/api-server",
      "branch": "main",
      "actions": { "iterate": true, "maintain": true, "push": true }
    },
    {
      "name": "frontend",
      "path": "/Users/alice/projects/frontend",
      "stack": "typescript",
      "agent": "claude",
      "repo": "alice/frontend",
      "branch": "main",
      "actions": { "maintain": true, "push": true }
    }
  ]
}

Workflow Formations

A workflow formation is not a first-class object in Foundry. It is the logical result of which blocks sink on which events — a chain that emerges when you emit a particular entry event. All blocks live in one engine. The formation that fires depends entirely on the entry event and the payload values that flow through it.

This page documents the formations that exist today and explores how the current block library could be recombined for different purposes.

The Block Library at a Glance

Every block declares its sinks (what triggers it), its emits (what it produces), and its kind (Observer or Mutator). The engine does the rest.

Shared Infrastructure

These blocks appear in multiple formations:

BlockKindSinks OnEmits
Resolve GatesObservercharter_check_completed, maintenance_requested, validation_requestedgate_resolution_completed
Run Preflight GatesObservergate_resolution_completedpreflight_completed
Run Verify GatesObserverexecution_completedgate_verification_completed
Route Gate ResultObservergate_verification_completedproject_iteration_completed / project_maintenance_completed / retry_requested
Retry ExecutionMutatorretry_requestedexecution_completed
Summarize ResultObserverproject_iteration_completed, project_maintenance_completedsummarize_completed
Commit and PushMutatorremediation_completed, project_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushed

Iteration Blocks

BlockKindSinks OnEmits
Check CharterObserveriteration_requestedcharter_check_completed
Assess ProjectObserverpreflight_completedassessment_completed
Triage AssessmentObserverassessment_triagedtriage_completed
Create PlanObservertriage_completedplan_completed
Execute PlanMutatorplan_completedexecution_completed

Maintenance Blocks

BlockKindSinks OnEmits
Execute MaintainMutatorgate_resolution_completedexecution_completed

Vulnerability Blocks

BlockKindSinks OnEmits
Scan DependenciesObserverscan_requestedvulnerability_detected (per CVE)
Audit Release TagObservervulnerability_detected, project_changes_pushedrelease_tag_audited
Audit Main BranchObserverrelease_tag_auditedmain_branch_audited
Remediate VulnerabilityMutatormain_branch_auditedremediation_completed
Cut ReleaseMutatormain_branch_auditedrelease_completed
Watch PipelineMutatorrelease_completedrelease_pipeline_completed
Install LocallyMutatorproject_changes_pushed, release_pipeline_completedlocal_install_completed

Task/Prompt Workflow Blocks

BlockKindSinks OnEmits
Direct PromptObserverpreflight_completed (task)task_run_started, plan_completed
Execute PlanMutatorplan_completed (task)execution_completed
Run Verify GatesObserverexecution_completed (task)gate_verification_completed
Review TaskObservergate_verification_completed (task)task_reviewed
Finalize TaskMutatortask_reviewedtask_run_completed
Request Campaign AdvanceObservertask_run_completed (campaign)campaign_advance_requested

Campaign Blocks

BlockKindSinks OnEmits
Advance CampaignMutatorcampaign_advance_requestedcampaign_advance_completed, execution_requested, campaign_completed, campaign_escalated, or campaign_paused
Surface Campaign TerminalObservercampaign_completed, campaign_escalatedops_digest_started

Strategic Loop Blocks

BlockKindSinks OnEmits
Strategic AssessorObserveriteration_requested (strategic=true)strategic_assessment_completed
Strategic Loop ControllerObserverstrategic_assessment_completed, inner_iteration_completediteration_requested (loop) / project_iteration_completed (done)

Orchestration Blocks

BlockKindSinks OnEmits
Validate ProjectObservermaintenance_run_startedproject_validation_completed
Route Project WorkflowObserverproject_validation_completediteration_requested / maintenance_requested

Current Formations

These are the formations that fire today, depending on which entry event you emit.

The Full Nightly Run

Entry event: maintenance_run_started

This is the broadest formation. It validates the project, routes to iteration and/or maintenance based on the project's registry flags, and chains the two sub-workflows together when both are enabled.

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C[[Route Project Workflow]]
    C -->|iterate=true| D([iteration_requested])
    C -->|maintain=true| E([maintenance_requested])
    D --> F[Iterate Formation]
    F -->|success + maintain=true| E
    E --> G[Maintain Formation]

Iterate Formation

Entry event: iteration_requested

The full assessment-to-execution pipeline with gate verification and bounded retry.

flowchart TD
    A([iteration_requested]) --> B[[Check Charter]]
    B -->|passed| C[[Resolve Gates]]
    C --> D[[Run Preflight Gates]]
    D -->|passed| E[[Assess Project]]
    E --> F[[Triage Assessment]]
    F -->|accepted| G[[Create Plan]]
    G --> H[[Execute Plan]]
    H --> I[[Run Verify Gates]]
    I --> J[[Route Gate Result]]
    J -->|pass| K([project_iteration_completed])
    J -->|fail, retries left| L[[Retry Execution]]
    L --> I
    K --> M[[Summarize Result]]
    K --> N[[Commit and Push]]

Task Formation

Entry event: execution_requested

A task executes one user-provided objective in an isolated Git worktree. It skips assessment and triage: the description is the plan. Unlike iterate and maintain, it never enters the generic retry loop. A separate reviewer produces a typed verdict before Foundry decides whether the work can land.

flowchart TD
    A([execution_requested]) --> B[[Check Charter]]
    B -->|passed| C[[Resolve Gates]]
    C --> D[[Run Preflight Gates]]
    D -->|task preflight skipped| E[[Direct Prompt]]
    E --> F([task_run_started])
    E --> G[[Execute Plan in worktree]]
    G --> H[[Run Verify Gates]]
    H --> I[[Review Task]]
    I --> J[[Finalize Task]]
    J --> K([task_run_completed])
    K -->|campaign task| L[[Request Campaign Advance]]

Usage:

foundry task my-project "Pick the highest priority interaction from et and implement it."

The older direct event shape remains supported for compatibility by emitting execution_requested with workflow="prompt" or workflow="task" in the payload.

complete work lands when required gates pass. A converging remainder also lands when at least one required gate ran and every required gate passed; its reviewer gaps remain in the typed result. defect, blocked_on_decision, and runner_error never land. Non-landing work is committed and preserved on a remote task branch or in a Git bundle.

Campaign Formation

Entry event: campaign_advance_requested

Campaign formation evaluates a durable mission against delivered trunk, campaign done evidence, neutral context artifacts, recent objectives, owner decisions, and any accumulated preserved work. It chooses exactly one of done, one next objective, or escalation.

flowchart TD
    A([campaign_advance_requested]) --> B[[Advance Campaign]]
    B -->|done| C([campaign_completed])
    B -->|advance| D([campaign_advance_completed])
    D --> E([execution_requested<br/>workflow=task])
    E --> F[Task Formation]
    F --> G([task_run_completed])
    G --> H[[Request Campaign Advance]]
    H --> A
    B -->|owner or budget| I([campaign_escalated])
    B -->|provider unavailable| J([campaign_paused])
    C --> K[[Surface Campaign Terminal]]
    I --> K

Each advance mints a trace and cycle span. Task-side events carry campaign_cycle, making concurrent cycles reconstructible from the event stream. A final budgeted task still receives completion evaluation; only a request for another task exceeds the budget. See Tasks and Campaigns for evidence, landing, preservation, pause, decision, and recovery rules.

Strategic Iterate Formation

Entry event: iteration_requested with strategic: true

A nested loop that wraps the iterate formation. The strategic assessor identifies multiple areas for improvement, then the loop controller enters the inner iterate formation for each area. After each inner iteration completes, an AI assessment decides whether to continue. Changes are committed per iteration.

flowchart TD
    A([iteration_requested<br/>strategic=true]) --> B[[Strategic Assessor]]
    B --> C([strategic_assessment_completed])
    C --> D[[Strategic Loop Controller]]
    D --> E([iteration_requested<br/>with loop_context])
    E --> F[Iterate Formation<br/>inner loop]
    F --> G([inner_iteration_completed])
    G --> H[[Commit and Push]]
    G --> D
    D -->|continue| E
    D -->|done| I([project_iteration_completed])
    I --> J[[Summarize Result]]
    I --> K[[Commit and Push]]

The inner iterate formation runs exactly as documented above, with one difference: Route Gate Result emits inner_iteration_completed instead of project_iteration_completed when loop_context is present in the payload. This allows the strategic loop controller to intercept the completion and decide whether to continue.

Terminal blocks (Summarize Result and Commit and Push) self-filter on loop_context — they skip intermediate completions and only fire on the final project_iteration_completed emitted by the strategic loop controller (which strips loop_context before emitting it).

Maintain Formation

Entry event: maintenance_requested

Dependency updates and general maintenance. Preflight gates are skipped (the codebase may be in a pre-maintenance state), but verification gates run after execution.

flowchart TD
    A([maintenance_requested]) --> B[[Resolve Gates]]
    B --> C[[Run Preflight Gates]]
    C -->|skipped| D[[Execute Maintain]]
    D --> E[[Run Verify Gates]]
    E --> F[[Route Gate Result]]
    F -->|pass| G([project_maintenance_completed])
    F -->|fail, retries left| H[[Retry Execution]]
    H --> E
    G --> I[[Summarize Result]]
    G --> J[[Commit and Push]]

Vulnerability Remediation Formation

Entry event: vulnerability_detected

Two paths through the same blocks, governed by the dirty payload flag.

flowchart TD
    A([vulnerability_detected]) --> B[[Audit Release Tag]]
    B --> C[[Audit Main Branch]]
    C --> D{dirty?}
    D -->|true| E[[Remediate Vulnerability]]
    E --> F[[Commit and Push]]
    F --> G[[Install Locally]]
    D -->|false| H[[Cut Release]]
    H --> I[[Watch Pipeline]]
    I --> J[[Install Locally]]

Scan Formation

Entry event: scan_requested

A broader entry point that discovers vulnerabilities and feeds them into the remediation formation. Scan Dependencies emits one vulnerability_detected event per CVE found, so a single scan can trigger multiple parallel remediation chains.

flowchart TD
    A([scan_requested]) --> B[[Scan Dependencies]]
    B -->|per CVE| C([vulnerability_detected])
    C --> D[Remediation Formation]

Validation Formation

Entry event: validation_requested

A read-only health check. No Mutator blocks fire — it just resolves gates, runs them, and reports the results.

flowchart TD
    A([validation_requested]) --> B[[Resolve Gates]]
    B --> C[[Run Preflight Gates]]
    C --> D[[Route Validation Result]]
    D --> E([validation_completed])

Possible Formations

The block library already supports formations that aren't part of the nightly run. Because the engine routes by event type and blocks self-filter on payload, you can trigger these directly.

Iterate Without Maintenance

Emit iteration_requested with actions.maintain=false. The iterate formation runs, and on success Route Gate Result emits project_iteration_completed without chaining to maintenance_requested.

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

This is useful when you want to improve code quality without touching dependencies — a focused structural improvement pass.

Maintenance Without Iteration

Emit maintenance_requested directly. The maintain formation runs on its own, skipping assessment, triage, and planning entirely.

foundry emit maintenance_requested my-project

This is a pure dependency update pass — update libraries, run gates, commit if they pass.

Scan Without Remediation

Emit scan_requested with dry_run throttle. Scan Dependencies and the audit blocks run (they are Observers), but Remediate Vulnerability and Cut Release (Mutators) are simulated rather than executed.

foundry emit scan_requested my-project --throttle dry_run

This tells you what vulnerabilities exist and whether main is dirty, without making any changes.

Remediation Without Scanning

Emit vulnerability_detected directly with the CVE details. This skips the scan entirely and jumps straight into the audit-and-fix chain.

foundry emit vulnerability_detected my-project \
  --payload '{"cve":"CVE-2026-1234","vulnerable":true,"dirty":true}'

This is how you would handle a vulnerability reported through a channel other than Foundry's scanner — a security advisory, a colleague's finding, or a CI notification.

Post-Push Audit

The Audit Release Tag block also sinks on project_changes_pushed. This means that after an iterate or maintain formation commits and pushes, the release tag is automatically re-audited. If the push introduced a vulnerability (or resolved one), the audit chain picks it up without a separate scan.

Strategic Iteration

Emit iteration_requested with strategic: true to enter the nested loop. The strategic assessor analyses the codebase holistically and the loop controller runs multiple inner iterate cycles until the AI determines the codebase has plateaued.

foundry emit iteration_requested my-project \
  --payload '{"strategic":true,"max_iterations":5}'

The max_iterations field caps the loop to prevent runaway iterations. Each inner cycle commits its changes independently.

Gate Check Only

Emit validation_requested to run all gates without modifying anything. This is the lightest formation — it tells you whether the project is healthy right now.

foundry emit validation_requested my-project

Designing New Formations

The current block library is a toolkit. The formations above are the ones we use today, but the same blocks can participate in formations we haven't built yet. A few principles guide what's possible:

  1. Entry events define scope. The deeper into a chain you emit, the narrower the formation. Emitting maintenance_run_started runs everything; emitting plan_completed skips assessment entirely and just executes a plan you provide.

  2. Payload values steer routing. Blocks self-filter on payload fields like dirty, accepted, workflow, and actions. Changing a payload value changes which blocks fire without changing any code.

  3. Throttle controls depth. The same formation behaves differently under full and dry_run. This gives you two versions of every formation for free.

  4. Shared blocks multiply formations. Commit and Push sinks on three different event types. Install Locally sinks on two. Every block that participates in multiple formations is a junction point where chains can converge or diverge.

Vulnerability Remediation Workflow

The vulnerability remediation workflow is Foundry's primary use case. It replaces a linear shell script pipeline with an event-driven chain of task blocks that branches based on the state of the codebase.

The Two Paths

When a vulnerability is detected, the chain branches based on whether the main branch still contains the vulnerability:

flowchart TD
    A[vulnerability_detected] --> B([Audit Release Tag])
    B --> C[release_tag_audited]
    C --> D([Audit Main Branch])
    D --> E[main_branch_audited]
    E --> F{dirty?}
    F -->|"dirty=true (vulnerability present on main)"| G([Remediate Vulnerability])
    G --> H[remediation_completed]
    H --> I([Commit and Push])
    I --> J[project_changes_committed]
    I --> K[project_changes_pushed]
    K --> L([Install Locally])
    L --> M[local_install_completed]
    F -->|"dirty=false (main already fixed, no release cut yet)"| N([Cut Release])
    N --> O[release_completed]
    O --> P([Watch Pipeline])
    P --> Q[release_pipeline_completed]
    Q --> R([Install Locally])
    R --> S[local_install_completed]

Dirty path — the vulnerability exists on main. Foundry remediates it (e.g., dependency update), commits, pushes, and reinstalls locally.

Clean path — main is already fixed (perhaps by a developer), but no release has been cut. Foundry tags a patch release, watches the CI pipeline, and reinstalls locally.

If the release tag is not vulnerable at all, the chain stops after release_tag_auditedAudit Main Branch self-filters and emits nothing.

Self-Filtering

The engine routes events by type only — it cannot inspect payloads. When both Remediate Vulnerability and Cut Release sink on main_branch_audited, both blocks receive every main_branch_audited event. Each block checks the dirty flag in the payload and returns an empty result if the condition doesn't match. This ensures only one path fires.

Running the Workflow

Full run (default)

All blocks execute and emit. The complete chain runs to local_install_completed:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

Then inspect the trace:

foundry trace <event_id>
vulnerability_detected (evt_...) project=my-tool
  → Audit Release Tag: ok — Release tag audited: CVE-2026-1234 vulnerable=true
    release_tag_audited (evt_...) project=my-tool
      → Audit Main Branch: ok — Main branch audited: CVE-2026-1234 dirty=true
        main_branch_audited (evt_...) project=my-tool
          → Remediate Vulnerability: ok — Remediated CVE-2026-1234
            remediation_completed (evt_...) project=my-tool
              → Commit and Push: ok — Committed and pushed fix for CVE-2026-1234
                project_changes_committed (evt_...) project=my-tool
                project_changes_pushed (evt_...) project=my-tool
                  → Install Locally: ok — Installed locally
                    local_install_completed (evt_...) project=my-tool
          → Cut Release: ok — Skipped: main branch is dirty

Notice that Cut Release still appears in the trace — it received the event but self-filtered and returned an empty result.

Dry run

Observers run and emit; mutators are simulated rather than executed:

foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

This tells you what would happen without actually remediating, committing, or releasing. The audit blocks produce their findings, and the Mutators emit simulated dry_run: true events so the rest of the chain stays visible.

Dry run

Only observers execute. Mutators are skipped entirely:

foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

The chain produces vulnerability_detectedrelease_tag_auditedmain_branch_audited and stops. No remediation, no commits, no releases.

Clean Path Example

When the main branch is already fixed but no release has been cut:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-5678", "vulnerable": true, "dirty": false}'

The trace shows the release path instead:

vulnerability_detected (evt_...) project=my-tool
  → Audit Release Tag: ok — ...
    release_tag_audited (evt_...) project=my-tool
      → Audit Main Branch: ok — Main branch audited: CVE-2026-5678 dirty=false
        main_branch_audited (evt_...) project=my-tool
          → Remediate Vulnerability: ok — Skipped: main branch is clean
          → Cut Release: ok — Cut patch release for CVE-2026-5678
            release_completed (evt_...) project=my-tool
              → Watch Pipeline: ok — Release pipeline completed successfully
                release_pipeline_completed (evt_...) project=my-tool
                  → Install Locally: ok — Installed locally
                    local_install_completed (evt_...) project=my-tool

Not Vulnerable

If the release tag has no vulnerability, the chain stops early:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-9999", "vulnerable": false}'

Only two events: vulnerability_detectedrelease_tag_audited. The Audit Main Branch block sees vulnerable=false and emits nothing.

Payload Fields

FieldUsed ByValuesDefault
cveAll blocksCVE identifier string"unknown"
vulnerableAudit Release Tag, Audit Main Branchtrue / falsetrue
dirtyAudit Main Branch, Remediate, Cut Releasetrue / falsetrue

Real Implementations

All blocks in the vulnerability remediation workflow perform real work:

  • Audit Release Tag — shells out to the stack-appropriate tool (cargo audit --json, npm audit --json, pip-audit --format=json, mix deps.audit) via the ScannerGateway abstraction
  • Audit Main Branch — checks out the main branch and runs the same scan
  • Remediate Vulnerability — invokes the configured AI agent (e.g. via the claude CLI) to apply a fix
  • Commit and Push — runs git add, git commit, and optionally git push
  • Cut Release — creates and pushes a git tag using the AI agent
  • Watch Pipeline — polls the GitHub Actions API until the release workflow completes or times out
  • Install Locally — runs the project's configured install command (e.g. cargo install --path .) or Homebrew formula after a successful release

Iteration Workflow

The iteration workflow is Foundry's code quality improvement engine. It identifies the most-violated engineering principle in a project, creates a targeted fix plan, executes it, and verifies the result against quality gates — with automatic retries on failure.

The Chain

flowchart TD
    A([iteration_requested]) --> B[[Resolve Gates]]
    B --> C([gate_resolution_completed])
    C --> D[[Run Preflight Gates]]
    D --> E([preflight_completed])
    E --> F[[Check Charter]]
    F --> G([charter_validated])
    G --> H[[Assess Project]]
    H --> I([project_assessed])
    I --> J[[Triage Assessment]]
    J --> K([assessment_triaged])
    K --> L[[Create Plan]]
    L --> M([plan_created])
    M --> N[[Execute Plan]]
    N --> O([execution_completed])
    O --> P[[Run Verify Gates]]
    P --> Q([gate_verification_completed])
    Q --> R[[Route Gate Result]]
    R -->|pass| S([project_iteration_completed])
    R -->|fail, retries left| T[[Retry Execution]]
    T --> O
    R -->|fail, retries exhausted| U([project_iteration_completed — failure])
    S -->|maintain=true| V([maintenance_requested])

Phase by Phase

1. Gate Resolution

Resolve Gates reads .hone-gates.json from the project directory and emits the gate definitions with workflow: "iterate". The actions object from the trigger event is forwarded through the chain so that gate routing can chain into the maintenance workflow afterwards.

Each gate has a name, a command, a required flag, an optional timeout (seconds), and an optional fix_command — an in-place command that mechanically repairs the gate's failure. A gate looks like:

{
  "name": "format",
  "command": "cargo fmt --check",
  "required": true,
  "fix_command": "cargo fmt"
}

Only declare a fix_command for gates whose failures are safely auto-fixable — formatters and lint autofixers (cargo fmt, ruff format, biome check --write, a clang-format target). Never for tests, typecheck, build, or security gates, where a "fix" would mask a real problem.

2. Preflight Gates

Run Preflight Gates executes every gate against the unmodified codebase.

When a gate fails and declares a fix_command, the runner runs the fix once and re-checks; a passing re-check resolves the gate (recorded with fix_applied: true on the result), and the in-place changes left in the working tree are committed by the downstream Commit and Push step. This is what lets formatter/lint gates self-heal: without it, a required formatting gate would abort the run before the maintain step that reformats the code — a deadlock where the failing gate blocks its own fix.

If a required gate fails and cannot self-heal (no fix_command, or the fix didn't resolve it), required_passed is false and the chain stops — Assess Project self-filters on preflight success.

This establishes a baseline: the iterate workflow only attempts improvements when the project is already in a passing state (or can be brought to one mechanically).

3. Charter Validation

Check Charter verifies that the project has intent documentation (e.g. CHARTER.md). If the charter is missing, passed is false and the chain stops at this point — there is no assessment without context.

4. Assessment

Assess Project invokes an AI agent with reasoning capability and read-only access to analyse the codebase. It identifies the single most-violated engineering principle, returning a severity score (1–10), the principle name, category, and a detailed assessment.

A second quick agent call generates a kebab-case audit filename for traceability.

5. Triage

Triage Assessment uses a quick agent call to decide whether the finding is worth acting on. It rejects issues with severity below 4 or findings that amount to busy-work rather than substantive improvement. On agent failure, triage defaults to accepted — it is better to attempt a fix than to silently skip.

If triage rejects the finding, Create Plan self-filters on accepted=false and the chain stops cleanly.

6. Plan Creation

Create Plan invokes an AI agent with reasoning capability and read-only access to produce a step-by-step correction plan. The plan is concrete — it names exact files and functions — minimal, and testable. Gate definitions are forwarded so the execution phase knows what must pass.

The plan agent is also asked to emit a machine-readable JSON block at the end of its response declaring whether correction is actually needed:

{ "correctionNeeded": true, "reason": "<one sentence>" }

Setting correctionNeeded to false signals that the agent examined the codebase and found the original assessment inaccurate — the codebase already satisfies the principle and no changes are warranted. This becomes a legitimate no-op (see §7). The flag defaults to true on any parse failure (fail-closed).

7. Execution

Execute Plan is the only Mutator in the assessment-to-execution pipeline. It invokes an AI agent with coding capability and full filesystem access. The agent receives the plan plus gate context (the gates it must satisfy) and applies the changes.

Under dry_run throttle, this block returns a simulated success without modifying any files.

Legitimate no-ops — When correctionNeeded is false in the PlanCompleted payload, a clean working tree after execution is treated as a success rather than a flake. The downstream gate verification runs normally and, assuming gates pass, the iteration completes successfully. This mirrors hone's busy-work-containment semantics: an agent that correctly concludes "nothing to do" should not be penalised with retries.

Silent no-op guard — When correctionNeeded is true (the default), an agent that exits successfully but makes no meaningful file changes is overridden to success: false, triggering the retry loop. This prevents agents that silently skip their work from consuming a passing iteration slot.

8. Gate Verification

Run Verify Gates re-reads .hone-gates.json from disk (a fresh read, not cached from phase 1) and runs every gate against the modified codebase. The retry_count from the payload tracks which attempt this is.

9. Routing and Retry

Route Gate Result makes the terminal decision:

ConditionAction
All required gates passEmit project_iteration_completed (success)
Required gates fail, retry count < 3Emit retry_requested with failure context
Required gates fail, retry count ≥ 3Emit project_iteration_completed (failure)

When retrying, Retry Execution receives the failure context (which gates failed and their output) and invokes a coding agent to fix only the issues causing those failures. The result loops back to Run Verify Gates for another round.

The maximum is 3 retries (4 total attempts: 1 initial + 3 retries).

10. Chaining to Maintenance

On success, if actions.maintain=true was forwarded through the chain, Route Gate Result also emits maintenance_requested. This triggers the Maintenance Workflow without re-querying the project configuration.

Self-Filtering

Several blocks use self-filtering to stop the chain gracefully without errors:

  • Assess Project — skips when workflow != "iterate" or preflight failed
  • Check Charter — skips when workflow != "iterate"
  • Create Plan — skips when accepted != true (triage rejected)
  • Summarize Result — skips when success != true

The engine routes by event type only and cannot inspect payloads. Each block checks the relevant payload fields and returns an empty result when the condition does not match.

Running the Workflow

Direct trigger

To run the iterate workflow for a single project:

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

With maintenance chaining

To iterate and then run maintenance:

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":true}}'

Via the maintenance run

The full maintenance lifecycle triggers iteration automatically when iterate=true in the project's registry entry:

foundry emit maintenance_run_started my-project

Dry run

Only observers execute. Mutators are skipped entirely:

foundry emit iteration_requested my-project \
  --throttle dry_run \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

Agent Capabilities

Foundry delegates AI work to the Claude CLI, mapping each block's capability hint to a concrete model:

CapabilityModelUse Case
Reasoningclaude-opus-5Deep analysis and planning
Codingclaude-sonnet-5Code generation and modification
Quickclaude-haiku-4-5-20251001Fast, lightweight decisions

Access levels control which CLI tools the agent may use:

AccessAllowed Tools
Read-onlyRead, Glob, Grep, WebFetch, WebSearch
FullAll tools (no restrictions)

Each phase in the iterate workflow maps to a specific capability and access level:

PhaseCapabilityModelAccessPurpose
AssessmentReasoningOpusRead-onlyDeep analysis of codebase
Audit namingQuickHaikuRead-onlyGenerate kebab-case filename
TriageQuickHaikuRead-onlyAccept/reject decision
Plan creationReasoningOpusRead-onlyStep-by-step correction plan
ExecutionCodingSonnetFullApply code changes
RetryCodingSonnetFullFix gate failures
SummarisationQuickHaikuRead-onlyGenerate headline and summary

All agent invocations use the --print flag (non-interactive output) and --dangerously-skip-permissions (unattended execution). Blocks that reference a project agent file pass it via --agent. Timeouts are set per-project from the registry entry, except Triage and Summarisation which use a fixed 120-second timeout for their lightweight Quick calls.

Payload Fields

FieldCarried ByPurpose
actionsAll events{iterate, maintain} flags for workflow routing
gatesGates resolved through plan creationGate definitions for execution context
audit_nameAssessment through plan creationKebab-case audit filename for traceability
severityAssessment through plan creationViolation severity (1–10)
principleAssessment through plan creationName of the violated principle
categoryAssessment through plan creationCategory of the violation
assessmentAssessment through plan creationDetailed assessment text
retry_countExecution through gate routingCurrent retry attempt (0-based)
failure_contextRetry requestedGate output from failed verification
correction_neededPlan completed through executionfalse when plan agent concluded no changes are warranted; defaults to true
correction_reasonPlan completed through executionOne-sentence explanation when correction_needed is false

Strategic Iteration

Strategic iteration wraps the standard iterate workflow in an outer loop. Instead of finding and fixing one issue, it assesses the project holistically, identifies multiple areas for improvement, and works through them one at a time — committing changes after each cycle and re-evaluating whether further work is warranted.

This is a two-layer nested loop:

  1. Outer loop (strategic) — AI identifies areas to improve, decides when to stop
  2. Inner loop (operational) — the existing iterate formation: assess, triage, plan, execute, verify gates, retry

Prerequisites

Before running a strategic iteration you need the same setup as a standard iterate workflow:

  • A project registered in the Foundry registry (foundry registry add)
  • A CHARTER.md (or equivalent intent documentation) in the project root
  • A .hone-gates.json defining quality gates
  • The foundryd daemon running

If you can successfully run foundry iterate my-project, you are ready for strategic iteration.

Step 1: Start the Daemon

foundryd

Verify the strategic blocks are registered in the startup logs:

INFO foundryd::engine: registered task block block="Strategic Assessor" sinks=[IterationRequested]
INFO foundryd::engine: registered task block block="Strategic Loop Controller" sinks=[StrategicAssessmentCompleted, InnerIterationCompleted]

Step 2: Dry Run First

Before committing to a full strategic run, use dry_run throttle to see the shape of the event chain without modifying any files:

foundry emit iteration_requested my-project \
  --throttle dry_run \
  --payload '{"strategic": true, "max_iterations": 3}'

Watch the event stream in another terminal:

foundry watch --project my-project

You should see events flowing through the strategic assessment, into the inner iterate chain, and back to the strategic controller. Mutator blocks (Execute Plan, Commit and Push) emit simulated success events under dry_run.

Step 3: Run a Strategic Iteration

foundry emit iteration_requested my-project \
  --payload '{"strategic": true, "max_iterations": 5}'

What happens

flowchart TD
    A([iteration_requested<br/>strategic=true]) --> B[[Strategic Assessor]]
    B -->|AI holistic assessment| C([strategic_assessment_completed])
    C --> D[[Strategic Loop Controller]]
    D -->|pick first area| E([iteration_requested<br/>with loop_context])
    E --> F[Inner Iterate Formation]
    F --> G([inner_iteration_completed])
    G --> H[[Commit and Push<br/>per-iteration commit]]
    G --> D
    D -->|AI: continue?| I{More work?}
    I -->|yes| E
    I -->|no| J([project_iteration_completed])
    J --> K[[Summarize Result]]
    J --> L[[Commit and Push]]
  1. Strategic Assessor analyses the codebase and produces a ranked list of improvement areas (e.g., "test coverage in auth module", "inconsistent error handling in API layer").

  2. Strategic Loop Controller picks the top area and enters the inner iterate formation by emitting iteration_requested with a loop_context payload.

  3. The inner iterate formation runs exactly as described in the Iteration Workflow guide — charter check, gate resolution, preflight, assessment, triage, plan, execute, verify, retry. The only difference is that Route Gate Result emits inner_iteration_completed instead of project_iteration_completed.

  4. Commit and Push fires on inner_iteration_completed, creating a per-iteration commit so each improvement is isolated in git history.

  5. Strategic Loop Controller receives inner_iteration_completed and asks an AI agent: "Is there more meaningful work to do, or has the codebase reached a plateau?" Based on the response and the iteration cap, it either re-enters the inner loop or completes.

  6. When the loop completes, the controller emits project_iteration_completed without loop_context, which triggers Summarize Result and a final Commit and Push.

Step 4: Monitor Progress

Watch the live event stream:

foundry watch --project my-project

Key events to look for:

EventMeaning
strategic_assessment_completedAI identified areas; check areas in payload
iteration_requested (with loop_context)Inner loop entered for an area
inner_iteration_completedOne cycle done; check success
project_changes_committedPer-iteration commit created
project_iteration_completedStrategic loop finished
summarize_completedFinal summary generated

Step 5: Review the Trace

After completion, inspect the full event chain:

foundry trace <event-id>

Use the event ID from the original iteration_requested event. The trace shows every block that executed, what it emitted, and how long it took — across all iterations of the loop.

Payload Reference

Entry payload

FieldTypeDefaultPurpose
strategicboolfalseMust be true to activate strategic mode
max_iterationsinteger5Maximum number of inner loop cycles
strategic_promptstring(built-in)Custom directive for the assessment and continue checks (see below)
actionsobjectOptional {maintain: true} to chain maintenance after the loop completes

loop_context (internal)

The loop_context object is managed by the strategic blocks. You do not set it manually — it is created by the Strategic Assessor and threaded through the chain automatically.

{
  "loop_context": {
    "strategic": {
      "iteration": 2,
      "max": 5,
      "total_areas": 3,
      "current_area": {"area": "test coverage", "severity": 8}
    }
  }
}

All blocks in the iterate chain forward loop_context transparently. Terminal blocks (Summarize Result, Commit and Push) skip when they see it on a completion event — they only fire on the final completion that the Strategic Loop Controller emits without loop_context.

Controlling the Loop

Iteration cap

The max_iterations field is the hard stop. Even if the AI wants to continue, the loop completes when this count is reached. Start with a low number (2–3) and increase once you are comfortable with the results.

Custom prompt

By default the Strategic Assessor analyses broad code quality and the continue check looks for remaining violations. You can replace both directives with a strategic_prompt to steer the loop toward a specific goal:

foundry emit iteration_requested my-project \
  --payload '{
    "strategic": true,
    "max_iterations": 5,
    "strategic_prompt": "Pick the highest priority interaction from et and implement it."
  }'

The prompt you supply is used in two places:

  1. Assessment — the Strategic Assessor wraps your prompt in a request to produce the areas JSON. The AI reads the codebase with your directive as context and returns a ranked list of work items.
  2. Continue check — after each inner iteration, the Strategic Loop Controller asks the AI the same prompt again to decide whether the loop should continue. The AI responds with {"continue": true/false}.

The prompt is stored inside loop_context.strategic.prompt so it survives the full inner chain and is available on every re-entry.

Some examples:

GoalPrompt
Product work from a backlog"Pick the highest priority interaction from et and implement it."
Focused refactoring"Find the module with the worst test coverage and add tests."
Dependency modernisation"Identify deprecated dependencies and upgrade them one at a time."
Security hardening"Find the most critical OWASP Top 10 risk in this codebase and fix it."

AI continue assessment

After each inner iteration, the Strategic Loop Controller asks an AI agent whether further improvement is warranted. When no strategic_prompt is set, the agent considers:

  • Are there remaining violations with severity >= 4?
  • Would another iteration produce meaningful improvement?
  • Has the codebase reached a diminishing-returns plateau?

When strategic_prompt is set, your prompt replaces this default reasoning — the AI uses your directive to decide whether more work remains.

If the inner iteration failed (gates did not pass after retries), the loop stops immediately rather than attempting further iterations on a broken codebase.

Throttle interaction

ThrottleStrategic AssessorInner IterateCommits
fullRunsRuns (modifies files)Real commits
dry_runRunsSimulated successNo commits

Chaining with Maintenance

To run a strategic iteration and then chain into maintenance:

foundry emit iteration_requested my-project \
  --payload '{"strategic": true, "max_iterations": 3, "actions": {"maintain": true}}'

Maintenance chaining is suppressed during the inner loop (to prevent maintenance from running after every cycle). It only fires after the strategic loop completes — the final project_iteration_completed event triggers Route Gate Result's normal chaining logic.

Example: Full Session

# Terminal 1: start daemon
foundryd

# Terminal 2: watch events
foundry watch --project my-api

# Terminal 3: trigger strategic iteration
foundry emit iteration_requested my-api \
  --payload '{"strategic": true, "max_iterations": 3}'

# After completion, review the trace
foundry trace evt_abc123def456

Expected event flow for a 2-iteration run:

iteration_requested (strategic=true)
  strategic_assessment_completed (areas: [test coverage, error handling])
    iteration_requested (loop_context: iteration=1)
      charter_check_completed
      gate_resolution_completed
      preflight_completed
      assessment_completed
      triage_completed
      plan_completed
      execution_completed
      gate_verification_completed
      inner_iteration_completed (success=true)
        project_changes_committed
    iteration_requested (loop_context: iteration=2)
      charter_check_completed
      ...
      inner_iteration_completed (success=true)
        project_changes_committed
    project_iteration_completed (no loop_context — loop done)
      summarize_completed
      project_changes_committed

Agent Capabilities

Strategic iteration adds two blocks with their own agent invocations on top of the standard iterate chain:

PhaseCapabilityModelAccessPurpose
Strategic AssessorReasoningclaude-opus-5Read-onlyHolistic codebase analysis producing ranked improvement areas
Continue checkQuickclaude-haiku-4-5-20251001Read-onlyDecide whether the loop should continue after each cycle

The inner iterate formation uses the same model assignments described in the Iteration Workflow guide.

When a strategic_prompt is set, both the assessment and continue-check prompts incorporate it — the same custom directive steers area selection and termination decisions.

Comparison with Standard Iterate

AspectStandard IterateStrategic Iterate
Entry payload{} or {actions: ...}{strategic: true, max_iterations: N}
ScopeOne issue per runMultiple issues per run
CommitsOne at the endOne per inner cycle + one final
Continue logicNone (single pass)AI re-assessment after each cycle
Inner retryUp to 3 retries on gate failureSame (unchanged)
Maintenance chainingAfter single completionAfter loop completion only

Maintenance Workflow

The maintenance workflow runs iterate and maintain automation against each registered project, committing and pushing any changes they produce. It is triggered nightly by the in-daemon nightly-maintenance sentinel (see Sentinels) and can also be invoked manually via foundry run or foundry emit.

How It Works

Each project goes through its own independent chain. The chain is driven entirely by events — no mutable state is shared between projects.

Per-Project Chain

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C([project_validation_completed])
    C --> D[[Route Project Workflow]]
    D -->|iterate=true| E([iteration_requested])
    D -->|iterate=false, maintain=true| F([maintenance_requested])
    D -->|no actions enabled| G([end])
    E --> H[[Resolve Gates]]
    H --> I[[Run Preflight Gates]]
    I --> J[[Check Charter]]
    J --> K[[Assess Project]]
    K --> L[[Triage Assessment]]
    L --> M[[Create Plan]]
    M --> N[[Execute Plan]]
    N --> O[[Run Verify Gates]]
    O --> P[[Route Gate Result]]
    P -->|pass| Q([project_iteration_completed])
    P -->|fail, retries left| R[[Retry Execution]]
    Q -->|maintain=true| F
    F --> S[[Resolve Gates]]
    S --> T[[Execute Maintain]]
    T --> U[[Run Verify Gates]]
    U --> V[[Route Gate Result]]
    V -->|pass| W([project_maintenance_completed])
    V -->|fail, retries left| X[[Retry Execution]]

Routing Logic

Route Project Workflow reads the actions flags forwarded in the project_validation_completed payload and makes a single decision:

ConditionEmits
status != "ok"nothing — chain stops
actions.iterate = trueiteration_requested
actions.iterate = false, actions.maintain = truemaintenance_requested
both falsenothing — no automation enabled

When iterate = true, the actions.maintain flag is forwarded inside the iteration_requested payload. After a successful iteration, the gate routing emits maintenance_requested automatically when that flag is true, so the maintain sub-workflow starts without an extra routing step.

Triggering a Maintenance Run

To run maintenance for a single project:

foundry emit project_validation_completed my-project \
  --payload '{"status":"ok","actions":{"iterate":true,"maintain":true}}'

To trigger the full nightly cycle:

foundry emit maintenance_run_started my-project

Throttle Behaviour

ThrottleEffect
fullAll blocks execute and emit events
dry_runObservers emit; mutators are skipped entirely

Under dry_run, only iteration_requested or maintenance_requested are emitted (by the Observer router). No execution blocks run.

Agent Capabilities

The maintenance workflow uses a single agent invocation in Execute Maintain:

PhaseCapabilityModelAccessPurpose
Execute MaintainCodingclaude-sonnet-5FullUpdate dependencies, fix vulnerabilities, resolve gate failures

Gate definitions are passed as context so the agent knows what must pass after its changes. If the project has an agent file registered, it is supplied via --agent.

See the Iteration Workflow for the full model-to-capability mapping and CLI parameters used across all agent invocations.

Tasks and Campaigns

Foundry has two engineering dispatch primitives:

  • A task executes one concrete objective immediately.
  • A campaign holds a broader mission and derives one task at a time from current repository state until evidence proves the mission complete.

Campaigns do not contain a pre-cut queue. The next objective is created only when the preceding task has a typed result, so stale downstream inventory and per-item retry loops are unnecessary.

Choosing a Task or Campaign

Use a task when you can state one objective whose acceptance evidence is available now:

foundry task parite-cli \
  "Add a --quiet flag and prove it suppresses progress output"

Use a campaign when any of these are true:

  • the mission spans several independently reviewable changes;
  • later work depends on what the repository reveals after earlier work lands;
  • the work may require an explicit owner decision;
  • production or other external evidence is part of completion;
  • a bounded cycle budget is needed to limit autonomous work.

A campaign is deliberately not a substitute for a backlog. If the work is already a known sequence of unrelated tasks, dispatch those tasks directly. Campaign formation is valuable when each next objective should be derived from mission minus current evidence.

Lifecycle at a Glance

flowchart TD
    A["staged campaign"] --> B["advance"]
    B --> C{"formation decision"}
    C -->|"done"| D["completed"]
    C -->|"advance one objective"| E["isolated task"]
    C -->|"human judgement or budget"| F["escalated"]
    E --> G{"typed task result"}
    G -->|"complete or remainder"| B
    G -->|"defect"| B
    G -->|"blocked on decision"| F
    G -->|"provider unavailable"| H["paused"]
    H -->|"resume"| B
    F -->|"decide or extend budget"| B
    A -->|"cancel"| I["cancelled"]
    B -->|"cancel"| I
    F -->|"cancel"| I
    H -->|"cancel"| I

cancelled is terminal and has no edge back: it records that the mission was abandoned, not achieved. Use paused for a campaign meant to resume.

Each dispatched task consumes one cycle. Formation, retries caused by a transient decision-provider transport failure, and an automatic provider pause do not consume cycles. A final budgeted task always receives one completion evaluation; only an attempted dispatch beyond the authorized budget escalates.

Durable Ownership

The daemon owns the durable campaign inventory. Online foundry campaign add/list/show/advance/pause/resume/decide/complete/cancel all go through typed gRPC and do not read, create, or mutate FOUNDRY_CAMPAIGNS_PATH. Successful online reads and mutations render the daemon's typed response directly, so stale client-side campaign files cannot mask the live daemon-owned state. Pass --offline only for direct-file recovery while the daemon is stopped. If the daemon is unreachable, the online command fails and leaves any absent or pre-existing client-side FOUNDRY_CAMPAIGNS_PATH byte-identical.

The read-only inventory surface starts with two gRPC queries:

  • ListCampaigns — returns summary/status records sorted by campaign name, with an optional exact project filter. Missing or empty stores return an empty list; malformed or unreadable stores return a gRPC error rather than an implicit empty inventory.
  • GetCampaign — retrieves the complete definition of one campaign by exact name, including intent_refs, context_paths, all done_evidence entries with the Gate/Review type distinction preserved, and escalation rules. Returns NOT_FOUND (not an implicit empty) when the name is absent from the store.

One-Shot Tasks

foundry task parite-cli "Add a --quiet flag and prove it suppresses progress output"
foundry task parite-cli "Fix the parser regression" --agent codex

The task formation:

  1. Creates a disposable Git worktree under ~/.foundry/worktrees/.
  2. Runs the coding agent and quality gates inside that worktree.
  3. Performs a read-only skeptical review against the objective and evidence.
  4. Emits one typed verdict: complete, remainder, defect, blocked_on_decision, or runner_error.
  5. Commits all task work before returning a terminal result.

Two verdicts may fast-forward the registered trunk branch. A complete verdict with passing required gates lands, as does a remainder — the reviewer's term for a finite list of missing work on a converging implementation — provided at least one required gate ran and every required gate passed. Converging work integrates rather than accumulating a long-lived divergent branch, and the green required gates are what keep trunk from going red; a remainder with no required gate to vouch for it does not land. Its reviewer gaps travel forward in the typed result and become the campaign's next objective.

defect, blocked_on_decision, and runner_error never land. Every result that does not land is pushed to a named preservation branch; if no remote push is possible, Foundry writes a Git bundle under ~/.foundry/preserved/. Either way the next cycle resumes from the preserved work; a bundle also carries HEAD, so you can fetch or clone it by hand to recover the work yourself. Tasks do not retry. A campaign decides whether the preserved result should seed another objective.

Campaign Definitions

Create a JSON definition file:

{
  "name": "parite-phase-2d",
  "project": "parite-cli",
  "mission": "Prove both retrieval entrypoints preserve raw response identity.",
  "intent_refs": ["parite.intent.raw-retrieval-evidence"],
  "context_paths": [".alloy/projections/AGENTS.generated.md"],
  "done_evidence": [
    {
      "kind": "gate",
      "command": "cargo test -p parite-core retrieval_parity",
      "required": true,
      "artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
    },
    {
      "kind": "review",
      "statement": "The parity suite compares unmasked response IDs across both real entrypoints."
    }
  ],
  "budget": { "max_cycles": 12 },
  "escalation": ["A behavior choice requires an owner decision."],
  "authorized_by": "Stacey",
  "agent_provider": "codex"
}

Definition fields

FieldRequiredMeaning
nameYesStable campaign identifier, unique in the campaign store
projectYesExact registered project name
missionYesDurable outcome the formation evaluates
intent_refsNoOpaque identifiers connecting the mission to source intent
context_pathsNoExisting repository-relative neutral artifacts
done_evidenceYesAt least one mechanical gate or review statement
budget.max_cyclesNoMaximum dispatched tasks; defaults to 20
escalationNoConditions that require the formation to stop for an owner
authorized_byOperationallyOwner identity required for decisions, completion, and resume
agent_providerNoCampaign-specific provider override

context_paths must be existing repository-relative files under the registered checkout. Absolute paths, parent traversal, missing files, and symlink escapes are rejected before the definition is saved. Foundry reads these neutral artifacts but never invokes the tool that produced them.

Designing done evidence

Use a gate for a deterministic command that can run against the delivered repository checkout:

{
  "kind": "gate",
  "command": "cargo test -p parite-core retrieval_parity",
  "required": true,
  "artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
}

Every declared artifact must exist before the command is eligible to pass. This prevents a test runner that silently ignores a missing path from producing false-green evidence.

Use a review for a semantic or externally verified claim:

{
  "kind": "review",
  "statement": "Production preserves raw identity across both entrypoints."
}

Required gates are re-run by formation against delivered trunk and block done. A required command that asserts another host's state through ssh, rsync, systemctl, launchctl, or similar tooling is rejected when the campaign is added: code in a disposable task worktree cannot make such a gate pass reliably. Express deployment evidence as a review statement that the owner verifies, or mark a remote probe as optional.

Campaign gates are not task acceptance criteria. A dispatched task runs the project gates resolved from its own checkout. Formation must state acceptance evidence the task can actually produce inside that worktree; it does not copy campaign gate commands into the objective.

Managing a Campaign

foundry campaign add ./parite-phase-2d.json
foundry campaign list
foundry campaign show parite-phase-2d
foundry campaign advance parite-phase-2d
foundry campaign pause parite-phase-2d
foundry campaign decide parite-phase-2d --decision "Use the generated tonic client path."
foundry campaign resume parite-phase-2d
# When the cycle budget was exhausted, explicitly authorize more work:
foundry campaign resume parite-phase-2d --add-cycles 1

New definitions start as staged. An authorized staged campaign becomes active on its first advance. Each advance re-runs mechanical done-evidence, reviews the repository and context artifacts, then makes exactly one decision:

  • done — all required gate and review evidence is satisfied.
  • advance — dispatch exactly one next objective from mission minus current state.
  • escalate — stop because the budget, an escalation rule, runner failure, or owner judgment requires attention.
StatusMeaningValid next control
stagedDefinition exists; no cycle has startedadvance, pause, cancel
activeFormation or a task may advance the missionadvance, pause, cancel
pausedAdvancement is intentionally stoppedresume, complete, cancel
escalatedBudget, policy, or human judgement stopped the missiondecide, resume, complete, cancel
completedEvidence or owner authorization closed the missionNone
cancelledAn owner abandoned the mission before its evidenceNone

The status transition table

Every rule governing which control is legal from which status lives in exactly one place: foundry_sdk::campaign::transition. The daemon's gRPC handlers, the CLI's --offline recovery path, and the AdvanceCampaign formation block all delegate to the same Campaign methods (pause, resume, record_owner_decision, complete, cancel, check_advanceable) rather than re-deriving the rules independently — --offline differs from the online path only in transport and event emission, never in legality. The table below is generated from that module's exhaustive state-machine test and is the authoritative specification:

Statuspauseresumedecidecompletecanceladvance
stagedallowed †rejected: wrong statusrejected: wrong statusallowedallowedallowed
activeallowed †rejected: wrong statusrejected: wrong statusallowedallowedallowed
pausedallowed †allowedrejected: wrong statusallowedallowedrejected: wrong status
escalatedallowed †allowedallowedallowedallowedrejected: wrong status
completedallowed †rejected: wrong statusrejected: wrong statusno-op (already settled)rejected: already completerejected: wrong status
cancelledallowed †rejected: wrong statusrejected: wrong statusrejected: cancelled campaignno-op (already settled)rejected: wrong status

resume and complete additionally require authorized_by to be set, regardless of status; cancel deliberately does not, so an unauthorized campaign is never stranded with no reachable terminal state.

pause is unconditional today, including on a completed or cancelled campaign — see the TODO(campaign-status) on Campaign::pause in foundry-sdk. That this can resurrect a terminal status into paused is a known open question, not a decision this table is asserting is correct.

A done decision made while a required done-evidence gate is red is rewritten into an advance. The synthesized objective carries the campaign mission and each failing gate's own output, not just the command that failed, and forbids reverting or shrinking landed mission work — or broadening a lint allowance — to turn the gate green.

Task results auto-request the next advance. A result that did not land carries its preserved branch into the next task, so the campaign resumes warm. A blocked_on_decision escalates immediately, and so does a runner_error that describes a fault in the run. cycles_completed counts dispatched tasks, while cycles_landed counts only task results whose work actually landed on trunk.

A provider failure is not treated as a campaign failure. If the decision agent cannot be reached, Foundry re-asks it up to three times with a widening backoff before giving up, and the resulting escalation names every attempt — a single transport blip no longer ends a healthy campaign. A malformed decision is not retried: the agent answered, so re-asking would only repeat it.

When the provider itself is unusable — an exhausted account, revoked authentication, or an open circuit breaker — the campaign moves to paused rather than escalated, whether that surfaces during formation or as the executor's runner_error verdict. Nothing about the campaign's own work is wrong, so no cycle is consumed and the pending run result is preserved. Once the provider is usable again, foundry campaign resume continues from exactly where it stopped.

The stop emits a campaign_paused event carrying the reason. It is deliberately not a terminal event — it neither ends the campaign nor requires resurrection — but it is emitted because an automatic pause is the one kind nobody is watching: an operator who runs foundry campaign pause already knows, whereas a campaign that quietly stops itself is otherwise indistinguishable from one still working. The reason also appears in the advance block's summary in the run trace.

Formation reasons about two trees, because they can differ. The live repository snapshot is the delivered trunk state and is what a done decision is judged against. Separately, when the previous cycle did not land, its preserved branch becomes the next execution's base ref — so formation is also shown an ACCUMULATED UNMERGED WORK section listing the commits and changed files reachable from that ref but absent from trunk. An advance objective is cut from mission minus trunk-plus-accumulated. Without this the agent inspects only trunk and re-cuts objectives the preserved branch already satisfied. When the final budgeted task lands, Foundry still evaluates the repository for completion. Only a decision to dispatch another task is converted to a budget escalation.

Formation is also shown an OBJECTIVE HISTORY section: the objectives this campaign has already cut, oldest first, each with the typed verdict its execution returned and whether that work landed. The campaign retains the eight most recent — the full text of every objective is already durable in the campaign_advance_completed event stream, so the stored history is formation's working memory rather than an archive, and stays bounded on a long mission.

The prompt forbids restating an entry from that history. When the objective being cut substantially repeats an earlier one, exactly two readings are available. Either the earlier work exists on the preserved branch and the inspection missed it, in which case the accumulated section applies and the objective becomes reconcile-and-land. Or the earlier cycle returned remainder and its gaps are genuinely open, in which case re-dispatching the same request has already failed once: the agent must name the blocking sub-gap, change the approach, or escalate for an owner decision.

Observing and Reconstructing a Cycle

foundry campaign advance prints the root event ID, streams block progress, and renders the completed trace. The same run remains available through:

foundry history --project parite-cli
foundry trace <campaign-advance-event-id> --verbose

A campaign run mints a trace ID, and every advance mints a fresh cycle span within it. Task-side events carry both campaign and campaign_cycle, so concurrent campaigns in the same project cannot make cycle boundaries ambiguous.

CampaignAdvanceCompleted records the formation inputs that matter for audit:

  • the exact prompt shown to the decision agent;
  • the selected agent provider;
  • the formation decision and reason;
  • the objective, when one was dispatched; and
  • the gate results produced by done-evidence commands.

Forced decisions that do not consult an agent record no prompt or provider. This is intentional evidence that formation was bypassed, not missing telemetry.

cycles_completed counts dispatched tasks. cycles_landed counts task results whose changes reached trunk. The bounded objective_history stored with the campaign is working memory for formation; the append-only event stream is the complete historical record.

Pausing and Resuming

Pausing prevents automatic or manual advancement. If an already-running task finishes after the pause, Foundry records its result without changing the paused state. The typed result and preservation ref remain pending in the campaign store; the next manual advance after resume consumes them, so formation sees the exact reviewer gaps and the task continues from preserved work.

Resume is valid for both paused and escalated campaigns. When an escalation is budget-only (the engine stopped because the cycle limit was reached but no human judgment question was recorded), resume is the right command — it returns the campaign to active without requiring an owner-decision record:

foundry campaign resume parite-phase-2d

resume requires authorized_by to be set and will not silently reactivate an exhausted campaign. When cycles_completed >= max_cycles, pass --add-cycles N to explicitly authorize more work; the engine rejects resume without an extension on an exhausted budget:

foundry campaign resume parite-phase-2d --add-cycles 1

Recording an Owner Decision

Completion and escalation are terminal events and are forced into the next ops digest as an anomaly. Campaign-store mutations are serialized across the CLI and daemon, so a control command cannot overwrite an in-flight formation decision. If a daemon-side save fails during pause, resume, decide, or complete, the RPC returns INTERNAL, leaves the persisted daemon-owned store byte-identical, and complete does not emit CampaignCompleted.

When a task escalates with a human judgment question, record the owner's policy before the next advance:

foundry campaign decide parite-phase-2d \
  --decision "Keep the generated tonic client boundary; do not add raw JSON shims."

decide is valid only for an escalated campaign. It appends an owner decision record with the decision text, the campaign's authorized_by value, and a timestamp, then returns the campaign to active. Subsequent formation prompts include every recorded owner decision as binding context, so the next advance can continue under explicit policy instead of re-escalating on the same question.

By default, foundry campaign decide is an online mutation: it requires a reachable foundryd daemon and sends the decision through the DecideCampaign RPC. If the daemon is unreachable, the command fails and does not touch the client-side FOUNDRY_CAMPAIGNS_PATH.

If you need to update the file while the daemon is stopped, opt into the direct store path explicitly:

foundry campaign decide parite-phase-2d \
  --decision "Keep the generated tonic client boundary; do not add raw JSON shims." \
  --offline

External Completion

When production or other owner-reviewed evidence proves the mission shipped without another formation cycle, close the campaign explicitly:

foundry campaign complete parite-phase-2d \
  --reason "Production verification confirms every required outcome shipped."

This is an owner-authorized terminal transition. Foundry retains the reason and timestamp, clears any stale pending result, and emits the same completion event used by an internally completed campaign. Use --offline only while the daemon is stopped; the direct-file path cannot emit the terminal event.

Cancelling a Campaign

When a mission is abandoned rather than achieved — superseded by a different approach, overtaken by events, or simply wrong — cancel it:

foundry campaign cancel parite-phase-2d \
  --reason "Superseded by the streaming rewrite."

Cancellation is a distinct cancelled status, not a flavour of completed. Completion in Foundry is an evidence claim, so recording an abandoned campaign as complete would put a false assertion into the audit trail and the ops digest. It is terminal and not resumable; if the campaign should come back later, use pause instead.

Unlike complete, cancellation does not require authorized_by — an unauthorized campaign cannot be advanced to completion either, so requiring an owner would leave it stranded with no reachable terminal state. The --reason is always mandatory and always reaches the campaign_cancelled event; it is additionally recorded as an owner decision when the campaign has an owner.

By default the cancellation is graceful: the in-flight cycle runs to completion, its work is committed and preserved exactly as usual, and no successor cycle is dispatched. To stop immediately instead:

# Kill the running agent, keep its work (committed and pushed or bundled)
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now

# Kill the running agent and throw its uncommitted work away
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now --discard-work

A whole campaign runs inside a single daemon task, so --now aborts that task: the running agent process is killed, and the cycle's worktree is left orphaned because normal finalization never ran. Foundry then disposes of that worktree according to --discard-work — preserving the work to a branch or bundle by default, or deleting the worktree and its local branch when asked. A remote branch pushed by an earlier cycle is never deleted; it is the audit trail for work that did reach a durable ref.

--discard-work requires --now, because a graceful cancellation has already committed and preserved the cycle's work by the time it stops — there would be nothing uncommitted left to discard.

Two limits worth knowing. --now kills the agent process itself, but not the tool subprocesses that agent spawned; those are reparented and run to their own completion. And the aborted run produces no trace file, so reconstruct it from the aborted_event_id recorded on the campaign_cancelled event rather than from foundry trace.

--offline cancellation is graceful-only and emits no terminal event, matching offline complete. --offline --now is refused rather than quietly downgraded: with no daemon there is no workflow to abort, and reporting a kill that never happened would be worse than failing. The legality of the cancellation itself — whether this status may become cancelled at all — is not a separate offline rule; it is the same Campaign::cancel the daemon calls, so a completed campaign is rejected and an already-cancelled one is a no-op on both paths identically. See "The status transition table" above.

Online and Offline Control

By default, every campaign control command is daemon-authoritative:

  • add sends the JSON definition through AddCampaign; the daemon validates the referenced project and context paths against daemon-owned registry state, persists atomically, and returns the durable CampaignDetail that the CLI renders directly.
  • list renders ListCampaigns directly.
  • show renders GetCampaign directly.
  • advance dispatches AdvanceCampaign, prints the returned root event ID, watches the workflow, and then renders the trace from that daemon-owned event.
  • pause, resume, decide, and complete render the typed CampaignDetail returned by their respective RPCs.

Without --offline, an unreachable daemon is always an error. The CLI does not warn and fall back to direct file mutation automatically.

If foundryd is not running, pass --offline to opt into direct-file recovery:

foundry campaign add ./parite-phase-2d.json --offline
foundry campaign list --offline
foundry campaign show parite-phase-2d --offline
foundry campaign pause parite-phase-2d --offline
foundry campaign resume parite-phase-2d --offline
foundry campaign resume parite-phase-2d --add-cycles 1 --offline
foundry campaign decide parite-phase-2d --decision "Keep the daemon boundary." --offline
foundry campaign complete parite-phase-2d --reason "Production verification confirms every required outcome shipped." --offline

The offline path reads or mutates the file directly and cannot emit workflow events. Restart foundryd afterward so its in-memory state is refreshed from disk before you resume normal online control.

advance has no offline execution path because formation requires the daemon's engine, registered blocks, live project state, and event persistence.

Recovery and Preservation

Task execution never retries inside a single formation. When work does not land, FinalizeTask commits it before returning:

  • it first pushes a named task branch to the project's remote;
  • if the push is unavailable, it writes a Git bundle under ~/.foundry/preserved/;
  • the next campaign cycle uses that preservation reference as its base;
  • bundle recovery discovers the branch ref directly, and new bundles also include HEAD for ordinary git clone or git fetch recovery.

Formation judges done only against delivered trunk. It uses the accumulated preserved branch to decide what the next task should do. This prevents a campaign from declaring success for unintegrated work without forgetting work that has not landed yet.

Dry Run

Campaign advancement honors event throttle. A dry-run campaign_advance_requested simulates the next objective without mutating the campaign store or repository. It executes exactly one simulated task through review and terminal result, then stops without recursively auto-advancing:

foundry emit campaign_advance_requested \
  --project parite-cli \
  --throttle dry_run \
  --payload '{"campaign":"parite-phase-2d"}' \
  --wait

Sentinels (Scheduled Triggers)

A sentinel is a declarative, named, scheduled trigger that lives inside foundryd and emits an event into the engine when its schedule fires. They internalise the kinds of proactive workflows that previously required an external scheduler (a launchd plist, a cron entry, a systemd timer).

The shipping example is nightly-maintenance: at 02:00 every day it emits maintenance_cycle_started with project = "system", which is exactly the event the foundry run command used to emit from outside the daemon.

Why have them at all?

Before sentinels, the nightly maintenance run lived in launchd/com.mojility.foundry-maintenance.plist. That meant:

  • The trace store and event history had no record of why a cycle started. It just appeared, as if conjured from outside the daemon.
  • Adding a second proactive workflow meant editing a per-machine system file.
  • Pausing or rescheduling required editing that system file too.

Sentinels move all of that inside foundryd. The launchd plist's only job becomes "keep foundryd running"; everything proactive happens through the event bus, the scheduler, and the registry the daemon already owns.

How they fire

flowchart LR
    A[sentinels.json] -->|loaded at startup| B(Scheduler)
    B -->|tokio::time::sleep until next firing| C{deadline reached?}
    C -->|yes| D[build Event from EmitSpec]
    D --> E[engine.process]
    C -->|reload signal| B
    F[foundry sentinel<br/>enable/disable] -->|gRPC mutation| G[Notify::notify_one]
    G --> B

The scheduler always knows the soonest upcoming firing across every enabled sentinel. It blocks on tokio::select! between that deadline and a reload Notify; when the reload fires (because a CLI command toggled a sentinel's enabled state), the loop recomputes deadlines.

The default seed

On first start the daemon writes ~/.foundry/sentinels.json with the canonical seed set — currently four entries:

{
  "version": 1,
  "sentinels": [
    {
      "name": "nightly-maintenance",
      "schedule": { "cron": "0 2 * * *" },
      "emit": {
        "event_type": "maintenance_cycle_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "daily-commit-digest",
      "schedule": { "cron": "0 17 * * *" },
      "emit": {
        "event_type": "commit_digest_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "ops-digest",
      "schedule": { "cron": "0 */3 * * *" },
      "emit": {
        "event_type": "ops_digest_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "nightly-supply-chain",
      "schedule": { "cron": "0 6 * * *" },
      "emit": {
        "event_type": "supply_chain_scan_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    }
  ]
}

The daily-commit-digest sentinel drives the Commit Digest formation. The ops-digest and nightly-supply-chain sentinels drive the Ops Digest and Supply Chain workflows.

Subsequent starts read the existing file and additively merge any seed entries whose names are not yet present — so new Foundry releases that ship additional canonical sentinels reach existing installs automatically on the next restart, without manual JSON edits and without overwriting user toggles or hand-edited cron on entries already in the file. The path is overridable via FOUNDRY_SENTINELS_PATH.

Schedule format

Slice 1 supports a single schedule kind, cron, taking a standard 5-field expression evaluated in local time:

minute hour day-of-month month day-of-week

Examples:

CronMeans
0 2 * * *Every day at 02:00 local
*/30 * * * *Every 30 minutes
0 9 * * 1Every Monday at 09:00 local
0 17 * * 1-517:00 on weekdays

Six-field expressions (second minute hour dom month dow) are also accepted verbatim; five-field expressions are auto-padded with a leading 0 for seconds. The schedule shape is an externally-tagged enum ({"cron": "..."}) so future kinds (interval, event_silence) can be added without breaking the existing wire format.

Defaults you should know about

  • Time zone is local. A cron expression of 0 2 * * * means 02:00 wherever the daemon is running, not 02:00 UTC. This matches how the launchd plist behaved.
  • Catch-up policy is "skip missed firings." If the daemon was down at 02:00 and starts at 05:00, the next firing is the next 02:00. Sentinels intentionally never play catch-up on miss — workflows like maintenance should not run twice in quick succession because the daemon was offline.
  • Auto-seeding only happens once. Subsequent restarts read the existing file even if you have deleted every entry. To restore the seed: remove ~/.foundry/sentinels.json and restart.

CLI

foundry sentinel list | show | enable | disable mirrors foundry registry exactly:

foundry sentinel list
foundry sentinel show nightly-maintenance

# Toggle through gRPC (preferred — the daemon's scheduler wakes immediately):
foundry sentinel disable nightly-maintenance
foundry sentinel enable nightly-maintenance

# Toggle the file directly when the daemon is not running:
foundry sentinel disable --offline nightly-maintenance

Without --offline, all four commands are daemon-authoritative:

  • list calls SentinelList
  • show calls SentinelShow
  • enable calls SentinelEnable
  • disable calls SentinelDisable

Online reads render the daemon response directly and never read the client-side FOUNDRY_SENTINELS_PATH. If foundryd is unreachable, the command fails with a stable actionable error and leaves any absent or pre-existing client-side sentinel file untouched byte-for-byte. Pass --offline only for explicit direct-file recovery while the daemon is stopped.

Online enable and disable are persistence-atomic at the daemon boundary: if the daemon cannot save the sentinel store, the RPC returns INTERNAL, the daemon-owned in-memory store stays unchanged, list and show continue to render the pre-mutation state, the scheduler does not reload, and the on-disk sentinels.json stays byte-identical. The save path writes a temporary file in the same directory and renames it into place only after the full JSON payload is ready, so a failed save does not expose destination truncation or partial bytes.

Adding a new sentinel

There are two paths:

  • Canonical sentinels (the ones every Foundry install should have) are added to SentinelStore::default_seed() in foundry-sdk. The additive seed-merge that runs on every daemon start will append them to existing sentinels.json files automatically — no JSON editing, no migration step. This is how daily-commit-digest reached installs that were already running Slice 1.
  • One-off, machine-local sentinels still need a manual edit. Open ~/.foundry/sentinels.json in your editor, append an entry following the schema shown above, and restart the daemon. foundry sentinel add | remove | edit is deferred to a later slice.

Future slices will also bring non-cron schedule kinds (interval, event_silence) so sentinels can be event-driven, not just timer-driven.

Relationship to launchd

After Slice 1, launchd/com.mojility.foundry-maintenance.plist is removed. Only com.mojility.foundryd.plist remains, and its job shrinks to "keep the daemon alive." If you are upgrading from an older Foundry install that had the maintenance plist loaded, unload and delete it:

launchctl unload ~/Library/LaunchAgents/com.mojility.foundry-maintenance.plist
rm ~/Library/LaunchAgents/com.mojility.foundry-maintenance.plist

After the next foundryd restart, foundry sentinel list should show nightly-maintenance as enabled. Without this cleanup step the daemon and the legacy plist would both emit maintenance_cycle_started each night, doubling the work.

Trusted-LAN Control Plane

Foundry's control plane is plaintext gRPC. It is intended only for loopback or for a trusted LAN or VPN segment that you already control with host firewalls and network policy. Foundry does not add TLS, authentication, clustering, or multi-writer coordination in this slice.

Address Configuration

foundryd still defaults to 127.0.0.1:50051.

Set FOUNDRYD_LISTEN_ADDR to bind somewhere else at daemon startup:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051 foundryd
FOUNDRYD_LISTEN_ADDR=192.168.10.24:50051 foundryd

foundry resolves the daemon URL in this order:

  1. Explicit --addr
  2. FOUNDRY_DAEMON_ADDR
  3. http://127.0.0.1:50051

Examples:

foundry --addr http://mojility-ops-01:50051 status

export FOUNDRY_DAEMON_ADDR=http://mojility-ops-01:50051
foundry status

Only expose the daemon on a network you already trust. If the host is not on a trusted segment, keep the default loopback bind.

Authoritative State Inventory

Before moving daemon authority to another host, account for every daemon-owned or daemon-written path. Inventory the actual resolved value for every override below on the current Mac before you stop it, because any path moved outside ~/.foundry/ must be copied directly and is part of the single authority set.

| Env var | Default path | Purpose | | --- | --- | | FOUNDRY_REGISTRY_PATH | ~/.foundry/registry.json | Project registry authority | | FOUNDRY_CAMPAIGNS_PATH | ~/.foundry/campaigns.json | Durable campaign authority | | FOUNDRY_SENTINELS_PATH | ~/.foundry/sentinels.json | Sentinel authority | | FOUNDRY_AGENT_CONFIG_PATH | ~/.foundry/agents.json | Agent model/provider configuration | | FOUNDRY_WORKTREES_DIR | ~/.foundry/worktrees/ | Isolated task worktrees | | FOUNDRY_PRESERVED_DIR | ~/.foundry/preserved/ | Preserved bundles/refs for non-complete work | | FOUNDRY_EVENTS_DIR | ~/.foundry/events/ | Durable event log root | | FOUNDRY_TRACES_DIR | ~/.foundry/traces/ | Durable trace storage root | | FOUNDRY_AUDITS_DIR | ~/.foundry/audits/ | Audit artifact root | | FOUNDRY_DIGESTS_DIR | ~/.foundry/digests/ | Commit digest output root | | FOUNDRY_OPS_DIGESTS_DIR | ~/.foundry/ops-digests/ | Ops digest output root | | FOUNDRY_TRIAGE_DIR | ~/.foundry/triage/ | Maintenance triage digest root | | FOUNDRY_SUPPLY_CHAIN_DIR | ~/.foundry/supply-chain/ | Supply-chain digest output root | | FOUNDRY_OPS_EVENTS_DIR | ~/Work/Operations/Events/intake/ | MBOS JSONL intake root consumed by ops digests |

These non-overridable paths are still part of the authority copy set:

  • ~/.foundry/ops-digest.watermark
  • ~/.foundry/agent-sessions/

The environment source itself is also authoritative because it defines where the daemon reads and writes:

  • macOS launchd plist environment entries on the current Mac
  • Linux ~/.config/foundry/foundryd.env on mojility-ops-01

Mac to mojility-ops-01 Migration Runbook

This is a single-authority cutover. Do not allow both daemons to accept writes at the same time.

1. Prepare mojility-ops-01

Install the release binaries and service unit, but do not start the daemon yet:

mkdir -p ~/.config/foundry ~/.config/systemd/user
cp systemd/foundryd.service ~/.config/systemd/user/foundryd.service

Create ~/.config/foundry/foundryd.env on mojility-ops-01:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051
FOUNDRY_DIGESTS_DIR=/home/svetzal/Work/Operations/Automation/commit-digests
FOUNDRY_OPS_DIGESTS_DIR=/home/svetzal/Work/Operations/Automation/ops-digests
FOUNDRY_OPS_EVENTS_DIR=/home/svetzal/Work/Operations/Events/intake
FOUNDRY_SUPPLY_CHAIN_DIR=/home/svetzal/Work/Operations/Automation/supply-chain-audits

Adjust host firewalls so only trusted LAN or VPN clients can reach TCP 50051.

Before cutover day, prepare the destination parents for any override that will live outside ~/.foundry/, especially FOUNDRY_OPS_EVENTS_DIR.

2. Confirm Authority Campaigns Are Complete

Before cutover, the following authority campaigns must all be complete:

  • Registry authority campaign
  • Campaign authority campaign
  • Sentinel authority campaign
  • Observability authority campaign

The point is to freeze topology and state semantics before the copy, not to migrate live concurrent writes.

3. Stop the Mac Daemon

Freeze the current writer before copying any state:

launchctl unload ~/Library/LaunchAgents/com.mojility.foundryd.plist

Do not start mojility-ops-01 yet. At this moment there should be zero active writable daemons.

4. Inventory the Mac's Actual Authoritative Paths

Record the real source path for every override, including paths that still happen to point inside ~/.foundry/. The migration copy must use these resolved locations, not assumptions:

plutil -p ~/Library/LaunchAgents/com.mojility.foundryd.plist

Build a cutover checklist that names the exact current Mac source and the exact mojility-ops-01 destination for each of:

  • FOUNDRY_REGISTRY_PATH
  • FOUNDRY_CAMPAIGNS_PATH
  • FOUNDRY_SENTINELS_PATH
  • FOUNDRY_AGENT_CONFIG_PATH
  • FOUNDRY_WORKTREES_DIR
  • FOUNDRY_PRESERVED_DIR
  • FOUNDRY_EVENTS_DIR
  • FOUNDRY_TRACES_DIR
  • FOUNDRY_AUDITS_DIR
  • FOUNDRY_DIGESTS_DIR
  • FOUNDRY_OPS_DIGESTS_DIR
  • FOUNDRY_TRIAGE_DIR
  • FOUNDRY_SUPPLY_CHAIN_DIR
  • FOUNDRY_OPS_EVENTS_DIR
  • ~/.foundry/ops-digest.watermark
  • ~/.foundry/agent-sessions/
  • ~/Library/LaunchAgents/com.mojility.foundryd.plist

5. Perform the One-Time Copy

Copy the default Foundry home once, then copy every actual override source from the checklist once while both daemons remain stopped:

rsync -a --delete ~/.foundry/ mojility-ops-01:~/.foundry/
rsync -a ~/.config/foundry/foundryd.env mojility-ops-01:~/.config/foundry/foundryd.env
rsync -a ~/Work/Operations/Events/intake/ mojility-ops-01:/home/svetzal/Work/Operations/Events/intake/

Then run one rsync -a per overridden path whose authoritative source is outside ~/.foundry/ or differs from the destination in ~/.config/foundry/foundryd.env. Do not skip directories that are only outputs; they are still part of the authoritative state expected by the new sole writer.

If the Mac launchd plist carries environment-only overrides that are not in a file, reproduce them in ~/.config/foundry/foundryd.env before starting mojility-ops-01.

6. Start mojility-ops-01 as the New Sole Authority

ssh mojility-ops-01 '
  systemctl --user daemon-reload &&
  systemctl --user enable --now foundryd &&
  systemctl --user status foundryd --no-pager
'

Point the Mac CLI at the new authority:

export FOUNDRY_DAEMON_ADDR=http://mojility-ops-01:50051

7. Validate Remote Command Parity

Run these from the Mac against mojility-ops-01:

foundry status
foundry registry list
foundry campaign list
foundry sentinel list
foundry history

Then validate a representative detail read from each authority surface:

foundry registry show <project>
foundry campaign show <campaign>
foundry sentinel show nightly-maintenance

Also confirm that each copied output root and the MBOS intake root exist at the authoritative destination paths declared in ~/.config/foundry/foundryd.env.

Do not retire the Mac daemon until these remote reads and path checks match expectations.

8. Roll Back if Parity Fails

If parity checks fail, stop the remote daemon before re-enabling the Mac:

ssh mojility-ops-01 'systemctl --user stop foundryd'
unset FOUNDRY_DAEMON_ADDR
launchctl load ~/Library/LaunchAgents/com.mojility.foundryd.plist

If mojility-ops-01 accepted any writes before failure, copy ~/.foundry/ back to the Mac while both daemons are stopped, then copy back every env-overridden authoritative path from the checklist before restarting only one daemon.

9. Retire the Mac Daemon Only After Success

Once remote parity is confirmed, leave the Mac daemon unloaded and keep the CLI pointed at http://mojility-ops-01:50051. From that point forward, mojility-ops-01 is the only writable Foundry authority.

Commit Digest

Every day at 17:00 local time, Foundry walks every active project in the registry, asks the agent to summarise the day's commits, and drops a markdown digest at a stable path — ready to read with your evening coffee. This is the second proactive formation Foundry ships, alongside the nightly maintenance run.

The digest exists to answer one question reliably each day: what shipped across my work today? It is intentionally lightweight — no code review, no opinion on quality, just a clear roll-up grouped by project with a heads-up section for anything that looks risky.

How it fires

flowchart TD
    A([daily-commit-digest @ 17:00<br/>Sentinel]) -->|emit| B([commit_digest_started<br/>project=system])
    B --> C[[ObserveCommits<br/>Observer block]]
    C -->|enumerate active projects<br/>run git log per project| D([commits_observed<br/>per-project commit data])
    D --> E[[SummarizeCommits<br/>Agent-invoking block]]
    E -->|Claude renders<br/>plain-English digest| F([commit_summary_composed<br/>markdown body])
    F --> G[[WriteCommitDigest]]
    G -->|writes file<br/>FOUNDRY_DIGESTS_DIR/YYYY-MM-DD.md| H([commit_digest_completed<br/>success, digest_path])

The chain is linear — no fan-out, no scatter/gather. Each block sinks on its trigger event and emits the next. CommitDigestStarted is a span opener, so every digest run gets its own trace_id.

Where the digest lands

{FOUNDRY_DIGESTS_DIR}/YYYY-MM-DD.md

The default digests_dir is ~/.foundry/digests/. Set FOUNDRY_DIGESTS_DIR in the daemon's launchd plist to put it wherever you prefer. The typical Operations-side override is the same pattern that already maps ~/.foundry/audits to ~/Work/Operations/Automation/maintenance-audits:

<key>EnvironmentVariables</key>
<dict>
  <key>FOUNDRY_DIGESTS_DIR</key>
  <string>/Users/svetzal/Work/Operations/Automation/commit-digests</string>
</dict>

The date in the filename is the firing date in local time. Rerunning the digest the same day overwrites — last one wins.

What's in the file

Each day's digest looks roughly like this:

# Commit Digest — 2026-05-28

_17 commits across 14 projects._

## ⚠ Heads-up

- `foundry` touched the release workflow — verify the changelog wiring.

## foundry

- `c80d68a` — feat(daemon): add and wire the commit-digest formation
- `b597878` — feat(core): seed-merge default sentinels on daemon startup
- `1503580` — feat(core): add CommitDigest event variants

Shipped Slice 2 of the Sentinel work end-to-end today.

## context-mixer2

- `4f2e891` — fix(parser): handle empty preludes

Single fix to the prelude parser; ready for the next release.

The agent renders this from the raw evidence the observer collected. It is explicitly told not to invent commits or facts not in the source data. If the agent is unavailable the digest is still written, but as a "raw evidence" fallback — a flat list of every commit grouped by project, with a warning line at the top noting the agent was unreachable.

Defaults you should know about

  • Time window is rolling 24 hours. git log --since="24 hours ago" evaluated at firing time. If the daemon was down at 17:00 yesterday, today's window misses that day's commits — same skip-missed catch-up policy as the nightly maintenance run.
  • Active projects only. Projects with a skip reason in the registry are excluded — their commits will not appear in the digest. Use foundry registry show <name> to see why.
  • Merge commits are excluded (--no-merges). The digest reports what was authored, not how it landed.
  • Empty days produce a file too. "No commits across N projects in the last 24 hours." Absence is a fact — the file is your daily proof the sentinel ran.
  • A failing git log for one project does not abort the digest. The error is captured inline under that project's section so you can fix the local repo without losing visibility on the others.
  • Throttle gating. A dry-run firing (foundry emit commit_digest_started --throttle dry_run) runs the full chain — observer queries, agent composes — but does not write the file. Useful for previewing the digest before letting it land.

Triggering it manually

The schedule is the primary path. To trigger immediately:

foundry emit commit_digest_started --project system
foundry watch         # observe the chain
cat ~/.foundry/digests/$(date +%Y-%m-%d).md

To pause the daily run:

foundry sentinel disable daily-commit-digest

And to re-enable:

foundry sentinel enable daily-commit-digest

Event taxonomy

EventPayloadNotes
CommitDigestStarted{ project_count }Span opener. Sentinel emits with an empty payload; observer fills the count once the active registry is known.
CommitsObserved{ window_hours, projects: [{ name, branch, commits, error? }] }Raw evidence. Carries the full SHA per commit so the agent can render the 7-char prefix without truncation ambiguity.
CommitSummaryCompleted{ markdown, project_count, total_commits }The agent-rendered body, before the file header is prepended.
CommitDigestCompleted{ success, digest_path?, project_count, total_commits }Terminal. digest_path is None on a dry-run firing and on persistence failure.

Adding more sentinels later

The daily-commit-digest entry was added to the canonical default seed when Slice 2 shipped. Existing installs from Slice 1 picked it up automatically on the next daemon restart via the additive seed-merge — see Sentinels for how the merge works.

Ops Digest

Every three hours, Foundry reads the MBOS event intake JSONL files, checks whether enough new operational activity has accumulated, asks the agent to summarise it, and drops a markdown digest at a stable path. This is the third proactive formation Foundry ships, alongside the nightly maintenance run and the daily commit digest.

The digest exists to answer one question reliably across the working day: what happened in my business and technical systems since the last check? It is intentionally lightweight — no deep triage, no automated response, just a clear roll-up grouped by domain with an anomaly section when something needs your attention.

How it fires

flowchart TD
    A([ops-digest @ 0 */3 * * *<br/>Sentinel]) -->|emit| B([ops_digest_started<br/>project=system])
    B --> C[[ObserveEvents<br/>Observer block]]
    C -->|reads intake JSONL<br/>applies pressure gate| D{gate<br/>satisfied?}
    D -->|no — skip| E([ops_digest_completed<br/>skipped=true])
    D -->|yes — proceed| F([ops_observed<br/>lean event digests])
    F --> G[[SummarizeEvents<br/>Agent-invoking block]]
    G -->|Claude renders<br/>domain-grouped digest| H([ops_summary_composed<br/>markdown body])
    H --> I[[WriteOpsDigest]]
    I -->|writes file + advances watermark| J([ops_digest_completed<br/>success, digest_path])

The chain is linear — no fan-out. OpsDigestStarted is a span opener, so every digest run gets its own trace_id.

Pressure gate

ObserveEvents applies a pressure gate before passing events downstream. The gate is satisfied when either condition holds:

  • At least 25 new events have arrived since the last watermark.
  • At least one event qualifies as an anomaly (see below).

When the gate is not satisfied ObserveEvents emits OpsDigestCompleted{skipped: true} directly and the chain terminates cleanly — no agent call, no file written.

Anomaly classification

An event is an anomaly if it matches any of these conditions:

ConditionDetail
Urgency P0Any MBOS event with "urgency": "P0"
ci_pipeline_failureAlways anomalous
maintenance_intervention_recordedWhen intervention.outcome == "unresolved"
dependency_vulnerability_detectedWhen vulnerability.severity is "high" or "critical"
maintenance_run_completedWhen maintenance.reposFailed > 0

Where the digest lands

{FOUNDRY_OPS_DIGESTS_DIR}/YYYY-MM-DD.md

The default ops_digests_dir is ~/.foundry/ops-digests/. Set FOUNDRY_OPS_DIGESTS_DIR in the daemon's launchd plist to put it wherever you prefer. The typical Operations-side override pattern:

<key>EnvironmentVariables</key>
<dict>
  <key>FOUNDRY_OPS_DIGESTS_DIR</key>
  <string>/Users/svetzal/Work/Operations/Automation/ops-digests</string>
</dict>

Running the digest multiple times in one day appends to the same dated file — last one wins (atomic rename).

Where the intake comes from

ObserveEvents reads MBOS JSONL files from:

{FOUNDRY_OPS_EVENTS_DIR}/YYYY-MM.jsonl

The default ops_events_intake_dir is ~/Work/Operations/Events/intake. Override with FOUNDRY_OPS_EVENTS_DIR.

Each line in the file is a MBOS event object with at minimum an id, type, occurredAt, urgency, and summary field. Malformed lines are silently skipped so a bad event never aborts the chain.

Watermark-based incremental ingestion

After each successful digest write, WriteOpsDigest atomically advances ~/.foundry/ops-digest.watermark to the occurredAt timestamp of the newest event included in that digest. On the next run ObserveEvents reads the watermark and only considers events with occurredAt strictly after that timestamp — so you never re-process the same event in two digests.

On the very first run (no watermark file), the lookback window is 24 hours.

A dry-run firing (--throttle dry_run) runs the full chain — reads events, applies the gate, invokes the agent — but does not write the file or advance the watermark.

What's in the file

A typical digest looks roughly like this:

# Ops Digest — 2026-05-29

_47 operational events._

## ⚠ Anomalies

- `ci_pipeline_failure` on foundry — PR #42 gate failure, suspected flake.

## Infrastructure

- [P1] `maintenance_run_completed` | 3 repos processed, 1 failed (hone-cli)
- [P2] `dependency_vulnerability_detected` | moderate CVE in libc

## AI

- [P2] `ai_session_start` | 12 agent sessions this period
- [P2] `hone_iteration_started` | 8 iterations across 5 projects

## Clients

- [P1] `email_inbound_support` | Acme Corp — billing question
- [P2] `whatsapp_inbound_support_message` | Quick status check from BetaCo

## Summary

One CI failure on foundry that looks like a test flake — worth a quick check.
Maintenance ran cleanly on all other projects. Moderate client activity,
nothing urgent beyond the billing query.

Triggering it manually

foundry emit ops_digest_started --project system
foundry watch         # observe the chain
cat ~/.foundry/ops-digests/$(date +%Y-%m-%d).md

To pause the three-hourly run:

foundry sentinel disable ops-digest

And to re-enable:

foundry sentinel enable ops-digest

Event taxonomy

EventPayloadNotes
OpsDigestStarted{ event_count }Span opener. Sentinel emits with an empty payload.
OpsObserved{ proceed, new_event_count, anomaly_present, new_watermark?, events: [{id, event_type, occurred_at, domain, urgency?, summary?, client?}] }Lean per-event digests. proceed=false causes downstream self-filter.
OpsSummaryCompleted{ markdown, event_count, new_watermark? }The agent-rendered body, before the file header is prepended.
OpsDigestCompleted{ success, skipped, digest_path?, event_count }Terminal. skipped=true when the pressure gate was not satisfied. digest_path is None on dry-run, skip, or failure.

Environment variables

VariableDefaultPurpose
FOUNDRY_OPS_DIGESTS_DIR~/.foundry/ops-digestsWhere digest files land
FOUNDRY_OPS_EVENTS_DIR~/Work/Operations/Events/intakeMBOS JSONL intake directory

Post-Maintenance Failure Triage

After each nightly maintenance run, Foundry automatically classifies every gate failure and writes a dated triage digest. The formation is propose-only: it reads the Foundry event log, analyses the failures, and writes a markdown report. It applies nothing to any project.

How It Works

The triage formation consists of two task blocks:

  1. TriageMaintenance — sinks on MaintenanceSummaryRequested. Reads the Foundry JSONL event log for the maintenance run window, extracts PreflightCompleted failures, classifies each into one of twelve domain classes, correlates infra flakes, detects chronic deadlocks, and emits MaintenanceTriageCompleted with a typed payload.

  2. WriteTriageDigest — sinks on MaintenanceTriageCompleted. Renders the classified verdicts into a structured markdown file and writes it atomically to ~/.foundry/triage/YYYY-MM-DD.md.

Failure Classes

ClassMeaningDefault Decision
agent_runner_faultAgent runner crashed, timed out, or produced a silent no-opsuppress_infra (correlate first)
ci_infra_flakeCI/infra ephemeral failure: filesystem error, OOM, network hiccupsuppress_infra (correlate first)
format_and_lint_driftFormatting or lint drift — mechanically fixableauto_fixable
vuln_with_fixSecurity advisory with a known fix versionauto_fixable
routine_dependency_bumpPatch/minor dependency bump (no constraints broken)auto_fixable
vuln_no_fixSecurity advisory with no available fixpolicy_call
dependency_major_bump_or_constraint_relaxMajor version bump or constraint relaxation requiredpolicy_call
gate_infra_misconfigGate toolchain or configuration problempolicy_call
compile_and_static_analysis_code_errorCompile error or static-analysis failureneeds_investigation
test_breakageTest suite failureneeds_investigation
chronic_deadlockN≥3 consecutive failures on the same gateescalate
triage_rejected_noiseReclassified as benign (e.g. git push --dry-run exit 1)reclassify_benign

Infra Correlation

When N≥3 distinct projects share the same normalised failure signature and all fall into the infra class (agent_runner_fault or ci_infra_flake), their individual verdicts are collapsed into a single InfraIncident with decision suppress_infra. This avoids noise from widespread infrastructure events flooding the digest.

Streak Detection

If the same (project, gate) pair has failed in N≥3 consecutive PreflightCompleted events within the lookback window (default 14 days), the failure is reclassified as ChronicDeadlock with decision Escalate. These appear at the top of the digest as Deadlock Escalations.

Digest Format

The digest at ~/.foundry/triage/YYYY-MM-DD.md contains:

  • Summary table — counts by category (total, suppressed, auto-fixable, policy, investigation, escalation)
  • Deadlock Escalations — chronic failures needing immediate attention
  • Auto-fixable Proposals — failures with a suggested fix command
  • Infra-suppressed (Correlated) — collapsed infra incidents
  • Policy Calls — failures requiring a human judgement
  • Needs Investigation — failures without a clear mechanical fix
  • Reclassified as Benign — outcomes accepted as noise

Configuration

Override the triage output directory with the FOUNDRY_TRIAGE_DIR environment variable. The default is ~/.foundry/triage/.

VariableDefaultPurpose
FOUNDRY_TRIAGE_DIR~/.foundry/triageTriage digest output directory

Dry-run Behaviour

When triggered with Throttle::DryRun (e.g. foundry run --dry-run), the WriteTriageDigest block skips the file write but still runs the full render. The MaintenanceTriageCompleted event is re-emitted with digest_path: null.

Event Chain

MaintenanceSummaryRequested
  └─► TriageMaintenance
        └─► MaintenanceTriageCompleted (verdicts, infra_incidents, counts)
              └─► WriteTriageDigest
                    └─► MaintenanceTriageCompleted (with digest_path populated)

Supply-Chain Scan

The supply-chain formation is a nightly, working-tree dependency-advisory scan across every managed project. It is advisory and never fails a project run. Its remediation engine is gated off by default; when explicitly enabled, it may apply verified dependency fixes and commit them locally without pushing.

Why it is its own formation

A supply-chain advisory is an external, time-triggered fact. It appears because the world changed — a CVE was published against a dependency — not because the project's own code regressed. It can go red on a repo with zero diff landed.

That makes it categorically different from a quality gate, which answers "is my code correct?" and fails because of your change. Cramming a time-triggered, externally-owned advisory check into the change-triggered, blocking preflight gate set has three failure modes:

  1. it aborts preflight, killing the very maintain step that could bump the dep;
  2. it has no memory — it re-discovers and re-fails the same CVE every night;
  3. it conflates "my code regressed" with "an advisory dropped" under one red checkmark, so a project with perfect code reads as failed.

The release-tag audit action (ReleaseTagAudited) already handles supply-chain at release time. This formation is the missing nightly working-tree lane: it scans what is checked out now, on a schedule, independent of whether code changed.

The chain

nightly-supply-chain sentinel  →  SupplyChainScanStarted
        →  ScanSupplyChain        →  SupplyChainScanned
        →  RemediateSupplyChain    →  SupplyChainRemediated
        →  WriteSupplyChainDigest  →  SupplyChainScanCompleted
  • ScanSupplyChain iterates every active registry project, runs the stack's audit tool (cargo audit, npm audit, mix deps.audit) against the working-tree lockfile, classifies each advisory against that repo's committed allowlist, and emits SupplyChainScanned. Each finding carries a fix version when the audit tool reports one and, where npm supplies it, the direct fix package whose upgrade removes a vulnerable transitive package. Python is the exception to "global tool": pip-audit is a project dependency, so it is run from the project's own .venv/bin/pip-audit — a repo that hasn't installed it reports cleanly under "Not scanned" rather than relying on a global PATH.
  • RemediateSupplyChain triages every live finding by fix availability and emits SupplyChainRemediated, carrying the scan through. A populated fix version means the advisory is mechanically auto-fixable; an empty one means a policy call — an exploitability judgement about our usage that stays human. When the auto-fix engine is enabled (see below) it also applies the fixable ones, behind a verify-and-rollback rail; otherwise it only classifies.
  • WriteSupplyChainDigest renders a deterministic markdown digest (no agent — CVE identifiers must never be paraphrased or hallucinated) and writes it atomically to {FOUNDRY_SUPPLY_CHAIN_DIR}/{YYYY-MM-DD}.md. Dry-run skips the write.

The schedule is 0 6 * * * (06:00 local), offset past the 02:00 maintenance run. The sentinel ships enabled in the canonical seed; disable it with foundry sentinel disable nightly-supply-chain.

The allowlist — committed per-repo memory

A gate is stateless: it re-fails the same advisory forever. A function remembers a decision. Each repo may carry a committed .supply-chain-allow.json at its root — a neutral artifact Foundry reads (it never writes it; acceptances are authored by a human and land through the repo's normal commit flow, so every decision lives in git history):

{
  "version": 1,
  "allowed": [
    {
      "cve": "GHSA-gv7w-rqvm-qjhr",
      "reason": "transitive dev-only dependency; not reachable in our runtime",
      "expires": "2026-09-01"
    }
  ]
}

Each entry classifies one advisory on the day of the scan:

StateConditionEffect
livenot in the allowlistreported as a finding
acceptedpresent, expires today-or-later (or absent)suppressed; noted under "Accepted"
lapsedpresent, expires has passedresurfaces as a live finding and is flagged under "Lapsed acceptances — re-decide"

The expiry is deliberate: an acceptance is a decision to revisit, not a permanent mute. A malformed expires string fails safe — the advisory resurfaces rather than hiding.

The digest

The digest opens with a triage lineN auto-fixable · M policy-call — splitting the live findings by fix availability. It then groups findings into sections: Live findings (a per-project CVE / package / severity / version / fix table, where the fix column shows the resolving version or policy call), Lapsed acceptances (now live, need a fresh decision), Accepted (active allowlist entries, for transparency), and Not scanned (projects whose audit tool was unavailable or had no lockfile — reported, never failed). A clean scan reads "No live supply-chain advisories."

The auto-fix engine (gated dark)

RemediateSupplyChain can do more than classify: it can apply a fixable advisory's fix. This is off by default and stays inert on every install until two conditions both hold — the env var FOUNDRY_SUPPLY_CHAIN_REMEDIATE is truthy and the run is at Full throttle (never under dry_run). With the gate off, the block is byte-for-byte the classifier.

When enabled, each fixable finding goes through a mandatory verify-and-rollback rail, and every change is reversible — committed locally, never pushed:

  1. Refuse a dirty tree. A project whose working tree carries uncommitted changes is skipped, so a rollback can always return to a known-clean HEAD.
  2. Apply. The fixer follows the project's stack and lockfile:
    • Rust: cargo update -p <pkg> --precise <fix> updates Cargo.lock.
    • TypeScript: Bun and npm projects update their native lockfile. A matching direct dependency or override pin is rewritten in package.json first; transitive advisories target npm's explicit fixAvailable.name package.
    • Python: uv projects rewrite a matching pyproject.toml requirement and run uv lock --upgrade-package <pkg>==<fix>. Unsupported stacks or projects without a supported lockfile report a visible apply_failed/no_fixer outcome rather than guessing.
  3. Verify. The repo's own .hone-gates.json gates are re-run. A repo with no gates is skipped — an unverifiable fix is never applied.
  4. Commit or revert. If the required gates pass, only the dependency files touched by that fixer are committed (chore(deps): bump … (supply-chain auto-fix)); otherwise those files are unstaged and restored from HEAD. Each applied fix commits immediately, so a later finding's rollback can never clobber an earlier success.

The digest gains a Remediation section — Auto-fixed, Reverted, and Not auto-fixed (needs attention) — only when the engine actually ran. Enable it by adding the env var to the daemon's launch environment; disable by removing it.

Release-tag audit scan errors

The ReleaseTagAudited event carries an optional scan_error field (Option<String> in the SDK; absent from the JSON wire format when None).

Invariant: a scan that could not run is reported as unknown, never as a clean result. When scan_error is set:

  • vulnerable reflects the upstream payload value (the last known state), not a fresh clean reading.
  • The cve field is likewise forwarded from upstream unchanged.
  • Downstream blocks that branch on vulnerable: false should check for a non-null scan_error before treating a result as authoritative.

scan_error is populated in three situations:

CauseExample message
git checkout <tag> returns non-zero"git checkout v1.2.3 failed: ..."
Scanner tool returned a tool-level error"cargo audit not found"
Scanner gateway itself returned Err"I/O error spawning audit tool"

Environment variables

VariableDefaultPurpose
FOUNDRY_SUPPLY_CHAIN_DIR~/.foundry/supply-chainDigest output directory
FOUNDRY_SUPPLY_CHAIN_REMEDIATE(unset → off)Set truthy (1/true/yes/on) to enable the auto-fix engine. Off by default.

Throttle Control

Throttle controls how far an event ripples through the task block chain. It's set at invocation time and propagated through every event in the chain.

Levels

LevelObserversMutatorsUse case
fullExecute + emitExecute + emitAutomated runs, nightly maintenance
dry_runExecute + emitSkip execution, simulate success via dry_run_events()Preview what would happen

full is the default.

How It Works

The throttle is a property of the event, not the task block. When a block emits downstream events, those events carry the same throttle as the triggering event. This means the throttle decision is made once (at invocation) and respected throughout the chain.

Under dry_run, Mutator blocks are not executed at all. Instead they simulate success via dry_run_events(). The simulated events carry dry_run: true and are still delivered downstream, so the full shape of the chain remains visible even though no Mutator actually ran.

foundry emit vulnerability_detected --project my-tool --throttle dry_run

  vulnerability_detected (throttle: dry_run)
    → Audit Main Branch (Observer) → executes, emits main_branch_audited
      → Cut Release (Mutator) → NOT executed, emits simulated
        release_completed (dry_run: true)
        → downstream blocks still see the chain

Observer vs Mutator

The key design question for every task block: is it an Observer or a Mutator?

  • Observer: reads state, runs scans, checks conditions. Never changes the world. Always runs, always emits, regardless of throttle.
  • Mutator: writes files, pushes commits, cuts releases, installs tools. Changes the world. Throttle controls whether it runs.

At dry_run, Mutators don't execute at all — they simulate success via dry_run_events(), and the simulated events carry dry_run: true.

Mutator blocks implement the SimulatedSuccess trait — dry_run_events() is generated by dry_run_via_simulation!() and must never be hand-written. The simulate() method produces a synthetic outcome, and success_events() is the single source of truth for the event shape.

CLI Usage

# Default: full
foundry emit greet_requested --project hello

# Explicit throttle
foundry emit greet_requested --project hello --throttle dry_run

Writing Task Blocks

To add a new task block to Foundry:

  1. Implement the TaskBlock trait
  2. Register it with the engine in main.rs

The TaskBlock Trait

#![allow(unused)]
fn main() {
use std::pin::Pin;
use foundry_sdk::event::{Event, EventType};
use foundry_sdk::task_block::{BlockKind, TaskBlock, TaskBlockResult};

pub struct MyBlock;

impl TaskBlock for MyBlock {
    fn name(&self) -> &'static str {
        "My Block"
    }

    fn kind(&self) -> BlockKind {
        BlockKind::Observer  // or BlockKind::Mutator
    }

    fn sinks_on(&self) -> &[EventType] {
        &[EventType::GreetRequested]  // which events trigger this block
    }

    fn execute(
        &self,
        trigger: &Event,
    ) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<TaskBlockResult>> + Send + '_>>
    {
        let project = trigger.project.clone();
        let throttle = trigger.throttle;

        Box::pin(async move {
            // Do your work here...

            Ok(TaskBlockResult {
                events: vec![
                    Event::new(
                        EventType::GreetingComposed,
                        project,
                        throttle,
                        serde_json::json!({"result": "done"}),
                    ),
                ],
                success: true,
                summary: "Did the thing".to_string(),
                raw_output: None,
                exit_code: None,
                audit_artifacts: vec![],
            })
        })
    }
}
}

Key Points

  • Propagate throttle: always pass trigger.throttle to emitted events
  • Clone what you need: extract data from trigger before the async move block
  • Return events: the engine handles routing them to downstream blocks
  • Observer vs Mutator: choose based on whether your block has side effects

TaskBlockResult Fields

FieldTypeDescription
eventsVec<Event>Events to emit downstream (subject to throttle)
successboolWhether the block's work succeeded
summaryStringHuman-readable one-line summary shown in traces
raw_outputOption<String>Combined stdout+stderr from any shell command — shown in foundry trace --verbose
exit_codeOption<i32>Exit code from any shell command — useful for observability
audit_artifactsVec<String>Paths to files produced by this block (e.g. audit logs). Listed under artifacts: in verbose trace output

Blocks that do not run external processes should set raw_output: None, exit_code: None, and audit_artifacts: vec![]. Blocks that shell out should populate raw_output with the combined output and exit_code with the process exit code so that traces provide full observability without needing to reproduce the command.

Registering

In foundryd/src/main.rs:

#![allow(unused)]
fn main() {
let mut engine = engine::Engine::new();
engine.register(Box::new(blocks::MyBlock));
}

RetryPolicy

Override retry_policy() to enable automatic retry of transient failures. The default is zero retries (execute exactly once).

#![allow(unused)]
fn main() {
use std::time::Duration;
use foundry_sdk::task_block::RetryPolicy;

fn retry_policy(&self) -> RetryPolicy {
    RetryPolicy {
        max_retries: 3,
        backoff: Duration::from_secs(5),
    }
}
}

With max_retries: N, the engine tries the block up to N + 1 times total (1 initial attempt plus up to N retries), sleeping backoff between each attempt. Both Err results and TaskBlockResult { success: false, .. } trigger a retry. The final attempt's outcome is what appears in the BlockExecution trace.

Use retries for operations that may fail transiently (network calls, shell commands that occasionally time out). Do not use retries for operations that are expected to fail deterministically (e.g. self-filtering by payload).

Gateway Pattern

Task blocks that execute external processes (shell commands, audit tools) receive those capabilities through gateway traits rather than calling the implementation directly. This isolates I/O at the block boundary and makes every block fully testable without spawning real processes.

The ShellGateway Trait

#![allow(unused)]
fn main() {
pub trait ShellGateway: Send + Sync {
    fn run<'a>(
        &'a self,
        working_dir: &'a Path,
        command: &'a str,
        args: &'a [&'a str],
        env: Option<&'a [(String, String)]>,
        timeout: Option<Duration>,
    ) -> Pin<Box<dyn Future<Output = anyhow::Result<CommandResult>> + Send + 'a>>;
}
}

In production, ProcessShellGateway delegates to crate::shell::run. Blocks accept the gateway through their constructor:

#![allow(unused)]
fn main() {
pub struct MyBlock {
    registry: Arc<Registry>,
    shell: Arc<dyn ShellGateway>,
}

impl MyBlock {
    pub fn new(registry: Arc<Registry>) -> Self {
        Self {
            registry,
            shell: Arc::new(ProcessShellGateway),
        }
    }

    #[cfg(test)]
    fn with_shell(registry: Arc<Registry>, shell: Arc<dyn ShellGateway>) -> Self {
        Self { registry, shell }
    }
}
}

Testing with Fakes

gateway::fakes (available only under #[cfg(test)]) provides pre-built fakes:

#![allow(unused)]
fn main() {
use crate::gateway::fakes::{FakeShellGateway, FakeScannerGateway};
use crate::shell::CommandResult;

// Always return a successful, empty result.
let shell = FakeShellGateway::success();

// Always return a failure with the given stderr.
let shell = FakeShellGateway::failure("not installed");

// Return a fixed result every time.
let shell = FakeShellGateway::always(CommandResult { ... });

// Return results in sequence (last one repeats).
let shell = FakeShellGateway::sequence(vec![first_result, second_result]);

// Inspect recorded invocations after the fact.
let invocations = shell.invocations();
assert_eq!(invocations[0].command, "git");
}

For scanner-based blocks:

#![allow(unused)]
fn main() {
let scanner = FakeScannerGateway::clean();
let scanner = FakeScannerGateway::with_vulnerabilities(vec![...]);
let scanner = FakeScannerGateway::with_error("cargo audit not installed");
}

This pattern allows testing every code path — including failure modes and edge cases — without any real I/O:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn detached_head_recovery_succeeds() {
    let dir = tempfile::tempdir().expect("tempdir");
    let registry = make_registry(/* ... */);

    // First call returns "HEAD" (detached); second call (checkout) succeeds.
    let shell = FakeShellGateway::sequence(vec![
        CommandResult { stdout: "HEAD\n".into(), exit_code: 0, success: true, .. },
        CommandResult { stdout: String::new(), exit_code: 0, success: true, .. },
    ]);
    let block = ValidateProject::with_shell(registry, shell);

    let result = block.execute(&trigger).await.unwrap();
    assert_eq!(result.events[0].payload["status"], "ok");
}
}

File Organisation

Place block implementations in foundryd/src/blocks/:

blocks/
├── mod.rs              # pub use declarations
├── greet.rs            # hello-world blocks (ComposeGreeting, DeliverGreeting)
├── validate.rs         # ValidateProject
├── resolve_gates.rs    # ResolveGates
├── run_preflight_gates.rs  # RunPreflightGates
├── run_verify_gates.rs     # RunVerifyGates
├── route_gate_result.rs    # RouteGateResult
├── route_validation_result.rs # RouteValidationResult
├── check_charter.rs    # CheckCharter
├── assess_project.rs   # AssessProject
├── triage_assessment.rs # TriageAssessment
├── create_plan.rs      # CreatePlan
├── execute_plan.rs     # ExecutePlan
├── execute_maintain.rs # ExecuteMaintain
├── retry_execution.rs  # RetryExecution
├── summarize_result.rs # SummarizeResult
├── git_ops.rs          # CommitAndPush
├── audit.rs            # AuditReleaseTag, AuditMainBranch
├── release.rs          # CutRelease, WatchPipeline
├── install.rs          # InstallLocally
├── remediate.rs        # RemediateVulnerability
└── scan.rs             # ScanDependencies

CLI Commands

The foundry CLI communicates with a running foundryd daemon over gRPC.

Global Options

OptionDefaultDescription
--addr <url>FOUNDRY_DAEMON_ADDR or http://127.0.0.1:50051Daemon address

foundry resolves the daemon URL in this order:

  1. Explicit --addr
  2. FOUNDRY_DAEMON_ADDR
  3. http://127.0.0.1:50051

foundry emit

Emit an event into the system. May trigger a workflow chain.

foundry emit <event_type> --project <project> [--throttle <level>] [--payload <json>] [--wait]
ArgumentRequiredDescription
event_typeYesEvent type name (positional)
--projectYesTarget project
--throttleNofull or dry_run (default: full)
--payloadNoJSON string with event-specific data
--waitNoBlock until processing completes, then display the trace

By default, emit returns immediately after the daemon accepts the event. Use --wait to block until the full event chain finishes and then display the trace output (equivalent to running foundry trace after completion).

Output (default):

Event emitted: evt_47fcb603e1b18c8435b8cc3b

Output (with --wait):

Event emitted: evt_47fcb603e1b18c8435b8cc3b
Waiting for processing to complete...
greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → Compose Greeting (1ms): ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → Deliver Greeting (0ms): ok — delivered greeting: Hello, Stacey!
---
Total: 2ms (blocks: 1ms)

foundry status

Show status of active workflows. Queries the daemon for workflows that are currently being processed in the background. The daemon tracks these via an in-memory WorkflowTracker that is populated when each Emit request spawns a background task and cleared on completion. This is a live view only; completed work moves to durable trace history and no longer appears here.

foundry status [workflow_id] [--span <span-id>]

Without an argument, shows all active workflows. With a workflow ID, shows details for that specific workflow. --span first resolves the span through the daemon's Span RPC and then keeps only active workflows in that span's trace. If the daemon is unreachable, the command fails; there is no offline status cache or trace-file fallback.

Output example:

evt_47fcb603e1b18c8435b8cc3b [iteration_requested] foundry — running

If no workflows are currently running:

No active workflows.

foundry watch

Stream live events as they are emitted in real time.

foundry watch [--project <project>]
OptionRequiredDescription
--projectNoFilter by project name; omit to see all projects

Server-side streaming — stays open until interrupted (Ctrl-C). Each line shows the event type, event ID, project, and payload (when non-empty).

Output example:

maintenance_run_started evt_abc project=my-tool
project_validation_completed evt_def project=my-tool
  payload: {"status":"ok","has_gates":true}
project_iteration_completed evt_ghi project=my-tool

foundry run

Trigger a maintenance run for all active projects or a single named project.

foundry run [--project <project>] [--throttle <level>]
OptionRequiredDescription
--projectNoLimit run to a single project by name; omit to run all projects
--throttleNofull or dry_run (default: full)

foundry run emits a maintenance_run_started event which triggers the maintenance workflow chain: validate → iterate (if enabled) → maintain (if enabled) → commit and push → post-push audit.

The command streams progress events in real time and exits automatically when the daemon broadcasts a maintenance_run_completed event at the end of the processing chain. This differs from foundry watch, which streams indefinitely.

When --project is omitted, the project name sent to the daemon is "system", which causes all active (non-skipped) projects to be processed.

Output:

Triggered maintenance run for my-tool
Event: evt_47fcb603e1b18c8435b8cc3b

[my-tool] maintenance_run_started
[my-tool] project_validation_completed (ok)
[my-tool] maintenance_run_completed (ok)

Use foundry trace <event_id> to inspect the full trace after the run completes.

foundry task

Run one concrete coding objective against a registered project.

foundry task <project> "<description>" [--agent <provider>]
ArgumentRequiredDescription
projectYesRegistered project name
descriptionYesOne concrete objective, supplied as a positional string
--agentNoOverride the registered agent provider for this task

The command waits for task_run_completed, streams block progress, and renders the full trace. Execution and verification occur in an isolated Git worktree. The terminal event contains one structural verdict:

VerdictMeaning
completeRequired gates and skeptical review passed; deliverable changes landed on trunk, or no landing was required
remainderThe objective is incomplete; gaps[] names what remains
defectThe implementation or evidence is wrong; diagnosis explains why
blocked_on_decisionA human choice is required; finding and options[] carry it
runner_errorThe agent, workspace, Git, or provider failed

Task execution has no retry route. All task work is committed before terminal state. Non-complete work is preserved on a remote branch or, if push fails, in a Git bundle. For landed work, preservation_ref carries the landed commit SHA; otherwise it identifies the preserved branch or bundle artifact.

foundry campaign

Manage durable, evidence-terminated engineering objectives.

foundry campaign add <definition.json>
foundry campaign list
foundry campaign show <name>
foundry campaign advance <name>
foundry campaign pause <name>
foundry campaign decide <name> --decision "Use the generated tonic client path."
foundry campaign complete <name> --reason "Production evidence confirms the mission shipped."
foundry campaign cancel <name> --reason "Superseded by the new architecture."
foundry campaign cancel <name> --reason "Wrong approach." --now --discard-work
foundry campaign resume <name>
foundry campaign resume <name> --add-cycles 2
SubcommandDaemon required?Description
addYes unless --offlineValidate and atomically add one JSON definition
listYes unless --offlineShow campaign status and cycle counts
showYes unless --offlineShow the complete stored campaign record
advanceYesRe-evaluate done evidence and dispatch one next task, complete, or escalate
pauseYes unless --offlineHalt future automatic and manual advancement
decideYes unless --offlineRecord an owner decision on an escalated campaign and return it to active
completeYes unless --offlineMark an authorized campaign complete with an auditable owner reason
cancelYes unless --offlineStop a campaign permanently, optionally killing the in-flight cycle
resumeYes unless --offlineReturn an authorized paused or escalated campaign to active state

The store defaults to ~/.foundry/campaigns.json and can be overridden with FOUNDRY_CAMPAIGNS_PATH. A definition requires non-empty name, project, and mission fields plus at least one done_evidence item. authorized_by is required before decide, complete, or resume. decide is valid only when the campaign is currently escalated; it appends an owner decision record and makes that policy available to the next formation run. resume --add-cycles N is required when an exhausted budget needs an explicit positive extension. Without --offline, all nine campaign subcommands are daemon-authoritative: they require a reachable foundryd daemon, fail without touching the client-side FOUNDRY_CAMPAIGNS_PATH if the daemon is unreachable, and render the daemon's typed response or workflow output directly rather than re-reading the client store. See Tasks and Campaigns for the definition schema and lifecycle.

advance has no offline formation path. It re-runs campaign done-evidence against delivered trunk, inspects recent objective history and accumulated preserved work, and chooses exactly one of done, one next objective, or escalation. A dispatched task runs the project's resolved gates in its isolated worktree; campaign gate commands are not copied into task acceptance evidence.

Each advance prints and watches a root event ID. Its events carry trace and span context, while task-side events also carry campaign_cycle. campaign_advance_completed retains the formation prompt, agent provider, and done-evidence gate results, allowing the cycle to be reconstructed through foundry trace <event-id> --verbose.

For online mutations, daemon-side persistence is atomic at the control-plane boundary: if a save fails, the command surfaces the typed gRPC INTERNAL error, the daemon-owned campaign store stays byte-identical on disk, and campaign complete does not emit a terminal completion event.

complete is the owner-authorized external terminal path. It accepts any non-completed campaign state, requires a non-empty --reason, clears any stale pending run result, records the reason with the authorizing owner and timestamp, and emits the normal campaign_completed terminal event. Repeating it for an already-completed campaign is idempotent.

cancel is the other external terminal path, and it means the opposite of complete: the campaign is being abandoned before its done evidence was met. It is a distinct cancelled status rather than a flavour of completed, because in Foundry completion is an evidence claim — recording an abandoned campaign as complete would put a false assertion into the audit trail and the ops digest. Cancellation is terminal and not resumable; use pause for a campaign you intend to come back to.

Unlike complete, cancel does not require authorized_by. An unauthorized campaign can never be advanced to completion either, so requiring an owner would strand it with no reachable terminal state. The --reason is still mandatory and always reaches the campaign_cancelled event; it is additionally recorded as an owner decision when the campaign has an owner. Repeating a cancellation is idempotent and emits no second event, and cancelling an already-completed campaign is refused.

By default the cancellation is graceful: the in-flight cycle runs to completion, Foundry commits and preserves its work exactly as it would normally, and no successor cycle is dispatched. Two flags change that:

FlagEffect
--nowAbort the in-flight workflow immediately, killing the running agent process
--discard-workThrow the terminated cycle's uncommitted work away instead of preserving it

With --now and no --discard-work, the orphaned worktree is committed and pushed to its task branch (falling back to a bundle under ~/.foundry/preserved), then removed. With --discard-work the worktree and its local branch are deleted outright; any remote branch an earlier cycle already pushed is deliberately left alone, since that is the audit trail for work which did reach a durable ref.

--discard-work requires --now. A graceful cancellation lets the cycle finish, and FinalizeTask has already committed and preserved its work by then, so there is nothing uncommitted left to discard — the flag would silently do nothing.

--now terminates the agent process, not every process descended from it. kill_on_drop reaches only the direct child, so tool subprocesses spawned by claude or codex are reparented and keep running until they exit on their own.

--offline cancellation is graceful-only and emits no event, matching offline complete. --offline --now is refused rather than silently downgraded: there is no daemon holding a workflow to abort, and reporting a kill that never happened would be worse than failing.

foundry sentinel

Inspect or toggle the daemon-owned scheduled sentinels.

foundry sentinel list [--offline]
foundry sentinel show <name> [--offline]
foundry sentinel enable <name> [--offline]
foundry sentinel disable <name> [--offline]
SubcommandDaemon required?Description
listYes unless --offlineRender every daemon-owned sentinel entry
showYes unless --offlineRender one exact-name daemon-owned sentinel
enableYes unless --offlineMark one sentinel enabled and wake the in-process scheduler immediately
disableYes unless --offlineMark one sentinel disabled and wake the in-process scheduler immediately

The store defaults to ~/.foundry/sentinels.json and can be overridden with FOUNDRY_SENTINELS_PATH. Without --offline, all four commands are daemon-authoritative: list renders SentinelList, show renders SentinelShow, enable calls SentinelEnable, and disable calls SentinelDisable. The online path never reads the client-side sentinel file, so an absent FOUNDRY_SENTINELS_PATH stays absent and a malformed trap file is left byte-identical.

If foundryd is unreachable, the command fails with a stable actionable error that names the matching offline recovery command. There is no silent fallback. Pass --offline only when the daemon is stopped and you intentionally want to read or mutate the sentinel JSON file directly.

Online enable and disable are persistence-atomic at the daemon boundary: the daemon writes a same-directory temporary file and renames it into place only after the full JSON payload is ready. If that save fails, the RPC returns INTERNAL, the daemon-owned in-memory sentinel store remains unchanged, list and show continue to reflect the pre-mutation state, the scheduler is not notified, and the on-disk sentinels.json bytes remain unchanged.

foundry validate

Validate quality gates for one or more projects without running iterate or maintain workflows. This is a read-only operation — no code changes are made.

foundry validate <project>...
foundry validate --all
ArgumentRequiredDescription
projectYes (unless --all)One or more project names (positional)
--allNoValidate all active projects in the registry

For each project, emits a validation_requested event which triggers: Resolve GatesRun Preflight GatesRoute Validation Resultvalidation_completed. No Mutator blocks are involved, so throttle level is irrelevant.

Output example:

Validating mojentic-ts...
  mojentic-ts: PASS
    lint: ok (required)
    format: ok (required)
    test: ok (required)
    build: ok (required)
    security: ok (optional)
validation_requested (evt_007572156d627d7b1211d76f) project=mojentic-ts
  → Resolve Gates (0ms): ok — mojentic-ts: resolved 5 gates for validate workflow
    gate_resolution_completed (evt_92531a666649d6464e569dc2) project=mojentic-ts
      → Run Preflight Gates (6931ms): ok — mojentic-ts: preflight gates passed
        preflight_completed (evt_08b0f626599a23ee8c648a8c) project=mojentic-ts
          → Route Validation Result (3ms): ok — mojentic-ts: validation passed
            validation_completed (evt_e60a246dfa9072414890fa24) project=mojentic-ts
---

Exits with code 0 if all projects pass, non-zero if any required gate fails. Optional gate failures are reported but do not affect the exit code.

foundry trace

View one completed event chain from the daemon-owned trace store.

foundry trace <event_id> [--verbose] [--flat]
ArgumentRequiredDescription
event_idYesRoot event ID returned by foundry emit (positional)
--verboseNoShow trigger payloads, emitted payloads, raw shell output, and audit artifact paths
--flatNoForce the legacy chronological event tree instead of the default span tree

By default this renders the daemon's span tree view. --flat forces the legacy chronological event tree. The CLI asks the daemon for the trace; it does not inspect FOUNDRY_TRACES_DIR directly unless you explicitly choose offline history browsing. Traces are persisted under ~/.foundry/traces/YYYY-MM-DD/, survive daemon restarts, and are available through the daemon even after the in-memory cache has expired.

Output (default):

greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting: ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting: ok — delivered greeting: Hello, Stacey!
        greeting_delivered (evt_f6e5d4c3b2a1) project=hello
---
Total: 2ms (blocks: 1ms)

Output (with --verbose):

greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting (1ms): ok — composed greeting for Stacey
    trigger: {"name":"Stacey"}
    emitted[0]: {"greeting":"Hello, Stacey!"}
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting (0ms): ok — delivered greeting: Hello, Stacey!
---
Total: 2ms (blocks: 1ms)

If the trace is unknown:

No trace found for evt_unknown (expired or unknown).

foundry history

Browse durable completed traces from the daemon-owned trace store.

foundry history [<date>] [--project <project>] [--offline]
ArgumentRequiredDescription
dateNoDate in YYYY-MM-DD format; omit to show the last 7 days
--projectNoFilter results by project name
--offlineNoExplicitly read local trace files instead of the daemon

Without --offline, history is daemon-authoritative: it calls the daemon's typed History RPC, renders daemon-owned results directly, and never reads or creates a client-side FOUNDRY_TRACES_DIR. If the daemon is unreachable, the command fails and suggests rerunning with --offline. Use --offline deliberately when you want direct file diagnostics against ~/.foundry/traces/ (or FOUNDRY_TRACES_DIR).

Each row shows the event ID, trace ID, success status, duration, event type, and project. Dates with no traces are omitted. Within a day, rows are rendered in deterministic newest-first order.

Output example:

2026-03-22
┌──────────────────────────────┬────────┬──────────┬──────────────────────────┬───────────┐
│ Event ID                     │ Status │ Duration │ Type                     │ Project   │
╞══════════════════════════════╪════════╪══════════╪══════════════════════════╪═══════════╡
│ evt_47fcb603e1b18c8435b8cc3b │ ok     │ 312ms    │ maintenance_run_started  │ my-tool   │
│ evt_a1b2c3d4e5f6789012345678 │ ok     │ 48ms     │ greet_requested          │ hello     │
└──────────────────────────────┴────────┴──────────┴──────────────────────────┴───────────┘

If no traces are found:

No traces found in the last 7 days.

foundry registry

Manage the project registry without editing the JSON file directly.

foundry registry <subcommand>

foundry registry init

Create an empty registry file at the default path (~/.foundry/registry.json). This is an explicit offline recovery command and requires --offline. It rejects runs without --offline before contacting the daemon or touching the registry path. Does nothing if the file already exists. This command never uses the daemon, even when foundryd is running.

foundry --offline registry init

foundry registry list

List all projects in the daemon-owned registry as a table. By default this requires a reachable foundryd daemon. Use --offline to read the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path renders the daemon response directly and does not create FOUNDRY_REGISTRY_PATH. Typed daemon failures render as daemon error: <Code> — <message>.

foundry registry list

Output example:

┌──────────┬────────────┬──────┬──────────────────────────┬───────┐
│ Name     │ Stack      │ Skip │ Actions                  │ Skill │
╞══════════╪════════════╪══════╪══════════════════════════╪═══════╡
│ my-tool  │ rust       │ no   │ iterate, maintain, push  │ auto  │
│ frontend │ typescript │ yes  │ maintain, push           │       │
└──────────┴────────────┴──────┴──────────────────────────┴───────┘

The Skill column shows auto (default derived command), cmd (custom command), off (explicitly disabled), or blank (not configured).

foundry registry show <name>

Show all details for a single project from the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to read the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path renders the daemon response directly and does not create FOUNDRY_REGISTRY_PATH. Missing projects surface the daemon's typed NotFound status.

foundry registry show my-tool

Output example:

Name:      my-tool
Path:      /Users/alice/projects/my-tool
Stack:     rust
Agent:     claude
Repo:      alice/my-tool
Branch:    main
Skip:      no
Actions:   iterate, maintain, push
Install:   brew: my-tool
Installs skill: yes (default -- runs my-tool init --global --force)
Timeout:   3600s (default)

foundry registry add

Add a new project to the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to write the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. In offline mode, if the registry file does not exist, it is created automatically. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Duplicate names and invalid inputs surface the daemon's typed AlreadyExists and InvalidArgument statuses.

foundry registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool \
  --branch main \
  [--iterate] [--maintain] [--push] [--audit] [--release] \
  [--install-command "cargo install --path ."] \
  [--install-brew my-formula] \
  [--notes "Human-readable notes about the project"] \
  [--timeout-secs 3600]
OptionRequiredDescription
--nameYesUnique project name
--pathYesAbsolute path to the project
--stackYesTechnology stack: rust, python, typescript, elixir, cpp
--agentYesAI agent name (e.g. claude)
--repoYesGitHub slug (owner/repo)
--branchNoDefault branch (default: main)
--iterateNoEnable iterate action
--maintainNoEnable maintain action
--pushNoEnable push action
--auditNoEnable audit action
--releaseNoEnable release action
--install-commandNoShell command to run for local install
--install-brewNoHomebrew formula name
--notesNoHuman-readable notes
--timeout-secsNoCommand timeout in seconds (default: 3600)

foundry registry remove <name>

Remove a project from the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to mutate the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Missing projects surface the daemon's typed NotFound status.

foundry registry remove my-tool

foundry registry edit <name>

Update settings for an existing project. Only the fields you pass are changed; all others are left as-is. By default this requires a reachable foundryd daemon. Use --offline to mutate the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Missing projects surface the daemon's typed NotFound status.

foundry registry edit my-tool \
  --skip "Waiting for CI to stabilise" \
  --timeout-secs 3600
OptionDescription
--pathUpdate the project path
--stackUpdate the technology stack
--agentUpdate the agent name
--repoUpdate the GitHub slug
--branchUpdate the default branch
--skipSet a skip reason (pass empty string "" to clear the skip)
--iterateSet iterate action (true/false)
--maintainSet maintain action
--pushSet push action
--auditSet audit action
--releaseSet release action
--install-commandSet install command
--install-brewSet Homebrew formula
--notesSet notes (pass empty string "" to clear)
--timeout-secsSet command timeout in seconds

gRPC API

The Foundry service is defined in proto/foundry.proto.

Service: Foundry

Emit(EmitRequest) → EmitResponse

Fire an event into the system. The engine spawns processing as a background task and returns the event ID immediately. Use Trace to check for completion, Status to see in-flight workflows, or Watch for real-time event streaming.

Request:

FieldTypeDescription
event_typestringEvent type name
projectstringTarget project
throttleThrottle enumTHROTTLE_FULL, THROTTLE_AUDIT_ONLY, THROTTLE_DRY_RUN
payload_jsonstringOptional JSON payload

Response:

FieldTypeDescription
event_idstringDeterministic ID of the created event
workflow_idstringID of the triggered workflow (if any)

Status(StatusRequest) → StatusResponse

Query active workflow states.

Request:

FieldTypeDescription
workflow_idstringSpecific workflow (empty for all active)

Response:

FieldTypeDescription
workflowsrepeated WorkflowStatusActive workflow states

Watch(WatchRequest) → stream WatchResponse

Server-side streaming of live events as they are processed by the engine. Optionally filtered by project name.

Request:

FieldTypeDescription
projectstringProject name to filter by; empty string for all projects

Response (stream):

FieldTypeDescription
event_idstringEvent identifier
event_typestringEvent type name
projectstringTarget project
payload_jsonstringEvent payload as JSON

RegistryAdd(RegistryAddRequest) → RegistryAddResponse

Add a project to the daemon's in-memory registry and persist the change to registry.json. The daemon is the single source of truth for registry state.

Request:

FieldTypeDescription
namestringUnique project name
pathstringAbsolute path on the local filesystem
stackstringTechnology stack: rust, python, typescript, elixir, cpp
agentstringAI agent name
repostringGitHub repo slug (owner/repo)
branchstringDefault branch (empty → main)
iterateboolEnable iterate action
maintainboolEnable maintain action
pushboolEnable push action
auditboolEnable audit action
releaseboolEnable release action
install_commandstringShell command for local install (mutually exclusive with install_brew)
install_brewstringHomebrew formula for local install (mutually exclusive with install_command)
notesstringHuman-readable notes (empty → none)
timeout_secsuint64Per-project timeout (0 → use default 3600 s)

Response:

FieldTypeDescription
projectProjectThe newly created project entry

Errors: ALREADY_EXISTS if the name is already in the registry; INVALID_ARGUMENT for an unknown stack or conflicting install fields; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry add ... routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

RegistryList(RegistryListRequest) → RegistryListResponse

List the daemon-owned registry inventory from the in-memory state held by foundryd. The online CLI path renders this response directly and does not read FOUNDRY_REGISTRY_PATH.

Request:

This message has no fields.

Response:

FieldTypeDescription
projectsrepeated ProjectEvery project currently loaded in the daemon-owned registry

Each Project carries the full registry data required by online clients: name, path, stack, agent, repo, branch, skip, action flags, install config, notes, timeout, installs-skill state, and audit exceptions.

Errors: None at the RPC layer; the daemon answers from already-loaded registry state.

CLI: foundry registry list routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is absent, the online path leaves it absent. foundry registry init is not part of this RPC surface and remains an offline-only recovery command.

RegistryShow(RegistryShowRequest) → RegistryShowResponse

Retrieve one exact-name project from the daemon-owned registry state. The match is exact and does not perform prefix or substring lookup.

Request:

FieldTypeDescription
namestringExact project name to retrieve

Response:

FieldTypeDescription
projectProjectThe full daemon-owned project record for that exact name

Errors: NOT_FOUND if no exact-name project exists.

CLI: foundry registry show <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is absent, the online path leaves it absent.

RegistryRemove(RegistryRemoveRequest) → RegistryRemoveResponse

Remove a project from the registry by name.

Request:

FieldTypeDescription
namestringProject name to remove

Errors: NOT_FOUND if no project with that name exists; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry remove <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

RegistryEdit(RegistryEditRequest) → RegistryEditResponse

Apply partial edits to an existing project. Only fields that are non-empty / non-zero are applied. Use clear_* booleans to explicitly clear optional fields (e.g. clear_skip = true to un-skip a project).

Request:

FieldTypeDescription
namestringProject to edit (required)
pathstringNew path (empty → no change)
stackstringNew stack (empty → no change)
agentstringNew agent (empty → no change)
repostringNew repo slug (empty → no change)
branchstringNew branch (empty → no change)
skipstringNew skip reason; non-empty sets it; empty → no change unless clear_skip
clear_skipboolRemove the skip flag
iterateboolSet iterate to true (use clear_iterate to set false)
clear_iterateboolSet iterate to false
maintainboolSet maintain to true
clear_maintainboolSet maintain to false
pushboolSet push to true
clear_pushboolSet push to false
auditboolSet audit to true
clear_auditboolSet audit to false
releaseboolSet release to true
clear_releaseboolSet release to false
install_commandstringSet a shell-command install
install_brewstringSet a Homebrew formula install
clear_installboolRemove the install config
notesstringSet notes (empty string + clear_notes = false → no change)
clear_notesboolRemove notes
timeout_secsuint64Set timeout (0 → no change unless clear_timeout)
clear_timeoutboolRevert timeout to the daemon default

Response:

FieldTypeDescription
projectProjectThe updated project entry

Errors: NOT_FOUND; INVALID_ARGUMENT for conflicting install fields or unknown stack; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry edit <name> ... routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

SentinelList(SentinelListRequest) → SentinelListResponse

List every daemon-owned sentinel from the in-memory scheduler control-plane state held by foundryd.

Request:

This message has no fields.

Response:

FieldTypeDescription
sentinelsrepeated SentinelEvery daemon-owned sentinel entry in scheduler evaluation order

Each Sentinel carries the exact scheduler contract the CLI needs to render: name, cron, emit_event_type, emit_project, emit_throttle, emit_payload_json, and enabled.

Errors: None at the RPC layer; the daemon answers from already-loaded sentinel state.

CLI: foundry sentinel list routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is absent, the online path leaves it absent.

SentinelShow(SentinelShowRequest) → SentinelShowResponse

Retrieve one exact-name sentinel from the daemon-owned scheduler control-plane state. The name match is exact and does not perform prefix or substring lookup.

Request:

FieldTypeDescription
namestringExact sentinel name to retrieve

Response:

FieldTypeDescription
sentinelSentinelThe full daemon-owned sentinel record for that name

Errors: NOT_FOUND if no exact-name sentinel exists.

CLI: foundry sentinel show <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is absent, the online path leaves it absent.

SentinelEnable(SentinelEnableRequest) → SentinelEnableResponse

Mark one daemon-owned sentinel as enabled, persist the updated sentinel store, and wake the in-process scheduler so the next firing is recomputed immediately.

Request:

FieldTypeDescription
namestringExact sentinel name to enable

Response:

FieldTypeDescription
sentinelSentinelThe committed daemon-owned sentinel record after enable

Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the daemon cannot persist the sentinel store.

CLI: foundry sentinel enable <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.

SentinelDisable(SentinelDisableRequest) → SentinelDisableResponse

Mark one daemon-owned sentinel as disabled, persist the updated sentinel store, and wake the in-process scheduler so any pending firing is cancelled immediately.

Request:

FieldTypeDescription
namestringExact sentinel name to disable

Response:

FieldTypeDescription
sentinelSentinelThe committed daemon-owned sentinel record after disable

Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the daemon cannot persist the sentinel store.

CLI: foundry sentinel disable <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.

AddCampaign(AddCampaignRequest) → AddCampaignResponse

Add one campaign definition to the daemon-owned campaign store. The daemon parses the JSON definition, validates the referenced project and context paths against daemon-owned registry state, acquires the exclusive campaign-store lock, and persists the new definition atomically.

Request:

FieldTypeDescription
definition_jsonstringFull campaign definition JSON exactly as accepted by foundry campaign add

Response:

FieldTypeDescription
campaignCampaignDetailFull durable definition that was persisted

Errors: INVALID_ARGUMENT when the JSON is invalid or the definition is structurally invalid; FAILED_PRECONDITION when the definition references an unknown registered project or invalid context artifact; ALREADY_EXISTS when a campaign with the same name already exists; INTERNAL when the store cannot be saved. On INTERNAL, the daemon leaves the on-disk campaign store unchanged.

CLI: foundry campaign add <definition.json> routes through this RPC by default. The online CLI renders the returned CampaignDetail directly and does not re-read FOUNDRY_CAMPAIGNS_PATH. Pass --offline only when you intentionally need direct-file recovery while the daemon is stopped.

ListCampaigns(ListCampaignsRequest) → ListCampaignsResponse

List the durable campaign inventory from the daemon's configured campaign store. This is a read-only query: it loads the store at request time, returns records in deterministic campaign-name order, and exposes summary/status fields only.

Request:

FieldTypeDescription
projectstringOptional exact project-name filter; empty string returns all campaigns

Response:

FieldTypeDescription
campaignsrepeated CampaignDurable inventory records sorted by campaign name

Errors: FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable. Missing or empty stores return an empty list.

PauseCampaign(PauseCampaignRequest) → PauseCampaignResponse

Pause a campaign. The operation is idempotent on the status field — pausing an already-paused campaign is not an error. The daemon holds an exclusive lock on the campaign store for the duration of the write, so a concurrent advance formation cannot interleave with a pause.

Any pending_run_result that was recorded before the pause is explicitly preserved: the operation never clears or overwrites it. The result remains available for the next manual advance after a subsequent resume.

Request:

FieldTypeDescription
namestringExact campaign name to pause

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the pause is applied

Errors: NOT_FOUND when no campaign with the given name exists; FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable or when persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign pause <name> routes through this RPC when the daemon is reachable. Pass --offline to bypass the daemon and mutate the store file directly (useful when foundryd is not running). Without --offline, an unreachable daemon is an error and the client-side campaigns path is left untouched.

ResumeCampaign(ResumeCampaignRequest) → ResumeCampaignResponse

Resume a paused or escalated campaign, optionally extending its cycle budget. The daemon holds an exclusive lock on the campaign store for the duration of the write.

pending_run_result is explicitly preserved: the operation never clears or overwrites it. The result remains available for the next manual advance after resume.

Accepted statuses: paused and escalated. Budget-only escalations (where the engine stopped because the cycle limit was reached) may be resumed with this RPC without recording an owner-decision entry. Use DecideCampaign when the escalation contains a human judgment question that requires a policy record.

Exhausted budget guard: when add_cycles == 0 and cycles_completed >= max_cycles, the RPC returns FAILED_PRECONDITION. Pass a positive add_cycles to explicitly authorize more work; the engine never silently reactivates an exhausted campaign.

Request:

FieldTypeDescription
namestringExact campaign name to resume
add_cyclesuint64Additional cycles to add to max_cycles before resuming (0 = no extension)

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the resume is applied

Errors: NOT_FOUND when no campaign with the given name exists; FAILED_PRECONDITION when the campaign is not paused or escalated, lacks authorized_by, the budget is exhausted and add_cycles == 0, add_cycles would overflow max_cycles, or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign resume <name> routes through this RPC when the daemon is reachable. Pass --offline to bypass the daemon and mutate the store file directly (useful when foundryd is not running). Without --offline, an unreachable daemon is an error and the client-side campaigns path is left untouched. The rendered output is built from the ResumeCampaignResponse.campaign detail — the CLI never re-reads the store file on the online path.

DecideCampaign(DecideCampaignRequest) → DecideCampaignResponse

Record an owner decision on an escalated campaign. The daemon holds the exclusive campaign-store lock for the full mutation, appends one durable owner decision record, and returns the campaign to active so the next advance can proceed with that policy in context.

The persisted owner decision carries the decision text, the campaign's current authorized_by identity, and the daemon timestamp. Existing counters and any stored pending_run_result are preserved.

Request:

FieldTypeDescription
namestringExact campaign name to update
decisionstringNon-empty owner decision text to record

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the decision is applied

Errors: NOT_FOUND when no campaign with the given name exists; INVALID_ARGUMENT when decision is empty after trimming; FAILED_PRECONDITION when the campaign is not escalated, lacks authorized_by, or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign decide <name> --decision "<text>" routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the store file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side campaigns path unchanged.

CompleteCampaign(CompleteCampaignRequest) → CompleteCampaignResponse

Mark an authorized campaign complete from outside the formation loop. The request requires a non-empty reason and an existing authorized_by owner. The daemon stores the reason as an append-only owner record, clears any pending run result, changes the status to completed, and emits the normal CampaignCompleted event for terminal observers. Calling it on an already completed campaign is idempotent.

Request:

FieldTypeDescription
namestringExact campaign name to complete
reasonstringEvidence-backed owner reason for external completion

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after completion is applied

Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown campaign, and FAILED_PRECONDITION when the campaign has no authorizing owner or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged and does not emit CampaignCompleted.

CLI: foundry campaign complete <name> --reason "<text>" routes through this RPC by default. The online CLI renders the returned CampaignDetail directly, never re-reads FOUNDRY_CAMPAIGNS_PATH, and leaves the client-side path untouched if the daemon is unreachable. Pass --offline only for direct-file recovery while the daemon is stopped.

CancelCampaign(CancelCampaignRequest) → CancelCampaignResponse

Stop a campaign permanently before its done evidence was met. Distinct from CompleteCampaign because completed is an evidence claim and cancellation records the opposite. The daemon changes the status to cancelled, clears any pending run result, stores the reason as an append-only owner record when the campaign has an owner, and emits CampaignCancelled for terminal observers.

Unlike CompleteCampaign, this does not require authorized_by — an unauthorized campaign has no other reachable terminal state.

With terminate_now, the daemon aborts the in-flight workflow before touching the store. Because a whole campaign runs inside one task, aborting it kills the running agent process and releases the campaign store lock that task was holding. The abort is awaited, so the subsequent store acquisition is uncontended. discard_work then selects how the orphaned worktree is disposed of by the DisposeCampaignWork block.

Request:

FieldTypeDescription
namestringExact campaign name to cancel
reasonstringOwner reason for abandoning the mission
terminate_nowboolAbort the in-flight workflow, killing the running agent process
discard_workboolThrow the terminated cycle's uncommitted work away

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after cancellation
event_idstringThe emitted CampaignCancelled event; empty when the call was a no-op

Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown campaign, FAILED_PRECONDITION when the campaign is already completed or the store is malformed, and INTERNAL when the store is unreadable or persistence fails. Cancelling an already-cancelled campaign is idempotent: it returns the current detail with an empty event_id and emits no second event.

CLI: foundry campaign cancel <name> --reason "<text>" [--now] [--discard-work] routes through this RPC by default. --discard-work requires --now. --offline uses a graceful-only direct-file path that emits no event; --offline --now is refused rather than downgraded.

AdvanceCampaign(AdvanceCampaignRequest) → AdvanceCampaignResponse

Dispatch one manual campaign-advance workflow for an active or staged campaign. The daemon validates the current status under the exclusive lock, releases the lock, emits CampaignAdvanceRequested, and returns immediately.

Request:

FieldTypeDescription
namestringExact campaign name to advance

Response:

FieldTypeDescription
campaignCampaignDetailCurrent campaign detail at dispatch time
event_idstringRoot event ID of the dispatched CampaignAdvanceRequested workflow

Errors: NOT_FOUND when the name is absent; FAILED_PRECONDITION when the campaign is paused, escalated, or completed; INTERNAL when the store is unreadable. Rejected advances do not mutate the store.

CLI: foundry campaign advance <name> routes through this RPC by default. The online CLI prints the returned root event_id, watches the daemon-owned workflow stream, and renders the daemon trace for that event. It does not re-read FOUNDRY_CAMPAIGNS_PATH.

GetCampaign(GetCampaignRequest) → GetCampaignResponse

Retrieve the complete durable definition of one campaign by exact name. Unlike ListCampaigns, this RPC returns the full detail record including intent_refs, context_paths, done_evidence (with the Gate/Review type distinction preserved), escalation rules, and all runtime status fields.

Request:

FieldTypeDescription
namestringExact campaign name to look up

Response:

FieldTypeDescription
campaignCampaignDetailFull durable definition of the campaign

Errors: NOT_FOUND when no campaign with the given name exists in the store (even when other campaigns are present); FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable.

CLI: foundry campaign show <name> routes through this RPC by default and renders the returned CampaignDetail directly, without re-reading FOUNDRY_CAMPAIGNS_PATH.

History(HistoryRequest) → HistoryResponse

List durable trace history from the daemon-owned trace store.

Request:

FieldTypeDescription
datestringExact day in YYYY-MM-DD format, or empty to request recent history
projectstringExact project filter, or empty for all projects
recent_daysuint32Number of recent days to return when date is empty; online CLI uses 7

Response:

FieldTypeDescription
daysrepeated HistoryDayMatching days, newest day first

Each HistoryDay carries a date plus repeated HistoryTrace. HistoryTrace contains event_id, event_type, project, success, total_duration_ms, and trace_id.

Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/ and survive daemon restarts. The daemon reads that store directly when serving history, preserving durable retention semantics even after in-memory cache entries expire.

CLI: foundry history routes through this RPC by default, renders the daemon response directly, and does not read or create a client-side FOUNDRY_TRACES_DIR unless --offline is explicit.

Trace(TraceRequest) → TraceResponse

Retrieve the trace of a completed event chain. Returns all events produced during processing and a record of each block execution.

Request:

FieldTypeDescription
event_idstringRoot event ID to look up

Response:

FieldTypeDescription
foundboolWhether a trace was found for the given event ID
eventsrepeated TraceEventAll events in the chain
block_executionsrepeated TraceBlockExecutionRecord of each block execution

Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/ and survive daemon restarts. The Trace RPC checks the in-memory store first (for recently completed chains) and falls back to disk for older traces.

CLI: foundry trace <event-id> routes through this RPC by default. When no trace is found, the CLI prints No trace found for <event-id> (expired or unknown).

Span(SpanRequest) → SpanResponse

Retrieve every event and block execution that belongs to one span.

Request:

FieldTypeDescription
span_idstringExact span ID to look up

Response:

FieldTypeDescription
foundboolWhether a span was found for the given span ID
eventsrepeated TraceEventEvents whose span_id matches the requested span
block_executionsrepeated TraceBlockExecutionBlocks whose own span or parent span matches
trace_idstringOwning trace ID, including for block-only spans
total_duration_msuint64Sum of returned block durations

Span is an in-memory lookup keyed by the daemon's span index. It is intended for live drill-down and status filtering rather than durable offline browsing. If the span is unknown, the response is found = false with empty collections and an empty trace_id.

Messages

WorkflowStatus

FieldTypeDescription
workflow_idstringWorkflow identifier
workflow_typestringWorkflow type name
projectstringTarget project
statestringpending, running, completed, failed
started_atstringISO 8601 timestamp
completed_atstringISO 8601 timestamp (empty if running)
task_blocksrepeated TaskBlockStatusPer-block status

TaskBlockStatus

FieldTypeDescription
namestringBlock name
statestringpending, running, completed, skipped, failed
started_atstringISO 8601 timestamp
completed_atstringISO 8601 timestamp
throttledboolTrue if emission was suppressed by throttle

TraceEvent

FieldTypeDescription
event_idstringDeterministic event identifier
event_typestringEvent type name
projectstringTarget project
occurred_atstringISO 8601 timestamp
throttleThrottle enumThrottle level for this event

Campaign

Summary-only wire form returned by ListCampaigns. Intentionally omits intent_refs, context_paths, done_evidence, and escalation — use GetCampaign to retrieve those fields.

FieldTypeDescription
namestringCampaign name
projectstringRegistered project name
missionstringCampaign mission statement
statusstringDurable status: staged, active, paused, escalated, or completed
cycles_completeduint64Number of dispatched task cycles
cycles_landeduint64Number of task results whose work reached trunk
max_cyclesuint64Configured campaign cycle budget
authorized_bystringOwner authorization identity, or empty when absent
agent_providerstringPreferred agent provider, or empty when absent
last_run_event_idstringMost recent campaign-run event ID, or empty when absent

CampaignDetail

Full durable definition of a campaign, as returned by GetCampaign. Carries all fields in Campaign plus the definition-time fields that the summary form omits.

FieldTypeDescription
namestringCampaign name
projectstringRegistered project name
missionstringCampaign mission statement
statusstringDurable status: staged, active, paused, escalated, or completed
cycles_completeduint64Number of dispatched task cycles
cycles_landeduint64Number of task results whose work reached trunk
max_cyclesuint64Configured campaign cycle budget
authorized_bystringOwner authorization identity, or empty when absent
agent_providerstringPreferred agent provider, or empty when absent
last_run_event_idstringMost recent campaign-run event ID, or empty when absent
intent_refsrepeated stringIntent reference labels that anchor the mission
context_pathsrepeated stringPaths to context documents for the campaign
done_evidencerepeated DoneEvidenceCompletion criteria with Gate/Review type distinction
escalationrepeated stringHuman-readable escalation instructions
owner_decisionsrepeated OwnerDecisionAppend-only owner policy decisions recorded after escalations

OwnerDecision

One recorded owner decision attached to a campaign after an escalation. These records are append-only and are threaded back into future campaign formation prompts as binding context.

FieldTypeDescription
decisionstringOwner-authored decision text
authorized_bystringOwner identity copied from the campaign at record time
decided_atstringRFC 3339 timestamp recorded by the daemon

DoneEvidence

One completion-evidence item. The kind field distinguishes the two variants.

Gate variant (kind = "gate"):

FieldTypeDescription
kindstring"gate"
commandstringShell command to evaluate
requiredboolWhether this gate is required for completion
statementstringEmpty for Gate items
artifactsrepeated stringRepository-relative paths that must exist before the gate can pass

Review variant (kind = "review"):

FieldTypeDescription
kindstring"review"
commandstringEmpty for Review items
requiredboolfalse for Review items
statementstringHuman-readable completion statement
artifactsrepeated stringEmpty for Review items

TraceBlockExecution

FieldTypeDescription
block_namestringName of the block that executed
trigger_event_idstringEvent ID that triggered this block
successboolWhether the block succeeded
summarystringHuman-readable summary of the result
emitted_event_idsrepeated stringIDs of events emitted by this block
duration_msuint64Wall-clock milliseconds for this block execution (including retries)
raw_outputstringCombined stdout+stderr from any shell command run by this block
exit_codeint32Exit code from any shell command run by this block
trigger_payload_jsonstringJSON payload of the event that triggered this block
emitted_payload_jsonsrepeated stringJSON payloads of events emitted by this block
audit_artifactsrepeated stringPaths to audit artefact files produced by this block

Throttle (enum)

ValueNumberDescription
THROTTLE_FULL0All blocks emit
THROTTLE_AUDIT_ONLY1Observers emit, mutators suppress
THROTTLE_DRY_RUN2Read-only, no side effects

Event Types

All event types are defined in foundry-sdk/src/event.rs as the EventType enum. The string representation uses snake_case.

Every event carries these common fields:

FieldTypeDescription
idstringDeterministic SHA-256 derived ID, prefixed evt_
event_typestringSnake-case event type name
projectstringProject this event relates to
occurred_atRFC 3339 timestampWhen the event happened
recorded_atRFC 3339 timestampWhen the event was logged
throttlestringfull or dry_run
payloadJSON objectEvent-type-specific fields (see below)
trace_idstring or nullOTel trace identity
span_idstring or nullCurrent workflow span identity
parent_span_idstring or nullParent span identity
causation_idstring or nullEvent that caused this event
gather_idstring or nullScatter/gather group identity

Hello-World (engine validation)

TypeDescription
greet_requestedRequest to compose and deliver a greeting
greeting_composedGreeting message has been composed
greeting_deliveredGreeting has been delivered (side effect)

greet_requested payload

FieldTypeDescription
namestringName to greet

greeting_composed payload

FieldTypeDescription
greetingstringComposed greeting text

Vulnerability Remediation

TypeDescription
scan_requestedRequest to scan a project for known vulnerabilities
vulnerability_detectedA vulnerability was found (or injected externally)
release_tag_auditedLatest release tag scanned for the vulnerability
main_branch_auditedMain branch checked for the same vulnerability
remediation_startedAutomated fix attempt initiated
remediation_completedFix attempt finished (success or failure)

vulnerability_detected payload

FieldTypeDescription
cvestringCVE or advisory ID (e.g. "CVE-2026-1234")
vulnerableboolWhether the project is affected
dirtybool (optional)Whether the main branch still contains the vulnerability

release_tag_audited payload

FieldTypeDescription
cvestringCVE from the scan or forwarded from the trigger
vulnerableboolWhether the release tag is affected
dirtybool (optional)Forwarded from the upstream trigger for downstream routing

main_branch_audited payload

FieldTypeDescription
cvestringCVE identifier
dirtybooltrue if the vulnerability is still present on main

remediation_completed payload

FieldTypeDescription
cvestringCVE that was remediated
successboolWhether the fix was applied successfully

Release Lifecycle

TypeDescription
release_requestedDecision made to cut a patch release
release_completedRelease tag created and pushed
release_pipeline_completedGitHub Actions build/publish workflow finished

release_completed payload

FieldTypeDescription
cvestringCVE that prompted the release
releasestringRelease type (e.g. "patch")
new_tagstring or nullSemver tag extracted from Claude CLI output
successboolWhether the Claude CLI invocation succeeded

release_pipeline_completed payload

FieldTypeDescription
statusstring"success" or "failure"
conclusionstring (optional)GitHub Actions conclusion label

Project Lifecycle

TypeDescription
project_validation_completedPre-flight checks for a maintenance run
project_iteration_completedIterate workflow finished
project_maintenance_completedMaintain workflow finished
project_changes_committedGit commit created
project_changes_pushedChanges pushed to remote

project_validation_completed payload

FieldTypeDescription
statusstring"ok", "error", or "skipped"
reasonstring (optional)Human-readable explanation when status is not "ok"
has_gatesbool (optional)Whether .hone-gates.json is present (only on "ok")

project_iteration_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"iterate"
successboolWhether the iterate workflow succeeded
summarystringHuman-readable summary of the result
changesbool (optional)Whether code changes were made

project_maintenance_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"maintain"
successboolWhether the maintain workflow succeeded
summarystringHuman-readable summary of the result
changesbool (optional)Whether code changes were made

project_changes_committed payload

FieldTypeDescription
cvestringCVE or "unknown" (from remediation path)
messagestringGit commit message used

project_changes_pushed payload

FieldTypeDescription
cvestringCVE or "unknown" (from remediation path)

Local Install

TypeDescription
local_install_completedLocal tool reinstallation finished

Maintenance Workflow

TypePayloadDescription
iteration_requested{ project }Triggers the iterate sub-workflow for a validated project
maintenance_requested{ project }Triggers the maintain sub-workflow for a validated project

Task Lifecycle

TypeDescription
task_run_startedIsolated one-shot task execution began
task_reviewedSkeptical review produced a structural verdict
task_run_completedTask work was landed, durably preserved, or completed with no landing required

task_run_completed payload

FieldTypeDescription
projectstringRegistered project name
successbooltrue only for a complete verdict
landedboolWhether complete or safe converging remainder work reached trunk
summarystringHuman-readable terminal summary
verdictstringcomplete, remainder, defect, blocked_on_decision, or runner_error
preservation_refstring (optional)Continuation ref: landed commit SHA, or remote branch / bundle:<path> for preserved work
campaignstring (optional)Campaign that dispatched the task
campaign_cycleinteger (optional)Campaign cycle that dispatched the task

Verdict-specific fields are gaps[], diagnosis, finding plus options[], or detail.

Campaign Formation

TypeDescription
campaign_advance_requestedRequest to re-evaluate a durable campaign
campaign_advance_completedFormation chose done, one next objective, or escalation
campaign_pausedProvider unavailability paused a non-terminal campaign
campaign_escalatedCampaign halted for budget, failure, rule, or owner judgment
campaign_completedRequired done evidence proved the mission complete
campaign_cancelledAn owner abandoned the mission before its evidence was met

campaign_cancelled payload

Flattens the shared terminal payload (campaign, project, reason, cycles_completed, cycles_landed) so generic terminal observers parse it unchanged, and adds the operator's disposition choices.

FieldTypeDescription
terminated_nowboolThe in-flight workflow was aborted rather than left to finish
discard_workboolThe terminated cycle's uncommitted work was thrown away
aborted_event_idstring?Root event of the aborted workflow; absent for a graceful stop

An aborted run never reaches trace persistence, so aborted_event_id is the only handle onto its partial events in the JSONL log — foundry trace has nothing to show for it.

campaign_advance_requested payload

FieldTypeDescription
campaignstringCampaign name
run_event_idstring (optional)Typed task result that triggered the advance
run_resultobject (optional)Full task_run_completed payload

campaign_advance_completed payload

FieldTypeDescription
campaignstringCampaign name
projectstringRegistered project name
cycles_completedintegerTasks dispatched by the campaign
cycles_landedintegerTask results whose task_run_completed.landed field was true
decisionstringdone, advance, or escalate
objectivestring (advance only)Exactly one next task objective
reasonstringEvidence or gap supporting the decision
promptstring (optional)Exact prompt shown to the formation agent
agent_providerstring (optional)Provider used for the formation decision
gate_resultsarrayResults from campaign done-evidence gates

Gate Orchestration

TypeDescription
gate_resolution_completedGate definitions loaded from .hone-gates.json
preflight_completedGates passed/failed on unmodified codebase
execution_completedCode changes applied (emitted by future execution blocks)
gate_verification_completedGates passed/failed after execution
retry_requestedGate failure triggers bounded retry

gate_resolution_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"iterate", "maintain", or "validate"
gatesarrayGate definitions (name, command, required, timeout_secs)
actionsobject (optional)Forwarded actions from the trigger event

preflight_completed payload

FieldTypeDescription
projectstringProject name
workflowstringWorkflow that triggered the preflight
all_passedboolWhether every gate passed
required_passedboolWhether all required gates passed
resultsarrayPer-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?)

Each results[] entry includes an optional duration_ms field (unsigned integer) recording how long the gate command took in milliseconds. This field is absent when loading results from events persisted before timing instrumentation was added.

A results[] entry also carries an optional fix_applied boolean: true when the gate initially failed but its fix_command repaired the working tree and the re-check then passed (a self-healed gate). The field is omitted when false, so it is absent for gates that passed clean and for events persisted before self-healing gates were added.

gate_verification_completed payload

FieldTypeDescription
projectstringProject name
workflowstringOriginating workflow
all_passedboolWhether every gate passed
required_passedboolWhether all required gates passed
retry_countnumberCurrent retry count (0 on first attempt)
resultsarrayPer-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?)

Each results[] entry includes an optional duration_ms field (unsigned integer) recording how long the gate command took in milliseconds. This field is absent when loading results from events persisted before timing instrumentation was added.

A results[] entry also carries an optional fix_applied boolean: true when the gate initially failed but its fix_command repaired the working tree and the re-check then passed (a self-healed gate). The field is omitted when false, so it is absent for gates that passed clean and for events persisted before self-healing gates were added.

retry_requested payload

FieldTypeDescription
projectstringProject name
workflowstringOriginating workflow
retry_countnumberIncremented retry count
failure_contextstringGate output from the failed verification
actionsobject (optional)Forwarded actions

Validation

TypeDescription
validation_requestedRequest to validate a project's gate health
validation_completedTerminal event with per-gate pass/fail results

validation_requested payload

FieldTypeDescription
projectstringProject name

validation_completed payload

FieldTypeDescription
projectstringProject name
successboolWhether all required gates passed
resultsarrayPer-gate results (name, passed, required, output snippet)

Maintenance Run Lifecycle

TypeDescription
maintenance_run_startedA maintenance run was triggered for a project
maintenance_run_completedAll projects processed, summary available

maintenance_run_started payload

FieldTypeDescription
projectstringProject name this run covers

maintenance_run_completed payload

FieldTypeDescription
totalnumberTotal number of projects processed
succeedednumberProjects that completed successfully
failednumberProjects that encountered an error
skippednumberProjects that were skipped (already active or skip=true)
projectsarrayPer-project result objects (name, status, duration_secs)

Release Tag Audit

TypeDescription
release_tag_auditedLatest release tag scanned (see payload above)

Agent Session Lifecycle

Emitted by foundryd whenever a Foundry-launched Claude Code agent session begins or ends. Used by visualisation tools (e.g. ops-visualizer) to show in-flight and historical agent activity, and to locate the per-session stream-json transcript on disk.

TypeDescription
agent_session_startedAn agent session has begun; transcript file path is included
agent_session_endedThe agent session has finished (success, failure, or unavailable)

agent_session_started payload

FieldTypeDescription
session_idstringUUID identifying this session; matches the transcript file basename
agent_typestringAgent runtime (currently always claude-code)
projectstringProject name (may be empty in v1)
working_dirstringAbsolute path of the working directory the agent ran in
source_log_pathstringAbsolute path to the per-session JSONL transcript (~/.foundry/agent-sessions/<session_id>.jsonl)
capabilitystringCapability label: reasoning, coding, or quick
accessstringTool access level: read_only or full
started_atRFC 3339 timestampWhen the session was launched
trace_idstringCorrelating trace ID (may be empty in v1)

agent_session_ended payload

FieldTypeDescription
session_idstringUUID identifying this session (matches agent_session_started)
statusstringOutcome: ok, agent_failed, or unavailable
exit_codenumberProcess exit code (omitted when the agent could not be invoked)
ended_atRFC 3339 timestampWhen the session finished
bytes_writtennumberTotal bytes streamed to the transcript file
errorstringError message when status = unavailable (omitted otherwise)