jcp-orchestrate: background-agent orchestrator profile mapping + seal budget denials #6
3 changed files with 382 additions and 58 deletions
139
jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md
Normal file
139
jcp/jcp-rs/crates/jcp-orchestrate/MAPPING.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# 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 **+ 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 |
|
||||
|
||||
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
|
||||
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.
|
||||
133
jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs
Normal file
133
jcp/jcp-rs/crates/jcp-orchestrate/examples/background_agents.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//! 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<String>,
|
||||
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<u32>,
|
||||
}
|
||||
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, 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();
|
||||
println!("\naudit: {verified}/{} sealed decisions verify under the steward key", fleet.receipts().len());
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue