Files
agents-marketplace/docs/ARCHITECTURE.md
FilipBl4gojevic 73700f983a Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
- docs/ARCHITECTURE.md: settlement-asset framing (AP3X bonded in escrow,
  settles on accepted work); remove references to external network
  adapters that are not part of the project
- docs/buyer + docs/supplier READMEs: commission/bond/settle vocabulary
- Buyer web UI: "Accept & Settle", escrow-framed labels, gateway-managed
  wallet wording; gateway landing title punctuation
- Delete EXECUTIVE_SUMMARY and local-llm-marketplace docs + exports:
  M1-era internal design snapshots superseded by ARCHITECTURE.md and the
  public docs site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:23:35 +02:00

312 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Local Agents Marketplace — Architecture (v1)
> Status: accepted for M0–M1 implementation
> Dated: 2026-04-24
> Scope: MVP validating "can agents drive prompts through a buyer → on-chain escrow → supplier inference path on Vector L2 / Cardano" — starting with a CPU-bound supplier on Hetzner.
---
## 1. Goal and scope
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
**What v1 is NOT.**
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
- Not multi-chain. Vector only (testnet and mainnet).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
- Not privacy-preserving end-to-end. TLS to supplier only, SaaS-parity model, documented in supplier ToS.
## 2. Decisions resolved (Q&A → architecture inputs)
| # | Decision |
|---|---|
| 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
| 5 | Capability model: **one capability per supplier process**, flat-per-prompt pricing. Multi-capability operators run multiple processes. |
| 6 | Supplier status: **polled** by indexer every ~20 s, staleness visible to buyers. |
| 7 | Supplier registry: **separate script hash** from Module-1, independently indexed. |
| 8 | First capability: `llm.text.generate.v1` — single-shot, no tools, no stream, flat-per-prompt, tiny Ollama (`qwen2.5:0.5b`) on CPU. |
| 9 | Tests: three-tier (mock / Ogmios read-only / real chain) **with an explicit `ChainProvider` interface** from day 1. Real CBOR fixtures. Independent buyer/supplier fixture construction. |
| 10 | Chain follower: extend the apex-dashboard pattern (Node.js + Ogmios + SQLite + REST/SSE). |
| 11 | Supplier process: **single-slot**. |
| 12 | Pricing race: buyer's escrow datum **references the ad UTxO** (spec-lock). Supplier honors the price in the referenced ad or lets the escrow time out. |
| 13 | SLA: supplier publishes `max_processing_ms` in the advertisement. Buyer sets `deliver_by = posted_at + max_processing_ms + network_buffer`. |
| 14 | Bonds: **symmetric 1 AP3X** on both sides. Buyer miss → forfeits bond. Supplier miss → forfeits bond. |
| 15 | Endpoint auth: `X-Escrow-Ref` header. Supplier verifies escrow on-chain before computing. |
| 16 | Privacy: SaaS-parity. TLS to supplier. Documented in ToS. |
## 3. Component diagram
```
┌──────────────────┐ ┌────────────────────────┐
│ Buyer app │ HTTP + X-Escrow-Ref │ Supplier node │
│ (web UI + lib │────────────────────────▶│ FastAPI / Node │
│ for agents) │ │ /capability │
│ │ │ /status │
│ optional: │ │ /v1/chat/completions │
│ OpenRouter │ │ │ │
│ client-side │ │ ▼ │
│ fallback │ │ Ollama (local LLM) │
└────────┬─────────┘ └──────────┬─────────────┘
│ │
│ REST / SSE │
▼ │
┌──────────────────────────────┐ │
│ Indexer │◀──── poll /status ────┘
│ (chain follower + status │
│ poller, SQLite + REST/SSE) │
└──────────────┬───────────────┘
│
│ Ogmios (chain-sync + queryLedgerState)
▼
┌──────────────────────────────────────────────────────────────┐
│ Vector L2 (Cardano) │
│ AdvertScript EscrowScript (Module-1, M2+) │
└──────────────────────────────────────────────────────────────┘
```
Three on-chain scripts: **AdvertScript** (registry), **EscrowScript** (happy-path escrow), **Module-1** (disputes, deferred). Indexer and Ogmios are shared infra. OpenRouter is a buyer-side client escape hatch — outside the protocol.
## 4. On-chain datum schemas
### 4.1 AdvertDatum
```
AdvertDatum {
supplier_pkh: VerificationKeyHash,
capability_id: ByteArray, // "llm.text.generate.v1"
model: ByteArray, // "qwen2.5:0.5b"
max_output_tokens: Int,
max_processing_ms: Int, // SLA for deliver_by math
price_lovelace: Int, // AP3X lovelace, flat per prompt
supplier_bond_lovelace: Int,
buyer_bond_lovelace: Int,
endpoint_url: ByteArray, // https://...
detail_uri: ByteArray, // off-chain JSON pointer
detail_hash: ByteArray(32), // sha256 of detail JSON
advertised_at: POSIXTime,
status: AdvertStatus // Active | Retired
}
Redeemers: PostAdvert | UpdateAdvert | RetireAdvert
```
One supplier process = one `AdvertDatum` UTxO. Update = spend old + post new (spec-lock race resolved by referencing the specific UTxO in escrow).
### 4.2 EscrowDatum (happy path, v1)
```
EscrowDatum {
buyer_pkh: VerificationKeyHash,
supplier_pkh: VerificationKeyHash,
advert_ref: OutputReference, // SPEC-LOCK to ad UTxO
capability_id: ByteArray, // duplicated for indexer filter
request_spec_hash: ByteArray(32), // sha256 canonical JSON of request envelope
prompt_hash: ByteArray(32), // sha256 of messages
payment_lovelace: Int,
buyer_bond_lovelace: Int,
supplier_bond_lovelace: Int,
deliver_by: POSIXTime,
posted_at: POSIXTime,
submitted_at: Option<POSIXTime>, // set on Submit
result_receipt_hash: Option<ByteArray>, // set on Submit
state: EscrowState
}
EscrowState = Open | Claimed | Submitted | Accepted | Reclaimed | Released
```
### 4.3 Redeemers
| Redeemer | Signer | Precondition | Effect |
|---|---|---|---|
| `Claim` | supplier | state=Open | Open → Claimed |
| `Submit` | supplier | state=Claimed | Claimed → Submitted, writes `submitted_at` + `result_receipt_hash` |
| `Accept` | buyer | state=Submitted | Submitted → Accepted; supplier gets payment + own bond; buyer gets own bond |
| `Reclaim` | buyer | state∈{Open,Claimed} AND now ≥ `deliver_by` | → Reclaimed; buyer takes payment + both bonds |
| `Release` | supplier | state=Submitted AND now ≥ `submitted_at + ACCEPT_WINDOW` | → Released; supplier gets payment + own bond + buyer bond |
**Constants** (proposed, v1):
- `ACCEPT_WINDOW = 10 min`
- `network_buffer = 30 s` (buyer-side convention, not enforced on-chain)
### 4.4 State diagram
```
Reclaim (after deliver_by)
┌──────────────────────┐
▼ │
┌─────────┐ │
PostEscrow ─▶ │ Open │ ─ Claim ─┐ │
└─────────┘ ▼ │
┌─────────┐│
┌────────── │ Claimed │┘
│ └────┬────┘
Reclaim │ Submit
(after deliver_by) ▼
│ ┌───────────┐
▼ │ Submitted │
┌──────────┐ └─────┬─────┘
│Reclaimed │ ┌────────┴────────┐
└──────────┘ │ Accept │ Release
│ (buyer) │ (after ACCEPT_WINDOW)
▼ ▼
┌──────────┐ ┌──────────┐
│ Accepted │ │ Released │
└──────────┘ └──────────┘
```
Terminal states: `Accepted`, `Reclaimed`, `Released`.
## 5. HTTP contracts
### 5.1 Supplier endpoints
```
GET /capability
→ { capability_id, model, max_output_tokens, max_processing_ms,
price_lovelace, advert_ref, supplier_pkh }
Must match on-chain advertisement. Buyer SHOULD verify.
GET /status
→ { status: "free"|"working"|"offline",
current_escrow_ref?: "txHash#ix",
last_seen: iso8601 }
POST /v1/chat/completions
Headers: X-Escrow-Ref: <txHash>#<ix>
Body: OpenAI-compatible ChatCompletionRequest
Rejects: stream=true, tools[], tool_choice, functions
Rejects: capability mismatch, max_output_tokens > advertised
Flow:
1. Pull escrow UTxO by ref (via Ogmios query)
2. Verify: state=Open, supplier_pkh=me, capability_id matches,
request_spec_hash = sha256(canonical(envelope)),
prompt_hash = sha256(messages)
3. Submit Claim tx (Open → Claimed)
4. Call local LLM
5. Build receipt: { prompt_hash, response_hash, model,
prompt_tokens, completion_tokens, wallclock_ms,
supplier_pkh, escrow_ref }
6. Sign receipt (Ed25519 with supplier key)
7. Submit Submit tx with result_receipt_hash = sha256(canonical(receipt))
8. Return { choices, usage, receipt, receipt_signature }
```
### 5.2 Indexer endpoints
```
GET /suppliers — all Active adverts + cached status
GET /suppliers/{pkh} — single supplier detail + recent jobs
GET /capabilities — distinct capability_id list with counts
GET /capabilities/{id}/suppliers — filtered list, sortable by price/latency
GET /escrows/{ref} — state lookup by OutputReference
GET /escrows?buyer={pkh}|supplier={pkh}
GET /events?stream=1 — SSE: new ads, escrow transitions
GET /health — sync cursor, Ogmios status
```
Every field traces back to chain via `advert_ref` / `escrow_ref`. Indexer serves cached data for speed.
## 6. Chain-follower extension (from apex-dashboard)
**Reused as-is**: `WsTransport`, `SqliteCache` core, `ChainSyncWorker` cursor/rollback logic, HTTP+SSE scaffold.
**Added for marketplace**:
- Config entry `config/marketplace-deployments.json` with `advert_script_hash` + `escrow_script_hash`.
- Two new address roles: `advert`, `escrow`.
- Two new decoders: `decodeAdvertDatum`, `decodeEscrowDatum`.
- Two new tables: `advertisements`, `escrows` (keyed by OutputReference).
- New event types: `PostAdvert`, `UpdateAdvert`, `RetireAdvert`, `PostEscrow`, `ClaimEscrow`, `SubmitEscrow`, `AcceptEscrow`, `ReclaimEscrow`, `ReleaseEscrow`.
- Status poller: background loop polling each Active supplier's `/status` every 20 s; writes to `supplier_status` table (off-chain).
- Aggregator: per-capability active-supplier counts, p95 wallclock from receipts.
Rollback semantics inherited: soft-delete + UTxO resurrection, per apex-dashboard `sqlite-cache.ts:328-360`.
## 7. Test architecture
### 7.1 Repository layout (target)
```
packages/
shared/
chain/
ChainProvider.ts # interface: queryUtxo, submitTx, evaluateTx, tip
MockChainProvider.ts # Tier 1 — in-memory, synthetic slots
ReadOnlyOgmiosProvider.ts # Tier 2 — eval+query only, no submit
LiveOgmiosProvider.ts # Tier 3 — full submit
cbor/
AdvertDatum.ts
EscrowDatum.ts
canonical.ts # deterministic JSON (RFC-8785 subset)
receipt/
build.ts sign.ts verify.ts
buyer/
supplier/
indexer/
contracts/
escrow/ advert/
tests/
unit/ # Tier 1, vitest
ogmios/ # Tier 2, read-only
lifecycle/ # Tier 3, docker-composed
fixtures/
buyer-side/ # independent CBOR builders (no shared helper)
supplier-side/ # independent CBOR builders
golden/ # cross-validation CBOR blobs
```
### 7.2 Discipline (from apex-dashboard + refinements)
- Real CBOR in fixtures (cbor-x), not JSON stubs.
- Buyer and supplier tests each build datums **independently from the spec**. No shared helper. Golden files cross-validate.
- `ChainProvider` is DI, not convention — fixes the missing seam in apex-dashboard.
- Property/fuzz tests on the escrow state machine (not present in apex-dashboard).
- Tier 3 runs under `SKIP_BEFORE_SLOT` from day 1 to avoid the glacial-sync pit.
- Mainnet kill-switch: env-gate + explicit `MAINNET=1` flag, hardcoded wallet allowlist.
### 7.3 Must-have adversarial cases for v1
Malformed datum · escrow to wrong script · supplier claims escrow addressed to other supplier · `request_spec_hash` / `prompt_hash` mismatch · Submit after `deliver_by` · Accept after `ACCEPT_WINDOW` · Reclaim before `deliver_by` · double-claim · double-submit · replay of Accept/Release · two concurrent escrows to same single-slot supplier · supplier status lies (`free` while actually `working`) · advert updated mid-flight (spec-lock must hold).
## 8. Milestones
| M | Goal | Duration | Exit criteria |
|----|------|----------|---------------|
| **M0** | Skeleton + `ChainProvider` seam | ~1 wk | Monorepo + CI + Tier 1 mock escrow lifecycle green |
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
## 9. Open follow-ups (not blocking M0 / M1)
1. **Locate Module-1 contract source** before M2. Initial grep across local checkouts failed — possibly on another machine or in a private repo.
2. **Canonical JSON** for `request_spec_hash`, `prompt_hash`, receipt. Proposal: RFC-8785 JCS subset (sorted keys, UTF-8 NFC, no whitespace).
3. **`ACCEPT_WINDOW` and `network_buffer` constants**. Proposed 10 min / 30 s.
4. **Receipt signing key**: same wallet key as `supplier_pkh`, or derived Ed25519 sub-key? Same-key is simpler; sub-key isolates risk.
5. **Mainnet safety**: env-gate + explicit flag + wallet allowlist before any mainnet Tier-3 run.
6. **Ollama-failure-leaves-Claimed (v1 hazard)**: if the supplier fails between Claim and Submit, the escrow stays in `Claimed` state on-chain. v1 has no recovery path — buyer must wait for `deliver_by` and `Reclaim`. M2+ should add either a supplier-side "abandonment" redeemer or a "failure receipt" path that releases funds without going through the dispute module.
7. **Buyer-side wallet fixture has invalid Ed25519 priv** (62 hex chars / 31 bytes; `tests/fixtures/buyer-side/wallet-keys.ts:32`). Currently harmless because no test signs with the buyer key, but M1-E will need this fixed via the same `priv → pub → pkh → bech32` derivation Catherine used for the supplier in M1-C.
8. **Supplier `index.ts` boots with `ReadOnlyOgmiosProvider`** which throws on `submitTx`. Real-chain Claim/Submit is therefore broken until M1-F adds a `LiveOgmiosProvider`. Boot script must either swap providers or branch on env.
9. **Buyer SDK uses structural receipt-signature check, not real Ed25519** in Tier-1 unit tests. The supplier-side wallet fixture's `SUPPLIER_PKH` is currently a hand-typed placeholder that does NOT equal `blake2b224(SUPPLIER_PUB_KEY_HEX)`, so the SDK can't perform cryptographic `verifyReceipt(...)` end-to-end against fixtures. M1-F must (a) re-derive `SUPPLIER_PKH` from the real pubkey and update any hardcoded references, (b) ensure mocks return a real `pub_key_hex` from `/capability` matching that derivation, (c) replace the structural check (`receipt_signature` is 128-char hex and not all zeros) with `verifyReceipt({receipt, signature}, pub_key_hex)` from `@marketplace/shared/receipt`. Real-chain Tier-3 lifecycle in M1-F naturally exercises the cryptographic path.
10. **Buyer SDK `httpClient.ts` carries an `HttpError.isSyncThrow` flag** and a "record-failure-and-return-sentinel" branch in `submitPrompt`. This was Catherine's defensive workaround for a Caroline test fixture that produced invalid hex synchronously (now fixed). Production fetch never throws synchronously; the branch is dead code. M1-F should remove the flag and the sentinel branch.
11. **`supplier/src/index.ts` reads `SUPPLIER_PUB_KEY_HEX`/`SUPPLIER_PKH`/`SUPPLIER_ADDRESS` from env** with `?? ""` fallbacks rather than deriving them from the priv. Inline note in code: "M1-D will plug in the proper Ed25519 + blake2b-224 pipeline." For testnet rollout 2026-04-27 we pass these explicitly via supplier/.env (computed from cardano-cli `address key-hash` + the .vkey cbor). Proper fix: derive in `main()` using `@noble/ed25519` + `blake2b224` + `bech32` (the same pipeline the buyer fixture wallet uses) and stop reading the env vars.
12. **Marketplace tx-builders produce synthetic JSON-in-hex CBOR**, not real Cardano tx CBOR. `LiveOgmiosProvider.submitTx` will get HTTP 400 from Ogmios on any real tx. Workaround used 2026-04-27: a one-shot `pycardano` script (`/tmp/post-advert.py`, modelled on `~/.openclaw/workspace-apex/testnet/deploy_agent_registry.py`) builds a real Cardano CBOR PostAdvert tx and submits via `https://submit.vector.testnet.apexfusion.org/api/submit/tx`. Proper fix (M1-F-4): rewrite all tx builders in `packages/shared/src/tx/**` using `@lucid-evolution/lucid` (already installed) so `pnpm tx:post-advert` and the supplier's Claim/Submit paths work without pycardano. Without this, the supplier `/v1/chat/completions` path will fail at the Claim stage (same 400 from Ogmios) — `/healthz` and `/capability` work today because they're read-only.
13. **Min-UTxO at advert script address requires ≥ ~1.69 AP3X** (datum size dependent). The on-chain advert posted 2026-04-27 locks 2 AP3X (rounded up). The Aiken validator does not constrain the locked value, so the operator-side tx builder must pick `max(supplier_bond_lovelace, min_utxo_for_datum)` — currently hardcoded as `max(SBOND, 2_000_000)` in `/tmp/post-advert.py`; M1-F-4's lucid-evolution rewrite should compute it dynamically from protocol params.
14. **`tests/fixtures/supplier-side/wallet-keys.ts` `SUPPLIER_PKH` is a placeholder `"abcdef0123…01"`**, NOT `blake2b224(SUPPLIER_PUB_KEY_HEX)`. The correct value is `9f356dd4cb466bbdddbfcfa3f4f61aa2a264cdb48707e60d1c829263` and address `addr1vx0n2mw5edrxh0wahl868a8kr232yexdkjrs0esdrjpfyccgazpvh`. This was tolerable until M1-F-4 because tests didn't run real lucid UPLC; with M1-F-4 live-CBOR tests, the local validator rejects on `signed_by` because the wallet's derived signing key produces a vkey witness that doesn't match the placeholder. **Production is unaffected** — the supplier's `/capability` returns its real priv-derived pkh, buyer constructs EscrowDatum with that pkh, validator checks pass. Cleanup queued as `M1-F-4-fix-fixtures`. Blast radius: 17+ files (both goldens regenerate). After cleanup, the test-only `presetWalletInputs ≥5 ADA padding` workaround in `packages/shared/src/tx/internal/liveCbor.ts` should also be removed.
15. **`tests/fixtures/...` invalid-checksum `addr_test1wrqq9…` script address** decode-fails in CML; Catherine workaround: override input UTxO address to blueprint-derived form before `collectFrom`. Same `M1-F-4-fix-fixtures` round.
16. **lucid-evolution / pnpm-store interaction**: `libsodium-wrappers-sumo`'s wrapper does `import "./libsodium-sumo.mjs"` but pnpm hoists the `.mjs` to a sibling pkg. Today's runtime workaround: copy the file into the wrapper's dir post-install. Clean fix: `pnpm patch libsodium-wrappers-sumo` with the import-path change, commit the patch.
## 10. Reference pointers (external)
- Apex-dashboard chain-follower pattern: `apex-dashboard/server/chain-sync-worker.ts` (private project; clone alongside this repo for cross-reference)
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
- Apex-dashboard test discipline: `apex-dashboard/tests/`
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`