Self-host

The contracts are permissionless and keyed by owner, so one deployment serves any number of firms and nobody needs to deploy their own. What a firm hosts is the supervisory plane — and its database is rebuildable from chain, which is both the disaster recovery story and the integrity argument.

Or one command, and none of that

A reviewer who wants to read the thing rather than install it needs Docker and nothing else:

docker compose up --build
    # http://localhost:8080/console/

The console, its API, the evidence spine and a preview chain come up on one origin — the arrangement the browser’s typed-data signature assumes, not a convenience. The recorded incident is loaded from db/seed/incident.sql: public chain data, each commitment naming a real sequence on a public HCS topic, so a reader who does not want to take our word for it can check the digests against the mirror node. The preview answers from the same enforcement bytecode that runs on testnet, on a simulated chain that settles nothing.

What that leaves out is everything that needs a funded key: the relayer, so nothing can be written, and the indexer, because what it finds is anchored to HCS as it is written. Both are behind a profile rather than failing in a way that reads as a broken product:

docker compose --profile live up --build   # with keys in .env

Started without them, each refuses with the line that says what it needs and stops after three attempts instead of printing the same stack every second. docker compose down -v takes the spine with it, which is how to start over.

Or the four services, by hand

Node 22 or newer, PostgreSQL 16, and a funded Hedera testnet key.

cp .env.example .env
    npm install

    createdb amw
    npm run db:setup                     # every migration in db/, in order

    npm run verify                       # secrets, docs, types and 261 tests
    npm run -w @amw/contracts deploy     # and verify:contracts, in one step

The database comes first because four packages test against a real one. Without a server those suites skip rather than fail, each naming the reason — so on a machine with no PostgreSQL, read the skip count as well as the pass count.

Then the four services, each in its own shell:

npm run -w @amw/api dev              # 8788 — what the console may read
    npm run -w @amw/relay dev            # 8789 — postage, no authority
    npm run -w @amw/console dev          # 5174 — the screens
    npm run -w @amw/intent-log watch     # the indexer, following the chain
    npm run -w @amw/contracts sim        # 8545 — the preview chain, optional

The crew's credential, and a backup

The supervisory crew needs model access; nothing else on any page does. A deployment with no interactive login uses a token from claude setup-token, and the numbered variants are backups, tried in order:

CLAUDE_CODE_OAUTH_TOKEN=…      # tried first
    CLAUDE_CODE_OAUTH_TOKEN_2=…    # when the first is out of capacity
    CLAUDE_CODE_OAUTH_TOKEN_3=…
    ANTHROPIC_API_KEY=…            # billed to API credit instead

A run falls through to the next credential only for capacity or authorisation — a spent session limit, a rate limit, an empty balance, an expired login. It never falls through because the model answered and the gate refused the answer: that would spend a second budget to be told the same thing. Which slot paid for a case is logged; the token never is.

With no credential at all, every route still answers and every figure still renders — case assembly reports that this deployment has no model credential, and says so in the reader's language rather than the operator's.

When a hold has nobody watching

A held action waits for a person. The console shows that wait and escalates its wording after four hours, but a control whose trigger is somebody having a tab open is not a control at three in the morning. Point the indexer at an endpoint and every hold is announced as it is indexed:

AMW_HELD_WEBHOOK_URL=https://firm.example/hooks/amw
    AMW_HELD_WEBHOOK_SECRET=…       # shared with the receiver
    AMW_CONSOLE_URL=https://…       # so the page carries a way in

The body states the action, the guard’s own flag word and the clauses it cites, when the hold was recorded and how long it has been waiting. It carries no notional and no counterparty — those stay in the firm’s custody and are reached through the console by someone who can prove they are entitled to them, exactly as the published commitment withholds them. It states no verdict: what a hold means is decided by the human who reads it.

POST /hooks/amw
    x-amw-event: action.held
    x-amw-timestamp: 1788201600
    x-amw-signature: sha256=…       # HMAC over "timestamp.body"

    {"event":"action.held","actionId":"9","clauses":["3.4","3.7"],
     "breachFlags":"0x140","heldForSeconds":150,"transaction":"…","console":"…"}

Delivery is recorded in amw.held_pages, not fired and forgotten: the row says when the hold was first seen, when the page arrived and what it took to get there, because “the firm was notified” is a claim about the past like any other. A refused endpoint is retried further apart each time to a ceiling of fifteen minutes, and retries stop when the hold is resolved. With no endpoint configured the hold is still recorded the moment it is seen — the clock starts either way — and the indexer says on start-up that nobody is being told.

Database roles

Three roles, and the separation is the point: amw_ingest writes what the chain said, amw_worker reads everything and may only append case steps and findings, and amw_console serves screens. case_steps is append-only at the grant level — no UPDATE, no DELETE — so a bug in a screen cannot manufacture evidence.