From 1a09b28d8245a98ac289d8a8b1cc9e41a28f2ee7 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 04:09:08 +0530 Subject: [PATCH 1/7] feat(arkiv): mirror exploring routing suggestions to Arkiv's Rabdi testnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ETHGlobal Mumbai "Build on Arkiv (existing projects)" track, swapped in for EF (no code had been built for that pick). An exploring suggestions call already records its draw in Postgres (routing_decisions, pre-existing); this adds a best-effort public mirror of the same decision on Arkiv, plus a new endpoint and frontend panel that read it back, so the write side is provably read somewhere a user sees rather than written and ignored. - control-plane/mergit_cp/arkiv_mirror.py: thin wrapper over the official arkiv-sdk (Python, pinned ==1.0.0b2 — no stable release yet), write/read, defensive decoding since Arkiv is a public permissionless database - routes_routing.py: mirror write after the Postgres transaction commits (never fails the request), new GET /v0/plans/{uuid}/arkiv-suggestions - frontend: "View on Arkiv" panel in SuggestAgents.tsx, reads live from the new endpoint, shown only after an exploring suggestion - arkiv/{schema,friction,reach,why-arkiv,how-you-use-arkiv}.md + README sections for the track's qualification requirements Needs a funded service wallet + access key (ARKIV_RPC_URL/ARKIV_PRIVATE_KEY/ ARKIV_ACCESS_KEY) on the hosted deploy before the wallet address and one live query can be filled into README.md/arkiv/schema.md; everything else is tested end to end against a fake Arkiv client (541 fast + 449 postgres tests green, ruff clean, pyright strict clean on mergit_cp). --- README.md | 24 +++ arkiv/friction.md | 47 +++++ arkiv/how-you-use-arkiv.md | 32 ++++ arkiv/reach.md | 31 +++ arkiv/schema.md | 71 +++++++ arkiv/why-arkiv.md | 24 +++ control-plane/README.md | 15 ++ control-plane/mergit_cp/app.py | 12 ++ control-plane/mergit_cp/arkiv_mirror.py | 170 ++++++++++++++++ control-plane/mergit_cp/config.py | 16 ++ control-plane/mergit_cp/deps.py | 15 +- control-plane/mergit_cp/routes_routing.py | 70 +++++++ control-plane/pyproject.toml | 3 + control-plane/tests/test_arkiv_mirror.py | 181 ++++++++++++++++++ control-plane/tests/test_routes_routing.py | 57 ++++++ control-plane/uv.lock | 109 ++++++++++- frontend/src/api/client.ts | 6 + frontend/src/api/types.ts | 18 ++ .../src/screens/live/run/SuggestAgents.tsx | 64 ++++++- 19 files changed, 957 insertions(+), 8 deletions(-) create mode 100644 arkiv/friction.md create mode 100644 arkiv/how-you-use-arkiv.md create mode 100644 arkiv/reach.md create mode 100644 arkiv/schema.md create mode 100644 arkiv/why-arkiv.md create mode 100644 control-plane/mergit_cp/arkiv_mirror.py create mode 100644 control-plane/tests/test_arkiv_mirror.py diff --git a/README.md b/README.md index 1a9bdcca..312fdf7c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,30 @@ 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):** `` +- **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`: + ``` + kind = "mergit.routing_decision" AND planId = "" + ``` +- **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`). +- **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 | diff --git a/arkiv/friction.md b/arkiv/friction.md new file mode 100644 index 00000000..25f533c6 --- /dev/null +++ b/arkiv/friction.md @@ -0,0 +1,47 @@ +# Arkiv feedback + +What got in the way building this, and how to reproduce each point. + +## The Python SDK is real, but easy to miss + +`docs.arkiv.network`'s own quickstarts (`start-here/first-entity`, `writing/create`) are all +TypeScript against `@arkiv-network/sdk` + `viem`. Our control plane is Python, so the first +conclusion a reader reaches is "writes need Node" — `createEntity` there is a signed, +custom-encoded transaction, not a plain JSON-RPC call, so hand-rolling it in another language looks +risky. The official Python SDK (`Arkiv-Network/arkiv-sdk-python`, `pip install --pre arkiv-sdk`, +built on `web3.py`) exists and does the same job, but we only found it by searching GitHub's +`arkiv-network` org directly — nothing on the docs site itself linked to it. **Reproduce:** read +`docs.arkiv.network/start-here/first-entity/` end to end; it never mentions the Python SDK or links +to it. + +## The Python SDK has no stable release, and the two listings disagree + +PyPI's `arkiv-sdk` has no `1.0.0` yet — the real releases are `1.0.0.dev1` through our pin, +`1.0.0b2`. That needs an exact-version pin (`arkiv-sdk==1.0.0b2`) rather than a range, since most +resolvers (uv included) only accept a prerelease when the specifier itself names one. Separately, +the GitHub repo's own "Releases" sidebar showed `v0.2.2` as "Latest" while PyPI already had several +newer `1.0.0` alphas and betas — someone comparing the two listings would reasonably conclude the +project is stuck pre-1.0 at `v0.2.2`, which understates what's actually published. **Reproduce:** +`pip index versions arkiv-sdk` (or the PyPI JSON API) against +`github.com/Arkiv-Network/arkiv-sdk-python/releases`. + +## Attributes are `str | int` only — structured data has to go in the payload + +`arkiv.types.Attributes` is `NewType("Attributes", dict[str, str | int])`. Our routing decision +carries a list of per-agent posteriors and ranked picks, which can't be an attribute value at all — +we only found this by reading the SDK's `types.py` rather than from a documented constraint. It's a +reasonable design (attributes are the indexed, queryable part; anything else belongs in the +payload), but the quickstart's single `str`/`dec`/`bytes` example doesn't make the boundary +explicit. We ended up putting `candidates`/`picks` in the JSON payload and only the filterable +fields (`planId`, `taskIndex`, `category`, `requester`, `decidedAt`, `kind`) as attributes +(`arkiv_mirror.py`, `schema.md`). + +## No live write exercised yet + +Everything in this track — `write_decision`, `read_decisions`, the `/arkiv-suggestions` endpoint, +and the frontend panel — is tested against a fake `RawArkivClient` (`tests/test_arkiv_mirror.py`, +the Arkiv-specific cases in `tests/test_routes_routing.py`) plus the full existing Postgres suite, +not against a real Rabdi write: that needs a funded service wallet and an access key, which only +someone with wallet access can obtain (`hub.arkiv.network/faucet`, `hub.arkiv.network/access-keys`) +and set as `ARKIV_RPC_URL`/`ARKIV_PRIVATE_KEY`/`ARKIV_ACCESS_KEY` on the hosted deploy. `schema.md`'s +wallet address is a placeholder until that happens. diff --git a/arkiv/how-you-use-arkiv.md b/arkiv/how-you-use-arkiv.md new file mode 100644 index 00000000..9a21c8d9 --- /dev/null +++ b/arkiv/how-you-use-arkiv.md @@ -0,0 +1,32 @@ +# 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** (`kind`, `planId`, `taskIndex`, `category`, `requester`, `decidedAt` — all indexed, + `schema.md`) are exactly the fields the product itself already groups suggestions by: one query + per plan (`arkiv_suggestions` in `routes_routing.py` builds `kind = "..." AND planId = "..."`), + 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. +- **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. +- **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). diff --git a/arkiv/reach.md b/arkiv/reach.md new file mode 100644 index 00000000..1826f396 --- /dev/null +++ b/arkiv/reach.md @@ -0,0 +1,31 @@ +# 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 (`kind = + "mergit.routing_decision"`), not a synthetic demo entity nobody else depends on. diff --git a/arkiv/schema.md b/arkiv/schema.md new file mode 100644 index 00000000..15d1a4ff --- /dev/null +++ b/arkiv/schema.md @@ -0,0 +1,71 @@ +# 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`, 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 entity per task per exploring `GET /v0/plans/{uuid}/suggestions?explore=true` call. Content +type `application/json`. + +| Attribute | Type | Indexed | What | +| --- | --- | --- | --- | +| `kind` | `str` | yes | Always `"mergit.routing_decision"` — the project's tag; filter on it, since Arkiv is a shared database other projects write to as well | +| `planId` | `str` | yes | The plan's `0x`-prefixed id (`mergit_receipt.plan_id`), so a query can ask for one plan's decisions | +| `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 | +| `requester` | `str` | yes | The plan owner's wallet address — a queryable attribute, **not** the entity's Arkiv-level owner (see "Who owns it" below) | +| `decidedAt` | `str` | yes | ISO-8601 UTC timestamp of the write | + +Payload (not an attribute, since Arkiv attributes are `str \| int` only — no nested structures): + +```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 (`ARKIV_PRIVATE_KEY`, funded from `hub.arkiv.network/faucet`) signs every write. +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. The requester's address is still fully queryable — as the +`requester` attribute above — just not the chain-level owner field. + +Service wallet address: ``. + +## 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 + +``` +kind = "mergit.routing_decision" AND planId = "" +``` + +Against `https://rpc.rabdi.db-chain.testnet.arkiv.network` via `arkiv_query`, or through +`GET /v0/plans/{uuid}/arkiv-suggestions` on the hosted app, which runs exactly this query and +returns the decoded result (see `friction.md` for the one gap in that endpoint). diff --git a/arkiv/why-arkiv.md b/arkiv/why-arkiv.md new file mode 100644 index 00000000..ffc2fdb8 --- /dev/null +++ b/arkiv/why-arkiv.md @@ -0,0 +1,24 @@ +# Why Arkiv + +**If this data sat in Postgres or any other database instead of Arkiv, what would we lose?** +We wouldn't lose anything functionally — Postgres is still the copy the product actually runs on, +and stays that way (`arkiv_mirror.py`'s own docstring: "Postgres stays the source of truth: nothing +here is read back to decide anything the product depends on"). What we'd lose is *provability to +someone outside Mergit*. Our `routing_decisions` table is already append-only with a trigger that +refuses `UPDATE`/`DELETE` (migration `0016_routing_decisions.sql`) — but that guarantee only holds +inside our own database, enforced by us, for anyone who trusts us to have set it up honestly and not +touched it since. On Arkiv, the same guarantee (an entity with a fixed owner and a set expiry) is +enforced by a network we don't operate, so "this decision wasn't quietly edited after the fact" is +something a requester, a researcher, or a competing agent-coordination project can check themselves +against a public RPC, not something they have to take Mergit's word for. + +**What did we keep off Arkiv, and why?** +Everything except the exploring-suggestion mirror: plans, tasks, budgets, approvals, accounts, +money events, the reputation posteriors themselves. Those stay in Postgres because they're either +load-bearing for a real financial decision (escrow, budget checks, what an agent actually gets +paid) or carry information a requester has no reason to make public (their goal text, their repo +URL, their account). Arkiv is a public database — every entity's attributes are readable by anyone, +and `$owner` only controls who can *change* an entity, not who can *see* it. A routing suggestion is +advice that was already shown to the requester in the API response; mirroring exactly that is a +natural fit. Mirroring a plan's goal text or budget would be publishing data nobody asked to make +public, for no reach benefit this track's own judging criteria reward. diff --git a/control-plane/README.md b/control-plane/README.md index 0970b3e0..21b81c1b 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -204,6 +204,21 @@ without it, `GET /v0/plans/{plan_uuid}/events` sees no session and answers `401` **Database.** The control plane uses its own database, not the gateway's: both have tables named `plans`, `tasks` and `outbox`, and spec/events-v0.md says neither reads the other's. +## Arkiv mirror (ETHGlobal Mumbai, "Build on Arkiv" track) + +An exploring `GET /v0/plans/{uuid}/suggestions?explore=true` call already records its draw in this +service's own `routing_decisions` table (pre-existing, migration `0016`); `mergit_cp/arkiv_mirror.py` +additionally mirrors it as a public entity on Arkiv's Rabdi testnet, after that Postgres transaction has +committed, so a down testnet or a slow RPC never fails the request (`routes_routing._mirror_decisions` +catches and logs). `GET /v0/plans/{uuid}/arkiv-suggestions` reads the mirror back for the plan's owner. +See `../arkiv/schema.md` for the entity shape. + +All optional, all unset by default (`Settings.arkiv_enabled`): `ARKIV_RPC_URL` (Rabdi's RPC, +`https://rpc.rabdi.db-chain.testnet.arkiv.network`), `ARKIV_PRIVATE_KEY` (one service wallet, funded from +`hub.arkiv.network/faucet`, that signs every mirrored entity — not a per-requester wallet), `ARKIV_ACCESS_KEY` +(from `hub.arkiv.network/access-keys`, raises the public RPC's rate limit), `ARKIV_ENTITY_TTL_DAYS` (default +30). Needs `arkiv-sdk` (`pyproject.toml`, pinned `==1.0.0b2`: the package has no stable release yet). + ## The worker `python -m mergit_worker` claims one `queued` job at a time (`SELECT … FOR UPDATE SKIP LOCKED`), runs the diff --git a/control-plane/mergit_cp/app.py b/control-plane/mergit_cp/app.py index a27864c5..f2da1d58 100644 --- a/control-plane/mergit_cp/app.py +++ b/control-plane/mergit_cp/app.py @@ -24,6 +24,7 @@ routes_runs, wallet_interest, ) +from .arkiv_mirror import ArkivMirror from .config import Settings from .db import migrate, open_pool from .events import EventDeliverer @@ -46,6 +47,7 @@ def create_app( process_outcomes: bool = True, update_reputation: bool = True, mailer: otp.Mailer | None = None, + arkiv: ArkivMirror | None = None, ) -> FastAPI: """``deliver_events=False`` leaves events in the outbox, and ``process_outcomes=False`` leaves finished jobs unprocessed, for tests that want to inspect or drive that state directly.""" @@ -69,6 +71,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.costs = PgCostSink(pool, prices) app.state.broadcaster = PlanBroadcaster() app.state.mailer = mailer or (otp.SmtpMailer(settings) if settings.email_enabled else None) + app.state.arkiv = arkiv or ( + ArkivMirror( + settings.arkiv_rpc_url, # type: ignore[arg-type] # non-None: arkiv_enabled checked it + settings.arkiv_private_key.get_secret_value(), # type: ignore[union-attr] + settings.arkiv_access_key.get_secret_value() if settings.arkiv_access_key else None, + settings.arkiv_entity_ttl_days, + ) + if settings.arkiv_enabled + else None + ) stop = asyncio.Event() deliverer = EventDeliverer( diff --git a/control-plane/mergit_cp/arkiv_mirror.py b/control-plane/mergit_cp/arkiv_mirror.py new file mode 100644 index 00000000..993b88b7 --- /dev/null +++ b/control-plane/mergit_cp/arkiv_mirror.py @@ -0,0 +1,170 @@ +"""Mirrors routing decisions (``routing_decisions``, migration 0016) onto Arkiv, the public Web3 database, +for ETHGlobal Mumbai's "Build on Arkiv (existing projects)" track. + +Postgres stays the source of truth: nothing here is read back to decide anything the product depends on. +This is a best-effort copy, written after the Postgres transaction that recorded the decision has already +committed, so a down testnet or a slow RPC never affects a real request (``routes_routing.suggestions`` +swallows and logs any `ArkivMirror` failure). The mirrored entity tags itself +``kind = "mergit.routing_decision"`` so anyone can query it without knowing anything else about Mergit; +the requester's address is a queryable attribute, not the Arkiv-level owner, since one service wallet +signs every write (see `Settings.arkiv_*`). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass +from typing import Any, Protocol, cast + +log = logging.getLogger(__name__) + +KIND = "mergit.routing_decision" +"""The project's tag on every entity this module writes (`arkiv.schema.md`'s ``kind`` attribute).""" + + +class RawArkivClient(Protocol): + """The slice of ``arkiv.Arkiv`` this module calls, as its own protocol so a test can fake it without + a real `arkiv.Arkiv` (which needs a funded account and a reachable RPC).""" + + def create_entity( + self, + payload: bytes | None = None, + content_type: str | None = None, + attributes: dict[str, str | int] | None = None, + expires_in: int | None = None, + ) -> tuple[Any, Any]: ... + + def query_entities(self, query: str) -> Any: ... + + +@dataclass(frozen=True) +class MirroredDecision: + """One routing decision as read back from Arkiv (`GET /v0/plans/{uuid}/arkiv-suggestions`).""" + + entity_key: str + plan_id: str + task_index: int + category: str + requester: str + decided_at: str + candidates: list[dict[str, Any]] + picks: list[dict[str, Any]] + + +class ArkivMirror: + """Lazily builds the real `arkiv.Arkiv` client on first use (constructing it makes no network call, + but importing ``arkiv`` eagerly would make the dependency mandatory for everyone who never sets + `ARKIV_*`).""" + + def __init__( + self, + rpc_url: str, + private_key: str, + access_key: str | None, + ttl_days: int, + *, + client: RawArkivClient | None = None, + ) -> None: + self._rpc_url = rpc_url + self._private_key = private_key + self._access_key = access_key + self.ttl_seconds = ttl_days * 86400 + self._client = client + """Pre-built only in tests, which fake `RawArkivClient` to avoid a real funded account and RPC.""" + + def _build(self) -> RawArkivClient: + from arkiv import Arkiv # local import: optional dependency, only needed when Arkiv is configured + from arkiv.account import NamedAccount + from arkiv.provider import ProviderBuilder + from web3.providers.base import BaseProvider + + url = f"{self._rpc_url}/{self._access_key}" if self._access_key else self._rpc_url + # .custom() with no .ws()/.async_mode() always builds a sync HTTPProvider; the builder's return + # type is wider (it also covers ws/async providers) than what we ever actually get here. + provider = cast(BaseProvider, ProviderBuilder().custom(url).build()) + account = NamedAccount.from_private_key("mergit-service", self._private_key) + # `client.arkiv` (an `ArkivModule`), not the `Arkiv`/`Web3` client itself, is what carries + # `create_entity`/`query_entities`. + return cast(RawArkivClient, Arkiv(provider, account=account).arkiv) + + @property + def client(self) -> RawArkivClient: + if self._client is None: + self._client = self._build() + return self._client + + async def write_decision( + self, + *, + plan_id: str, + task_index: int, + category: str, + requester: str, + decided_at: str, + candidates: list[dict[str, Any]], + picks: list[dict[str, Any]], + ) -> str: + """Mirrors one `routing_decisions` row. Returns the new entity's key. Raises on failure; callers + that must not fail the request for this (`routes_routing.suggestions`) catch and log it.""" + payload = json.dumps({"candidates": candidates, "picks": picks}).encode() + attributes: dict[str, str | int] = { + "kind": KIND, + "planId": plan_id, + "taskIndex": task_index, + "category": category, + "requester": requester, + "decidedAt": decided_at, + } + + def create() -> str: + entity_key, _receipt = self.client.create_entity( + payload=payload, + content_type="application/json", + attributes=attributes, + expires_in=self.ttl_seconds, + ) + return str(entity_key) + + return await asyncio.to_thread(create) + + async def read_decisions(self, plan_id: str, *, limit: int = 50) -> list[MirroredDecision]: + """Every live mirrored decision for one plan, most recent Arkiv write order undefined (callers + that care sort by `decided_at` themselves). Arkiv is a public, permissionless database: anyone + can write an entity tagged ``kind = "mergit.routing_decision"`` with any payload they like, so a + malformed match is skipped and logged rather than raised (`_decode`).""" + query = f'kind = "{KIND}" AND planId = "{plan_id}"' + + def fetch() -> list[MirroredDecision]: + out: list[MirroredDecision] = [] + for entity in self.client.query_entities(query): + decoded = _decode(entity) + if decoded is not None: + out.append(decoded) + if len(out) >= limit: + break + return out + + return await asyncio.to_thread(fetch) + + +def _decode(entity: Any) -> MirroredDecision | None: + try: + attrs: dict[str, Any] = entity.attributes or {} + parsed: Any = json.loads(entity.payload) if entity.payload else {} + # Valid JSON but not an object (e.g. a bare array) has no "candidates"/"picks" to read. + body: dict[str, Any] = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {} + return MirroredDecision( + entity_key=str(entity.key), + plan_id=str(attrs.get("planId", "")), + task_index=int(attrs.get("taskIndex", -1)), + category=str(attrs.get("category", "")), + requester=str(attrs.get("requester", "")), + decided_at=str(attrs.get("decidedAt", "")), + candidates=body.get("candidates", []), + picks=body.get("picks", []), + ) + except (TypeError, ValueError, AttributeError, json.JSONDecodeError): + log.warning("skipping an unreadable entity tagged %r: %r", KIND, getattr(entity, "key", None)) + return None diff --git a/control-plane/mergit_cp/config.py b/control-plane/mergit_cp/config.py index 7bc1054b..6c07b2c5 100644 --- a/control-plane/mergit_cp/config.py +++ b/control-plane/mergit_cp/config.py @@ -135,6 +135,18 @@ class Settings(BaseSettings): otp_max_attempts: int = Field(default=5, ge=1) otp_sends_per_hour: int = Field(default=5, ge=1) + arkiv_rpc_url: str | None = None + """Arkiv's Rabdi testnet RPC (``https://rpc.rabdi.db-chain.testnet.arkiv.network``). Unset: the + routing-decision mirror and its read endpoint are both no-ops (``arkiv_enabled``).""" + arkiv_access_key: SecretStr | None = None + """Raises the public RPC's heavy rate limit; free, from ``hub.arkiv.network/access-keys``.""" + arkiv_private_key: SecretStr | None = None + """One service wallet's key, funded from ``hub.arkiv.network/faucet``, that signs every mirrored + entity. Not a per-requester wallet: the Arkiv owner is this service account; the requester address is + a queryable attribute on the entity instead.""" + arkiv_entity_ttl_days: int = Field(default=30, ge=1) + """How long a mirrored routing decision lives on Arkiv before it expires.""" + worker_token: SecretStr """Shared secret the worker sends as ``Authorization: Bearer`` on POST /v0/internal/progress (plan task O7). Not named in spec/events-v0.md, which only covers the orchestrator-gateway leg; @@ -180,6 +192,10 @@ def google_enabled(self) -> bool: def email_enabled(self) -> bool: return bool(self.smtp_host and self.smtp_from) + @property + def arkiv_enabled(self) -> bool: + return bool(self.arkiv_rpc_url and self.arkiv_private_key) + @field_validator("cors_origins", "repo_hosts", "extra_domains", "planner_models", mode="before") @classmethod def _split_origins(cls, value: object) -> object: diff --git a/control-plane/mergit_cp/deps.py b/control-plane/mergit_cp/deps.py index 0308238a..02036d5c 100644 --- a/control-plane/mergit_cp/deps.py +++ b/control-plane/mergit_cp/deps.py @@ -1,9 +1,10 @@ -"""Request-scoped access to the process-wide settings and database pool.""" +"""Request-scoped access to the process-wide settings, database pool, and Arkiv mirror.""" -from typing import cast +from typing import Annotated, cast -from fastapi import Request +from fastapi import Depends, Request +from .arkiv_mirror import ArkivMirror from .config import Settings from .db import Pool @@ -14,3 +15,11 @@ def get_settings(request: Request) -> Settings: def get_pool(request: Request) -> Pool: return cast(Pool, request.app.state.pool) + + +def get_arkiv(request: Request) -> ArkivMirror | None: + """``None`` when `Settings.arkiv_enabled` is false (no RPC/key configured).""" + return cast("ArkivMirror | None", request.app.state.arkiv) + + +ArkivDep = Annotated[ArkivMirror | None, Depends(get_arkiv)] diff --git a/control-plane/mergit_cp/routes_routing.py b/control-plane/mergit_cp/routes_routing.py index 45c255ec..4218a0a3 100644 --- a/control-plane/mergit_cp/routes_routing.py +++ b/control-plane/mergit_cp/routes_routing.py @@ -13,6 +13,7 @@ """ import json +import logging import random import secrets from datetime import UTC, datetime @@ -26,13 +27,17 @@ from . import eligibility, plans from .agents import Agent +from .arkiv_mirror import ArkivMirror from .auth import PoolDep, PrincipalDep, SettingsDep +from .deps import ArkivDep from .reputation_model import Params, Posterior from .routes_plans import get_agents from .routing import BELOW_MAX_ATTEMPTS, OVER_BUDGET_IN_THIS_DRAW, Pick, rank router = APIRouter(prefix="/v0/plans", tags=["routing"]) +log = logging.getLogger(__name__) + NOTE = ( "A suggestion, not an assignment: you still choose the agents when you approve. It ranks agents by " "validated success per unit cost in this category, from outcomes the platform recorded; free-credit work " @@ -89,6 +94,7 @@ async def suggestions( settings: SettingsDep, pool: PoolDep, agents: Annotated[list[Agent], Depends(get_agents)], + arkiv: ArkivDep, explore: Annotated[bool, Query()] = False, ) -> dict[str, Any]: """Per task: the agents expected to fit its budget, best first (at most ``max_attempts``), and every @@ -180,6 +186,8 @@ async def suggestions( Jsonb(json.loads(json.dumps([_pick_record(p) for p in picks]))), ), ) + if arkiv is not None: + await _mirror_decisions(arkiv, plan.plan_id, plan.category, address, decisions, candidates) return { "plan_id": plan.plan_id, "mode": "explore" if explore else "greedy", @@ -189,6 +197,42 @@ async def suggestions( } +@router.get("/{plan_uuid}/arkiv-suggestions") +async def arkiv_suggestions( + plan_uuid: UUID, principal: PrincipalDep, pool: PoolDep, arkiv: ArkivDep +) -> dict[str, Any]: + """The owner's own exploring-suggestion mirror, as stored on Arkiv's Rabdi testnet rather than in + `routing_decisions` — proof that the write side (`_mirror_decisions`) is actually read somewhere a + user sees, not merely written and ignored (the Arkiv prize's own distinction). ``enabled: false`` when + `Settings.arkiv_enabled` is false; the frontend then hides the panel instead of calling this at all.""" + if arkiv is None: + return {"enabled": False, "decisions": []} + async with pool.connection() as conn: + plan = await plans.load(conn, plan_uuid) + if plan is None: + raise HTTPException(status_code=404, detail="no such plan") + if not principal.owns(plan.requester): + raise HTTPException( + status_code=403, detail="only the requester may see suggestions for this plan" + ) + decisions = await arkiv.read_decisions(plan.plan_id) + return { + "enabled": True, + "query": f'kind = "mergit.routing_decision" AND planId = "{plan.plan_id}"', + "decisions": [ + { + "entity_key": d.entity_key, + "task_index": d.task_index, + "category": d.category, + "decided_at": d.decided_at, + "candidates": d.candidates, + "picks": d.picks, + } + for d in decisions + ], + } + + def _empty_reason( left_out: dict[str, str], excluded: dict[str, str], candidates: dict[str, Posterior], prior: Posterior ) -> str: @@ -242,3 +286,29 @@ def _snapshot(candidates: dict[str, Posterior]) -> list[dict[str, Any]]: def _pick_record(pick: Pick) -> dict[str, Any]: return {"agent_id": pick.agent_id, "score": pick.score, "success": pick.success, "cost": pick.cost} + + +async def _mirror_decisions( + arkiv: ArkivMirror, + plan_id: str, + category: str, + requester: str, + decisions: list[tuple[int, int, list[Pick]]], + candidates: dict[str, Posterior], +) -> None: + """Best-effort: a down testnet or a slow RPC must not fail a suggestions call that Postgres already + recorded, so every failure is caught and logged, never raised.""" + decided_at = datetime.now(UTC).isoformat() + for index, _budget, picks in decisions: + try: + await arkiv.write_decision( + plan_id=plan_id, + task_index=index, + category=category, + requester=requester, + decided_at=decided_at, + candidates=_snapshot(candidates), + picks=[_pick_record(p) for p in picks], + ) + except Exception: # any SDK/RPC failure; Arkiv is a mirror, not the record of truth + log.warning("arkiv mirror failed for plan %s task %s", plan_id, index, exc_info=True) diff --git a/control-plane/pyproject.toml b/control-plane/pyproject.toml index 54d2c39c..4e317488 100644 --- a/control-plane/pyproject.toml +++ b/control-plane/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ # siwe 4.4 (last release Oct 2024) defines ALPHA in its grammar; abnf 2.9 refuses that at import. "abnf>=2.2,<2.9", "anthropic>=2.3,<3", + # Pinned exactly: arkiv-sdk has no 1.0 stable release yet, only betas; an exact pin is the one specifier + # pip/uv treat as an explicit opt-in to a prerelease, so this needs no extra --prerelease flag. + "arkiv-sdk==1.0.0b2", "docker>=7.1", "fastapi>=0.115", "httpx>=0.28", diff --git a/control-plane/tests/test_arkiv_mirror.py b/control-plane/tests/test_arkiv_mirror.py new file mode 100644 index 00000000..8c705b2b --- /dev/null +++ b/control-plane/tests/test_arkiv_mirror.py @@ -0,0 +1,181 @@ +"""`ArkivMirror`: the write/read shape against a fake `RawArkivClient`, no real RPC or funded account. + +No `pytest-asyncio` in this repo (see other test files): each test drives its coroutine with +``asyncio.run``, same as the rest of the suite drives Postgres helpers. +""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from typing import Any + +import pytest + +from mergit_cp.arkiv_mirror import KIND, ArkivMirror, MirroredDecision + + +@dataclass +class FakeEntity: + key: str + attributes: dict[str, str | int] + payload: bytes | None + + +class FakeRawClient: + """Fakes the slice of ``arkiv.arkiv`` (``ArkivModule``) that `ArkivMirror` calls. Plain ``__init__`` + attributes, not a dataclass with ``field(default_factory=list)``: pyright strict cannot narrow that + factory's return past ``list[Unknown]`` even with an explicit field annotation.""" + + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + self.entities: list[FakeEntity] = [] + self.queries: list[str] = [] + self.next_key = 0 + + def create_entity( + self, + payload: bytes | None = None, + content_type: str | None = None, + attributes: dict[str, str | int] | None = None, + expires_in: int | None = None, + ) -> tuple[str, object]: + self.next_key += 1 + key = f"0xentity{self.next_key}" + self.created.append( + { + "key": key, + "payload": payload, + "content_type": content_type, + "attributes": attributes, + "expires_in": expires_in, + } + ) + return key, object() + + def query_entities(self, query: str) -> list[FakeEntity]: + self.queries.append(query) + return self.entities + + +@pytest.fixture +def mirror() -> tuple[ArkivMirror, FakeRawClient]: + client = FakeRawClient() + return ArkivMirror("https://rpc.example", "0xkey", "access", 30, client=client), client + + +def test_write_decision_tags_and_shapes_the_entity(mirror: tuple[ArkivMirror, FakeRawClient]): + arkiv, client = mirror + key = asyncio.run( + arkiv.write_decision( + plan_id="0x" + "ab" * 32, + task_index=1, + category="code", + requester="0x" + "11" * 20, + decided_at="2026-11-06T00:00:00+00:00", + candidates=[{"agent_id": "a1", "alpha": 1.0}], + picks=[{"agent_id": "a1", "score": 0.9}], + ) + ) + assert key == "0xentity1" + [call] = client.created + assert call["content_type"] == "application/json" + assert call["expires_in"] == 30 * 86400 + assert call["attributes"] == { + "kind": KIND, + "planId": "0x" + "ab" * 32, + "taskIndex": 1, + "category": "code", + "requester": "0x" + "11" * 20, + "decidedAt": "2026-11-06T00:00:00+00:00", + } + body = json.loads(call["payload"]) + assert body == { + "candidates": [{"agent_id": "a1", "alpha": 1.0}], + "picks": [{"agent_id": "a1", "score": 0.9}], + } + + +def test_read_decisions_filters_by_plan_and_decodes_the_payload( + mirror: tuple[ArkivMirror, FakeRawClient], +): + arkiv, client = mirror + client.entities = [ + FakeEntity( + key="0xe1", + attributes={ + "kind": KIND, + "planId": "plan-1", + "taskIndex": 0, + "category": "code", + "requester": "0xreq", + "decidedAt": "2026-11-06T00:00:00+00:00", + }, + payload=json.dumps( + {"candidates": [{"agent_id": "a1"}], "picks": [{"agent_id": "a1"}]} + ).encode(), + ) + ] + + decisions = asyncio.run(arkiv.read_decisions("plan-1")) + + assert client.queries == [f'kind = "{KIND}" AND planId = "plan-1"'] + assert decisions == [ + MirroredDecision( + entity_key="0xe1", + plan_id="plan-1", + task_index=0, + category="code", + requester="0xreq", + decided_at="2026-11-06T00:00:00+00:00", + candidates=[{"agent_id": "a1"}], + picks=[{"agent_id": "a1"}], + ) + ] + + +def test_read_decisions_caps_at_the_limit(mirror: tuple[ArkivMirror, FakeRawClient]): + arkiv, client = mirror + client.entities = [ + FakeEntity(key=f"0xe{i}", attributes={"planId": "plan-1"}, payload=None) for i in range(5) + ] + + decisions = asyncio.run(arkiv.read_decisions("plan-1", limit=2)) + + assert len(decisions) == 2 + + +def test_read_decisions_skips_an_entity_it_cannot_decode(mirror: tuple[ArkivMirror, FakeRawClient]): + """Arkiv is a public, permissionless database: anyone can write an entity tagged + ``kind = "mergit.routing_decision"`` with any payload, so a genuinely malformed match (unparseable + JSON, a non-numeric ``taskIndex``) is skipped, not raised.""" + arkiv, client = mirror + client.entities = [ + FakeEntity(key="0xbad-payload", attributes={"planId": "plan-1"}, payload=b"not json"), + FakeEntity( + key="0xbad-index", + attributes={"planId": "plan-1", "taskIndex": "not a number"}, + payload=None, + ), + FakeEntity(key="0xgood", attributes={"planId": "plan-1", "taskIndex": 0}, payload=None), + ] + + decisions = asyncio.run(arkiv.read_decisions("plan-1")) + + assert [d.entity_key for d in decisions] == ["0xgood"] + + +def test_read_decisions_tolerates_a_payload_that_is_valid_json_but_not_an_object( + mirror: tuple[ArkivMirror, FakeRawClient], +): + """A payload like a bare JSON array parses fine but has no ``candidates``/``picks`` to read; the + entity still decodes, just with both empty, instead of `.get()` crashing on a non-dict.""" + arkiv, client = mirror + client.entities = [FakeEntity(key="0xe1", attributes={"planId": "plan-1"}, payload=b"[1, 2, 3]")] + + decisions = asyncio.run(arkiv.read_decisions("plan-1")) + + assert len(decisions) == 1 + assert decisions[0].candidates == [] + assert decisions[0].picks == [] diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index 0495f2b7..8cee70db 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -23,6 +23,7 @@ from mergit_cp import routes_routing from mergit_cp.agents import Agent from mergit_cp.app import create_app +from mergit_cp.arkiv_mirror import ArkivMirror from mergit_cp.config import Settings from mergit_cp.db import Pool from mergit_cp.eligibility import AT_CAP, IN_PROBATION @@ -45,6 +46,7 @@ ) from tests.cp_fakes import FakeLLM from tests.test_api import seed_plan +from tests.test_arkiv_mirror import FakeEntity, FakeRawClient from tests.test_eligibility import dispatch, invalid_verdict from tests.test_plans import app_client, create, llm, settings, sign_in # noqa: F401 (fixtures) from tests.test_reputation import gateway, run @@ -578,3 +580,58 @@ def test_an_empty_answer_with_agents_out_for_probation_does_not_blame_the_budget assert why(task) == {"3": IN_PROBATION} | dict.fromkeys("1245", OVER_BUDGET) assert task["reason"] == PARTLY_LEFT_OUT + NO_FIT assert task["reason"].startswith("some agents are left out for probation or the operator cap") + + +# -- Arkiv mirror (ETHGlobal Mumbai "Build on Arkiv" track): routing_decisions also written to Arkiv, and +# readable back through its own endpoint, with no Postgres field exposed anywhere this depends on. -- + + +@contextmanager +def arkiv_client(settings: Settings, llm: FakeLLM) -> Iterator[tuple[TestClient, FakeRawClient]]: + raw = FakeRawClient() + arkiv = ArkivMirror("https://rpc.example", "0xkey", None, 30, client=raw) + with TestClient( + create_app(settings, llm=llm, deliver_events=False, arkiv=arkiv), base_url="https://testserver" + ) as c: + yield c, raw + + +def test_an_exploring_suggestion_is_mirrored_to_arkiv_and_readable_back(settings: Settings, llm: FakeLLM): + with arkiv_client(settings, llm) as (c, raw): + sign_in(c) + plan = create(c) + got = c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}).json() + + # One Arkiv write per task, tagged for the project and keyed to this plan. + assert len(raw.created) == len(got["tasks"]) == 2 + for call in raw.created: + assert call["attributes"]["kind"] == "mergit.routing_decision" + assert call["attributes"]["planId"] == plan["plan_id"] + assert call["content_type"] == "application/json" + + # The read endpoint decodes what was written, not a copy kept in Postgres. + raw.entities = [ + FakeEntity(key=call["key"], attributes=call["attributes"], payload=call["payload"]) + for call in raw.created + ] + mirrored = c.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions").json() + assert mirrored["enabled"] is True + assert {d["task_index"] for d in mirrored["decisions"]} == {0, 1} + + +def test_arkiv_suggestions_is_owner_only(settings: Settings, llm: FakeLLM): + with arkiv_client(settings, llm) as (c, _raw): + sign_in(c) + plan = create(c) + c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}) + c.post("/v0/auth/logout") + sign_in(c) # a different, freshly generated account + resp = c.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions") + assert resp.status_code == 403 + + +def test_arkiv_suggestions_is_disabled_when_arkiv_is_not_configured(app_client: TestClient): + sign_in(app_client) + plan = create(app_client) + got = app_client.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions").json() + assert got == {"enabled": False, "decisions": []} diff --git a/control-plane/uv.lock b/control-plane/uv.lock index 50cefa42..eb40c1fb 100644 --- a/control-plane/uv.lock +++ b/control-plane/uv.lock @@ -229,6 +229,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "arkiv-sdk" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "web3" }, + { name = "zstd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/d5/e542f2423a0984c07e07ff52e652d1fe3e5ba019acb61519b86ab52bc9cb/arkiv_sdk-1.0.0b2.tar.gz", hash = "sha256:fa644b4608964d38d1f64b403e1afdc4bc6217b659b222517cceb5ae8bbde566", size = 129302, upload-time = "2026-03-04T20:58:59.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/9d/6abb5c56efdadb40efa9d05fbb1d1ca4855eb66b0f20bd323a956dc63c66/arkiv_sdk-1.0.0b2-py3-none-any.whl", hash = "sha256:8dfe56eb3d9d9cb8ef0e832970fa10d5a61332cca08f79da71c5b9e23143512e", size = 68417, upload-time = "2026-03-04T20:58:57.013Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -332,6 +346,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/87/4a7c3c33a709c9cc65f35082ea4f12fa402f63a7ed2ad84dbfff7390f364/bitarray-3.12.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:56ce357249ac3665e43f639a6cd0245bd449d707ee410b96452f0cc177d3e973", size = 244864, upload-time = "2026-10-19T07:24:28.457Z" }, ] +[[package]] +name = "brotli" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" }, + { url = "https://files.pythonhosted.org/packages/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" }, + { url = "https://files.pythonhosted.org/packages/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" }, + { url = "https://files.pythonhosted.org/packages/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b4/379b2e5584be70110b154021ffa61b84dc3b476d2c031317f5c3fbcfcde5/brotli-1.2.1-cp313-cp313-win32.whl", hash = "sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671", size = 333014, upload-time = "2026-10-22T13:58:23.112Z" }, + { url = "https://files.pythonhosted.org/packages/96/c2/0c7abb25013043d4bcf7dd768d343bf7dee6ce99ac8c172f7ee4aef1c772/brotli-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f", size = 371072, upload-time = "2026-10-22T13:58:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/5f9d96203fdce3ff9ba1186ce10220e26483ffa2384cde5430dd980e9057/brotli-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c", size = 380893, upload-time = "2026-10-22T13:58:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" }, + { url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" }, + { url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" }, + { url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/da/1ec2e9b4f500693e56e6391f128dd0d304d6d2b7f0d5090e80a802b722f1/brotli-1.2.1-cp314-cp314-win32.whl", hash = "sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81", size = 338208, upload-time = "2026-10-22T13:58:40.927Z" }, + { url = "https://files.pythonhosted.org/packages/24/5e/80cd38322263da5e92ea4935423f1d3cfa2509201673dce77b772c47b997/brotli-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd", size = 376707, upload-time = "2026-10-22T13:58:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/35/cb35c489fcd0a813bbb7500465af5a7bac3bee7d996f6a211bac9096ef2c/brotli-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b", size = 390280, upload-time = "2026-10-22T13:58:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" }, + { url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" }, + { url = "https://files.pythonhosted.org/packages/a9/53/35042a907c309af8defb423e58692bdc5e3e1fd3d03388892b7614270626/brotli-1.2.1-cp315-cp315-win32.whl", hash = "sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c", size = 338212, upload-time = "2026-10-22T13:58:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/883a32c23603e278ae93d8df31f0573b67382de827e6e05a6e9ee0b51896/brotli-1.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399", size = 376707, upload-time = "2026-10-22T13:58:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/2c574da9faf130a59c4c685ba1378f2f470b57fb9fa5d3b0f8b4e90579a0/brotli-1.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae", size = 390278, upload-time = "2026-10-22T13:58:59.512Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, +] + [[package]] name = "certifi" version = "2026.10.23" @@ -1247,6 +1326,7 @@ source = { editable = "." } dependencies = [ { name = "abnf" }, { name = "anthropic" }, + { name = "arkiv-sdk" }, { name = "docker" }, { name = "fastapi" }, { name = "httpx" }, @@ -1276,6 +1356,7 @@ dev = [ requires-dist = [ { name = "abnf", specifier = ">=2.2,<2.9" }, { name = "anthropic", specifier = ">=2.3,<3" }, + { name = "arkiv-sdk", specifier = "==1.0.0b2" }, { name = "docker", specifier = ">=7.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, @@ -2503,7 +2584,7 @@ wheels = [ [[package]] name = "web3" -version = "7.16.0" +version = "7.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2521,9 +2602,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/bdfa9e715804020c3f3676346065c18adbc207c9343a3458246d7430f45c/web3-7.16.0.tar.gz", hash = "sha256:b4a75a3fa94fef4d23d502eb3c2244146ef9a1ee0082cf1cb0a91586ba0510c3", size = 2211469, upload-time = "2026-05-01T21:22:20.666Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/b4/1f026b025c51ce092a7a573b9308a2526b92a4044f94d9af78e7bb820a1c/web3-7.13.0.tar.gz", hash = "sha256:281795e0f5d404c1374e1771f6710bb43e0c975f3141366eb1680763edfb4806", size = 2194483, upload-time = "2025-08-04T16:57:19.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/f9/5345c13f8469f3ce344b4d9934c0387b83a49420192482111e9c1fa95ec2/web3-7.16.0-py3-none-any.whl", hash = "sha256:760b2718c473980d70708c3593d9d28395db4b482f45e38a63a36fa028178f51", size = 1372181, upload-time = "2026-05-01T21:22:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/95/a2/fc390ac63c8008318769eba2de071e95b7ba59b5fdc099529db73e5f1356/web3-7.13.0-py3-none-any.whl", hash = "sha256:4da7e953300577b7dadbaf430e5fd4479b127b1ad4910234b230fdcb8a49f735", size = 1369941, upload-time = "2025-08-04T16:57:17.587Z" }, ] [[package]] @@ -2747,3 +2828,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] + +[[package]] +name = "zstd" +version = "1.5.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" }, + { url = "https://files.pythonhosted.org/packages/a1/63/0d392a8ec2231dee9fc2290faea7a6642584686720d6b77899ad8b12e35a/zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06", size = 269438, upload-time = "2025-06-23T12:57:52.507Z" }, + { url = "https://files.pythonhosted.org/packages/be/1f/85aae095f92811bed3d2944bbed971fe07ec1dd2d82c9eb1395d69d2123c/zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb", size = 228179, upload-time = "2025-06-23T12:57:51.031Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/547949993ea347ac44f5908262ebe6e85edfa7b11a5df136319789be731d/zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71", size = 302763, upload-time = "2025-06-23T12:51:51.615Z" }, + { url = "https://files.pythonhosted.org/packages/25/ca/4a6882846e3049be249031f825251a9229ecad471e18e7fd27974540549c/zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174", size = 149452, upload-time = "2025-06-23T12:57:32.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/aa/89339605864c9803e4738f176932a6c9f1ad99d03c03ef2cb0634ddca680/zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a", size = 166625, upload-time = "2025-06-23T12:57:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/07/e9/501291a2f9b300b2c73dcc6d086df778e895e71573df9575def54d9dbab2/zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6", size = 302906, upload-time = "2025-06-23T13:21:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/9243bb99b8525421a7db741604d29aebe9a849539500f2248d74bf2614be/zstd-1.5.7.2-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:a6105b8fa21dbc59e05b6113e8e5d5aaf56c5d2886aa5778d61030af3256bbb7", size = 302779, upload-time = "2025-06-23T13:09:22.701Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a5/6ed36bed134d065ff6198707e7411fde7436d7927e325b8ace26f9e21159/zstd-1.5.7.2-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:d0b0ca097efb5f67157c61a744c926848dcccf6e913df2f814e719aa78197a4b", size = 302910, upload-time = "2025-06-23T13:15:13.081Z" }, +] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 941e6a9f..a20a9fca 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,7 @@ import type { AgentInfo, ApproveRequest, ApproveResponse, + ArkivSuggestions, NewPlanRequest, Plan, PlanDetail, @@ -123,6 +124,11 @@ export const getReputation = () => request("GET", "/v0/agent export const getSuggestions = (planUuid: string, explore: boolean) => request("GET", `${planPath(planUuid)}/suggestions${explore ? "?explore=true" : ""}`); +/** The same exploring suggestions, read back from Arkiv's Rabdi testnet rather than from Postgres + * (ETHGlobal Mumbai's "Build on Arkiv" track). `enabled: false` when the server has no Arkiv configured. */ +export const getArkivSuggestions = (planUuid: string) => + request("GET", `${planPath(planUuid)}/arkiv-suggestions`); + /** Server-Sent Events for one plan. Open with `withCredentials: true`, or the session cookie is not sent. */ export const planEventsUrl = (planUuid: string) => `${base()}${planPath(planUuid)}/events`; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 563c7385..ff040d29 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -115,6 +115,24 @@ export type Suggestions = { tasks: TaskSuggestion[]; }; +/** One exploring suggestion mirrored onto Arkiv's Rabdi testnet (ETHGlobal Mumbai's "Build on Arkiv" track), + * as read back through `arkiv_query` rather than from this plan's own Postgres row. */ +export type ArkivDecision = { + entity_key: string; + task_index: number; + category: string; + decided_at: string; + candidates: unknown[]; + picks: unknown[]; +}; + +export type ArkivSuggestions = { + /** False when the server has no `ARKIV_*` configured; the panel that renders this should then hide. */ + enabled: boolean; + query?: string; + decisions: ArkivDecision[]; +}; + export type StepCost = { model: string | null; input_tokens: number; diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index a9735aa5..d6734089 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { errorMessage, getSuggestions } from "../../../api/client"; +import { errorMessage, getArkivSuggestions, getSuggestions } from "../../../api/client"; import { percent, usdc } from "../../../api/reputation"; -import type { Suggestions } from "../../../api/types"; +import type { ArkivSuggestions, Suggestions } from "../../../api/types"; /** * Advice on which agents to pick, from what the platform has validated about each (never from free-credit work). @@ -14,10 +14,15 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s const [busy, setBusy] = useState(false); const [error, setError] = useState(); const [got, setGot] = useState(); + const [arkivBusy, setArkivBusy] = useState(false); + const [arkivError, setArkivError] = useState(); + const [arkiv, setArkiv] = useState(); const ask = () => { setBusy(true); setError(undefined); + setArkiv(undefined); + setArkivError(undefined); getSuggestions(planUuid, explore).then( (s) => { setGot(s); @@ -30,6 +35,24 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s ); }; + /** An exploring answer (and only that: greedy writes nothing) is mirrored onto Arkiv's Rabdi testnet + * (ETHGlobal Mumbai's "Build on Arkiv" track); this reads it back from there, not from this plan's own + * database, to show the mirror is actually read somewhere a user sees. */ + const viewOnArkiv = () => { + setArkivBusy(true); + setArkivError(undefined); + getArkivSuggestions(planUuid).then( + (a) => { + setArkiv(a); + setArkivBusy(false); + }, + (e: unknown) => { + setArkivError(errorMessage(e)); + setArkivBusy(false); + }, + ); + }; + return (
Suggested agents @@ -90,6 +113,43 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s > Use these picks + {got.mode === "explore" && ( +
+ + {arkivError && ( + + {arkivError} + + )} + {arkiv && !arkiv.enabled && ( + Arkiv mirroring is off on this deployment. + )} + {arkiv?.enabled && ( +
+ + {arkiv.decisions.length === 0 + ? "Not found on Arkiv yet; the mirror write may still be in flight or failed silently." + : `${String(arkiv.decisions.length)} decision${arkiv.decisions.length === 1 ? "" : "s"} read back from Arkiv's Rabdi testnet, not this app's own database:`} + + {arkiv.decisions.map((d) => ( + + Task {String(d.task_index + 1)} · {d.entity_key} + + ))} + + Open the Rabdi explorer + +
+ )} +
+ )}
)} From fcefbb698a004160c94c80a869c174b506c54073 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 04:24:50 +0530 Subject: [PATCH 2/7] fix(arkiv): don't 500 the suggestions panel when Arkiv is unreachable Self-review found the read side wasn't defensive like the write side: constructing the real SDK client runs a balance check against the RPC (arkiv-sdk's Arkiv.__init__), so a bad key or a down Rabdi testnet raised inside GET /v0/plans/{uuid}/arkiv-suggestions uncaught, a 500 for an unrelated-to-core-product feature. Now caught and reported as {enabled: true, decisions: [], error: "could not reach Arkiv"}; the frontend shows that distinctly from "nothing mirrored yet". Also hardened _decode against a payload that's valid JSON but not an object (a bare array), which would otherwise crash .get() the same way. Full suite still green: 541 fast + 450 postgres, ruff/pyright clean, 292 frontend tests, tsc/eslint/build clean. --- control-plane/mergit_cp/routes_routing.py | 14 ++++++++++--- control-plane/tests/test_arkiv_mirror.py | 6 ++++++ control-plane/tests/test_routes_routing.py | 20 +++++++++++++++++++ frontend/src/api/types.ts | 3 +++ .../src/screens/live/run/SuggestAgents.tsx | 7 ++++++- 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/control-plane/mergit_cp/routes_routing.py b/control-plane/mergit_cp/routes_routing.py index 4218a0a3..f4e303cf 100644 --- a/control-plane/mergit_cp/routes_routing.py +++ b/control-plane/mergit_cp/routes_routing.py @@ -204,7 +204,10 @@ async def arkiv_suggestions( """The owner's own exploring-suggestion mirror, as stored on Arkiv's Rabdi testnet rather than in `routing_decisions` — proof that the write side (`_mirror_decisions`) is actually read somewhere a user sees, not merely written and ignored (the Arkiv prize's own distinction). ``enabled: false`` when - `Settings.arkiv_enabled` is false; the frontend then hides the panel instead of calling this at all.""" + `Settings.arkiv_enabled` is false; the frontend then hides the panel instead of calling this at all. + A reachability failure (bad key, unreachable RPC) is reported in ``error``, not a 500: this read is + advisory, same as the write it reads back, and a misconfigured or down testnet must not break the + page for every other, unrelated feature on it.""" if arkiv is None: return {"enabled": False, "decisions": []} async with pool.connection() as conn: @@ -215,10 +218,15 @@ async def arkiv_suggestions( raise HTTPException( status_code=403, detail="only the requester may see suggestions for this plan" ) - decisions = await arkiv.read_decisions(plan.plan_id) + query = f'kind = "mergit.routing_decision" AND planId = "{plan.plan_id}"' + try: + decisions = await arkiv.read_decisions(plan.plan_id) + except Exception: # any SDK/RPC failure; see the docstring + log.warning("arkiv read failed for plan %s", plan.plan_id, exc_info=True) + return {"enabled": True, "query": query, "decisions": [], "error": "could not reach Arkiv"} return { "enabled": True, - "query": f'kind = "mergit.routing_decision" AND planId = "{plan.plan_id}"', + "query": query, "decisions": [ { "entity_key": d.entity_key, diff --git a/control-plane/tests/test_arkiv_mirror.py b/control-plane/tests/test_arkiv_mirror.py index 8c705b2b..d9e22a4e 100644 --- a/control-plane/tests/test_arkiv_mirror.py +++ b/control-plane/tests/test_arkiv_mirror.py @@ -33,6 +33,8 @@ def __init__(self) -> None: self.entities: list[FakeEntity] = [] self.queries: list[str] = [] self.next_key = 0 + self.raises: Exception | None = None + """Set to make every call fail, as if the RPC were unreachable or the key were bad.""" def create_entity( self, @@ -41,6 +43,8 @@ def create_entity( attributes: dict[str, str | int] | None = None, expires_in: int | None = None, ) -> tuple[str, object]: + if self.raises is not None: + raise self.raises self.next_key += 1 key = f"0xentity{self.next_key}" self.created.append( @@ -55,6 +59,8 @@ def create_entity( return key, object() def query_entities(self, query: str) -> list[FakeEntity]: + if self.raises is not None: + raise self.raises self.queries.append(query) return self.entities diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index 8cee70db..e200fb5f 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -619,6 +619,26 @@ def test_an_exploring_suggestion_is_mirrored_to_arkiv_and_readable_back(settings assert {d["task_index"] for d in mirrored["decisions"]} == {0, 1} +def test_arkiv_suggestions_degrades_instead_of_500_when_arkiv_is_unreachable( + settings: Settings, llm: FakeLLM +): + """A bad key or a down RPC must not 500 the page for an unrelated feature; the owner still sees + their plan, just told Arkiv could not be reached.""" + with arkiv_client(settings, llm) as (c, raw): + sign_in(c) + plan = create(c) + c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}) + + raw.raises = ConnectionError("rpc unreachable") + resp = c.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions") + + assert resp.status_code == 200 + body = resp.json() + assert body["enabled"] is True + assert body["decisions"] == [] + assert body["error"] == "could not reach Arkiv" + + def test_arkiv_suggestions_is_owner_only(settings: Settings, llm: FakeLLM): with arkiv_client(settings, llm) as (c, _raw): sign_in(c) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ff040d29..a157e9d5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -130,6 +130,9 @@ export type ArkivSuggestions = { /** False when the server has no `ARKIV_*` configured; the panel that renders this should then hide. */ enabled: boolean; query?: string; + /** Set when the server reached Arkiv's RPC and the query itself failed; distinct from `decisions: []`, + * which also means the query ran fine but matched nothing yet. */ + error?: string; decisions: ArkivDecision[]; }; diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index d6734089..dff1685b 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -126,7 +126,12 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s {arkiv && !arkiv.enabled && ( Arkiv mirroring is off on this deployment. )} - {arkiv?.enabled && ( + {arkiv?.enabled && arkiv.error && ( + + Arkiv's testnet could not be reached just now; try again shortly. + + )} + {arkiv?.enabled && !arkiv.error && (
{arkiv.decisions.length === 0 From fe2a2796c7e5a2dafba6c5869d1c7010b7c09228 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 05:02:37 +0530 Subject: [PATCH 3/7] fix(arkiv): the Python SDK is wrong for Rabdi; rebuild writes/reads on what is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-review (prompted by a second pass before the PR was declared done) checked this track live against Rabdi for the first time instead of trusting the SDK's docs, and found the whole write path, and the read path's query shape, didn't work on this network: - arkiv-sdk (Python)'s query options (`includeData`) are rejected by Rabdi ("unknown field includeData"), and its write target (processor address) has no code deployed on Rabdi (`eth_getCode` returns "0x"). It targets a different, incompatible Arkiv network. Confirmed live, not from reading source: see arkiv/friction.md for the exact RPC errors. - Rabdi has no payload/content-type field at the protocol level at all — checked live on real entities. Everything is a named attribute. - The query literal syntax (`str('...')`, unquoted `addr(0x...)`) differs from what the JS quickstart's prose implies. Rebuilt on what's actually confirmed to work live: - Reads: plain httpx against `arkiv_query`, no SDK, now also filtered by `$owner` (a spoofing gap the first version had — anyone can write an entity with our exact schema tag). - Writes: a new small Node sidecar (chain/arkiv/), using the official JS SDK (confirmed, live, to target Rabdi's real write address), holding the service wallet's private key — the control plane's own process and env never hold it now, an improvement over the first version. One HTTP call carries every task's decision for one plan, so the sidecar sends one batched transaction (executeBatch), not one per task, serialized through an in-process queue to avoid a nonce race between concurrent explore calls. - Writes now run as a FastAPI BackgroundTask, not awaited inline: the first version blocked the suggestions response on the write (the SDK's call waits for a transaction receipt), which this fixes. - An unfunded test wallet, run against the real Rabdi RPC from both the bare sidecar and the built Docker image, reaches real gas estimation and fails there ("Execution error without revert data", on a wallet confirmed 0 balance) — as far as this can be verified without a funded wallet. - docker-compose: arkiv-sidecar service, gated by the `arkiv` profile (same opt-in pattern as keyrelay), config validated with `docker compose config`. - Docs (arkiv/*.md, both READMEs) rewritten to only state what was actually checked live, including the wrong turns, rather than what the SDK's docs imply. Verified: full control-plane suite (545 fast + 450 postgres), ruff, pyright strict clean, frontend (292 vitest, tsc, eslint, vite build) all still green. The Arkiv panel itself was exercised in a real browser (mocked network responses, not a real backend) by temporarily swapping the app's render root — reverted before this commit, never shipped. Still blocked on a funded service wallet (hub.arkiv.network/faucet is wallet-connect gated, not a plain API, so this can't be done from here) for an actual successful write and a real entity key. --- README.md | 11 +- arkiv/friction.md | 112 ++++---- arkiv/how-you-use-arkiv.md | 10 +- arkiv/reach.md | 5 +- arkiv/schema.md | 52 ++-- chain/arkiv/.gitignore | 1 + chain/arkiv/Dockerfile | 17 ++ chain/arkiv/README.md | 55 ++++ chain/arkiv/package-lock.json | 247 +++++++++++++++++ chain/arkiv/package.json | 15 ++ chain/arkiv/src/schema.js | 19 ++ chain/arkiv/src/server.js | 110 ++++++++ control-plane/README.md | 26 +- control-plane/mergit_cp/app.py | 6 +- control-plane/mergit_cp/arkiv_mirror.py | 248 +++++++++-------- control-plane/mergit_cp/config.py | 24 +- control-plane/mergit_cp/routes_routing.py | 46 ++-- control-plane/pyproject.toml | 7 +- control-plane/tests/test_arkiv_mirror.py | 293 ++++++++++----------- control-plane/tests/test_routes_routing.py | 79 ++++-- control-plane/uv.lock | 103 -------- deploy/compose.controlplane.yaml | 20 ++ 22 files changed, 987 insertions(+), 519 deletions(-) create mode 100644 chain/arkiv/.gitignore create mode 100644 chain/arkiv/Dockerfile create mode 100644 chain/arkiv/README.md create mode 100644 chain/arkiv/package-lock.json create mode 100644 chain/arkiv/package.json create mode 100644 chain/arkiv/src/schema.js create mode 100644 chain/arkiv/src/server.js diff --git a/README.md b/README.md index 312fdf7c..1280764e 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,19 @@ Full writeup: [`arkiv/schema.md`](arkiv/schema.md), [`arkiv/why-arkiv.md`](arkiv [`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):** `` +- **Service wallet (writes every mirrored entity):** `` - **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`: ``` - kind = "mergit.routing_decision" AND planId = "" + arkiv.schema = str('mergit.routing_decision') AND planId = str('') AND $owner = addr() ``` - **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. @@ -100,7 +103,7 @@ Full writeup: [`arkiv/schema.md`](arkiv/schema.md), [`arkiv/why-arkiv.md`](arkiv | `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 | | diff --git a/arkiv/friction.md b/arkiv/friction.md index 25f533c6..9a6a8733 100644 --- a/arkiv/friction.md +++ b/arkiv/friction.md @@ -1,47 +1,69 @@ # Arkiv feedback -What got in the way building this, and how to reproduce each point. - -## The Python SDK is real, but easy to miss - -`docs.arkiv.network`'s own quickstarts (`start-here/first-entity`, `writing/create`) are all -TypeScript against `@arkiv-network/sdk` + `viem`. Our control plane is Python, so the first -conclusion a reader reaches is "writes need Node" — `createEntity` there is a signed, -custom-encoded transaction, not a plain JSON-RPC call, so hand-rolling it in another language looks -risky. The official Python SDK (`Arkiv-Network/arkiv-sdk-python`, `pip install --pre arkiv-sdk`, -built on `web3.py`) exists and does the same job, but we only found it by searching GitHub's -`arkiv-network` org directly — nothing on the docs site itself linked to it. **Reproduce:** read -`docs.arkiv.network/start-here/first-entity/` end to end; it never mentions the Python SDK or links -to it. - -## The Python SDK has no stable release, and the two listings disagree - -PyPI's `arkiv-sdk` has no `1.0.0` yet — the real releases are `1.0.0.dev1` through our pin, -`1.0.0b2`. That needs an exact-version pin (`arkiv-sdk==1.0.0b2`) rather than a range, since most -resolvers (uv included) only accept a prerelease when the specifier itself names one. Separately, -the GitHub repo's own "Releases" sidebar showed `v0.2.2` as "Latest" while PyPI already had several -newer `1.0.0` alphas and betas — someone comparing the two listings would reasonably conclude the -project is stuck pre-1.0 at `v0.2.2`, which understates what's actually published. **Reproduce:** -`pip index versions arkiv-sdk` (or the PyPI JSON API) against -`github.com/Arkiv-Network/arkiv-sdk-python/releases`. - -## Attributes are `str | int` only — structured data has to go in the payload - -`arkiv.types.Attributes` is `NewType("Attributes", dict[str, str | int])`. Our routing decision -carries a list of per-agent posteriors and ranked picks, which can't be an attribute value at all — -we only found this by reading the SDK's `types.py` rather than from a documented constraint. It's a -reasonable design (attributes are the indexed, queryable part; anything else belongs in the -payload), but the quickstart's single `str`/`dec`/`bytes` example doesn't make the boundary -explicit. We ended up putting `candidates`/`picks` in the JSON payload and only the filterable -fields (`planId`, `taskIndex`, `category`, `requester`, `decidedAt`, `kind`) as attributes -(`arkiv_mirror.py`, `schema.md`). - -## No live write exercised yet - -Everything in this track — `write_decision`, `read_decisions`, the `/arkiv-suggestions` endpoint, -and the frontend panel — is tested against a fake `RawArkivClient` (`tests/test_arkiv_mirror.py`, -the Arkiv-specific cases in `tests/test_routes_routing.py`) plus the full existing Postgres suite, -not against a real Rabdi write: that needs a funded service wallet and an access key, which only -someone with wallet access can obtain (`hub.arkiv.network/faucet`, `hub.arkiv.network/access-keys`) -and set as `ARKIV_RPC_URL`/`ARKIV_PRIVATE_KEY`/`ARKIV_ACCESS_KEY` on the hosted deploy. `schema.md`'s -wallet address is a placeholder until that happens. +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. diff --git a/arkiv/how-you-use-arkiv.md b/arkiv/how-you-use-arkiv.md index 9a21c8d9..8d1367d0 100644 --- a/arkiv/how-you-use-arkiv.md +++ b/arkiv/how-you-use-arkiv.md @@ -3,12 +3,14 @@ **Do the things we filter on, who can edit each entity, and when it expires fit what the product does?** -- **Filters** (`kind`, `planId`, `taskIndex`, `category`, `requester`, `decidedAt` — all indexed, - `schema.md`) are exactly the fields the product itself already groups suggestions by: one query - per plan (`arkiv_suggestions` in `routes_routing.py` builds `kind = "..." AND planId = "..."`), +- **Filters** (the built-in `arkiv.schema`, plus `planId`, `taskIndex`, `category`, `requester`, + `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. + 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. - **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 diff --git a/arkiv/reach.md b/arkiv/reach.md index 1826f396..76f5dc33 100644 --- a/arkiv/reach.md +++ b/arkiv/reach.md @@ -27,5 +27,6 @@ Two concrete audiences, neither hypothetical: - **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 (`kind = - "mergit.routing_decision"`), not a synthetic demo entity nobody else depends on. + 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. diff --git a/arkiv/schema.md b/arkiv/schema.md index 15d1a4ff..e4925624 100644 --- a/arkiv/schema.md +++ b/arkiv/schema.md @@ -1,7 +1,8 @@ # 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`, called from +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`). @@ -17,19 +18,31 @@ new endpoint that reads it back from there. ## Entity -One entity per task per exploring `GET /v0/plans/{uuid}/suggestions?explore=true` call. Content -type `application/json`. +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 | | --- | --- | --- | --- | -| `kind` | `str` | yes | Always `"mergit.routing_decision"` — the project's tag; filter on it, since Arkiv is a shared database other projects write to as well | | `planId` | `str` | yes | The plan's `0x`-prefixed id (`mergit_receipt.plan_id`), so a query can ask for one plan's decisions | | `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 | -| `requester` | `str` | yes | The plan owner's wallet address — a queryable attribute, **not** the entity's Arkiv-level owner (see "Who owns it" below) | +| `requester` | `addr` | yes | The plan owner's wallet address — a queryable attribute, **not** the entity's Arkiv-level owner (see "Who owns it" below) | | `decidedAt` | `str` | yes | ISO-8601 UTC timestamp of the write | +| `body` | `bytes` | no | The candidates/picks JSON, as raw bytes — see below | + +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`). -Payload (not an attribute, since Arkiv attributes are `str \| int` only — no nested structures): +`body`'s JSON: ```json { @@ -45,13 +58,16 @@ a redesign of it. ## Who owns it, and why -One service wallet (`ARKIV_PRIVATE_KEY`, funded from `hub.arkiv.network/faucet`) signs every write. -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. The requester's address is still fully queryable — as the -`requester` attribute above — just not the chain-level owner field. +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. The requester's address is still fully queryable — as the `requester` +attribute above — just not the chain-level owner field. -Service wallet address: ``. +Service wallet address: ``. ## Expiry @@ -63,9 +79,13 @@ cheap. ## One query anyone can run ``` -kind = "mergit.routing_decision" AND planId = "" +arkiv.schema = str('mergit.routing_decision') AND planId = str('') AND $owner = addr() ``` -Against `https://rpc.rabdi.db-chain.testnet.arkiv.network` via `arkiv_query`, or through -`GET /v0/plans/{uuid}/arkiv-suggestions` on the hosted app, which runs exactly this query and -returns the decoded result (see `friction.md` for the one gap in that endpoint). +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. diff --git a/chain/arkiv/.gitignore b/chain/arkiv/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/chain/arkiv/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/chain/arkiv/Dockerfile b/chain/arkiv/Dockerfile new file mode 100644 index 00000000..3ad77594 --- /dev/null +++ b/chain/arkiv/Dockerfile @@ -0,0 +1,17 @@ +# The Arkiv write sidecar (ETHGlobal Mumbai "Build on Arkiv" track): holds the service wallet's +# private key, never the control plane's own process. Build from this directory: +# docker build -t mergit-arkiv-sidecar:local chain/arkiv +FROM node:22-slim AS builder +WORKDIR /src +COPY package.json package-lock.json . +RUN npm ci --omit=dev --no-audit --no-fund + +FROM node:22-slim +RUN useradd --system --uid 10001 arkiv +WORKDIR /app +COPY --from=builder /src/node_modules node_modules +COPY package.json . +COPY src src +USER arkiv +EXPOSE 8088 +CMD ["node", "src/server.js"] diff --git a/chain/arkiv/README.md b/chain/arkiv/README.md new file mode 100644 index 00000000..f44f3782 --- /dev/null +++ b/chain/arkiv/README.md @@ -0,0 +1,55 @@ +# Arkiv write sidecar + +ETHGlobal Mumbai "Build on Arkiv" track. The one thing this exists for: signing and sending the write +Rabdi's current protocol needs — a batched `executeBatch` create of routing-decision entities — which +only the official JS SDK (`@arkiv-network/sdk`) demonstrably does correctly against Rabdi. The control +plane's own `arkiv_mirror.py` reads directly (plain `arkiv_query` over httpx, no SDK); only the write +needs this, and only this process ever holds the service wallet's private key. + +## Why a sidecar, not Python + +The only Python SDK (`arkiv-sdk` on PyPI) targets a different, incompatible Arkiv network — verified +live against Rabdi: its query options (`includeData`) and its write target (processor address) are both +rejected by Rabdi's node. See `../../arkiv/friction.md` for the exact RPC errors. The JS SDK's own write +target and query language both matched Rabdi live when checked the same way. + +## Running it + +``` +npm install +ARKIV_PRIVATE_KEY=0x... ARKIV_SIDECAR_TOKEN=... node src/server.js +``` + +Env vars: + +| Name | Required | What | +| --- | --- | --- | +| `ARKIV_PRIVATE_KEY` | yes | The service wallet's key. Fund it from `hub.arkiv.network/faucet`; get an access key from `hub.arkiv.network/access-keys`. Never the control plane's own env. | +| `ARKIV_SIDECAR_TOKEN` | yes | Shared secret the control plane sends as `Authorization: Bearer`; must match `ARKIV_SIDECAR_TOKEN` in `deploy/secrets/api.env`. | +| `ARKIV_ACCESS_KEY` | no | Raises the public RPC's heavy rate limit. | +| `PORT` | no | Default `8088`. | + +`GET /healthz` — liveness. `POST /internal/mirror` — the one real endpoint (see `src/server.js`'s own +comments for the body shape); everything else is `404`, and a wrong or missing bearer token is `401`. + +## Deploying it + +Gated by the `arkiv` compose profile (`deploy/compose.controlplane.yaml`), same opt-in pattern as +`keyrelay`: nothing starts until `deploy/secrets/arkiv.env` exists (create it with the two vars above) +and the profile is passed — + +``` +docker compose -f deploy/compose.testnet.yaml -f deploy/compose.controlplane.yaml --profile arkiv up -d --build +``` + +Until then, `ARKIV_SIDECAR_URL` in `api.env` points nowhere running, and every mirror write just fails +and gets logged — never a crash, same as any other Arkiv misconfiguration (`arkiv_mirror.py`'s own +docstring). + +## What's verified, and what isn't + +Checked live against Rabdi, from this sidecar's own code path: the schema (`src/schema.js`) builds and +sends; an unfunded test wallet's write reaches real on-chain gas estimation and fails there +("Execution error without revert data", on a wallet confirmed to hold 0 tGLM) — not at encoding, not at +signing, not at the RPC connection. That is as far as this can be verified without a funded wallet. No +successful create, and so no real entity key, exists yet. diff --git a/chain/arkiv/package-lock.json b/chain/arkiv/package-lock.json new file mode 100644 index 00000000..f1dbcd04 --- /dev/null +++ b/chain/arkiv/package-lock.json @@ -0,0 +1,247 @@ +{ + "name": "mergit-arkiv-sidecar", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mergit-arkiv-sidecar", + "version": "0.1.0", + "dependencies": { + "@arkiv-network/sdk": "1.0.1", + "viem": "^2.21.0" + }, + "engines": { + "node": ">=22.12" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@arkiv-network/sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@arkiv-network/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-DCOFdGunn4I5nYrvIV/YIkMCkLZM7EQNSyNPtqpmQkP2JzTEfRLBJleBSB2rokWMa833/9P8DMiwsEXACr3CUQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0", + "viem": "^2.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "viem": { + "optional": false + } + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.14.59", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.59.tgz", + "integrity": "sha512-Y+DPEQScTEGKteQATjtytE1JRQ5cg1R2+DvYQ/lbhUCm/xS9n0Jnkkf2AqQyIntefQxs8xY6dZ+kyNZ7zf2FzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem": { + "version": "2.57.16", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.57.16.tgz", + "integrity": "sha512-+4uaUw3NfkqjaqsZp8Zci94K33oc9rv7mIUgvPduJxsY/tI0ASi2YkaXoUVSBGfdIw/QPWuVnIf9LTFf35IoMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.59", + "ws": "8.21.0" + }, + "peerDependencies": { + "mppx": ">=0.17.0", + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "mppx": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/chain/arkiv/package.json b/chain/arkiv/package.json new file mode 100644 index 00000000..cd8b3651 --- /dev/null +++ b/chain/arkiv/package.json @@ -0,0 +1,15 @@ +{ + "name": "mergit-arkiv-sidecar", + "version": "0.1.0", + "private": true, + "description": "Signs and sends the one write Rabdi needs: a batched create of routing-decision entities. Holds the service wallet's private key, never the control plane.", + "type": "module", + "engines": { "node": ">=22.12" }, + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "@arkiv-network/sdk": "1.0.1", + "viem": "^2.21.0" + } +} diff --git a/chain/arkiv/src/schema.js b/chain/arkiv/src/schema.js new file mode 100644 index 00000000..f91105d5 --- /dev/null +++ b/chain/arkiv/src/schema.js @@ -0,0 +1,19 @@ +// The entity shape every mirrored routing decision takes on Arkiv. `defineSchema`'s own name +// ("mergit.routing_decision") becomes the built-in, always-queryable `arkiv.schema` attribute — +// verified live against Rabdi: `arkiv.schema = str('my-app.posts')` matches the docs' own example +// entity on the real network, so this is the real filter, not SDK sugar. +// +// `body` carries the JSON payload (candidates/picks) as raw bytes: Arkiv attributes are +// `str | i32 | u64 | u256 | dec | addr | bytes` only, no nested structures (confirmed by reading +// the SDK's own `Attributes` type), and a plain JSON string is smaller round-tripped as UTF-8 +// bytes than re-encoded through any of the scalar types. +import { defineSchema, i32, str, bytes, addr } from "@arkiv-network/sdk/schema"; + +export const RoutingDecision = defineSchema("mergit.routing_decision", { + planId: str().indexed(), + taskIndex: i32().indexed(), + category: str().indexed(), + requester: addr().indexed(), + decidedAt: str().indexed(), + body: bytes(), +}); diff --git a/chain/arkiv/src/server.js b/chain/arkiv/src/server.js new file mode 100644 index 00000000..ac8a9cc3 --- /dev/null +++ b/chain/arkiv/src/server.js @@ -0,0 +1,110 @@ +// The one thing this sidecar exists for: a single `executeBatch` write per call, so every task's +// routing decision for one `explore=true` suggestions call lands in ONE transaction, not one per +// task. Reads never come through here — they're plain JSON-RPC `arkiv_query` calls the control +// plane makes itself (see control-plane/mergit_cp/arkiv_mirror.py); only a write needs real +// transaction encoding, which only the JS SDK demonstrably supports against Rabdi (verified live: +// the Python `arkiv-sdk` targets a different, incompatible processor address/query shape). +// +// The private key lives only here, never in the control plane's own process or env. +import { createServer } from "node:http"; +import { createWalletClient, ExpirationTime } from "@arkiv-network/sdk"; +import { addr } from "@arkiv-network/sdk/schema"; +import { rabdi } from "@arkiv-network/sdk/chains"; +import { http as viemHttp } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { RoutingDecision } from "./schema.js"; + +const PORT = Number(process.env.PORT ?? 8088); +const ACCESS_KEY = process.env.ARKIV_ACCESS_KEY ?? ""; +const PRIVATE_KEY = process.env.ARKIV_PRIVATE_KEY; +const TOKEN = process.env.ARKIV_SIDECAR_TOKEN; + +if (!PRIVATE_KEY) throw new Error("ARKIV_PRIVATE_KEY is required"); +if (!TOKEN) throw new Error("ARKIV_SIDECAR_TOKEN is required"); + +const account = privateKeyToAccount(PRIVATE_KEY); +const walletClient = createWalletClient({ + account, + chain: rabdi({ accessKey: ACCESS_KEY }), + transport: viemHttp(), +}); + +// Serializes every write through this one account: two concurrent explore calls (different plans, +// different HTTP requests) must not send two transactions with the same nonce. A single-process +// promise chain is enough for one service wallet; it is not meant to survive a second replica. +let queue = Promise.resolve(); +function enqueue(fn) { + const result = queue.then(fn, fn); + queue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function send(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); +} + +const server = createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/healthz") { + res.writeHead(200).end("ok"); + return; + } + if (req.method !== "POST" || req.url !== "/internal/mirror") { + res.writeHead(404).end(); + return; + } + if (req.headers.authorization !== `Bearer ${TOKEN}`) { + res.writeHead(401).end(); + return; + } + + let payload; + try { + payload = JSON.parse(await readBody(req)); + } catch { + send(res, 400, { error: "invalid json" }); + return; + } + + const { decisions, ttl_days: ttlDays } = payload; + if (!Array.isArray(decisions) || decisions.length === 0) { + send(res, 400, { error: "decisions must be a non-empty array" }); + return; + } + + try { + const { txHash, createdEntities } = await enqueue(() => + walletClient.executeBatch({ + creates: decisions.map((d) => ({ + schema: RoutingDecision, + values: { + planId: d.plan_id, + taskIndex: d.task_index, + category: d.category, + requester: addr(d.requester), + decidedAt: d.decided_at, + body: new TextEncoder().encode( + JSON.stringify({ candidates: d.candidates, picks: d.picks }), + ), + }, + expiration: ExpirationTime.fromDays(ttlDays ?? 30), + })), + }), + ); + send(res, 200, { tx_hash: txHash, entity_keys: createdEntities.map((e) => e.key) }); + } catch (err) { + send(res, 502, { error: String(err?.message ?? err) }); + } +}); + +server.listen(PORT, () => { + console.log(`arkiv sidecar listening on :${PORT}, wallet ${account.address}`); +}); diff --git a/control-plane/README.md b/control-plane/README.md index 21b81c1b..8701d7c9 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -208,16 +208,26 @@ without it, `GET /v0/plans/{plan_uuid}/events` sees no session and answers `401` An exploring `GET /v0/plans/{uuid}/suggestions?explore=true` call already records its draw in this service's own `routing_decisions` table (pre-existing, migration `0016`); `mergit_cp/arkiv_mirror.py` -additionally mirrors it as a public entity on Arkiv's Rabdi testnet, after that Postgres transaction has -committed, so a down testnet or a slow RPC never fails the request (`routes_routing._mirror_decisions` -catches and logs). `GET /v0/plans/{uuid}/arkiv-suggestions` reads the mirror back for the plan's owner. -See `../arkiv/schema.md` for the entity shape. +additionally mirrors it as a public entity on Arkiv's Rabdi testnet, scheduled with FastAPI's +`BackgroundTasks` so it runs after the response is already on its way — a down testnet or a slow +write (the sidecar waits for the transaction to land) never holds up the request +(`routes_routing._mirror_decisions` catches and logs any failure). `GET +/v0/plans/{uuid}/arkiv-suggestions` reads the mirror back for the plan's owner. See +`../arkiv/schema.md` for the entity shape and `../chain/arkiv/README.md` for the write side. + +**Reads** are plain httpx against Rabdi's `arkiv_query` JSON-RPC — no SDK; the only Python Arkiv SDK +on PyPI targets a different, incompatible network (confirmed live, `../arkiv/friction.md`). **Writes** +go to `../chain/arkiv/`, a small Node sidecar holding the service wallet's private key and using the +official JS SDK (the one confirmed correct for Rabdi), so this service's own process and env never +hold that key. All optional, all unset by default (`Settings.arkiv_enabled`): `ARKIV_RPC_URL` (Rabdi's RPC, -`https://rpc.rabdi.db-chain.testnet.arkiv.network`), `ARKIV_PRIVATE_KEY` (one service wallet, funded from -`hub.arkiv.network/faucet`, that signs every mirrored entity — not a per-requester wallet), `ARKIV_ACCESS_KEY` -(from `hub.arkiv.network/access-keys`, raises the public RPC's rate limit), `ARKIV_ENTITY_TTL_DAYS` (default -30). Needs `arkiv-sdk` (`pyproject.toml`, pinned `==1.0.0b2`: the package has no stable release yet). +`https://rpc.rabdi.db-chain.testnet.arkiv.network`), `ARKIV_OWNER_ADDRESS` (the service wallet's +*public* address, to filter reads by `$owner` — not its key, which this service never holds), +`ARKIV_ACCESS_KEY` (from `hub.arkiv.network/access-keys`, raises the public RPC's rate limit), +`ARKIV_SIDECAR_URL` (the sidecar's internal address) and `ARKIV_SIDECAR_TOKEN` (the shared secret +sent as `Authorization: Bearer`, same pattern as `WORKER_TOKEN`), `ARKIV_ENTITY_TTL_DAYS` (default +30). No extra Python dependency: reads are plain `httpx`, already a dependency. ## The worker diff --git a/control-plane/mergit_cp/app.py b/control-plane/mergit_cp/app.py index f2da1d58..ebd84b15 100644 --- a/control-plane/mergit_cp/app.py +++ b/control-plane/mergit_cp/app.py @@ -74,8 +74,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.arkiv = arkiv or ( ArkivMirror( settings.arkiv_rpc_url, # type: ignore[arg-type] # non-None: arkiv_enabled checked it - settings.arkiv_private_key.get_secret_value(), # type: ignore[union-attr] settings.arkiv_access_key.get_secret_value() if settings.arkiv_access_key else None, + settings.arkiv_owner_address, # type: ignore[arg-type] # non-None: arkiv_enabled checked it + settings.arkiv_sidecar_url, + settings.arkiv_sidecar_token.get_secret_value() if settings.arkiv_sidecar_token else None, settings.arkiv_entity_ttl_days, ) if settings.arkiv_enabled @@ -107,6 +109,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: if task is not None: with suppress(asyncio.CancelledError): await task + if app.state.arkiv is not None: + await app.state.arkiv.aclose() await deliverer.aclose() await costs.aclose() app.state.pr_store.close() diff --git a/control-plane/mergit_cp/arkiv_mirror.py b/control-plane/mergit_cp/arkiv_mirror.py index 993b88b7..41834368 100644 --- a/control-plane/mergit_cp/arkiv_mirror.py +++ b/control-plane/mergit_cp/arkiv_mirror.py @@ -1,170 +1,164 @@ -"""Mirrors routing decisions (``routing_decisions``, migration 0016) onto Arkiv, the public Web3 database, -for ETHGlobal Mumbai's "Build on Arkiv (existing projects)" track. +"""Mirrors routing decisions (``routing_decisions``, migration 0016) onto Arkiv's Rabdi testnet, for +ETHGlobal Mumbai's "Build on Arkiv (existing projects)" track. Postgres stays the source of truth: nothing here is read back to decide anything the product depends on. -This is a best-effort copy, written after the Postgres transaction that recorded the decision has already -committed, so a down testnet or a slow RPC never affects a real request (``routes_routing.suggestions`` -swallows and logs any `ArkivMirror` failure). The mirrored entity tags itself -``kind = "mergit.routing_decision"`` so anyone can query it without knowing anything else about Mergit; -the requester's address is a queryable attribute, not the Arkiv-level owner, since one service wallet -signs every write (see `Settings.arkiv_*`). +This is a best-effort copy, scheduled after the request that recorded the decision has already responded +(`routes_routing.suggestions` uses `BackgroundTasks`), so a down testnet or a slow write never holds up a +real request. + +Two separate paths, deliberately: + +* **Reads** (`read_decisions`) are a plain JSON-RPC ``arkiv_query`` call over httpx, no SDK. Verified live + against Rabdi: it returns ``key``/``owner``/``attributes`` but never a top-level payload field — Rabdi + has no such field at the protocol level (``arkiv/friction.md``), so every value this module writes is a + named *attribute*, including the JSON blob (``body``, a ``bytes``-typed attribute), not the "payload" + concept the old (wrong-network) Python SDK exposes. +* **Writes** (`write_decisions`) go to a small Node sidecar (`chain/arkiv/`) that holds the one service + wallet's key and uses the official JS SDK, the only one confirmed to encode a write Rabdi accepts. One + HTTP call carries every task's decision for one plan, so the sidecar sends ONE batched transaction + (``executeBatch``), not one per task — fewer round trips, and no nonce race between a plan's own tasks. """ from __future__ import annotations -import asyncio import json import logging from dataclasses import dataclass -from typing import Any, Protocol, cast - -log = logging.getLogger(__name__) - -KIND = "mergit.routing_decision" -"""The project's tag on every entity this module writes (`arkiv.schema.md`'s ``kind`` attribute).""" +from typing import Any, cast +import httpx -class RawArkivClient(Protocol): - """The slice of ``arkiv.Arkiv`` this module calls, as its own protocol so a test can fake it without - a real `arkiv.Arkiv` (which needs a funded account and a reachable RPC).""" - - def create_entity( - self, - payload: bytes | None = None, - content_type: str | None = None, - attributes: dict[str, str | int] | None = None, - expires_in: int | None = None, - ) -> tuple[Any, Any]: ... +log = logging.getLogger(__name__) - def query_entities(self, query: str) -> Any: ... +SCHEMA = "mergit.routing_decision" +"""`defineSchema`'s own name (`chain/arkiv/src/schema.js`), which Arkiv turns into the built-in, +always-queryable `arkiv.schema` attribute — confirmed live: `arkiv.schema = str('my-app.posts')` +matches the docs' own example entity on the real Rabdi network, so this is a real filter, not sugar.""" @dataclass(frozen=True) -class MirroredDecision: - """One routing decision as read back from Arkiv (`GET /v0/plans/{uuid}/arkiv-suggestions`).""" +class Decision: + """One routing decision to mirror (`write_decisions`) or as read back (`read_decisions`'s + `MirroredDecision`, which adds `entity_key`).""" - entity_key: str - plan_id: str task_index: int category: str - requester: str decided_at: str candidates: list[dict[str, Any]] picks: list[dict[str, Any]] -class ArkivMirror: - """Lazily builds the real `arkiv.Arkiv` client on first use (constructing it makes no network call, - but importing ``arkiv`` eagerly would make the dependency mandatory for everyone who never sets - `ARKIV_*`).""" +@dataclass(frozen=True) +class MirroredDecision(Decision): + entity_key: str = "" + +class ArkivMirror: def __init__( self, rpc_url: str, - private_key: str, access_key: str | None, + owner_address: str, + sidecar_url: str | None, + sidecar_token: str | None, ttl_days: int, *, - client: RawArkivClient | None = None, + http_client: httpx.AsyncClient | None = None, ) -> None: - self._rpc_url = rpc_url - self._private_key = private_key - self._access_key = access_key - self.ttl_seconds = ttl_days * 86400 - self._client = client - """Pre-built only in tests, which fake `RawArkivClient` to avoid a real funded account and RPC.""" - - def _build(self) -> RawArkivClient: - from arkiv import Arkiv # local import: optional dependency, only needed when Arkiv is configured - from arkiv.account import NamedAccount - from arkiv.provider import ProviderBuilder - from web3.providers.base import BaseProvider - - url = f"{self._rpc_url}/{self._access_key}" if self._access_key else self._rpc_url - # .custom() with no .ws()/.async_mode() always builds a sync HTTPProvider; the builder's return - # type is wider (it also covers ws/async providers) than what we ever actually get here. - provider = cast(BaseProvider, ProviderBuilder().custom(url).build()) - account = NamedAccount.from_private_key("mergit-service", self._private_key) - # `client.arkiv` (an `ArkivModule`), not the `Arkiv`/`Web3` client itself, is what carries - # `create_entity`/`query_entities`. - return cast(RawArkivClient, Arkiv(provider, account=account).arkiv) - - @property - def client(self) -> RawArkivClient: - if self._client is None: - self._client = self._build() - return self._client - - async def write_decision( - self, - *, - plan_id: str, - task_index: int, - category: str, - requester: str, - decided_at: str, - candidates: list[dict[str, Any]], - picks: list[dict[str, Any]], - ) -> str: - """Mirrors one `routing_decisions` row. Returns the new entity's key. Raises on failure; callers - that must not fail the request for this (`routes_routing.suggestions`) catch and log it.""" - payload = json.dumps({"candidates": candidates, "picks": picks}).encode() - attributes: dict[str, str | int] = { - "kind": KIND, - "planId": plan_id, - "taskIndex": task_index, - "category": category, - "requester": requester, - "decidedAt": decided_at, + self._rpc_url = f"{rpc_url}/{access_key}" if access_key else rpc_url + self._owner = owner_address.lower() + self._sidecar_url = sidecar_url + self._sidecar_token = sidecar_token + self._ttl_days = ttl_days + self._http = http_client or httpx.AsyncClient() + + async def aclose(self) -> None: + await self._http.aclose() + + async def write_decisions(self, plan_id: str, requester: str, decisions: list[Decision]) -> None: + """One HTTP call to the sidecar, one on-chain transaction for every task in `decisions`. Raises + on failure (no sidecar configured, unreachable, out of funds, a bad key); the caller decides + whether that may fail the request (it must not — see this module's docstring).""" + if not self._sidecar_url: + raise RuntimeError("ARKIV_SIDECAR_URL is not set; nothing to mirror to") + body = { + "ttl_days": self._ttl_days, + "decisions": [ + { + "plan_id": plan_id, + "requester": requester, + "task_index": d.task_index, + "category": d.category, + "decided_at": d.decided_at, + "candidates": d.candidates, + "picks": d.picks, + } + for d in decisions + ], } - - def create() -> str: - entity_key, _receipt = self.client.create_entity( - payload=payload, - content_type="application/json", - attributes=attributes, - expires_in=self.ttl_seconds, - ) - return str(entity_key) - - return await asyncio.to_thread(create) + headers = {"Authorization": f"Bearer {self._sidecar_token}"} if self._sidecar_token else {} + response = await self._http.post( + f"{self._sidecar_url}/internal/mirror", json=body, headers=headers, timeout=120.0 + ) + response.raise_for_status() + + def describe_query(self, plan_id: str) -> str: + """The exact query `read_decisions` runs, for display (README's/the panel's "one query anyone + can run") — real and copy-pasteable, not illustrative: schema tag, plan id, and the owner filter + that keeps a spoofed entity with Mergit's own tag out of someone else's results.""" + return ( + f"arkiv.schema = str('{SCHEMA}') AND planId = str('{plan_id}')" + f" AND $owner = addr({self._owner})" + ) async def read_decisions(self, plan_id: str, *, limit: int = 50) -> list[MirroredDecision]: - """Every live mirrored decision for one plan, most recent Arkiv write order undefined (callers - that care sort by `decided_at` themselves). Arkiv is a public, permissionless database: anyone - can write an entity tagged ``kind = "mergit.routing_decision"`` with any payload they like, so a - malformed match is skipped and logged rather than raised (`_decode`).""" - query = f'kind = "{KIND}" AND planId = "{plan_id}"' - - def fetch() -> list[MirroredDecision]: - out: list[MirroredDecision] = [] - for entity in self.client.query_entities(query): - decoded = _decode(entity) - if decoded is not None: - out.append(decoded) - if len(out) >= limit: - break - return out - - return await asyncio.to_thread(fetch) - - -def _decode(entity: Any) -> MirroredDecision | None: + """Every live mirrored decision for one plan, filtered by schema, plan id, *and* `$owner` (the + service wallet) — without the owner filter, anyone could write a spoofed entity with Mergit's + own schema tag and a matching `planId` and have it show up in another plan's panel.""" + query = self.describe_query(plan_id) + response = await self._http.post( + self._rpc_url, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "arkiv_query", + "params": [query, {"select": {"key": True, "attributes": True}, "limit": limit}], + }, + timeout=20.0, + ) + response.raise_for_status() + body = response.json() + if "error" in body: + raise RuntimeError(f"arkiv_query failed: {body['error']}") + out: list[MirroredDecision] = [] + for entity in body.get("result", {}).get("data", []): + decoded = _decode(entity) + if decoded is not None: + out.append(decoded) + return out + + +def _decode(entity: dict[str, Any]) -> MirroredDecision | None: + """Arkiv is a public, permissionless database: anyone can write an entity matching our schema, + plan id and owner filter (if they also control the service wallet — they don't, but a malformed + or future-schema entity from our own future code is still a real possibility), so a decode failure + is skipped and logged, never raised.""" try: - attrs: dict[str, Any] = entity.attributes or {} - parsed: Any = json.loads(entity.payload) if entity.payload else {} - # Valid JSON but not an object (e.g. a bare array) has no "candidates"/"picks" to read. - body: dict[str, Any] = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {} + attrs = {a["name"]: a["value"] for a in entity.get("attributes", [])} + body: dict[str, Any] = {} + raw_body = attrs.get("body") + if isinstance(raw_body, str) and raw_body.startswith("0x"): + parsed: Any = json.loads(bytes.fromhex(raw_body[2:])) + if isinstance(parsed, dict): + body = cast("dict[str, Any]", parsed) return MirroredDecision( - entity_key=str(entity.key), - plan_id=str(attrs.get("planId", "")), + entity_key=str(entity.get("key", "")), task_index=int(attrs.get("taskIndex", -1)), category=str(attrs.get("category", "")), - requester=str(attrs.get("requester", "")), decided_at=str(attrs.get("decidedAt", "")), candidates=body.get("candidates", []), picks=body.get("picks", []), ) - except (TypeError, ValueError, AttributeError, json.JSONDecodeError): - log.warning("skipping an unreadable entity tagged %r: %r", KIND, getattr(entity, "key", None)) + except (TypeError, ValueError, KeyError, json.JSONDecodeError): + log.warning("skipping an unreadable entity tagged %r: %r", SCHEMA, entity.get("key")) return None diff --git a/control-plane/mergit_cp/config.py b/control-plane/mergit_cp/config.py index 6c07b2c5..ee636a89 100644 --- a/control-plane/mergit_cp/config.py +++ b/control-plane/mergit_cp/config.py @@ -136,14 +136,21 @@ class Settings(BaseSettings): otp_sends_per_hour: int = Field(default=5, ge=1) arkiv_rpc_url: str | None = None - """Arkiv's Rabdi testnet RPC (``https://rpc.rabdi.db-chain.testnet.arkiv.network``). Unset: the - routing-decision mirror and its read endpoint are both no-ops (``arkiv_enabled``).""" + """Arkiv's Rabdi testnet RPC (``https://rpc.rabdi.db-chain.testnet.arkiv.network``), used directly + (``arkiv_query`` over httpx) for reads. Unset: the mirror and its read endpoint are both no-ops + (``arkiv_enabled``).""" arkiv_access_key: SecretStr | None = None """Raises the public RPC's heavy rate limit; free, from ``hub.arkiv.network/access-keys``.""" - arkiv_private_key: SecretStr | None = None - """One service wallet's key, funded from ``hub.arkiv.network/faucet``, that signs every mirrored - entity. Not a per-requester wallet: the Arkiv owner is this service account; the requester address is - a queryable attribute on the entity instead.""" + arkiv_owner_address: str | None = None + """The service wallet's public address (not its key — this service never holds that; see + ``chain/arkiv/``). Read-only, used to filter `read_decisions` by `$owner` so a spoofed entity + with Mergit's own schema tag cannot show up in someone else's results.""" + arkiv_sidecar_url: str | None = None + """The Arkiv write sidecar (`chain/arkiv/`), the only thing that holds the service wallet's private + key and can sign a write Rabdi accepts (`arkiv_mirror.py`'s own docstring explains why the control + plane cannot do this itself).""" + arkiv_sidecar_token: SecretStr | None = None + """Shared secret sent as ``Authorization: Bearer`` to the sidecar, same pattern as `worker_token`.""" arkiv_entity_ttl_days: int = Field(default=30, ge=1) """How long a mirrored routing decision lives on Arkiv before it expires.""" @@ -194,7 +201,10 @@ def email_enabled(self) -> bool: @property def arkiv_enabled(self) -> bool: - return bool(self.arkiv_rpc_url and self.arkiv_private_key) + """Reads need only the RPC and the wallet's public address. A write additionally needs the + sidecar reachable (`arkiv_mirror.write_decisions` raises its own clear error when it isn't, + caught the same best-effort way as any other mirror failure).""" + return bool(self.arkiv_rpc_url and self.arkiv_owner_address) @field_validator("cors_origins", "repo_hosts", "extra_domains", "planner_models", mode="before") @classmethod diff --git a/control-plane/mergit_cp/routes_routing.py b/control-plane/mergit_cp/routes_routing.py index f4e303cf..7c93cdd2 100644 --- a/control-plane/mergit_cp/routes_routing.py +++ b/control-plane/mergit_cp/routes_routing.py @@ -20,14 +20,14 @@ from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from psycopg import AsyncConnection from psycopg.rows import TupleRow from psycopg.types.json import Jsonb from . import eligibility, plans from .agents import Agent -from .arkiv_mirror import ArkivMirror +from .arkiv_mirror import ArkivMirror, Decision from .auth import PoolDep, PrincipalDep, SettingsDep from .deps import ArkivDep from .reputation_model import Params, Posterior @@ -95,6 +95,7 @@ async def suggestions( pool: PoolDep, agents: Annotated[list[Agent], Depends(get_agents)], arkiv: ArkivDep, + background_tasks: BackgroundTasks, explore: Annotated[bool, Query()] = False, ) -> dict[str, Any]: """Per task: the agents expected to fit its budget, best first (at most ``max_attempts``), and every @@ -187,7 +188,9 @@ async def suggestions( ), ) if arkiv is not None: - await _mirror_decisions(arkiv, plan.plan_id, plan.category, address, decisions, candidates) + background_tasks.add_task( + _mirror_decisions, arkiv, plan.plan_id, plan.category, address, decisions, candidates + ) return { "plan_id": plan.plan_id, "mode": "explore" if explore else "greedy", @@ -218,7 +221,7 @@ async def arkiv_suggestions( raise HTTPException( status_code=403, detail="only the requester may see suggestions for this plan" ) - query = f'kind = "mergit.routing_decision" AND planId = "{plan.plan_id}"' + query = arkiv.describe_query(plan.plan_id) try: decisions = await arkiv.read_decisions(plan.plan_id) except Exception: # any SDK/RPC failure; see the docstring @@ -304,19 +307,24 @@ async def _mirror_decisions( decisions: list[tuple[int, int, list[Pick]]], candidates: dict[str, Posterior], ) -> None: - """Best-effort: a down testnet or a slow RPC must not fail a suggestions call that Postgres already - recorded, so every failure is caught and logged, never raised.""" + """Runs as a `BackgroundTasks` entry, after the response is already on its way: a down testnet or + a slow write (the sidecar waits for the transaction to land) must not hold up a suggestions call + that Postgres already recorded, so every failure is caught and logged, never raised. One call to + `write_decisions` for every task in this plan's draw, so the sidecar sends one transaction, not + one per task (`chain/arkiv/src/server.js`'s `executeBatch`).""" decided_at = datetime.now(UTC).isoformat() - for index, _budget, picks in decisions: - try: - await arkiv.write_decision( - plan_id=plan_id, - task_index=index, - category=category, - requester=requester, - decided_at=decided_at, - candidates=_snapshot(candidates), - picks=[_pick_record(p) for p in picks], - ) - except Exception: # any SDK/RPC failure; Arkiv is a mirror, not the record of truth - log.warning("arkiv mirror failed for plan %s task %s", plan_id, index, exc_info=True) + snapshot = _snapshot(candidates) + to_write = [ + Decision( + task_index=index, + category=category, + decided_at=decided_at, + candidates=snapshot, + picks=[_pick_record(p) for p in picks], + ) + for index, _budget, picks in decisions + ] + try: + await arkiv.write_decisions(plan_id, requester, to_write) + except Exception: # any sidecar/RPC failure; Arkiv is a mirror, not the record of truth + log.warning("arkiv mirror failed for plan %s", plan_id, exc_info=True) diff --git a/control-plane/pyproject.toml b/control-plane/pyproject.toml index 4e317488..d77ec941 100644 --- a/control-plane/pyproject.toml +++ b/control-plane/pyproject.toml @@ -8,9 +8,10 @@ dependencies = [ # siwe 4.4 (last release Oct 2024) defines ALPHA in its grammar; abnf 2.9 refuses that at import. "abnf>=2.2,<2.9", "anthropic>=2.3,<3", - # Pinned exactly: arkiv-sdk has no 1.0 stable release yet, only betas; an exact pin is the one specifier - # pip/uv treat as an explicit opt-in to a prerelease, so this needs no extra --prerelease flag. - "arkiv-sdk==1.0.0b2", + # NOT arkiv-sdk (Python): verified live against Rabdi that its query options and write target + # (processor address) are both wrong for this network — see arkiv/friction.md. Reads here are + # plain httpx against arkiv_query; writes go to chain/arkiv/ (the JS SDK, which is correct for + # Rabdi) over HTTP. "docker>=7.1", "fastapi>=0.115", "httpx>=0.28", diff --git a/control-plane/tests/test_arkiv_mirror.py b/control-plane/tests/test_arkiv_mirror.py index d9e22a4e..c16c8b71 100644 --- a/control-plane/tests/test_arkiv_mirror.py +++ b/control-plane/tests/test_arkiv_mirror.py @@ -1,4 +1,5 @@ -"""`ArkivMirror`: the write/read shape against a fake `RawArkivClient`, no real RPC or funded account. +"""`ArkivMirror`: writes (one HTTP call to the sidecar) and reads (plain `arkiv_query` over httpx), both +against a mocked transport (`respx`), never a real Rabdi RPC or a real sidecar process. No `pytest-asyncio` in this repo (see other test files): each test drives its coroutine with ``asyncio.run``, same as the rest of the suite drives Postgres helpers. @@ -8,180 +9,164 @@ import asyncio import json -from dataclasses import dataclass -from typing import Any +import httpx import pytest +import respx -from mergit_cp.arkiv_mirror import KIND, ArkivMirror, MirroredDecision - - -@dataclass -class FakeEntity: - key: str - attributes: dict[str, str | int] - payload: bytes | None - - -class FakeRawClient: - """Fakes the slice of ``arkiv.arkiv`` (``ArkivModule``) that `ArkivMirror` calls. Plain ``__init__`` - attributes, not a dataclass with ``field(default_factory=list)``: pyright strict cannot narrow that - factory's return past ``list[Unknown]`` even with an explicit field annotation.""" - - def __init__(self) -> None: - self.created: list[dict[str, Any]] = [] - self.entities: list[FakeEntity] = [] - self.queries: list[str] = [] - self.next_key = 0 - self.raises: Exception | None = None - """Set to make every call fail, as if the RPC were unreachable or the key were bad.""" - - def create_entity( - self, - payload: bytes | None = None, - content_type: str | None = None, - attributes: dict[str, str | int] | None = None, - expires_in: int | None = None, - ) -> tuple[str, object]: - if self.raises is not None: - raise self.raises - self.next_key += 1 - key = f"0xentity{self.next_key}" - self.created.append( - { - "key": key, - "payload": payload, - "content_type": content_type, - "attributes": attributes, - "expires_in": expires_in, - } - ) - return key, object() +from mergit_cp.arkiv_mirror import SCHEMA, ArkivMirror, Decision - def query_entities(self, query: str) -> list[FakeEntity]: - if self.raises is not None: - raise self.raises - self.queries.append(query) - return self.entities +RPC = "https://rpc.example" +SIDECAR = "https://sidecar.example" +OWNER = "0x1111111111111111111111111111111111111111" @pytest.fixture -def mirror() -> tuple[ArkivMirror, FakeRawClient]: - client = FakeRawClient() - return ArkivMirror("https://rpc.example", "0xkey", "access", 30, client=client), client - - -def test_write_decision_tags_and_shapes_the_entity(mirror: tuple[ArkivMirror, FakeRawClient]): - arkiv, client = mirror - key = asyncio.run( - arkiv.write_decision( - plan_id="0x" + "ab" * 32, - task_index=1, - category="code", - requester="0x" + "11" * 20, - decided_at="2026-11-06T00:00:00+00:00", - candidates=[{"agent_id": "a1", "alpha": 1.0}], - picks=[{"agent_id": "a1", "score": 0.9}], - ) - ) - assert key == "0xentity1" - [call] = client.created - assert call["content_type"] == "application/json" - assert call["expires_in"] == 30 * 86400 - assert call["attributes"] == { - "kind": KIND, - "planId": "0x" + "ab" * 32, - "taskIndex": 1, - "category": "code", - "requester": "0x" + "11" * 20, - "decidedAt": "2026-11-06T00:00:00+00:00", - } - body = json.loads(call["payload"]) - assert body == { - "candidates": [{"agent_id": "a1", "alpha": 1.0}], - "picks": [{"agent_id": "a1", "score": 0.9}], +def mirror() -> ArkivMirror: + return ArkivMirror(RPC, None, OWNER, SIDECAR, "sidecar-secret", 30) + + +def _entity( + key: str, *, task_index: int, category: str, decided_at: str, body: dict[str, object] +) -> dict[str, object]: + encoded = "0x" + json.dumps(body).encode().hex() + return { + "key": key, + "attributes": [ + {"name": "taskIndex", "type": "i32", "value": task_index}, + {"name": "category", "type": "str", "value": category}, + {"name": "decidedAt", "type": "str", "value": decided_at}, + {"name": "body", "type": "bytes", "value": encoded}, + ], } -def test_read_decisions_filters_by_plan_and_decodes_the_payload( - mirror: tuple[ArkivMirror, FakeRawClient], -): - arkiv, client = mirror - client.entities = [ - FakeEntity( - key="0xe1", - attributes={ - "kind": KIND, - "planId": "plan-1", - "taskIndex": 0, - "category": "code", - "requester": "0xreq", - "decidedAt": "2026-11-06T00:00:00+00:00", - }, - payload=json.dumps( - {"candidates": [{"agent_id": "a1"}], "picks": [{"agent_id": "a1"}]} - ).encode(), +def test_write_decisions_sends_one_batched_call(mirror: ArkivMirror): + with respx.mock(assert_all_called=True) as mock: + route = mock.post(f"{SIDECAR}/internal/mirror").respond( + json={"tx_hash": "0xdead", "entity_keys": ["0xe1", "0xe2"]} ) - ] - - decisions = asyncio.run(arkiv.read_decisions("plan-1")) - - assert client.queries == [f'kind = "{KIND}" AND planId = "plan-1"'] - assert decisions == [ - MirroredDecision( - entity_key="0xe1", - plan_id="plan-1", - task_index=0, - category="code", - requester="0xreq", - decided_at="2026-11-06T00:00:00+00:00", - candidates=[{"agent_id": "a1"}], - picks=[{"agent_id": "a1"}], + asyncio.run( + mirror.write_decisions( + "plan-1", + "0xrequester", + [ + Decision( + 0, "code", "2026-11-06T00:00:00+00:00", [{"agent_id": "a1"}], [{"agent_id": "a1"}] + ), + Decision( + 1, "code", "2026-11-06T00:00:00+00:00", [{"agent_id": "a2"}], [{"agent_id": "a2"}] + ), + ], + ) ) - ] + assert route.call_count == 1 + sent = json.loads(route.calls[0].request.content) + assert sent["ttl_days"] == 30 + assert [d["task_index"] for d in sent["decisions"]] == [0, 1] + assert all(d["plan_id"] == "plan-1" for d in sent["decisions"]) + assert all(d["requester"] == "0xrequester" for d in sent["decisions"]) + assert route.calls[0].request.headers["authorization"] == "Bearer sidecar-secret" -def test_read_decisions_caps_at_the_limit(mirror: tuple[ArkivMirror, FakeRawClient]): - arkiv, client = mirror - client.entities = [ - FakeEntity(key=f"0xe{i}", attributes={"planId": "plan-1"}, payload=None) for i in range(5) - ] +def test_write_decisions_raises_when_no_sidecar_is_configured(): + arkiv = ArkivMirror(RPC, None, OWNER, None, None, 30) + with pytest.raises(RuntimeError): + asyncio.run(arkiv.write_decisions("plan-1", "0xrequester", [Decision(0, "code", "t", [], [])])) - decisions = asyncio.run(arkiv.read_decisions("plan-1", limit=2)) - assert len(decisions) == 2 +def test_write_decisions_raises_on_a_sidecar_error(mirror: ArkivMirror): + with respx.mock(assert_all_called=True) as mock: + mock.post(f"{SIDECAR}/internal/mirror").respond(502, json={"error": "insufficient funds"}) + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(mirror.write_decisions("plan-1", "0xreq", [Decision(0, "code", "t", [], [])])) -def test_read_decisions_skips_an_entity_it_cannot_decode(mirror: tuple[ArkivMirror, FakeRawClient]): - """Arkiv is a public, permissionless database: anyone can write an entity tagged - ``kind = "mergit.routing_decision"`` with any payload, so a genuinely malformed match (unparseable - JSON, a non-numeric ``taskIndex``) is skipped, not raised.""" - arkiv, client = mirror - client.entities = [ - FakeEntity(key="0xbad-payload", attributes={"planId": "plan-1"}, payload=b"not json"), - FakeEntity( - key="0xbad-index", - attributes={"planId": "plan-1", "taskIndex": "not a number"}, - payload=None, - ), - FakeEntity(key="0xgood", attributes={"planId": "plan-1", "taskIndex": 0}, payload=None), - ] - - decisions = asyncio.run(arkiv.read_decisions("plan-1")) - +def test_describe_query_matches_what_read_decisions_sends(mirror: ArkivMirror): + query = mirror.describe_query("plan-1") + assert query == ( + f"arkiv.schema = str('{SCHEMA}') AND planId = str('plan-1') AND $owner = addr({OWNER})" + ) + with respx.mock(assert_all_called=True) as mock: + route = mock.post(RPC).respond(json={"jsonrpc": "2.0", "id": 1, "result": {"data": []}}) + asyncio.run(mirror.read_decisions("plan-1")) + sent = json.loads(route.calls[0].request.content) + assert sent["method"] == "arkiv_query" + assert sent["params"][0] == query + + +def test_read_decisions_decodes_the_bytes_body_attribute(mirror: ArkivMirror): + entity = _entity( + "0xe1", + task_index=0, + category="code", + decided_at="2026-11-06T00:00:00+00:00", + body={"candidates": [{"agent_id": "a1"}], "picks": [{"agent_id": "a1"}]}, + ) + with respx.mock(assert_all_called=True) as mock: + mock.post(RPC).respond(json={"jsonrpc": "2.0", "id": 1, "result": {"data": [entity]}}) + decisions = asyncio.run(mirror.read_decisions("plan-1")) + assert len(decisions) == 1 + d = decisions[0] + assert d.entity_key == "0xe1" + assert d.task_index == 0 + assert d.category == "code" + assert d.decided_at == "2026-11-06T00:00:00+00:00" + assert d.candidates == [{"agent_id": "a1"}] + assert d.picks == [{"agent_id": "a1"}] + + +def test_read_decisions_raises_on_an_rpc_error(mirror: ArkivMirror): + with respx.mock(assert_all_called=True) as mock: + mock.post(RPC).respond(json={"jsonrpc": "2.0", "id": 1, "error": {"code": -32001, "message": "bad"}}) + with pytest.raises(RuntimeError): + asyncio.run(mirror.read_decisions("plan-1")) + + +def test_read_decisions_raises_on_an_unreachable_rpc(mirror: ArkivMirror): + with respx.mock(assert_all_called=True) as mock: + mock.post(RPC).mock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(httpx.ConnectError): + asyncio.run(mirror.read_decisions("plan-1")) + + +def test_read_decisions_skips_an_entity_it_cannot_decode(mirror: ArkivMirror): + """Arkiv is a public, permissionless database: anyone can write an entity matching our schema tag, + plan id and owner filter, so a genuinely malformed match (unparseable body, a non-numeric + ``taskIndex``) is skipped, not raised.""" + bad_body = { + "key": "0xbad1", + "attributes": [ + {"name": "taskIndex", "type": "i32", "value": 0}, + {"name": "body", "type": "bytes", "value": "0xnotvalidhex"}, + ], + } + bad_index = { + "key": "0xbad2", + "attributes": [{"name": "taskIndex", "type": "str", "value": "not a number"}], + } + good = _entity("0xgood", task_index=1, category="code", decided_at="t", body={}) + with respx.mock(assert_all_called=True) as mock: + mock.post(RPC).respond( + json={"jsonrpc": "2.0", "id": 1, "result": {"data": [bad_body, bad_index, good]}} + ) + decisions = asyncio.run(mirror.read_decisions("plan-1")) assert [d.entity_key for d in decisions] == ["0xgood"] -def test_read_decisions_tolerates_a_payload_that_is_valid_json_but_not_an_object( - mirror: tuple[ArkivMirror, FakeRawClient], -): - """A payload like a bare JSON array parses fine but has no ``candidates``/``picks`` to read; the - entity still decodes, just with both empty, instead of `.get()` crashing on a non-dict.""" - arkiv, client = mirror - client.entities = [FakeEntity(key="0xe1", attributes={"planId": "plan-1"}, payload=b"[1, 2, 3]")] - - decisions = asyncio.run(arkiv.read_decisions("plan-1")) - +def test_read_decisions_tolerates_a_body_that_is_valid_json_but_not_an_object(mirror: ArkivMirror): + """A `body` like a bare JSON array parses fine but has no `candidates`/`picks` to read; the entity + still decodes, just with both empty.""" + entity = { + "key": "0xe1", + "attributes": [ + {"name": "taskIndex", "type": "i32", "value": 0}, + {"name": "body", "type": "bytes", "value": "0x" + json.dumps([1, 2, 3]).encode().hex()}, + ], + } + with respx.mock(assert_all_called=True) as mock: + mock.post(RPC).respond(json={"jsonrpc": "2.0", "id": 1, "result": {"data": [entity]}}) + decisions = asyncio.run(mirror.read_decisions("plan-1")) assert len(decisions) == 1 assert decisions[0].candidates == [] assert decisions[0].picks == [] diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index e200fb5f..1e954971 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -16,8 +16,10 @@ from types import SimpleNamespace from typing import Any +import httpx import psycopg import pytest +import respx from fastapi.testclient import TestClient from mergit_cp import routes_routing @@ -46,7 +48,6 @@ ) from tests.cp_fakes import FakeLLM from tests.test_api import seed_plan -from tests.test_arkiv_mirror import FakeEntity, FakeRawClient from tests.test_eligibility import dispatch, invalid_verdict from tests.test_plans import app_client, create, llm, settings, sign_in # noqa: F401 (fixtures) from tests.test_reputation import gateway, run @@ -582,38 +583,64 @@ def test_an_empty_answer_with_agents_out_for_probation_does_not_blame_the_budget assert task["reason"].startswith("some agents are left out for probation or the operator cap") -# -- Arkiv mirror (ETHGlobal Mumbai "Build on Arkiv" track): routing_decisions also written to Arkiv, and -# readable back through its own endpoint, with no Postgres field exposed anywhere this depends on. -- +# -- Arkiv mirror (ETHGlobal Mumbai "Build on Arkiv" track): routing_decisions also written to Arkiv +# (one batched call to the sidecar, chain/arkiv/), and readable back through its own endpoint. Both the +# sidecar and the Rabdi RPC are mocked at the HTTP layer (`respx`), never a real network or process. -- + +ARKIV_RPC = "https://rpc.example" +ARKIV_SIDECAR = "https://sidecar.example" +ARKIV_OWNER = "0x1111111111111111111111111111111111111111" + + +def _fake_entity(decision: dict[str, Any]) -> dict[str, Any]: + """An entity shaped like a real `arkiv_query` match, encoding one decision the sidecar "wrote" + (`_entity` in `test_arkiv_mirror.py` is the unit-level version of this).""" + encoded_body = "0x" + json.dumps( + {"candidates": decision["candidates"], "picks": decision["picks"]} + ).encode().hex() + return { + "key": f"0xe{decision['task_index']}", + "attributes": [ + {"name": "taskIndex", "type": "i32", "value": decision["task_index"]}, + {"name": "category", "type": "str", "value": decision["category"]}, + {"name": "decidedAt", "type": "str", "value": decision["decided_at"]}, + {"name": "body", "type": "bytes", "value": encoded_body}, + ], + } @contextmanager -def arkiv_client(settings: Settings, llm: FakeLLM) -> Iterator[tuple[TestClient, FakeRawClient]]: - raw = FakeRawClient() - arkiv = ArkivMirror("https://rpc.example", "0xkey", None, 30, client=raw) - with TestClient( - create_app(settings, llm=llm, deliver_events=False, arkiv=arkiv), base_url="https://testserver" - ) as c: - yield c, raw +def arkiv_client(settings: Settings, llm: FakeLLM) -> Iterator[tuple[TestClient, respx.MockRouter]]: + arkiv = ArkivMirror(ARKIV_RPC, None, ARKIV_OWNER, ARKIV_SIDECAR, "sidecar-secret", 30) + with ( + respx.mock(assert_all_called=False) as mock, + TestClient( + create_app(settings, llm=llm, deliver_events=False, arkiv=arkiv), + base_url="https://testserver", + ) as c, + ): + mock.post(f"{ARKIV_SIDECAR}/internal/mirror").respond( + json={"tx_hash": "0xdead", "entity_keys": []} + ) + yield c, mock def test_an_exploring_suggestion_is_mirrored_to_arkiv_and_readable_back(settings: Settings, llm: FakeLLM): - with arkiv_client(settings, llm) as (c, raw): + with arkiv_client(settings, llm) as (c, mock): sign_in(c) plan = create(c) + write_route = mock.routes[-1] # the default write mock registered by arkiv_client got = c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}).json() - # One Arkiv write per task, tagged for the project and keyed to this plan. - assert len(raw.created) == len(got["tasks"]) == 2 - for call in raw.created: - assert call["attributes"]["kind"] == "mergit.routing_decision" - assert call["attributes"]["planId"] == plan["plan_id"] - assert call["content_type"] == "application/json" - - # The read endpoint decodes what was written, not a copy kept in Postgres. - raw.entities = [ - FakeEntity(key=call["key"], attributes=call["attributes"], payload=call["payload"]) - for call in raw.created - ] + # One write call, carrying every task's decision — never one call per task. + assert write_route.call_count == 1 + sent = json.loads(write_route.calls[0].request.content) + assert len(sent["decisions"]) == len(got["tasks"]) == 2 + assert all(d["plan_id"] == plan["plan_id"] for d in sent["decisions"]) + + # The read endpoint decodes what was (supposedly) written on Arkiv, not a copy kept in Postgres. + entities = [_fake_entity(d) for d in sent["decisions"]] + mock.post(ARKIV_RPC).respond(json={"jsonrpc": "2.0", "id": 1, "result": {"data": entities}}) mirrored = c.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions").json() assert mirrored["enabled"] is True assert {d["task_index"] for d in mirrored["decisions"]} == {0, 1} @@ -624,12 +651,12 @@ def test_arkiv_suggestions_degrades_instead_of_500_when_arkiv_is_unreachable( ): """A bad key or a down RPC must not 500 the page for an unrelated feature; the owner still sees their plan, just told Arkiv could not be reached.""" - with arkiv_client(settings, llm) as (c, raw): + with arkiv_client(settings, llm) as (c, mock): sign_in(c) plan = create(c) c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}) - raw.raises = ConnectionError("rpc unreachable") + mock.post(ARKIV_RPC).mock(side_effect=httpx.ConnectError("rpc unreachable")) resp = c.get(f"/v0/plans/{plan['plan_uuid']}/arkiv-suggestions") assert resp.status_code == 200 @@ -640,7 +667,7 @@ def test_arkiv_suggestions_degrades_instead_of_500_when_arkiv_is_unreachable( def test_arkiv_suggestions_is_owner_only(settings: Settings, llm: FakeLLM): - with arkiv_client(settings, llm) as (c, _raw): + with arkiv_client(settings, llm) as (c, _mock): sign_in(c) plan = create(c) c.get(f"/v0/plans/{plan['plan_uuid']}/suggestions", params={"explore": "true"}) diff --git a/control-plane/uv.lock b/control-plane/uv.lock index eb40c1fb..0bae7900 100644 --- a/control-plane/uv.lock +++ b/control-plane/uv.lock @@ -229,20 +229,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] -[[package]] -name = "arkiv-sdk" -version = "1.0.0b2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "brotli" }, - { name = "web3" }, - { name = "zstd" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/d5/e542f2423a0984c07e07ff52e652d1fe3e5ba019acb61519b86ab52bc9cb/arkiv_sdk-1.0.0b2.tar.gz", hash = "sha256:fa644b4608964d38d1f64b403e1afdc4bc6217b659b222517cceb5ae8bbde566", size = 129302, upload-time = "2026-03-04T20:58:59.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/9d/6abb5c56efdadb40efa9d05fbb1d1ca4855eb66b0f20bd323a956dc63c66/arkiv_sdk-1.0.0b2-py3-none-any.whl", hash = "sha256:8dfe56eb3d9d9cb8ef0e832970fa10d5a61332cca08f79da71c5b9e23143512e", size = 68417, upload-time = "2026-03-04T20:58:57.013Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -346,71 +332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/87/4a7c3c33a709c9cc65f35082ea4f12fa402f63a7ed2ad84dbfff7390f364/bitarray-3.12.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:56ce357249ac3665e43f639a6cd0245bd449d707ee410b96452f0cc177d3e973", size = 244864, upload-time = "2026-10-19T07:24:28.457Z" }, ] -[[package]] -name = "brotli" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, - { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, - { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, - { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, - { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, - { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" }, - { url = "https://files.pythonhosted.org/packages/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" }, - { url = "https://files.pythonhosted.org/packages/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" }, - { url = "https://files.pythonhosted.org/packages/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" }, - { url = "https://files.pythonhosted.org/packages/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" }, - { url = "https://files.pythonhosted.org/packages/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" }, - { url = "https://files.pythonhosted.org/packages/8c/b4/379b2e5584be70110b154021ffa61b84dc3b476d2c031317f5c3fbcfcde5/brotli-1.2.1-cp313-cp313-win32.whl", hash = "sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671", size = 333014, upload-time = "2026-10-22T13:58:23.112Z" }, - { url = "https://files.pythonhosted.org/packages/96/c2/0c7abb25013043d4bcf7dd768d343bf7dee6ce99ac8c172f7ee4aef1c772/brotli-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f", size = 371072, upload-time = "2026-10-22T13:58:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/5f9d96203fdce3ff9ba1186ce10220e26483ffa2384cde5430dd980e9057/brotli-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c", size = 380893, upload-time = "2026-10-22T13:58:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" }, - { url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" }, - { url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" }, - { url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" }, - { url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" }, - { url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" }, - { url = "https://files.pythonhosted.org/packages/04/da/1ec2e9b4f500693e56e6391f128dd0d304d6d2b7f0d5090e80a802b722f1/brotli-1.2.1-cp314-cp314-win32.whl", hash = "sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81", size = 338208, upload-time = "2026-10-22T13:58:40.927Z" }, - { url = "https://files.pythonhosted.org/packages/24/5e/80cd38322263da5e92ea4935423f1d3cfa2509201673dce77b772c47b997/brotli-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd", size = 376707, upload-time = "2026-10-22T13:58:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/c5/35/cb35c489fcd0a813bbb7500465af5a7bac3bee7d996f6a211bac9096ef2c/brotli-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b", size = 390280, upload-time = "2026-10-22T13:58:43.256Z" }, - { url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" }, - { url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" }, - { url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" }, - { url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" }, - { url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" }, - { url = "https://files.pythonhosted.org/packages/a9/53/35042a907c309af8defb423e58692bdc5e3e1fd3d03388892b7614270626/brotli-1.2.1-cp315-cp315-win32.whl", hash = "sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c", size = 338212, upload-time = "2026-10-22T13:58:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/38/6c/883a32c23603e278ae93d8df31f0573b67382de827e6e05a6e9ee0b51896/brotli-1.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399", size = 376707, upload-time = "2026-10-22T13:58:58.292Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d5/2c574da9faf130a59c4c685ba1378f2f470b57fb9fa5d3b0f8b4e90579a0/brotli-1.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae", size = 390278, upload-time = "2026-10-22T13:58:59.512Z" }, - { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, - { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, - { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, - { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, -] - [[package]] name = "certifi" version = "2026.10.23" @@ -1326,7 +1247,6 @@ source = { editable = "." } dependencies = [ { name = "abnf" }, { name = "anthropic" }, - { name = "arkiv-sdk" }, { name = "docker" }, { name = "fastapi" }, { name = "httpx" }, @@ -1356,7 +1276,6 @@ dev = [ requires-dist = [ { name = "abnf", specifier = ">=2.2,<2.9" }, { name = "anthropic", specifier = ">=2.3,<3" }, - { name = "arkiv-sdk", specifier = "==1.0.0b2" }, { name = "docker", specifier = ">=7.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, @@ -2828,25 +2747,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] - -[[package]] -name = "zstd" -version = "1.5.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" }, - { url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" }, - { url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" }, - { url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" }, - { url = "https://files.pythonhosted.org/packages/a1/63/0d392a8ec2231dee9fc2290faea7a6642584686720d6b77899ad8b12e35a/zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06", size = 269438, upload-time = "2025-06-23T12:57:52.507Z" }, - { url = "https://files.pythonhosted.org/packages/be/1f/85aae095f92811bed3d2944bbed971fe07ec1dd2d82c9eb1395d69d2123c/zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb", size = 228179, upload-time = "2025-06-23T12:57:51.031Z" }, - { url = "https://files.pythonhosted.org/packages/31/4e/547949993ea347ac44f5908262ebe6e85edfa7b11a5df136319789be731d/zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71", size = 302763, upload-time = "2025-06-23T12:51:51.615Z" }, - { url = "https://files.pythonhosted.org/packages/25/ca/4a6882846e3049be249031f825251a9229ecad471e18e7fd27974540549c/zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174", size = 149452, upload-time = "2025-06-23T12:57:32.116Z" }, - { url = "https://files.pythonhosted.org/packages/e7/aa/89339605864c9803e4738f176932a6c9f1ad99d03c03ef2cb0634ddca680/zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a", size = 166625, upload-time = "2025-06-23T12:57:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/07/e9/501291a2f9b300b2c73dcc6d086df778e895e71573df9575def54d9dbab2/zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6", size = 302906, upload-time = "2025-06-23T13:21:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f7/9243bb99b8525421a7db741604d29aebe9a849539500f2248d74bf2614be/zstd-1.5.7.2-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:a6105b8fa21dbc59e05b6113e8e5d5aaf56c5d2886aa5778d61030af3256bbb7", size = 302779, upload-time = "2025-06-23T13:09:22.701Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a5/6ed36bed134d065ff6198707e7411fde7436d7927e325b8ace26f9e21159/zstd-1.5.7.2-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:d0b0ca097efb5f67157c61a744c926848dcccf6e913df2f814e719aa78197a4b", size = 302910, upload-time = "2025-06-23T13:15:13.081Z" }, -] diff --git a/deploy/compose.controlplane.yaml b/deploy/compose.controlplane.yaml index 9eb2ac65..be1f80b6 100644 --- a/deploy/compose.controlplane.yaml +++ b/deploy/compose.controlplane.yaml @@ -79,6 +79,26 @@ services: GATEWAY_URL: http://gateway:8082 STORE_URL: http://store:8081 AGENTS_FILE: /agents.json + # Unset: the Arkiv mirror (ETHGlobal Mumbai "Build on Arkiv" track) is a no-op, same as any other + # ARKIV_* left blank (mergit_cp/config.py's arkiv_enabled). The sidecar below holds the one secret + # this needs beyond its public RPC/owner; ARKIV_SIDECAR_TOKEN is shared with it in secrets/api.env. + ARKIV_SIDECAR_URL: http://arkiv-sidecar:8088 + + # The one write Rabdi needs: holds the service wallet's private key, never the control plane's own + # process (chain/arkiv/README's own reasoning). Started only once deploy/secrets/arkiv.env exists — + # `docker compose --profile arkiv ... up -d`, same opt-in pattern as `keyrelay` above. + arkiv-sidecar: + profiles: ["arkiv"] + build: { context: ../chain/arkiv } + image: mergit-arkiv-sidecar:local + restart: unless-stopped + env_file: secrets/arkiv.env + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8088/healthz').then(r => process.exit(r.ok ? 0 : 1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s worker: build: { context: .., dockerfile: control-plane/worker.Dockerfile } From e1e111d4dfed9d93d95d832d1958fe8dc9eb4382 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 05:27:42 +0530 Subject: [PATCH 4/7] feat(arkiv): link each decision straight to its Arkiv explorer entity page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live: https://rabdi.explorer.arkiv.network/entity/ is a real page (full entity, attributes, tx history), not a guess — checked against a real entity already on the chain. Stronger than the generic explorer homepage link this replaces: one click per decision goes straight to proof. --- .../src/screens/live/run/SuggestAgents.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index dff1685b..f634d0bb 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -139,18 +139,16 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s : `${String(arkiv.decisions.length)} decision${arkiv.decisions.length === 1 ? "" : "s"} read back from Arkiv's Rabdi testnet, not this app's own database:`} {arkiv.decisions.map((d) => ( - + Task {String(d.task_index + 1)} · {d.entity_key} - + ))} - - Open the Rabdi explorer -
)} From 3e4e5d71db869bc1e7778fd271ea692a4cd2b1b4 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 05:42:47 +0530 Subject: [PATCH 5/7] =?UTF-8?q?fix(arkiv):=20address=20Yash's=20review=20?= =?UTF-8?q?=E2=80=94=20real=20bug,=20privacy=20gap,=20incomplete=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed real: - server.js mapped createdEntities.map(e => e.key), but the SDK's own type (ExecuteBatchReturnType.createdEntities: Hex[]) says those are already plain strings, not objects — checked against the actual .d.ts, not taken on faith. entity_keys always came back [null, ...]; nothing read it yet, so nothing broke, but a future consumer would have. Fixed. - requester was on the entity and never queried (reads filter by $owner, not requester), and Arkiv entities are append-only, so it would outlive a requester's own "discard this draft" forever (until TTL). Dropped. Also kills the separate merged-account bug (mirrored principal.address, not plan.requester) for free, since the field no longer exists. - The mirror wasn't actually replayable, despite why-arkiv.md's own claim: migration 0016 says routing_decisions exists so "any draw can be replayed from its seed and the posteriors it saw," but seed was never on the Arkiv copy. Added decisionId, seed, budgetMicros via INSERT ... RETURNING on the Postgres insert (not recomputed separately), plus decidedAt now comes from Postgres's own created_at instead of a second clock reading in the background task. - Added ORDER BY $key ASC to the read query (pagination stability, per the docs' own note); verified live, no parse error. - Implemented the "matches ✓" idea: the panel now compares the picks Postgres just answered with against what Arkiv reads back for the same task and shows whether they match — a real verification feature, not just two separate lists. Deferred, not done (flagged to the user, their call): - An arkiv_mirrors table + outbox-style retry loop for lost writes. Real engineering, not required by the track's judging rules with ~1 day left; best-effort stays for the hackathon. - Deduping entities across repeated explore clicks: not a bug — each explore call is a genuinely new Thompson draw, and Postgres's own routing_decisions is append-only for the same reason. - The posteriors-snapshot-instead-of-routing_decisions pivot: a legitimate design question, too large a change to make now without re-verifying everything live again. Re-verified against the real Rabdi RPC (unfunded wallet): a 2-entity batch with the new schema reaches gas estimation and fails there as before (expected, 0 balance) — confirms the new fields and the fixed entity_keys encode correctly, not just that they compile. Full suite green: 546 fast + 450 postgres, ruff/pyright clean, 292 frontend tests, tsc/eslint/build clean. --- arkiv/how-you-use-arkiv.md | 13 ++-- arkiv/schema.md | 16 +++-- chain/arkiv/src/schema.js | 13 +++- chain/arkiv/src/server.js | 10 ++- control-plane/mergit_cp/arkiv_mirror.py | 41 +++++++++-- control-plane/mergit_cp/routes_routing.py | 55 ++++++++++---- control-plane/tests/test_arkiv_mirror.py | 72 +++++++++++++------ control-plane/tests/test_routes_routing.py | 6 ++ frontend/src/api/types.ts | 7 +- .../src/screens/live/run/SuggestAgents.tsx | 45 ++++++++---- 10 files changed, 208 insertions(+), 70 deletions(-) diff --git a/arkiv/how-you-use-arkiv.md b/arkiv/how-you-use-arkiv.md index 8d1367d0..5a7a4ba3 100644 --- a/arkiv/how-you-use-arkiv.md +++ b/arkiv/how-you-use-arkiv.md @@ -3,21 +3,24 @@ **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`, `taskIndex`, `category`, `requester`, - `decidedAt` — all indexed, `schema.md`) are exactly the fields the product itself already groups - suggestions by: one query per plan (`ArkivMirror.describe_query` builds +- **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. + 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. + 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 diff --git a/arkiv/schema.md b/arkiv/schema.md index e4925624..d2a1b1aa 100644 --- a/arkiv/schema.md +++ b/arkiv/schema.md @@ -30,12 +30,19 @@ 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 | -| `requester` | `addr` | yes | The plan owner's wallet address — a queryable attribute, **not** the entity's Arkiv-level owner (see "Who owns it" below) | -| `decidedAt` | `str` | yes | ISO-8601 UTC timestamp of the write | +| `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 @@ -63,8 +70,7 @@ One service wallet signs every write. Its *private key* lives only in `chain/ark 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. The requester's address is still fully queryable — as the `requester` -attribute above — just not the chain-level owner field. +their own suggestion history. Service wallet address: ``. @@ -79,7 +85,7 @@ cheap. ## One query anyone can run ``` -arkiv.schema = str('mergit.routing_decision') AND planId = str('') AND $owner = addr() +arkiv.schema = str('mergit.routing_decision') AND planId = str('') AND $owner = addr() ORDER BY $key ASC ``` The `$owner` clause matters: without it, anyone could write an entity with our exact schema tag and diff --git a/chain/arkiv/src/schema.js b/chain/arkiv/src/schema.js index f91105d5..3602ead3 100644 --- a/chain/arkiv/src/schema.js +++ b/chain/arkiv/src/schema.js @@ -3,17 +3,26 @@ // verified live against Rabdi: `arkiv.schema = str('my-app.posts')` matches the docs' own example // entity on the real network, so this is the real filter, not SDK sugar. // +// `decisionId`, `seed` and `budgetMicros` link this entity back to the exact Postgres +// `routing_decisions` row and let anyone replay the draw — the thing migration 0016 says that +// table exists for ("any draw can be replayed from its seed and the posteriors it saw"). No +// `requester`: a discarded draft plan can't be deleted off Arkiv (entities are append-only, only +// expiry removes them), so attaching a wallet address to data that outlives a "discard" is wrong, +// and nothing ever queried by it anyway (`$owner`, the service wallet, is what reads filter on). +// // `body` carries the JSON payload (candidates/picks) as raw bytes: Arkiv attributes are // `str | i32 | u64 | u256 | dec | addr | bytes` only, no nested structures (confirmed by reading // the SDK's own `Attributes` type), and a plain JSON string is smaller round-tripped as UTF-8 // bytes than re-encoded through any of the scalar types. -import { defineSchema, i32, str, bytes, addr } from "@arkiv-network/sdk/schema"; +import { defineSchema, i32, str, bytes, u64 } from "@arkiv-network/sdk/schema"; export const RoutingDecision = defineSchema("mergit.routing_decision", { planId: str().indexed(), + decisionId: u64().indexed(), taskIndex: i32().indexed(), category: str().indexed(), - requester: addr().indexed(), + seed: u64().indexed(), + budgetMicros: u64().indexed(), decidedAt: str().indexed(), body: bytes(), }); diff --git a/chain/arkiv/src/server.js b/chain/arkiv/src/server.js index ac8a9cc3..a7506239 100644 --- a/chain/arkiv/src/server.js +++ b/chain/arkiv/src/server.js @@ -8,7 +8,7 @@ // The private key lives only here, never in the control plane's own process or env. import { createServer } from "node:http"; import { createWalletClient, ExpirationTime } from "@arkiv-network/sdk"; -import { addr } from "@arkiv-network/sdk/schema"; +import { u64 } from "@arkiv-network/sdk/schema"; import { rabdi } from "@arkiv-network/sdk/chains"; import { http as viemHttp } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -87,9 +87,11 @@ const server = createServer(async (req, res) => { schema: RoutingDecision, values: { planId: d.plan_id, + decisionId: u64(BigInt(d.decision_id)), taskIndex: d.task_index, category: d.category, - requester: addr(d.requester), + seed: u64(BigInt(d.seed)), + budgetMicros: u64(BigInt(d.budget_micros)), decidedAt: d.decided_at, body: new TextEncoder().encode( JSON.stringify({ candidates: d.candidates, picks: d.picks }), @@ -99,7 +101,9 @@ const server = createServer(async (req, res) => { })), }), ); - send(res, 200, { tx_hash: txHash, entity_keys: createdEntities.map((e) => e.key) }); + // `createdEntities` is already `Hex[]` (the SDK's own type), not objects with a `.key` — an + // earlier version mapped `.key` off each string here, which silently produced `[null, ...]`. + send(res, 200, { tx_hash: txHash, entity_keys: createdEntities }); } catch (err) { send(res, 502, { error: String(err?.message ?? err) }); } diff --git a/control-plane/mergit_cp/arkiv_mirror.py b/control-plane/mergit_cp/arkiv_mirror.py index 41834368..9294c6cc 100644 --- a/control-plane/mergit_cp/arkiv_mirror.py +++ b/control-plane/mergit_cp/arkiv_mirror.py @@ -39,10 +39,16 @@ @dataclass(frozen=True) class Decision: """One routing decision to mirror (`write_decisions`) or as read back (`read_decisions`'s - `MirroredDecision`, which adds `entity_key`).""" + `MirroredDecision`, which adds `entity_key`). `decision_id` and `seed` link this entity back to + the exact `routing_decisions` row and let anyone replay the draw — the thing migration 0016 says + that table exists for; a mirror without them is only a record of the outcome, not of how it was + reached.""" + decision_id: int task_index: int category: str + seed: int + budget_micros: int decided_at: str candidates: list[dict[str, Any]] picks: list[dict[str, Any]] @@ -75,10 +81,15 @@ def __init__( async def aclose(self) -> None: await self._http.aclose() - async def write_decisions(self, plan_id: str, requester: str, decisions: list[Decision]) -> None: + async def write_decisions(self, plan_id: str, decisions: list[Decision]) -> None: """One HTTP call to the sidecar, one on-chain transaction for every task in `decisions`. Raises on failure (no sidecar configured, unreachable, out of funds, a bad key); the caller decides - whether that may fail the request (it must not — see this module's docstring).""" + whether that may fail the request (it must not — see this module's docstring). + + No requester address on the entity: a discarded draft plan can't be deleted off Arkiv (entities + are append-only; only expiry removes them), so attaching the requester's wallet to public, + permanent-until-expiry data would outlive the user's own "discard" action. Nothing here ever + filtered reads by it either — `describe_query` only needs `planId` and `$owner`.""" if not self._sidecar_url: raise RuntimeError("ARKIV_SIDECAR_URL is not set; nothing to mirror to") body = { @@ -86,9 +97,11 @@ async def write_decisions(self, plan_id: str, requester: str, decisions: list[De "decisions": [ { "plan_id": plan_id, - "requester": requester, + "decision_id": d.decision_id, "task_index": d.task_index, "category": d.category, + "seed": d.seed, + "budget_micros": d.budget_micros, "decided_at": d.decided_at, "candidates": d.candidates, "picks": d.picks, @@ -105,10 +118,12 @@ async def write_decisions(self, plan_id: str, requester: str, decisions: list[De def describe_query(self, plan_id: str) -> str: """The exact query `read_decisions` runs, for display (README's/the panel's "one query anyone can run") — real and copy-pasteable, not illustrative: schema tag, plan id, and the owner filter - that keeps a spoofed entity with Mergit's own tag out of someone else's results.""" + that keeps a spoofed entity with Mergit's own tag out of someone else's results. Ordered by + key so a second page (`read_decisions`' own `limit`) is stable across calls, per the docs' + own pagination note.""" return ( f"arkiv.schema = str('{SCHEMA}') AND planId = str('{plan_id}')" - f" AND $owner = addr({self._owner})" + f" AND $owner = addr({self._owner}) ORDER BY $key ASC" ) async def read_decisions(self, plan_id: str, *, limit: int = 50) -> list[MirroredDecision]: @@ -138,6 +153,15 @@ async def read_decisions(self, plan_id: str, *, limit: int = 50) -> list[Mirrore return out +def _as_int(value: Any) -> int: + """`i32` attributes (`taskIndex`) come back as a plain JSON number; `u64` ones (`decisionId`, + `seed`, `budgetMicros`) come back as a hex quantity (`"0x2a"`) — confirmed live against Rabdi's + own `select` output. Both go through `int()` once this normalizes the hex case.""" + if isinstance(value, str) and value.startswith("0x"): + return int(value, 16) + return int(value) + + def _decode(entity: dict[str, Any]) -> MirroredDecision | None: """Arkiv is a public, permissionless database: anyone can write an entity matching our schema, plan id and owner filter (if they also control the service wallet — they don't, but a malformed @@ -153,8 +177,11 @@ def _decode(entity: dict[str, Any]) -> MirroredDecision | None: body = cast("dict[str, Any]", parsed) return MirroredDecision( entity_key=str(entity.get("key", "")), - task_index=int(attrs.get("taskIndex", -1)), + decision_id=_as_int(attrs.get("decisionId", -1)), + task_index=_as_int(attrs.get("taskIndex", -1)), category=str(attrs.get("category", "")), + seed=_as_int(attrs.get("seed", 0)), + budget_micros=_as_int(attrs.get("budgetMicros", 0)), decided_at=str(attrs.get("decidedAt", "")), candidates=body.get("candidates", []), picks=body.get("picks", []), diff --git a/control-plane/mergit_cp/routes_routing.py b/control-plane/mergit_cp/routes_routing.py index 7c93cdd2..90398431 100644 --- a/control-plane/mergit_cp/routes_routing.py +++ b/control-plane/mergit_cp/routes_routing.py @@ -17,7 +17,7 @@ import random import secrets from datetime import UTC, datetime -from typing import Annotated, Any +from typing import Annotated, Any, NamedTuple from uuid import UUID from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query @@ -38,6 +38,20 @@ log = logging.getLogger(__name__) + +class MirrorRow(NamedTuple): + """One just-inserted `routing_decisions` row, exactly as Postgres has it — `decision_id` and + `created_at` come from the `INSERT ... RETURNING`, not recomputed, so the Arkiv mirror + (`_mirror_decisions`) links back to the real row and carries its real timestamp, not a second, + slightly later one taken when the background task happens to run.""" + + decision_id: int + task_index: int + budget_micros: int + created_at: datetime + picks: list[Pick] + + NOTE = ( "A suggestion, not an assignment: you still choose the agents when you approve. It ranks agents by " "validated success per unit cost in this category, from outcomes the platform recorded; free-credit work " @@ -173,10 +187,12 @@ async def suggestions( status_code=429, detail="too many exploring suggestions this hour; try again later" ) await conn.execute("INSERT INTO suggestion_attempts (requester) VALUES (%s)", (address,)) + mirror_rows: list[MirrorRow] = [] for index, budget, picks in decisions: - await conn.execute( + cur = await conn.execute( "INSERT INTO routing_decisions (plan_uuid, task_index, category, seed, budget_micros," - " candidates, picks) VALUES (%s, %s, %s, %s, %s, %s, %s)", + " candidates, picks) VALUES (%s, %s, %s, %s, %s, %s, %s)" + " RETURNING decision_id, created_at", ( plan.plan_uuid, index, @@ -187,9 +203,13 @@ async def suggestions( Jsonb(json.loads(json.dumps([_pick_record(p) for p in picks]))), ), ) + row = await cur.fetchone() + assert row is not None # INSERT ... RETURNING always returns the row it just inserted + decision_id, created_at = row + mirror_rows.append(MirrorRow(decision_id, index, budget, created_at, picks)) if arkiv is not None: background_tasks.add_task( - _mirror_decisions, arkiv, plan.plan_id, plan.category, address, decisions, candidates + _mirror_decisions, arkiv, plan.plan_id, plan.category, seed, mirror_rows, candidates ) return { "plan_id": plan.plan_id, @@ -233,8 +253,11 @@ async def arkiv_suggestions( "decisions": [ { "entity_key": d.entity_key, + "decision_id": d.decision_id, "task_index": d.task_index, "category": d.category, + "seed": d.seed, + "budget_micros": d.budget_micros, "decided_at": d.decided_at, "candidates": d.candidates, "picks": d.picks, @@ -303,28 +326,34 @@ async def _mirror_decisions( arkiv: ArkivMirror, plan_id: str, category: str, - requester: str, - decisions: list[tuple[int, int, list[Pick]]], + seed: int, + rows: list[MirrorRow], candidates: dict[str, Posterior], ) -> None: """Runs as a `BackgroundTasks` entry, after the response is already on its way: a down testnet or a slow write (the sidecar waits for the transaction to land) must not hold up a suggestions call that Postgres already recorded, so every failure is caught and logged, never raised. One call to `write_decisions` for every task in this plan's draw, so the sidecar sends one transaction, not - one per task (`chain/arkiv/src/server.js`'s `executeBatch`).""" - decided_at = datetime.now(UTC).isoformat() + one per task (`chain/arkiv/src/server.js`'s `executeBatch`). + + Mirrors `routing_decisions` for real, not just a record of which agents got picked: `decision_id` + and `seed` are what migration 0016 means by "any draw can be replayed from its seed and the + posteriors it saw" — without them the Arkiv copy can't back that claim (`arkiv/why-arkiv.md`).""" snapshot = _snapshot(candidates) to_write = [ Decision( - task_index=index, + decision_id=row.decision_id, + task_index=row.task_index, category=category, - decided_at=decided_at, + seed=seed, + budget_micros=row.budget_micros, + decided_at=row.created_at.isoformat(), candidates=snapshot, - picks=[_pick_record(p) for p in picks], + picks=[_pick_record(p) for p in row.picks], ) - for index, _budget, picks in decisions + for row in rows ] try: - await arkiv.write_decisions(plan_id, requester, to_write) + await arkiv.write_decisions(plan_id, to_write) except Exception: # any sidecar/RPC failure; Arkiv is a mirror, not the record of truth log.warning("arkiv mirror failed for plan %s", plan_id, exc_info=True) diff --git a/control-plane/tests/test_arkiv_mirror.py b/control-plane/tests/test_arkiv_mirror.py index c16c8b71..bc552cab 100644 --- a/control-plane/tests/test_arkiv_mirror.py +++ b/control-plane/tests/test_arkiv_mirror.py @@ -14,7 +14,7 @@ import pytest import respx -from mergit_cp.arkiv_mirror import SCHEMA, ArkivMirror, Decision +from mergit_cp.arkiv_mirror import SCHEMA, ArkivMirror, Decision, _as_int RPC = "https://rpc.example" SIDECAR = "https://sidecar.example" @@ -26,15 +26,40 @@ def mirror() -> ArkivMirror: return ArkivMirror(RPC, None, OWNER, SIDECAR, "sidecar-secret", 30) +def _decision(task_index: int, **over: object) -> Decision: + base = { + "decision_id": 100 + task_index, + "task_index": task_index, + "category": "code", + "seed": 123456789, + "budget_micros": 500_000, + "decided_at": "2026-11-06T00:00:00+00:00", + "candidates": [{"agent_id": "a1"}], + "picks": [{"agent_id": "a1"}], + } + return Decision(**(base | over)) # type: ignore[arg-type] + + def _entity( - key: str, *, task_index: int, category: str, decided_at: str, body: dict[str, object] + key: str, + *, + decision_id: int = 100, + task_index: int, + category: str = "code", + seed: int = 123456789, + budget_micros: int = 500_000, + decided_at: str, + body: dict[str, object], ) -> dict[str, object]: encoded = "0x" + json.dumps(body).encode().hex() return { "key": key, "attributes": [ + {"name": "decisionId", "type": "u64", "value": hex(decision_id)}, {"name": "taskIndex", "type": "i32", "value": task_index}, {"name": "category", "type": "str", "value": category}, + {"name": "seed", "type": "u64", "value": hex(seed)}, + {"name": "budgetMicros", "type": "u64", "value": hex(budget_micros)}, {"name": "decidedAt", "type": "str", "value": decided_at}, {"name": "body", "type": "bytes", "value": encoded}, ], @@ -46,46 +71,37 @@ def test_write_decisions_sends_one_batched_call(mirror: ArkivMirror): route = mock.post(f"{SIDECAR}/internal/mirror").respond( json={"tx_hash": "0xdead", "entity_keys": ["0xe1", "0xe2"]} ) - asyncio.run( - mirror.write_decisions( - "plan-1", - "0xrequester", - [ - Decision( - 0, "code", "2026-11-06T00:00:00+00:00", [{"agent_id": "a1"}], [{"agent_id": "a1"}] - ), - Decision( - 1, "code", "2026-11-06T00:00:00+00:00", [{"agent_id": "a2"}], [{"agent_id": "a2"}] - ), - ], - ) - ) + asyncio.run(mirror.write_decisions("plan-1", [_decision(0), _decision(1)])) assert route.call_count == 1 sent = json.loads(route.calls[0].request.content) assert sent["ttl_days"] == 30 assert [d["task_index"] for d in sent["decisions"]] == [0, 1] + assert [d["decision_id"] for d in sent["decisions"]] == [100, 101] assert all(d["plan_id"] == "plan-1" for d in sent["decisions"]) - assert all(d["requester"] == "0xrequester" for d in sent["decisions"]) + assert all(d["seed"] == 123456789 for d in sent["decisions"]) + assert all(d["budget_micros"] == 500_000 for d in sent["decisions"]) + assert all("requester" not in d for d in sent["decisions"]) assert route.calls[0].request.headers["authorization"] == "Bearer sidecar-secret" def test_write_decisions_raises_when_no_sidecar_is_configured(): arkiv = ArkivMirror(RPC, None, OWNER, None, None, 30) with pytest.raises(RuntimeError): - asyncio.run(arkiv.write_decisions("plan-1", "0xrequester", [Decision(0, "code", "t", [], [])])) + asyncio.run(arkiv.write_decisions("plan-1", [_decision(0)])) def test_write_decisions_raises_on_a_sidecar_error(mirror: ArkivMirror): with respx.mock(assert_all_called=True) as mock: mock.post(f"{SIDECAR}/internal/mirror").respond(502, json={"error": "insufficient funds"}) with pytest.raises(httpx.HTTPStatusError): - asyncio.run(mirror.write_decisions("plan-1", "0xreq", [Decision(0, "code", "t", [], [])])) + asyncio.run(mirror.write_decisions("plan-1", [_decision(0)])) def test_describe_query_matches_what_read_decisions_sends(mirror: ArkivMirror): query = mirror.describe_query("plan-1") assert query == ( - f"arkiv.schema = str('{SCHEMA}') AND planId = str('plan-1') AND $owner = addr({OWNER})" + f"arkiv.schema = str('{SCHEMA}') AND planId = str('plan-1')" + f" AND $owner = addr({OWNER}) ORDER BY $key ASC" ) with respx.mock(assert_all_called=True) as mock: route = mock.post(RPC).respond(json={"jsonrpc": "2.0", "id": 1, "result": {"data": []}}) @@ -98,8 +114,10 @@ def test_describe_query_matches_what_read_decisions_sends(mirror: ArkivMirror): def test_read_decisions_decodes_the_bytes_body_attribute(mirror: ArkivMirror): entity = _entity( "0xe1", + decision_id=42, task_index=0, - category="code", + seed=999, + budget_micros=500_000, decided_at="2026-11-06T00:00:00+00:00", body={"candidates": [{"agent_id": "a1"}], "picks": [{"agent_id": "a1"}]}, ) @@ -109,8 +127,11 @@ def test_read_decisions_decodes_the_bytes_body_attribute(mirror: ArkivMirror): assert len(decisions) == 1 d = decisions[0] assert d.entity_key == "0xe1" + assert d.decision_id == 42 assert d.task_index == 0 assert d.category == "code" + assert d.seed == 999 + assert d.budget_micros == 500_000 assert d.decided_at == "2026-11-06T00:00:00+00:00" assert d.candidates == [{"agent_id": "a1"}] assert d.picks == [{"agent_id": "a1"}] @@ -145,7 +166,7 @@ def test_read_decisions_skips_an_entity_it_cannot_decode(mirror: ArkivMirror): "key": "0xbad2", "attributes": [{"name": "taskIndex", "type": "str", "value": "not a number"}], } - good = _entity("0xgood", task_index=1, category="code", decided_at="t", body={}) + good = _entity("0xgood", task_index=1, decided_at="t", body={}) with respx.mock(assert_all_called=True) as mock: mock.post(RPC).respond( json={"jsonrpc": "2.0", "id": 1, "result": {"data": [bad_body, bad_index, good]}} @@ -170,3 +191,10 @@ def test_read_decisions_tolerates_a_body_that_is_valid_json_but_not_an_object(mi assert len(decisions) == 1 assert decisions[0].candidates == [] assert decisions[0].picks == [] + + +def test_as_int_reads_both_plain_numbers_and_hex_quantities(): + """`i32` attributes come back as a plain JSON number; `u64` ones come back as a hex quantity — + confirmed live against Rabdi's own `select` output (`arkiv/friction.md`).""" + assert _as_int(5) == 5 + assert _as_int("0x2a") == 42 diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index 1e954971..4acb1de6 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -637,6 +637,12 @@ def test_an_exploring_suggestion_is_mirrored_to_arkiv_and_readable_back(settings sent = json.loads(write_route.calls[0].request.content) assert len(sent["decisions"]) == len(got["tasks"]) == 2 assert all(d["plan_id"] == plan["plan_id"] for d in sent["decisions"]) + # Replayable, not just a record of the outcome: links back to the real Postgres row and + # carries what a replay needs (migration 0016's own reason routing_decisions exists). + assert all(isinstance(d["decision_id"], int) for d in sent["decisions"]) + assert all(d["seed"] == got["seed"] for d in sent["decisions"]) + assert all(isinstance(d["budget_micros"], int) for d in sent["decisions"]) + assert all("requester" not in d for d in sent["decisions"]) # The read endpoint decodes what was (supposedly) written on Arkiv, not a copy kept in Postgres. entities = [_fake_entity(d) for d in sent["decisions"]] diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a157e9d5..1c7321f1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -115,15 +115,20 @@ export type Suggestions = { tasks: TaskSuggestion[]; }; +export type ArkivPick = { agent_id: string; score: number; success: number; cost: number }; + /** One exploring suggestion mirrored onto Arkiv's Rabdi testnet (ETHGlobal Mumbai's "Build on Arkiv" track), * as read back through `arkiv_query` rather than from this plan's own Postgres row. */ export type ArkivDecision = { entity_key: string; + decision_id: number; task_index: number; category: string; + seed: number; + budget_micros: number; decided_at: string; candidates: unknown[]; - picks: unknown[]; + picks: ArkivPick[]; }; export type ArkivSuggestions = { diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index f634d0bb..1097383f 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -1,7 +1,19 @@ import { useState } from "react"; import { errorMessage, getArkivSuggestions, getSuggestions } from "../../../api/client"; import { percent, usdc } from "../../../api/reputation"; -import type { ArkivSuggestions, Suggestions } from "../../../api/types"; +import type { ArkivDecision, ArkivSuggestions, Suggestions, TaskSuggestion } from "../../../api/types"; + +/** Does what Arkiv has for this task match the picks Postgres just answered with — proof the mirror + * carries what the product actually ranked, not just a write nobody checks against anything. */ +function picksMatch( + task: TaskSuggestion | undefined, + decision: ArkivDecision | undefined, +): boolean | undefined { + if (!task || !decision) return undefined; + const postgres = task.picks.map((p) => p.agent_id); + const arkiv = decision.picks.map((p) => p.agent_id); + return postgres.length === arkiv.length && postgres.every((id, i) => id === arkiv[i]); +} /** * Advice on which agents to pick, from what the platform has validated about each (never from free-credit work). @@ -138,17 +150,26 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s ? "Not found on Arkiv yet; the mirror write may still be in flight or failed silently." : `${String(arkiv.decisions.length)} decision${arkiv.decisions.length === 1 ? "" : "s"} read back from Arkiv's Rabdi testnet, not this app's own database:`} - {arkiv.decisions.map((d) => ( - - Task {String(d.task_index + 1)} · {d.entity_key} - - ))} + {arkiv.decisions.map((d) => { + const matches = picksMatch(got.tasks[d.task_index], d); + return ( +
+ + Task {String(d.task_index + 1)} · {d.entity_key} + + {matches !== undefined && ( + + {matches ? "matches ✓" : "does not match the picks above"} + + )} +
+ ); + })} )} From f127c6bc79da6297826094eecfcaf7668b93b76c Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 06:00:24 +0530 Subject: [PATCH 6/7] style(arkiv): satisfy CI's ruff format / prettier check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PR checks were failing on pure formatting — ruff format --check (test_routes_routing.py) and prettier --check (SuggestAgents.tsx) — neither of which I'd run, only the linters (ruff check, eslint). No logic change. Re-verified after reformatting: ruff check/format, pyright, full control- plane suite (546 fast + 450 postgres), tsc, eslint, vitest (292), and the e2e specs that touch this component (e2e/live/run.spec.ts, 21/21) all green. --- control-plane/tests/test_routes_routing.py | 10 ++++------ frontend/src/screens/live/run/SuggestAgents.tsx | 9 ++------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index 4acb1de6..b9aa4d9c 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -595,9 +595,9 @@ def test_an_empty_answer_with_agents_out_for_probation_does_not_blame_the_budget def _fake_entity(decision: dict[str, Any]) -> dict[str, Any]: """An entity shaped like a real `arkiv_query` match, encoding one decision the sidecar "wrote" (`_entity` in `test_arkiv_mirror.py` is the unit-level version of this).""" - encoded_body = "0x" + json.dumps( - {"candidates": decision["candidates"], "picks": decision["picks"]} - ).encode().hex() + encoded_body = ( + "0x" + json.dumps({"candidates": decision["candidates"], "picks": decision["picks"]}).encode().hex() + ) return { "key": f"0xe{decision['task_index']}", "attributes": [ @@ -619,9 +619,7 @@ def arkiv_client(settings: Settings, llm: FakeLLM) -> Iterator[tuple[TestClient, base_url="https://testserver", ) as c, ): - mock.post(f"{ARKIV_SIDECAR}/internal/mirror").respond( - json={"tx_hash": "0xdead", "entity_keys": []} - ) + mock.post(f"{ARKIV_SIDECAR}/internal/mirror").respond(json={"tx_hash": "0xdead", "entity_keys": []}) yield c, mock diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index 1097383f..193aeaab 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -5,10 +5,7 @@ import type { ArkivDecision, ArkivSuggestions, Suggestions, TaskSuggestion } fro /** Does what Arkiv has for this task match the picks Postgres just answered with — proof the mirror * carries what the product actually ranked, not just a write nobody checks against anything. */ -function picksMatch( - task: TaskSuggestion | undefined, - decision: ArkivDecision | undefined, -): boolean | undefined { +function picksMatch(task: TaskSuggestion | undefined, decision: ArkivDecision | undefined): boolean | undefined { if (!task || !decision) return undefined; const postgres = task.picks.map((p) => p.agent_id); const arkiv = decision.picks.map((p) => p.agent_id); @@ -135,9 +132,7 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s {arkivError} )} - {arkiv && !arkiv.enabled && ( - Arkiv mirroring is off on this deployment. - )} + {arkiv && !arkiv.enabled && Arkiv mirroring is off on this deployment.} {arkiv?.enabled && arkiv.error && ( Arkiv's testnet could not be reached just now; try again shortly. From 74c231cf1d40ceb897c5dc56b0cc0637d3da6cc6 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 07:42:58 +0530 Subject: [PATCH 7/7] fix(deploy): update.sh never knew about the arkiv profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while answering "is this safe to merge": deploy/compose.controlplane.yaml gates arkiv-sidecar behind the arkiv profile, but update.sh (the server's own redeploy script) only activated keyrelay's profile conditionally — it would never pass --profile arkiv, even after deploy/secrets/arkiv.env exists. Without this, every future auto-deploy after someone sets that file up would silently leave the sidecar off until someone ran the compose command by hand. Confirmed safe to merge as-is: `docker compose config` with no --profile arkiv and no deploy/secrets/arkiv.env on disk still exits 0 (the profile- gated service and its env_file requirement are skipped entirely), so this whole feature stays off by default until the secrets file is actually created on the server — merging does not change prod behavior. --- deploy/server/update.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deploy/server/update.sh b/deploy/server/update.sh index 6ae74a87..b3e1f6d7 100755 --- a/deploy/server/update.sh +++ b/deploy/server/update.sh @@ -5,8 +5,11 @@ set -euo pipefail cd "$(dirname "$0")/../.." what=${1:-all} -# The key rotator runs only where it has been configured (see tools/keyrelay/README.md). -profiles=(); [ -f deploy/secrets/keyrelay/keyrelay.toml ] && profiles=(--profile keyrelay) +# The key rotator and the Arkiv write sidecar each run only where they've been configured (see +# tools/keyrelay/README.md and chain/arkiv/README.md). +profiles=() +[ -f deploy/secrets/keyrelay/keyrelay.toml ] && profiles+=(--profile keyrelay) +[ -f deploy/secrets/arkiv.env ] && profiles+=(--profile arkiv) compose() { docker compose "${profiles[@]}" -f deploy/compose.testnet.yaml -f deploy/compose.controlplane.yaml -f deploy/compose.server.yaml "$@"; } case "$what" in all|services|web|app) ;; *) echo "usage: $0 [all|services|web|app]" >&2; exit 2 ;; esac