Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save Jing-yilin/a852b258eb8508b6dbbd9dc077e4af50 to your computer and use it in GitHub Desktop.

Select an option

Save Jing-yilin/a852b258eb8508b6dbbd9dc077e4af50 to your computer and use it in GitHub Desktop.
PR #39 Plan — Receipt State Machine Completion & Pluggable DiscoveryBackend (closes #20 + #21)

PR #39 — Receipt State Machine Completion & Pluggable DiscoveryBackend

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%


Motivation

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 INDEXED or ANCHORED, and the receipt-count endpoint lacks world-signed attestation.
  • #21 (0% done): The gateway's DiscoveryEngine is 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 Analysis (from SPEC-0.2.0 audit)

# 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"

User Stories

US-001: Complete Receipt State Machine (Closes #20)

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.

Implementation

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 values

Step 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:

  • ReceiptState has 8 states: +Indexed, +Anchored
  • mark_indexed() transitions Confirmed/TimeoutFinalized → Indexed
  • mark_anchored() transitions Indexed → Anchored, stores proof
  • Invalid transitions return LifecycleError::InvalidTransition
  • ReceiptProposal has indexed_at, anchored_at, anchor_proof fields
  • SqliteReceiptStore persists new states
  • BatchAnchorer::anchor_and_update() transitions receipts through Indexed → Anchored
  • list_receipts_by_state() added to ReceiptStore trait
  • Test: Confirmed → Indexed → Anchored happy path
  • Test: Proposed → Indexed fails (invalid transition)
  • Test: Anchored receipt has valid proof data
  • Test: empty batch → NothingToAnchor

US-002: World-Attested Receipt Counts

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.

Implementation

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:

  • ReceiptCountResponse includes attested_at and attestation fields
  • 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

US-003: Pluggable DiscoveryBackend Trait (Closes #21)

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.

Implementation

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:

  • DiscoveryBackend trait with 7 methods (register, heartbeat, deregister, query, get_world, list_all, prune_offline)
  • EmbeddedDiscoveryBackend implements trait (wraps existing logic)
  • GatewayState holds Arc<dyn DiscoveryBackend>
  • All handlers call through trait, not direct struct methods
  • DiscoveryError error type with NotFound, AlreadyRegistered, Backend variants
  • All existing gateway tests pass without modification
  • Test: EmbeddedDiscoveryBackend register → query → heartbeat → deregister cycle
  • Test: mock backend can be substituted (trait is object-safe)
  • Zero behavior change for existing embedded gateway users

US-004: Receipt Replication — Actual HTTP POST

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}".

Implementation

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
  • ReplicationError with Serialization, Network, RemoteRejected variants
  • AWN_REPLICATION_TARGET_URL env 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)

Files Changed

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


Spec Coverage Impact

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 Closure Map

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

Dependency Graph

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 Plan

Unit Tests

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

Integration Tests

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

Out of Scope

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 Assessment

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

Implementation Notes for hal Agent

  1. US-001: When adding states to the enum, also update the Display impl and any match statements that use ReceiptState. Grep for ReceiptState:: across the codebase.
  2. US-001: The BatchAnchorer in anchor_batch.rs already has a MockAnchorBackend. Use it in tests — don't create a new mock.
  3. US-002: Reuse the existing EVENT_SIGN_DOMAIN from src/version.rs for signing. The receipt-count attestation signing should use the same domain separator as SSE events (world-level signing).
  4. US-003: The refactor is purely mechanical — move methods from DiscoveryEngine into EmbeddedDiscoveryBackend and add &self where needed. Use RwLock for interior mutability since the trait methods take &self.
  5. US-003: Make sure DiscoveryBackend is object-safe (no generics, no Self in return types). Use async_trait for async methods.
  6. US-004: reqwest::Client is already available in the daemon via Arc. Reuse it for the replicator instead of creating a new client.
  7. Run cargo fmt and cargo clippy before every commit. CI is strict.
  8. Keep #[serde(rename_all = "camelCase")] on any new serializable structs.

Remaining Spec Gaps After This PR

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment