Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ Delivery order, from [PRD §14](docs/PRD.md#14-delivery-phases):
Documentation lived in a separate `mergit-docs` repository until October 2026 and now lives here
under `docs/`.

## ETHGlobal Mumbai — Arkiv track

Continuity Track pick: Arkiv's ["Build on Arkiv (existing projects)"](https://ethglobal.com/events/mumbai/prizes/arkiv).
Mergit's router already picks agents for a task and, on an exploring call, records the draw in its
own Postgres (`routing_decisions`, pre-existing); what's new at this event is a second, public copy
of each exploring decision written to Arkiv's Rabdi testnet and read back through a new endpoint.
Full writeup: [`arkiv/schema.md`](arkiv/schema.md), [`arkiv/why-arkiv.md`](arkiv/why-arkiv.md),
[`arkiv/how-you-use-arkiv.md`](arkiv/how-you-use-arkiv.md), [`arkiv/reach.md`](arkiv/reach.md),
[`arkiv/friction.md`](arkiv/friction.md).

- **Service wallet (writes every mirrored entity):** `<TODO: fill in once a funded
ARKIV_PRIVATE_KEY is set on chain/arkiv/'s deploy — see chain/arkiv/README.md>`
- **One query anyone can run**, against `https://rpc.rabdi.db-chain.testnet.arkiv.network`
(`arkiv_query`) or through the hosted app's `GET /v0/plans/{uuid}/arkiv-suggestions`:
```
arkiv.schema = str('mergit.routing_decision') AND planId = str('<a real plan_id>') AND $owner = addr(<service wallet address above>)
```
- **In the app:** the live run screen's "Suggested agents" panel, after asking for an exploring
suggestion, shows a "View on Arkiv" button that reads the mirrored entities back from Arkiv live
(`frontend/src/screens/live/run/SuggestAgents.tsx`).
- **Writes go through a small sidecar** (`chain/arkiv/`, Node, the official JS SDK), not the Python
control plane directly — the only Python Arkiv SDK targets a different, incompatible network,
confirmed live (`arkiv/friction.md`). Reads stay plain Python (httpx against `arkiv_query`).
- **AI disclosure:** this track's implementation (`mergit_cp/arkiv_mirror.py`, the
`/arkiv-suggestions` route, the frontend panel, the `arkiv/*.md` files, this section) was written
with AI assistance (Claude Code), reviewed and tested by the team before merging.

## Repository layout

| Path | What | Language |
Expand All @@ -76,7 +103,7 @@ under `docs/`.
| `runtime/` | Receipt builder (`receipt/`) and its Python binding (`py/`, module `mergit_receipt`) | Rust |
| `verifier/` | Receipt verifier library and `mergit-verify` CLI; `wasm/` builds it for the browser. Never imports `runtime/` | Rust |
| `evidence/` | Receipt store (`store/`) and validator (`validator/`) | Rust |
| `chain/` | Contract client (`client/`), chain gateway with payment path and free credits (`gateway/`), generated ABIs (`abi/`) | Rust |
| `chain/` | Contract client (`client/`), chain gateway with payment path and free credits (`gateway/`), generated ABIs (`abi/`); the Arkiv write sidecar (`arkiv/`) is Node/JS, not Rust | Rust |
| `control-plane/` | Planner, task graph, approvals, router, agent workers | Python |
| `frontend/` | Web app | TypeScript, React |
| `deploy/` | Docker images, local compose, hosting | |
Expand Down
69 changes: 69 additions & 0 deletions arkiv/friction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Arkiv feedback

What got in the way building this, and how to reproduce each point. Everything below was checked
live against Rabdi (`https://rpc.rabdi.db-chain.testnet.arkiv.network`), not assumed from docs —
an earlier version of this file made claims from reading the Python SDK's source alone, which
turned out to be wrong for this network; this version corrects that.

## The official Python SDK targets a different, incompatible network

`arkiv-sdk` on PyPI (`Arkiv-Network/arkiv-sdk-python`) exists and looks like exactly what a Python
backend needs. Live, it doesn't work against Rabdi at all, in two separate ways:

- Its query options use `includeData` (`{"select": ..., "includeData": {...}}` style params). Rabdi
rejects it outright: `"unknown field includeData, expected one of atBlock, select, limit, offset,
cursor"`.
- Its write target (contract/processor address) is `0x…61726B6976`. Rabdi's own write address,
confirmed by checking its code via `eth_getCode`, is `0x4400000000000000000000000000000000000044`
— the one address `eth_getCode` on Rabdi actually returns bytecode for; the SDK's address returns
`"0x"` (nothing deployed there on this chain). The newest prerelease (`1.0.0b3.dev0`) has migrated
the write address but still sends the old, rejected query options for reads.

**Reproduce:** `pip install --pre arkiv-sdk`, point `ProviderBuilder().custom(...)` at Rabdi's RPC,
and run any `query_entities` call; the node returns the `includeData` error above.

## Rabdi's own JSON-RPC has no payload/content-type field at all

The docs' `select` field list (`key`, `owner`, `creator`, `createdAt`, `updatedAt`, `expiresAt`,
`creationFlags`, `attributes`, `attributeSchema`, `count`) looks complete until you look for where
the actual binary content goes. Checked live on dozens of real entities already on Rabdi
(`arkiv_getEntity`): none ever returns a top-level `payload` or `contentType` field, including ones
whose author clearly meant to store a binary blob (one real entity uses a custom attribute literally
*named* `payload`, since the protocol has none). Everything lives in `attributes`, including
anything you'd think of as "the payload" — hence `body` in our own schema is a `bytes`-typed
*attribute*, not the payload/content-type pair the JS quickstart's prose implies and the (wrong-network)
Python SDK's `create_entity(payload=..., content_type=...)` signature assumes.

**Reproduce:** `arkiv_getEntity` any real key from a live `arkiv_query` result; the response's keys
are exactly the `select` list above, never `payload`.

## The query language's literal syntax isn't what the JS quickstart's prose suggests

The attribute-value syntax the raw JSON-RPC wants is stricter than a first read of the docs
suggests: string literals need `str('...')` (bare `"..."` is a parse error, `-32001`), and address
literals need **unquoted** hex — `addr(0xAbC…)`, not `addr('0xAbC…')` (quoted gives `-32003`, "addr
takes a 0x-prefixed address"). Neither is spelled out next to the `select`-field docs; both were
found by reading the actual error messages from a live node.

**Reproduce:** `arkiv_query` with `'kind = "x"'` → `-32001`; with `$owner = addr('0x...')` → `-32003`.

## A JSON-RPC reachability probe can silently ignore its own filter

One specific sequence produced 60 real, unrelated entities from a query meant to match zero: an
`$owner`-only filter against a throwaway burn address (`0x…dEaD`) returned every entity anyone has
ever sent that address, which is correct — but it looked, for a few minutes, like the `kind`/schema
filter in a *different* query wasn't being applied, until re-running each query in isolation with a
`count`-only `select` showed the schema filter alone genuinely matches zero. Not an Arkiv bug: two
different test queries were run back to back and their results were briefly misread as one. Worth
recording anyway, since it's an easy trap when scripting several `arkiv_query` calls in a row and
reading output after the fact rather than per-call.

## No live write exercised yet — but a real, verified attempt, not a guess

`chain/arkiv/`'s sidecar, using the official JS SDK (the one confirmed above to target the right
network), reaches real on-chain gas estimation with an unfunded throwaway wallet (confirmed `0`
balance via `eth_getBalance`) and fails there — `"Execution error without revert data"` — not at
encoding, not at signing, not at the RPC connection. That is the furthest this can be verified
without a funded wallet, which needs a human with a browser wallet (`hub.arkiv.network/faucet` is
wallet-connect gated, not a plain API). No successful create yet, so no real entity key exists; the
placeholders in `schema.md` and the README stay placeholders until someone funds a wallet.
37 changes: 37 additions & 0 deletions arkiv/how-you-use-arkiv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# How we use Arkiv

**Do the things we filter on, who can edit each entity, and when it expires fit what the product
does?**

- **Filters** (the built-in `arkiv.schema`, plus `planId`, `decisionId`, `taskIndex`, `category`,
`seed`, `budgetMicros`, `decidedAt` — all indexed, `schema.md`) are exactly the fields the product
itself already groups suggestions by: one query per plan (`ArkivMirror.describe_query` builds
`arkiv.schema = str('mergit.routing_decision') AND planId = str('...') AND $owner = addr('...')`),
which is the same shape as the existing Postgres query `decisions()` in the test suite uses
(`SELECT ... FROM routing_decisions WHERE plan_uuid = %s`). We didn't invent new query axes for
Arkiv; we reused the ones the feature already needed, plus `$owner`, which Arkiv being a public,
permissionless database makes necessary and Postgres's own access control never required. No
`requester`: review caught that it was never filtered on and would outlive a discarded draft, so
it's a Postgres-only column now.
- **Who can edit:** nobody, by design — we never call `update_entity`, `extend_entity`, or
`delete_entity`. A routing decision is a historical fact about one draw at one point in time
(paper §9.3: "any draw can be replayed from its seed and the posteriors it saw"); the matching
Postgres row is enforced append-only by a database trigger for the same reason
(`routing_decisions_append_only()`, migration `0016`). Arkiv's entity having a fixed owner and no
update path we exercise is a match for that invariant, not an extra feature we're using because
it's there — and `seed` plus `body`'s posterior snapshot are both on the entity now, so "any draw
can be replayed" is something the public copy can actually back, not just the Postgres original.
- **Expiry** (`ARKIV_ENTITY_TTL_DAYS`, default 30) fits the data's own lifetime: a suggestion is
advice for a plan that is still a *draft*. Once a requester approves, funds, and runs a plan, the
suggestion is behind them — Postgres keeps it forever as an audit trail because storage there is
cheap and ours; Arkiv keeps it only long enough to be checkable, then lets it go, which matches
"ephemeral coordination state... that [agents] let expire," the exact framing Arkiv's own
Build-on-Arkiv prize uses for this pattern.

**Does each Arkiv feature we use change something users see?**
Yes, directly: `GET /v0/plans/{uuid}/arkiv-suggestions` is a new endpoint the frontend's
`SuggestAgents` panel calls through a "View on Arkiv" button, shown only after an exploring
suggestion (greedy writes nothing, so there's nothing to show it for). It renders the entity keys
read back live from Arkiv's query API — not a value cached in our own database — so what the user
sees is proof the mirror round-trips, not a write nobody reads back (the one thing this track's
qualification rules call out as not counted).
32 changes: 32 additions & 0 deletions arkiv/reach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Reach

**Who uses this today, and will they use the part on Arkiv?**
Mergit's `/v0/plans/{uuid}/suggestions?explore=true` is already a live, used feature: a requester
drafting a plan can ask for an exploring (rather than greedy) ranking of which agent to run each
task, and that call already writes a replay row to our own Postgres today, independent of this
event. Every exploring call a requester makes now also gets mirrored to Arkiv — the same users,
the same action, no new UI flow for them to opt into.

**Will it ship to them after Mumbai?**
Yes, assuming it holds up: nothing about the mirror is event-only scaffolding. `ArkivMirror` is a
best-effort side effect (`routes_routing._mirror_decisions` catches and logs any failure rather
than raising it), so leaving it on costs us nothing on the request path even if Arkiv's testnet has
an outage, and removing it later is a one-line settings change (`ARKIV_RPC_URL` unset), not a
migration. The honest caveat: Arkiv itself is currently testnet-only (see its own Terms of Use), so
"after Mumbai" for us currently means "until Arkiv has a production network," at which point moving
the service wallet over is the only change needed.

**Who else would use a copy of this data on Arkiv?**
Two concrete audiences, neither hypothetical:

- **Our own router's future work (paper §9.3, §7.5).** The reputation/routing paper already wants
an auditable, replayable record of every exploring draw — that's *why* `routing_decisions` is
append-only in Postgres today. A public copy is strictly more auditable than a private table only
we can read: anyone can verify a draw was recorded honestly and wasn't edited after the fact,
which a database we control cannot prove to an outside party.
- **Other agent-coordination projects on Arkiv.** This is a direct instance of the "ephemeral
coordination state that agents share, query and let expire" pattern Arkiv's own Build-on-Arkiv
prize names as a target use case. A team building on Arkiv to compare how different projects
model agent-assignment data would have a real, running example to query
(`arkiv.schema = str('mergit.routing_decision')`), not a synthetic demo entity nobody else
depends on.
97 changes: 97 additions & 0 deletions arkiv/schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Arkiv schema

What we write to Arkiv's Rabdi testnet, for ETHGlobal Mumbai's "Build on Arkiv (existing projects)"
track. Code: `control-plane/mergit_cp/arkiv_mirror.py` (reads, plain httpx), `chain/arkiv/` (the
sidecar that writes, holding the service wallet's key), called from
`control-plane/mergit_cp/routes_routing.py` (`_mirror_decisions`) and read back in
`GET /v0/plans/{uuid}/arkiv-suggestions` (`arkiv_suggestions`).

## What's new at this event, and what existed before

Mergit's router already picked agents for a task before Mumbai: `GET /v0/plans/{uuid}/suggestions`
ranks the roster by validated success per unit cost (`routing.py`, paper §9.3), and — only when
asked to explore — records the draw in our own Postgres table `routing_decisions` (migration
`0016_routing_decisions.sql`) so it can be replayed from its seed. **That Postgres write, the
ranking logic, and the whole suggestions endpoint are pre-existing.** What's new at this event is
everything that follows: a second, public copy of each exploring decision written to Arkiv, and a
new endpoint that reads it back from there.

## Entity

One `arkiv.schema = "mergit.routing_decision"` entity per task per exploring
`GET /v0/plans/{uuid}/suggestions?explore=true` call, all of one call's tasks in a single batched
transaction (`chain/arkiv/src/server.js`'s `executeBatch`), not one transaction per task.

`defineSchema("mergit.routing_decision", {...})` (`chain/arkiv/src/schema.js`) makes the schema's own
name the built-in `arkiv.schema` attribute — confirmed live against Rabdi: `arkiv.schema =
str('my-app.posts')` matches the docs' own example entity on the real network, so filtering on it is
a real query, not SDK sugar.

| Attribute | Type | Indexed | What |
| --- | --- | --- | --- |
| `planId` | `str` | yes | The plan's `0x`-prefixed id (`mergit_receipt.plan_id`), so a query can ask for one plan's decisions |
| `decisionId` | `u64` | yes | The exact `routing_decisions.decision_id` this entity mirrors — links the public copy back to the real row, not just a record of the outcome |
| `taskIndex` | `i32` | yes | Which task in the plan this decision is for |
| `category` | `str` | yes | The task's category, same value the router ranked within |
| `seed` | `u64` | yes | The Thompson draw's seed (`routing_decisions.seed`) — with it, and `body`'s posterior snapshot, anyone can replay the draw and check it wasn't rigged (migration 0016's own reason the Postgres table is append-only) |
| `budgetMicros` | `u64` | yes | The task's budget in USDC micros, the same value the draw ranked candidates against |
| `decidedAt` | `str` | yes | ISO-8601 UTC timestamp, taken from Postgres's own `created_at` on the same row (not a second, separately-computed one) |
| `body` | `bytes` | no | The candidates/picks JSON, as raw bytes — see below |

No `requester` attribute: an earlier version of this schema had one, dropped after review. Arkiv
entities are append-only — only expiry removes them — so attaching a wallet address to data that
outlives a requester's own "discard this draft" action was wrong, and nothing here ever filtered
reads by it anyway (`$owner`, the one service wallet, is what every read already filters on).

There is no separate "payload" field at the protocol level on Rabdi: checked live, `arkiv_getEntity`
never returns one, on any entity, ours or anyone else's (`friction.md`). Everything is a named
attribute, including the blob, which is why `body` is itself an attribute (`bytes`-typed) rather than
a payload/content-type pair — the Python SDK's `create_entity(payload=..., content_type=...)` API,
which assumes that pair exists, targets a different, incompatible network (`friction.md`).

`body`'s JSON:

```json
{
"candidates": [{"agent_id": "...", "alpha": 0, "beta": 0, "kappa": 0, "tau": 0, "n": 0}],
"picks": [{"agent_id": "...", "score": 0, "success": 0, "cost": 0}]
}
```

`candidates` is every roster agent's posterior at the moment of the draw (what `_snapshot` in
`routes_routing.py` already recorded to Postgres); `picks` is the ordered result. Same shapes
Postgres's `routing_decisions.candidates`/`.picks` columns hold — this is a mirror of that row, not
a redesign of it.

## Who owns it, and why

One service wallet signs every write. Its *private key* lives only in `chain/arkiv/`'s own env
(`ARKIV_PRIVATE_KEY`), never in the control plane's process or env — the control plane only knows
the wallet's *public* address (`ARKIV_OWNER_ADDRESS`), which it needs to filter reads (below). It is
not a per-requester wallet: Mergit requesters sign in with Ethereum but never hold funds on Arkiv's
testnet, so making them the Arkiv-level owner would mean asking them to fund a second chain to see
their own suggestion history.

Service wallet address: `<TODO: fill in once a funded ARKIV_PRIVATE_KEY is set on chain/arkiv/'s
deploy — see chain/arkiv/README.md>`.

## Expiry

`ARKIV_ENTITY_TTL_DAYS` (default 30) from first write. A routing suggestion is advice for one draft
plan; once that plan is approved, funded, run and done, the suggestion that preceded it has no
further use, so letting it expire rather than grow an unbounded public log is correct, not merely
cheap.

## One query anyone can run

```
arkiv.schema = str('mergit.routing_decision') AND planId = str('<a real plan_id from a live run>') AND $owner = addr(<the service wallet address above>) ORDER BY $key ASC
```

The `$owner` clause matters: without it, anyone could write an entity with our exact schema tag and
a matching `planId` and have it show up as if it were ours (Arkiv is public and permissionless —
checked live, nothing stops a third party from using any tag). Run it against
`https://rpc.rabdi.db-chain.testnet.arkiv.network` via `arkiv_query`
(`{"select": {"key": true, "attributes": true}}`), or through
`GET /v0/plans/{uuid}/arkiv-suggestions` on the hosted app, which runs exactly this query
(`ArkivMirror.describe_query`) and returns the decoded result.
Loading
Loading