Date: 2026-04-05
Branch: hal/receipt-states-discovery (from compound/daemon-local-runtime-foundation)
Spec refs: §2 §5-7, §3 §5, §5 §2-4, §8 §4, §10 §8, §11 §1-2
Issues: Closes #21 (DiscoveryBackend trait), closes #20 (durable receipt processing)
Priority: HIGH — #20 and #21 are the last two open P0 issues
Estimated: ~600 LoC net new across 9 files
Coverage lift: overall ~84% → ~89%
After PR #38 (Trust Chain Hardening & Benchmark World), two P0 issues remain open:
- #20 (~80% done): Receipt processing is durable (outbox + background worker + lifecycle states) but the state machine is incomplete — receipts never transition to
INDEXEDorANCHORED, and the receipt-count endpoint lacks world-signed attestation. - #21 (0% done): The gateway's
DiscoveryEngineis a monolithic struct with all logic inline. There is no trait abstraction, making it impossible to swap backends (e.g., federated, file-based, remote).
This PR closes both issues by completing the receipt state machine and extracting the DiscoveryBackend trait.
| # | Gap | Spec | Current State |
|---|---|---|---|
| 1 | ReceiptState enum missing Indexed and Anchored |
§2 §5 | Only 6 states (Proposed, Confirmed, TimeoutFinalized, Disputed, Resolved, Rejected) |
| 2 | BatchAnchorer doesn't transition receipts to Anchored after Merkle root submission |
§2 §6 | Merkle tree built + mock backend called, but receipt state unchanged |
| 3 | receipt-count endpoint returns bare counts without world-signed attestation |
§10 §8.4 | ReceiptCountResponse has no signature field |
| 4 | No DiscoveryBackend trait — all logic hardcoded in DiscoveryEngine |
§5 §4, §11 §1 | Monolithic struct, not pluggable |
| 5 | HttpReceiptReplicator never POSTs — only logs intent |
§2 §7 | replication.rs:131-134 deferred to "Phase 3" |
Goal: Add Indexed and Anchored states to the receipt lifecycle, and wire BatchAnchorer to transition receipts through them.
Spec: §2 §5 — "CONFIRMED → INDEXED → ANCHORED (Merkle root on-chain)"
Current State: ReceiptState in src/trust/lifecycle.rs:46-61 has 6 states but no Indexed or Anchored. The BatchAnchorer in src/trust/anchor_batch.rs builds Merkle trees and calls the anchor backend, but never updates receipt state. The receipts table in src/trust/storage.rs only stores 'confirmed' and 'timeout-finalized' states.
Step 1: Add two new states to ReceiptState:
// src/trust/lifecycle.rs
pub enum ReceiptState {
Proposed,
Confirmed,
TimeoutFinalized,
Disputed,
Resolved,
Rejected,
// NEW:
/// Receipt stored in local index, awaiting batch anchoring.
Indexed,
/// Receipt's Merkle root has been submitted to an anchor backend.
Anchored,
}Step 2: Add state transition methods to ReceiptLifecycleManager:
/// Transition confirmed/timeout-finalized receipt → Indexed.
/// Called after receipt is stored in the local index.
pub fn mark_indexed(&mut self, receipt_id: &str) -> Result<(), LifecycleError> {
let proposal = self.get_proposal_mut(receipt_id)?;
match proposal.state {
ReceiptState::Confirmed | ReceiptState::TimeoutFinalized => {
proposal.state = ReceiptState::Indexed;
proposal.indexed_at = Some(Utc::now().to_rfc3339());
Ok(())
}
_ => Err(LifecycleError::InvalidTransition { from: proposal.state, to: ReceiptState::Indexed }),
}
}
/// Transition Indexed receipt → Anchored.
/// Called after Merkle root is submitted to anchor backend.
pub fn mark_anchored(
&mut self,
receipt_id: &str,
merkle_root: &str,
leaf_index: u32,
proof: Vec<String>,
) -> Result<(), LifecycleError> {
let proposal = self.get_proposal_mut(receipt_id)?;
match proposal.state {
ReceiptState::Indexed => {
proposal.state = ReceiptState::Anchored;
proposal.anchored_at = Some(Utc::now().to_rfc3339());
proposal.anchor_proof = Some(AnchorProof {
receipt_id: receipt_id.to_string(),
merkle_root: merkle_root.to_string(),
leaf_index,
proof,
});
Ok(())
}
_ => Err(LifecycleError::InvalidTransition { from: proposal.state, to: ReceiptState::Anchored }),
}
}Step 3: Add indexed_at, anchored_at, anchor_proof fields to ReceiptProposal:
pub struct ReceiptProposal {
// ... existing fields ...
pub indexed_at: Option<String>,
pub anchored_at: Option<String>,
pub anchor_proof: Option<AnchorProof>,
}Step 4: Update SqliteReceiptStore — add state column support for new states:
// src/trust/storage.rs — update the state column to accept new values
// In store_receipt() or update_proposal_state():
// Allow 'indexed' and 'anchored' as valid state valuesStep 5: Wire BatchAnchorer to update states after anchoring:
// src/trust/anchor_batch.rs — after successful anchor_batch() call:
// 1. Mark all receipts in batch as Indexed (pre-anchor)
// 2. Call anchor_backend.anchor_batch()
// 3. On success, mark all receipts as Anchored with proof data
pub async fn anchor_and_update(
&self,
store: &dyn ReceiptStore,
lifecycle: &mut ReceiptLifecycleManager,
) -> Result<AnchorResult, AnchorError> {
let unanchored = store.list_receipts_by_state(ReceiptState::Confirmed)?;
if unanchored.is_empty() { return Ok(AnchorResult::NothingToAnchor); }
// Mark as Indexed
for r in &unanchored {
lifecycle.mark_indexed(&r.receipt_id)?;
}
// Build Merkle tree
let tree = MerkleTree::from_receipts(&unanchored)?;
let root = tree.root_hex();
// Submit to backend
let anchor_id = self.backend.anchor_batch(&root, unanchored.len()).await?;
// Mark as Anchored with proofs
for (i, r) in unanchored.iter().enumerate() {
let proof = tree.proof(i)?;
lifecycle.mark_anchored(
&r.receipt_id,
&root,
i as u32,
proof.siblings.iter().map(|h| hex::encode(h)).collect(),
)?;
}
Ok(AnchorResult::Anchored { root, count: unanchored.len(), anchor_id })
}Step 6: Add list_receipts_by_state() to ReceiptStore trait:
fn list_receipts_by_state(&self, state: ReceiptState) -> Result<Vec<TrustReceipt>, StorageError>;Files: src/trust/lifecycle.rs, src/trust/storage.rs, src/trust/anchor_batch.rs
AC:
-
ReceiptStatehas 8 states: +Indexed, +Anchored -
mark_indexed()transitions Confirmed/TimeoutFinalized → Indexed -
mark_anchored()transitions Indexed → Anchored, stores proof - Invalid transitions return
LifecycleError::InvalidTransition -
ReceiptProposalhasindexed_at,anchored_at,anchor_prooffields -
SqliteReceiptStorepersists new states -
BatchAnchorer::anchor_and_update()transitions receipts through Indexed → Anchored -
list_receipts_by_state()added toReceiptStoretrait - Test: Confirmed → Indexed → Anchored happy path
- Test: Proposed → Indexed fails (invalid transition)
- Test: Anchored receipt has valid proof data
- Test: empty batch → NothingToAnchor
Goal: Add a world-signed attestation to the receipt-count endpoint response, so disclosure verifiers can trust the count.
Spec: §10 §8.4 — Receipt count response should be attestable by the world operator.
Current State: GET /v0/trust/receipt-count/{agentId} in src/world_api/trust_endpoints.rs returns ReceiptCountResponse with bare counts but no signature.
Step 1: Add attestation fields to ReceiptCountResponse:
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReceiptCountResponse {
// ... existing fields (agent_id, world_id, receipt_count, count_by_type, etc.) ...
/// ISO 8601 timestamp of this attestation
pub attested_at: String,
/// World operator's Ed25519 signature over the count data,
/// enabling disclosure verifiers to trust the count.
/// `null` if world signing key is not configured.
pub attestation: Option<CountAttestation>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CountAttestation {
pub algorithm: String, // "Ed25519"
pub signature: String, // hex-encoded
pub signing_key_id: String, // world key ID
}Step 2: Sign the count response when world signing key is available:
// In the receipt-count handler, after building the response:
let attestation = if let Some(ref signing_key) = state.world_signing_key {
let payload = serde_json::json!({
"agentId": response.agent_id,
"worldId": response.world_id,
"receiptCount": response.receipt_count,
"countByType": response.count_by_type,
"attestedAt": response.attested_at,
});
let canonical = jcs_canonicalize(&payload)?;
let signing_input = format!("{}\0{}", EVENT_SIGN_DOMAIN, canonical);
let signature = signing_key.sign(signing_input.as_bytes());
Some(CountAttestation {
algorithm: "Ed25519".to_string(),
signature: hex::encode(signature.to_bytes()),
signing_key_id: signing_key.key_id().to_string(),
})
} else {
None
};Files: src/world_api/trust_endpoints.rs
AC:
-
ReceiptCountResponseincludesattested_atandattestationfields - When world signing key present → response includes Ed25519 signature
- When no signing key →
attestation: null(backward compatible) - Signature covers: agentId, worldId, receiptCount, countByType, attestedAt
- Signing uses domain-separated format (same as event signing)
- Test: response with signing key → attestation present
- Test: response without signing key → attestation null
- Test: signature verifies against world public key
Goal: Extract the DiscoveryEngine's logic into a DiscoveryBackend trait, enabling pluggable backends (embedded, file-based, remote HTTP).
Spec: §5 §4 — "Phase 2: Federated — Multiple Gateway operators, periodic sync"; §11 §1 — "Gateway — World discovery + liveness (control plane)"
Current State: DiscoveryEngine in src/gateway/engine.rs is a monolithic struct. All logic (register, query, heartbeat, deregister) is implemented directly on the struct with no trait abstraction. Issue #21 tracks this.
Step 1: Define the DiscoveryBackend trait:
// src/gateway/backend.rs (new file)
use crate::gateway::types::*;
/// Pluggable backend for world discovery.
/// The embedded implementation stores worlds in-memory.
/// Future implementations: file-based, remote HTTP federation, gossip.
#[async_trait::async_trait]
pub trait DiscoveryBackend: Send + Sync {
/// Register a new world announcement.
async fn register(&self, announcement: &WorldAnnouncement) -> Result<(), DiscoveryError>;
/// Update heartbeat for a registered world.
async fn heartbeat(&self, world_id: &str) -> Result<(), DiscoveryError>;
/// Remove a world from discovery.
async fn deregister(&self, world_id: &str) -> Result<(), DiscoveryError>;
/// Query worlds matching the given filter.
async fn query(&self, filter: &DiscoveryQuery) -> Result<DiscoveryResponse, DiscoveryError>;
/// Get a single world by ID.
async fn get_world(&self, world_id: &str) -> Result<Option<RegisteredWorld>, DiscoveryError>;
/// List all registered worlds (for liveness sweep).
async fn list_all(&self) -> Result<Vec<RegisteredWorld>, DiscoveryError>;
/// Remove worlds that exceed the offline threshold.
async fn prune_offline(&self, offline_threshold: std::time::Duration) -> Result<Vec<String>, DiscoveryError>;
}Step 2: Rename current DiscoveryEngine to EmbeddedDiscoveryBackend and implement the trait:
// src/gateway/engine.rs — refactor
pub struct EmbeddedDiscoveryBackend {
worlds: RwLock<HashMap<String, RegisteredWorld>>,
}
#[async_trait::async_trait]
impl DiscoveryBackend for EmbeddedDiscoveryBackend {
async fn register(&self, announcement: &WorldAnnouncement) -> Result<(), DiscoveryError> {
// Move existing register logic here
}
async fn heartbeat(&self, world_id: &str) -> Result<(), DiscoveryError> {
// Move existing heartbeat logic here
}
async fn query(&self, filter: &DiscoveryQuery) -> Result<DiscoveryResponse, DiscoveryError> {
// Move existing query/filter logic here
}
// ... etc
}Step 3: Update GatewayServer / handlers to use Arc<dyn DiscoveryBackend>:
// src/gateway/server.rs or handlers.rs
pub struct GatewayState {
pub backend: Arc<dyn DiscoveryBackend>,
// ... other fields
}Step 4: Update all call sites — handle_announce(), handle_heartbeat(), handle_discover(), handle_deannounce() — to go through the trait.
Step 5: Add DiscoveryError type:
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("world not found: {0}")]
NotFound(String),
#[error("world already registered: {0}")]
AlreadyRegistered(String),
#[error("backend error: {0}")]
Backend(String),
}Files: src/gateway/backend.rs (new), src/gateway/engine.rs, src/gateway/handlers.rs, src/gateway/server.rs, src/gateway/mod.rs
AC:
-
DiscoveryBackendtrait with 7 methods (register, heartbeat, deregister, query, get_world, list_all, prune_offline) -
EmbeddedDiscoveryBackendimplements trait (wraps existing logic) -
GatewayStateholdsArc<dyn DiscoveryBackend> - All handlers call through trait, not direct struct methods
-
DiscoveryErrorerror type with NotFound, AlreadyRegistered, Backend variants - All existing gateway tests pass without modification
- Test:
EmbeddedDiscoveryBackendregister → query → heartbeat → deregister cycle - Test: mock backend can be substituted (trait is object-safe)
- Zero behavior change for existing embedded gateway users
Goal: Make HttpReceiptReplicator actually POST receipts to a remote endpoint instead of just logging intent.
Spec: §2 §7 — "at-least-once delivery for receipt replication"
Current State: HttpReceiptReplicator in src/trust/replication.rs:72-138 has the trait implementation but the replicate() method only appends to an in-memory log with a comment "In Phase 3, this would be: POST {self.indexer_url}".
Step 1: Replace the stub with actual HTTP POST:
#[async_trait::async_trait]
impl ReceiptReplicator for HttpReceiptReplicator {
async fn replicate(&self, receipt: &TrustReceipt) -> Result<(), ReplicationError> {
let url = format!("{}/v0/trust/receipts/replicate", self.target_url);
let payload = serde_json::to_value(receipt)
.map_err(|e| ReplicationError::Serialization(e.to_string()))?;
let response = self.client
.post(&url)
.json(&payload)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| ReplicationError::Network(e.to_string()))?;
if response.status().is_success() {
Ok(())
} else {
Err(ReplicationError::RemoteRejected {
status: response.status().as_u16(),
body: response.text().await.unwrap_or_default(),
})
}
}
}Step 2: Add ReplicationError variants:
#[derive(Debug, thiserror::Error)]
pub enum ReplicationError {
#[error("serialization error: {0}")]
Serialization(String),
#[error("network error: {0}")]
Network(String),
#[error("remote rejected (HTTP {status}): {body}")]
RemoteRejected { status: u16, body: String },
#[error("disabled")]
Disabled,
}Step 3: Keep NoopReceiptReplicator as the default (replication is opt-in via config).
Step 4: Wire the outbox retry worker to use HttpReceiptReplicator when AWN_REPLICATION_TARGET_URL is configured:
// In CompoundDaemon startup or OutboxConfig:
let replicator: Box<dyn ReceiptReplicator> = match std::env::var("AWN_REPLICATION_TARGET_URL") {
Ok(url) => Box::new(HttpReceiptReplicator::new(url, client.clone())),
Err(_) => Box::new(NoopReceiptReplicator),
};Files: src/trust/replication.rs, src/daemon/compound.rs (wiring)
AC:
-
HttpReceiptReplicator::replicate()actually POSTs receipt JSON to target URL - 10-second timeout on HTTP calls
-
ReplicationErrorwith Serialization, Network, RemoteRejected variants -
AWN_REPLICATION_TARGET_URLenv var enables HTTP replication - No env var →
NoopReceiptReplicator(zero behavior change) - Outbox retry worker uses the configured replicator
- Test: successful POST → Ok(())
- Test: network error → ReplicationError::Network
- Test: 4xx response → ReplicationError::RemoteRejected
- Test: no target URL → NoopReceiptReplicator (noop)
| File | Change Type | Description |
|---|---|---|
src/trust/lifecycle.rs |
Modified | ~60 LoC — Add Indexed/Anchored states + transition methods |
src/trust/storage.rs |
Modified | ~30 LoC — Support new states in SQL + list_receipts_by_state() |
src/trust/anchor_batch.rs |
Modified | ~50 LoC — anchor_and_update() with state transitions |
src/world_api/trust_endpoints.rs |
Modified | ~40 LoC — CountAttestation + signing |
src/gateway/backend.rs |
New | ~80 LoC — DiscoveryBackend trait + DiscoveryError |
src/gateway/engine.rs |
Modified | ~100 LoC — Rename to EmbeddedDiscoveryBackend + implement trait |
src/gateway/handlers.rs |
Modified | ~30 LoC — Use Arc |
src/gateway/server.rs |
Modified | ~15 LoC — GatewayState uses trait |
src/gateway/mod.rs |
Modified | ~2 LoC — pub mod backend |
src/trust/replication.rs |
Modified | ~60 LoC — Actual HTTP POST + ReplicationError |
src/daemon/compound.rs |
Modified | ~15 LoC — Wire replicator from env var |
Total: ~600 LoC net new across 11 files
| Section | Before (post-#38) | After | Delta | What Changed |
|---|---|---|---|---|
| §02 Trust Receipts | 90% | 97% | +7% | Indexed/Anchored states, replication |
| §05 Gateway | 82% | 95% | +13% | DiscoveryBackend trait |
| §10 World API | 88% | 93% | +5% | Attested receipt counts |
| §11 Platform Architecture | 78% | 85% | +7% | Pluggable discovery |
| Overall | ~84% | ~89% | +5% |
| Issue | Status After This PR | What Remains |
|---|---|---|
| #20 (Durable receipt processing) | Closed | State machine complete: Proposed → Confirmed → Indexed → Anchored |
| #21 (Pluggable DiscoveryBackend) | Closed | Trait extracted; EmbeddedDiscoveryBackend is default impl |
This PR depends on compound/daemon-local-runtime-foundation (post PR #38).
US-001 (Receipt state machine) ─── independent
US-002 (Attested receipt counts) ─── depends on US-001 (needs Indexed/Anchored states in storage)
US-003 (DiscoveryBackend trait) ─── independent
US-004 (HTTP replication) ─── independent
Recommended order:
US-001 first (core state machine, others depend on storage changes)
→ US-002 (small, builds on US-001 storage)
→ US-003 (independent, large refactor)
→ US-004 (small, wiring change)
| Test | Story | Validates |
|---|---|---|
confirmed_to_indexed |
US-001 | Confirmed → Indexed succeeds |
timeout_finalized_to_indexed |
US-001 | TimeoutFinalized → Indexed succeeds |
proposed_to_indexed_fails |
US-001 | Proposed → Indexed rejected |
indexed_to_anchored |
US-001 | Indexed → Anchored with proof data |
confirmed_to_anchored_fails |
US-001 | Skipping Indexed state rejected |
anchor_and_update_happy_path |
US-001 | Batch: Confirmed → Indexed → Anchored |
anchor_empty_batch |
US-001 | No unanchored → NothingToAnchor |
anchored_receipt_has_proof |
US-001 | Proof fields populated (root, index, siblings) |
list_receipts_by_state |
US-001 | SQL query filters by state correctly |
count_with_attestation |
US-002 | Response includes Ed25519 attestation |
count_without_signing_key |
US-002 | Response has attestation: null |
count_attestation_verifies |
US-002 | Signature validates against public key |
embedded_backend_register_query |
US-003 | Register → query returns world |
embedded_backend_heartbeat |
US-003 | Heartbeat updates last_seen |
embedded_backend_deregister |
US-003 | Deregister → query returns nothing |
embedded_backend_prune_offline |
US-003 | Stale worlds pruned |
mock_backend_substitution |
US-003 | Arc works with mock |
http_replicator_success |
US-004 | POST 200 → Ok(()) |
http_replicator_network_error |
US-004 | Connection refused → ReplicationError::Network |
http_replicator_rejected |
US-004 | POST 400 → ReplicationError::RemoteRejected |
noop_replicator_default |
US-004 | No target URL → noop |
| Test | Story | Validates |
|---|---|---|
receipt_full_lifecycle |
US-001+002 | Proposed → Confirmed → Indexed → Anchored with proof |
gateway_backend_swap |
US-003 | Gateway works with custom backend implementation |
replication_with_outbox |
US-004 | Outbox entry → HTTP POST to target |
| Feature | Why | Where |
|---|---|---|
| On-chain anchor backends (ERC-8004, EAS) | Requires blockchain integration | Future PR |
| Federated DiscoveryBackend (HTTP sync) | Phase 2 per spec | Future PR |
| Dispute Tier 2/3 | Requires governance infrastructure | Future PR |
| ZK reputation proofs | Phase 2+ per spec | Future |
| Gossip-based discovery (libp2p) | Phase 3 per spec | Future |
| Risk | Level | Mitigation |
|---|---|---|
| Storage migration for new states | Medium | New states are additive; old rows remain valid |
| DiscoveryBackend refactor breaks gateway tests | Low | EmbeddedDiscoveryBackend preserves exact behavior |
| HTTP replication overwhelms target | Low | Outbox has exponential backoff + max attempts |
| Attestation signing adds latency to receipt-count | Low | Ed25519 signing is <1ms |
| State machine complexity | Low | Transitions are linear (no branching); clear error messages |
- US-001: When adding states to the enum, also update the
Displayimpl and anymatchstatements that useReceiptState. Grep forReceiptState::across the codebase. - US-001: The
BatchAnchorerinanchor_batch.rsalready has aMockAnchorBackend. Use it in tests — don't create a new mock. - US-002: Reuse the existing
EVENT_SIGN_DOMAINfromsrc/version.rsfor signing. The receipt-count attestation signing should use the same domain separator as SSE events (world-level signing). - US-003: The refactor is purely mechanical — move methods from
DiscoveryEngineintoEmbeddedDiscoveryBackendand add&selfwhere needed. UseRwLockfor interior mutability since the trait methods take&self. - US-003: Make sure
DiscoveryBackendis object-safe (no generics, noSelfin return types). Useasync_traitfor async methods. - US-004:
reqwest::Clientis already available in the daemon viaArc. Reuse it for the replicator instead of creating a new client. - Run
cargo fmtandcargo clippybefore every commit. CI is strict. - Keep
#[serde(rename_all = "camelCase")]on any new serializable structs.
| Gap | Spec Section | Priority | Notes |
|---|---|---|---|
| On-chain anchor backends | §2 §6 | P2 | Requires blockchain integration (Base L2) |
| Dispute Tier 2/3 | §8 §4 | P2 | Requires governance + multi-operator arbitration |
| Federated DiscoveryBackend | §5 §4 | P2 | Phase 2 per spec — HTTP sync between gateways |
| Payment pool settlement (smart contract) | §6 §3 | P2 | Non-custodial on-chain escrow |
| ZK reputation proofs | §7 §5 | P3 | Phase 2+, Noir circuits |
| System service install (launchd/systemd) | CLI spec §19 | P3 | Convenience feature |
With PR #38 + #39, all P0 issues are closed and spec coverage reaches ~89%. Remaining gaps are P2/P3 items that depend on external infrastructure (blockchain, multi-operator governance).