From cb38b264e9e5b83aa4fd254f15ed1428cc2e397e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 10 Jul 2026 06:38:22 -0400 Subject: [PATCH 1/2] jcp-orchestrate: background-agent orchestrator profile mapping + runnable example MAPPING.md records what this crate ingests: the background-agent orchestrator pattern (tracker -> branch -> dispatch -> PR, coding agents in isolated containers, local-handoff CLI doubling as an MCP server, BYO subscription, event-triggered automations, chat/forge mention bindings). The pattern is commodity; what no deployed instance produces is a signed, energy-accounted, verifiable receipt of what ran, under whose authority, for how many joules. The doc maps the pattern onto the reconcile loop, the Sandbox S0-S7 rungs its container model sits low on (~S2/S3; the step up is an S5 microVM plus sandbox-tee::energy), the Joule Table seams its UX surfaces never carried, and the JCP grant its credential model stops short of (authentication vs authority) - then scopes the frontier the category leaves empty. examples/background_agents.rs drives it end to end: two backends handle `implement` and the fleet fails over when the first exhausts its joule budget; a `review` backend flakes twice and lands on the third attempt, charged for every attempt; a `deploy` task is refused at routing because no backend holds that capability. Six sealed decisions, all verifying under the steward key. Tests and clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md | 129 +++++++++++++++++ .../examples/background_agents.rs | 131 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md create mode 100644 jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md new file mode 100644 index 000000000..eff7ce752 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md @@ -0,0 +1,129 @@ +# The background-agent orchestrator pattern → `jcp-orchestrate` + +`jcp-orchestrate` does not invent an orchestration shape. It **ingests** the one +the agent-work ecosystem converged on — a background-agent orchestrator that runs +coding agents in the cloud — and supplies the three things every deployed +instance of that pattern lacks: a **capability gate**, a **joule budget**, and a +**sealed audit**. + +This document maps the pattern — as built by the production background-agent +orchestrators — onto (1) this crate's reconcile loop, (2) the Sandbox S0–S7 +isolation ladder its container model sits low on, (3) the Joule Table seams its UX +surfaces never carried, and (4) the JCP grant its credential model stops short of +— then scopes the frontier the whole category leaves empty. + +## 0. Why map a commodity pattern + +The background-agent orchestrator is a well-worn shape: a tracker of work items; +each item run by a coding agent in an isolated container on its own repo copy, +auto-branched, checkpointed to a forge as machine-authored commits and pull +requests; a CLI for local handoff that doubles as an MCP server; bring-your-own +subscription or API keys; recurring and event-triggered automations; chat and +forge @-mention bindings. Several products shipped exactly this. The shape is not +in dispute and not a moat — it is commodity. + +What none of them produced is the durable artifact: a **signed, energy-accounted, +verifiable receipt** of what ran, under whose authority, for how many joules. +Everyone can build the orchestrator; the accountability is the open lane. This +crate keeps the loop and makes each dispatch a JCP decision made **outside** the +agent. + +## 1. The reconcile loop → `Orchestrator` (ingest, not compete) + +The pattern's control plane is a tracker → route → dispatch → retry loop over a +*heterogeneous* fleet, where a worker is one coding-agent backend on one item and +a different backend on the next. `Orchestrator` keeps that loop and gates every +dispatch. + +| Background-agent orchestrator concept | `jcp-orchestrate` symbol | What TX adds | +|---|---|---| +| Work tracker, one branch per task | `Task { id, kind, payload }` + the sealed decision ledger (`receipts()`) | The run is an **auditable ledger of sealed decisions**, not just a branch list | +| A coding-agent backend (any CLI/framework) | `Harness` — any backend that quotes a joule cost and reports an outcome | The orchestrator is **blind to what runs inside**; backends are `register`ed, not hardcoded | +| Assign a task to a backend | `dispatch` **decided against a grant, outside the backend** (`route` → gate) | Who-runs-what is a runtime authorization, never the agent's own choice | +| Retry on failure | bounded retry **+ capacity-aware failover** to a backend still under budget | An exhausted backend fails over; a flaky one retries to `max_attempts` | +| Bring-your-own subscription / API key | `Capability` (task kinds) **+ `budget_uj`** per backend | Authority, not just authentication — see §4 | +| Unbounded agent loop (the documented cost failure) | joule budget **debited + enforced before the side effect** | A hard, pre-side-effect energy ceiling per grant | +| Machine-authored commit / PR as the record | JCR-1 receipt sealed per decision, allow **and** deny | The **non-repudiable audit** a git history cannot be | + +Net: the pattern is *what the loop looks like in production*; `Orchestrator` is +*the same loop with the gate, the budget, and the sealed audit the meta-harness +leaves to the platform*. The `background_agents` example drives it end to end. + +## 2. Container isolation → Sandbox S0–S7 (attested execution) + +The pattern isolates each task in a container with its own repo copy. On the +Sandbox ladder that is roughly **S2–S3**: namespace/cgroup isolation, no attested +boot, no measured execution, no energy-counter provenance. The container keeps +tasks from colliding; it proves nothing to a third party about what ran. + +| Pattern isolation | Sandbox rung | The step up | +|---|---|---| +| Container, own repo copy | ~S2/S3 (namespace isolation) | Integrity asserted by the host, attested to no one | +| — | **S5** — microVM (proven on real bare-metal KVM + RAPL) | A hardware-isolation domain with a measured boot; a receipt can name the image that ran | +| — | **S6** — TEE attestation (`sandbox-tee::energy`) | An `EnergyStatement` bound into a TEE report's `report_data`; the honest `IntegrityAttested` vs `PhysicallyRooted` trust ladder | + +The move is not a better container. It is running the *same* task inside an S5 +microVM under an attested boot, and binding the energy reading's provenance into +the receipt. The pattern's isolation stops at "tasks don't collide"; the ladder +continues to "a third party can verify what ran and what it cost." + +## 3. The UX surfaces → Joule Table seams (gate / meter / receipt) + +The surfaces the pattern converged on are the right surfaces — they are simply +unmetered, ungated, and unsealed. Each rebinds through an existing binding, and +each gains the three seams the bare orchestrator never had. + +| Pattern surface | Joule Table binding | The three seams it gains | +|---|---|---| +| Local-handoff CLI + MCP task server | **Joule Hook** (editor/host binding) | gate = grant on task creation; meter = joules per task; receipt = MCP receipt server | +| Chat / forge @-mention kicks off a task | **Joule Desk** (teammate binding) | signed-grant authority for the mention; a per-receipt-verifiable ledger of what the teammate did | +| Recurring + event-triggered automations | **Durable agent execution** (Sandbox + JCP) | durable resumable runs, env-manifest identity, standing grants with a joule ceiling, mid-run steering | +| Browser streaming / notifications | mid-run steering over an attested run | monitoring becomes attested control | + +The pattern proved the surfaces convert. It never made any of those actions +accountable. The seams are exactly that missing accountability. + +## 4. Credentials → JCP grant (authentication → authority) + +Bring-your-own subscription answers **who is paying** — an identity with a quota +at a vendor. A JCP grant answers **what this principal may do, for how many +joules, decided where the model can't reach**: a capability and an energy budget +are the same signed, bounded, debited object. A vendor key lets an agent run +until the bill stops it; a grant denies the action itself — an out-of-scope task +kind, or one over the joule ceiling — *before* the side effect, and seals both the +allow and the deny. Auth says "the card works"; the grant says "this action, this +budget, this receipt." + +## 5. The frontier the pattern leaves open + +The background-agent orchestrators shipped every convenience and none of the +trust layer. The empty lane is the one no instance of the pattern occupies: a +**per-run, per-task, energy-accounted, attested, third-party-verifiable receipt** +of agentic work — `efficiency/1` rolled up from JCR-1 decisions, with the energy +counter's provenance attested through `sandbox-tee`. No competitor issues it; +regulation mandates nothing per-task; the category publishes rates, not +outcome-anchored receipts. + +## 6. A conformant flow (the profile in one paragraph) + +Register each coding-agent backend as a `Harness` with a **capability** (the task +kinds it may handle) and a **joule budget**. Track work items as the pattern does; +run the reconcile loop, but make every dispatch a JCP decision against that grant, +**sealed as JCR-1 before the task runs**. Run the task inside a **Sandbox S5 +microVM** under an attested boot, with the energy reading's provenance bound via +`sandbox-tee::energy`. Roll the per-task receipts up with `aggregate()` into an +`efficiency/1` figure (`uj_per_accepted_token`, waste share, outcome anchor). +Rebind the surfaces — local-handoff/MCP through **Joule Hook**, chat/forge through +**Joule Desk**, automations through **Durable agent execution** — each carrying +gate / meter / receipt. The result is the pattern's exact UX, with the one thing +it structurally could not produce: a signed, energy-accounted, verifiable record +of every action. + +## Status + +- Reconcile loop + capability/budget gate + sealed ledger — shipped (`src/lib.rs`). +- End-to-end demonstration — shipped (`examples/background_agents.rs`). +- Sandbox rung integration (run the harness inside an S5 microVM) and the + `efficiency/1` roll-up — the composition this document scopes; the primitives + (`sandbox-tee::energy`, `jcp-efficiency::aggregate`) are already shipped in their + own crates. diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs new file mode 100644 index 000000000..156c3092c --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs @@ -0,0 +1,131 @@ +//! A background-agent orchestrator, gated: run a batch of coding tasks across a +//! heterogeneous fleet of coding-agent backends — each in its own isolated +//! sandbox, on its own branch — where every dispatch is capability-gated, +//! joule-budgeted, and sealed **before** the agent runs. +//! +//! This is the shape the cloud background-agent products converged on (tracker → +//! branch → dispatch → PR). The orchestrator is blind to which backend runs a +//! task; it only knows each backend quotes a joule cost and reports an outcome. +//! What it adds over a bare meta-harness is the one artifact none of them +//! produce: an ordered, third-party-verifiable ledger of every decision — who was +//! authorised to run what, for how many joules, and how it turned out. +//! +//! ```text +//! cargo run -p jcp-orchestrate --example background_agents +//! ``` + +use std::cell::Cell; + +use ed25519_dalek::SigningKey; +use jcp_orchestrate::{Harness, HarnessOutcome, Orchestrator, Task}; + +/// A coding-agent backend that completes at a fixed quoted cost. Stands in for +/// any agent CLI/framework running in its own sandbox container — the +/// orchestrator neither knows nor cares which. +struct Backend { + id: String, + kinds: Vec, + cost_uj: u64, +} +impl Harness for Backend { + fn id(&self) -> &str { + &self.id + } + fn handles(&self, kind: &str) -> bool { + self.kinds.iter().any(|k| k == kind) + } + fn estimate(&self, _task: &Task) -> u64 { + self.cost_uj + } + fn run(&self, _task: &Task) -> HarnessOutcome { + HarnessOutcome::done(self.cost_uj) + } +} + +/// A backend whose sandbox flakes `fails` times (transient container/network +/// failure) before the task lands — the bounded-retry path. +struct Flaky { + id: String, + kind: String, + cost_uj: u64, + fails: Cell, +} +impl Harness for Flaky { + fn id(&self) -> &str { + &self.id + } + fn handles(&self, kind: &str) -> bool { + kind == self.kind + } + fn estimate(&self, _task: &Task) -> u64 { + self.cost_uj + } + fn run(&self, _task: &Task) -> HarnessOutcome { + let left = self.fails.get(); + if left > 0 { + self.fails.set(left - 1); + HarnessOutcome::retry(self.cost_uj, "sandbox cold-start timeout") + } else { + HarnessOutcome::done(self.cost_uj) + } + } +} + +fn main() { + // The principal that authorises the fleet, and the steward that seals every + // decision. A third party trusts the steward key, not the orchestrator. + let mut fleet = Orchestrator::new( + SigningKey::from_bytes(&[1u8; 32]), + SigningKey::from_bytes(&[7u8; 32]), + "did:example:background-agents", + 3, // max attempts per item + ); + + // Two backends can `implement`; the first has budget for two tasks, then the + // fleet fails over to the second. A `review` backend flakes twice before it + // lands. Nothing is granted `deploy` — a capability no backend holds. + fleet + .register(Box::new(Backend { id: "coder-alpha".into(), kinds: vec!["implement".into()], cost_uj: 120 }), &["implement"], 300) + .unwrap(); + fleet + .register(Box::new(Backend { id: "coder-beta".into(), kinds: vec!["implement".into()], cost_uj: 120 }), &["implement"], 1_000) + .unwrap(); + fleet + .register(Box::new(Flaky { id: "reviewer".into(), kind: "review".into(), cost_uj: 90, fails: Cell::new(2) }), &["review"], 1_000) + .unwrap(); + + // A batch of work items — each is a branch a backend will take up. + fleet.submit(Task::new("feat/parser-tokenize", "implement", b"add tokenizer to the parser".to_vec())); + fleet.submit(Task::new("feat/parser-ast", "implement", b"build the AST layer".to_vec())); + fleet.submit(Task::new("feat/parser-errors", "implement", b"structured parse errors".to_vec())); + fleet.submit(Task::new("review/pr-482", "review", b"review the tokenizer PR".to_vec())); + fleet.submit(Task::new("ops/ship-prod", "deploy", b"push to production".to_vec())); // ungranted capability + + let report = fleet.run(); + + println!("── run report ──"); + println!(" completed : {}", report.done); + println!(" denied : {} (no backend holds that capability)", report.denied); + println!(" failed : {}", report.failed); + println!(" authorised: {} µJ across the fleet", report.total_uj); + println!(" sealed : {} decision receipts", report.receipts); + + println!("\n── per branch ──"); + for id in ["feat/parser-tokenize", "feat/parser-ast", "feat/parser-errors", "review/pr-482", "ops/ship-prod"] { + println!(" {id:22} → {:?}", fleet.state_of(id).unwrap()); + } + + println!("\n── joules per backend ──"); + for b in ["coder-alpha", "coder-beta", "reviewer"] { + println!(" {b:12} {} µJ", fleet.spent_uj(b)); + } + + // The whole run is one ordered, verifiable ledger — the audit a bare + // background-agent orchestrator has no way to produce. Every allow was sealed + // before its backend ran; the `deploy` item was refused at routing, before + // any backend saw it. + let steward = fleet.steward().to_string(); + let pk = fleet.steward_pubkey(); + let verified = fleet.receipts().iter().filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok()).count(); + println!("\naudit: {verified}/{} sealed decisions verify under the steward key", fleet.receipts().len()); +} -- 2.54.0 (Apple Git-157) From c3b94b236bc2c2ef62ff22f1709bd2a00cc1c179 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 12 Jul 2026 12:23:09 -0400 Subject: [PATCH 2/2] jcp-orchestrate: seal budget denials instead of pre-filtering them away The joule ceiling is JCP's flagship enforcement, and the spec's contract is that every decision is sealed, allow and deny alike. dispatch() did not honour that. route() pre-filtered out any harness whose remaining budget could not cover the task's quote, so an over-budget harness never reached runtime.decide() -- the ceiling fired, the loop failed over, and no receipt recorded that it had. The enforcement happened; the audit of it did not. Replace route() with candidates(), which filters on capability only. dispatch() now offers the task to each granted harness in turn, letting the runtime make and seal the decision -- so a harness that cannot pay is denied on the record before the loop fails over to one that can. Denied dispatches are still not debited (Runtime::decide only debits on allow), so per-harness joule totals are unchanged. One refusal deliberately seals nothing: a task whose kind no registered harness holds. There is no grant to decide against, and JCP seals decisions about a grant, not the absence of one. It is recorded denied in the RunReport instead. Module doc and MAPPING.md now state both cases precisely rather than claiming a blanket allow-and-deny seal. Two regression tests: budget_denials_are_sealed_not_silently_skipped (the deny lands in the ledger, verifies under the steward key, and is not debited) and ungranted_kind_seals_nothing. All six pre-existing tests pass unchanged. The background_agents example now shows 7 sealed decisions, up from 6 -- the seventh being the budget denial that used to disappear. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md | 12 +- .../examples/background_agents.rs | 6 +- jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs | 172 ++++++++++++------ 3 files changed, 127 insertions(+), 63 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md index eff7ce752..0a6437de4 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md +++ b/jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md @@ -40,7 +40,7 @@ dispatch. | Work tracker, one branch per task | `Task { id, kind, payload }` + the sealed decision ledger (`receipts()`) | The run is an **auditable ledger of sealed decisions**, not just a branch list | | A coding-agent backend (any CLI/framework) | `Harness` — any backend that quotes a joule cost and reports an outcome | The orchestrator is **blind to what runs inside**; backends are `register`ed, not hardcoded | | Assign a task to a backend | `dispatch` **decided against a grant, outside the backend** (`route` → gate) | Who-runs-what is a runtime authorization, never the agent's own choice | -| Retry on failure | bounded retry **+ capacity-aware failover** to a backend still under budget | An exhausted backend fails over; a flaky one retries to `max_attempts` | +| Retry on failure | bounded retry **+ failover through a sealed denial** to a backend still under budget | An exhausted backend is denied *on the record*, then fails over; a flaky one retries to `max_attempts` | | Bring-your-own subscription / API key | `Capability` (task kinds) **+ `budget_uj`** per backend | Authority, not just authentication — see §4 | | Unbounded agent loop (the documented cost failure) | joule budget **debited + enforced before the side effect** | A hard, pre-side-effect energy ceiling per grant | | Machine-authored commit / PR as the record | JCR-1 receipt sealed per decision, allow **and** deny | The **non-repudiable audit** a git history cannot be | @@ -49,6 +49,16 @@ Net: the pattern is *what the loop looks like in production*; `Orchestrator` is *the same loop with the gate, the budget, and the sealed audit the meta-harness leaves to the platform*. The `background_agents` example drives it end to end. +**What is and isn't sealed.** Every decision made *against a grant* is sealed — +the allow, and the deny when a backend is out of budget or out of scope. Budget +enforcement in particular must leave an artifact: a joule ceiling that fires +silently is an enforcement nobody can audit, so a backend that cannot pay is +denied through the runtime and that denial is on the record *before* the loop +fails over to the next. The one refusal that seals nothing is a task whose kind +**no** registered backend holds: there is no grant to decide against, and JCP +seals decisions about a grant, not the absence of one. Such an item is recorded +denied in the `RunReport` and never reaches a backend. + ## 2. Container isolation → Sandbox S0–S7 (attested execution) The pattern isolates each task in a container with its own repo copy. On the diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs index 156c3092c..f1cf4e9df 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs @@ -122,8 +122,10 @@ fn main() { // The whole run is one ordered, verifiable ledger — the audit a bare // background-agent orchestrator has no way to produce. Every allow was sealed - // before its backend ran; the `deploy` item was refused at routing, before - // any backend saw it. + // before its backend ran, and so was every deny: `coder-alpha` refusing the + // third branch for want of joules is on the record, not silently skipped. + // Only `ops/ship-prod` seals nothing — no backend holds `deploy`, so there was + // no grant to decide against. let steward = fleet.steward().to_string(); let pk = fleet.steward_pubkey(); let verified = fleet.receipts().iter().filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok()).count(); diff --git a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs index 62318d131..696417201 100644 --- a/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-orchestrate/src/lib.rs @@ -15,11 +15,16 @@ //! orchestrator is blind to what runs inside it. Each harness is registered with //! a **capability** (the task kinds it may handle) and a **joule budget**; every //! dispatch is framed, **decided against that grant outside the harness**, sealed -//! as a JCR-1 receipt, and only then run. A task whose kind no harness is granted -//! is denied; a harness past its joule budget is denied and the loop **fails over** -//! to another harness that still has capacity; a flaky harness retries up to a -//! bound. The whole run is an ordered ledger of sealed decisions — the audit a -//! bare meta-harness has no way to produce. +//! as a JCR-1 receipt — **allow and deny alike** — and only then run. A harness +//! past its joule budget is denied, that denial is sealed, and the loop **fails +//! over** to another harness that still has capacity; a flaky harness retries up +//! to a bound. The whole run is an ordered ledger of sealed decisions — the audit +//! a bare meta-harness has no way to produce. +//! +//! One refusal is deliberately *not* sealed: a task whose kind **no** registered +//! harness is granted. There is no grant to decide against, and JCP seals +//! decisions about a grant, not the absence of one — so the item is recorded as +//! denied in the [`RunReport`] and never reaches a harness. //! //! Ingest, not compete: the compose-many-harnesses shape is the surface; JCP //! contributes the capability, the energy budget, and the sealed audit. @@ -248,71 +253,81 @@ impl Orchestrator { self.report() } - /// Route a task to the first registered harness that both handles its kind - /// and still has budget for its quote — capability-and-budget-aware routing, - /// so an exhausted harness fails over to the next. - fn route(&self, task: &Task) -> Option { - self.order.iter().find_map(|id| { - let harness = self.harnesses.get(id)?; - if !harness.handles(&task.kind) { - return None; - } - let grant = self.grants.get(id)?; - let remaining = grant.grant.budget_uj.saturating_sub(self.runtime.spent(&grant.grant.id())); - (remaining >= harness.estimate(task)).then(|| id.clone()) - }) + /// The harnesses granted this task's kind, in registration order. + /// + /// Budget is deliberately **not** filtered here. A harness that cannot pay + /// for the task is denied by the runtime — and that denial is sealed — before + /// the loop fails over to the next candidate. Pre-filtering on budget would + /// enforce the joule ceiling but leave no receipt of having done so. + fn candidates(&self, task: &Task) -> Vec { + self.order + .iter() + .filter(|id| self.harnesses[*id].handles(&task.kind)) + .cloned() + .collect() } fn dispatch(&mut self, i: usize) { self.items[i].attempts += 1; let task = self.items[i].task.clone(); - let id = match self.route(&task) { - Some(id) => id, - None => { - self.items[i].state = - WorkState::Denied("no harness is granted this kind with budget to spare".into()); - return; - } - }; - - let grant = self.grants[&id].clone(); - let cost = self.harnesses[&id].estimate(&task); - let frame = Frame { - kind: FrameKind::AgentTask, - trust: Trust::Control, - grant_id: grant.grant.id(), - action: task.kind.clone(), - resource: harness_resource(&id), - payload_hash: ContentId::of_bytes(&task.payload), - }; - let decision = self.runtime.decide(&frame, &grant, self.caller, Energy::estimator(cost), self.now); - let receipt = seal(&frame, &grant.grant, &decision, &self.steward_sk, &self.steward) - .expect("seal dispatch decision"); - self.receipts.push(receipt); - self.now += 1; - - if !decision.is_allow() { - let reason = match &decision.verdict { - Verdict::Deny(r) => r.label().to_string(), - Verdict::Allow => "allow".to_string(), - }; - self.items[i].state = WorkState::Denied(format!("gate: {reason}")); + let candidates = self.candidates(&task); + if candidates.is_empty() { + // The fleet holds no grant for this kind, so there is no grant to + // decide against: the item is refused before any harness sees it. + // This seals no receipt — JCP seals decisions about a grant, not the + // absence of one. + self.items[i].state = WorkState::Denied("no harness is granted this kind".into()); return; } - let outcome = self.harnesses[&id].run(&task); - self.items[i].state = match outcome.status { - Status::Done => WorkState::Done, - Status::Retry => { - if self.items[i].attempts < self.max_attempts { - WorkState::Pending - } else { - WorkState::Failed(format!("exhausted {} attempts: {}", self.max_attempts, outcome.output)) - } + // Offer the task to each granted harness in turn. Every decision — allow + // and deny alike — is sealed before anything runs; a denied harness (out + // of budget, out of scope) fails over to the next with its denial on the + // record. + let mut last_reason = String::new(); + for id in candidates { + let grant = self.grants[&id].clone(); + let cost = self.harnesses[&id].estimate(&task); + let frame = Frame { + kind: FrameKind::AgentTask, + trust: Trust::Control, + grant_id: grant.grant.id(), + action: task.kind.clone(), + resource: harness_resource(&id), + payload_hash: ContentId::of_bytes(&task.payload), + }; + let decision = self.runtime.decide(&frame, &grant, self.caller, Energy::estimator(cost), self.now); + let receipt = seal(&frame, &grant.grant, &decision, &self.steward_sk, &self.steward) + .expect("seal dispatch decision"); + self.receipts.push(receipt); + self.now += 1; + + if !decision.is_allow() { + last_reason = match &decision.verdict { + Verdict::Deny(r) => r.label().to_string(), + Verdict::Allow => "allow".to_string(), + }; + continue; } - Status::Failed => WorkState::Failed(outcome.output), - }; + + let outcome = self.harnesses[&id].run(&task); + self.items[i].state = match outcome.status { + Status::Done => WorkState::Done, + Status::Retry => { + if self.items[i].attempts < self.max_attempts { + WorkState::Pending + } else { + WorkState::Failed(format!("exhausted {} attempts: {}", self.max_attempts, outcome.output)) + } + } + Status::Failed => WorkState::Failed(outcome.output), + }; + return; + } + + // Every granted harness refused — each refusal is sealed in the ledger. + self.items[i].state = WorkState::Denied(format!("gate: {last_reason}")); } fn report(&self) -> RunReport { @@ -485,6 +500,43 @@ mod tests { assert!(matches!(o.state_of("t1"), Some(WorkState::Failed(_)))); } + #[test] + fn budget_denials_are_sealed_not_silently_skipped() { + // The joule ceiling is JCP's flagship enforcement, so firing it must leave + // an artifact. Primary funds one task; the second forces a denial on it, + // which is sealed, and the loop fails over to the backup. + let mut o = orch(); + o.register(Box::new(Fixed { id: "primary".into(), kinds: vec!["work".into()], cost: 100 }), &["work"], 100).unwrap(); + o.register(Box::new(Fixed { id: "backup".into(), kinds: vec!["work".into()], cost: 100 }), &["work"], 100).unwrap(); + o.submit(Task::new("t0", "work", b"a".to_vec())); + o.submit(Task::new("t1", "work", b"b".to_vec())); + let r = o.run(); + + assert_eq!(r.done, 2); // t0 on primary, t1 failed over to backup + // t0: 1 allow. t1: primary DENY (sealed) + backup allow → 3 in total. + assert_eq!(r.receipts, 3); + assert_eq!(o.spent_uj("primary"), 100); // the denied dispatch was not debited + assert_eq!(o.spent_uj("backup"), 100); + + // Every sealed decision — the deny included — verifies under the steward key. + for rcpt in o.receipts() { + assert!(jcp_receipt::extract(rcpt, o.steward(), &o.steward_pubkey()).is_ok()); + } + } + + #[test] + fn ungranted_kind_seals_nothing() { + // No harness holds the capability → no grant to decide against → the item + // is refused, and correctly leaves no receipt behind. + let mut o = orch(); + o.register(Box::new(Fixed { id: "summarizer".into(), kinds: vec!["summarize".into()], cost: 10 }), &["summarize"], 1_000) + .unwrap(); + o.submit(Task::new("t1", "deploy", b"x".to_vec())); + let r = o.run(); + assert_eq!(r.denied, 1); + assert_eq!(r.receipts, 0); + } + #[test] fn duplicate_harness_rejected() { let mut o = orch(); -- 2.54.0 (Apple Git-157)