Skip to main content
This page tests what you need in order to run Reversi for other people: who may sign in, who gets told, what evidence you can hand to an auditor, how long data is kept, where it may be sent, and where it lives. The first guide, Manual testing, covers the core product; do its section 5.1 and 5.2 first if you have never run the server. Run everything from the repository root, in bash or zsh, after npm install && npm run build. Scratch files go in try/ops/ (delete the folder to start over). Services use ports 7800 to 7849 on 127.0.0.1 only; change them if they are busy.
Every command and output below comes from a real run against this repository, in the order shown. IDs, timestamps and hashes will differ on your machine. Slack, Teams, email, PagerDuty, Opsgenie and OpenTelemetry are exercised only against fakes you start on localhost, never against the real services. The console is checked with curl (it is server-rendered HTML); open the same URLs in a browser to see them drawn. See Not covered here for the full list.
A block that starts with # Terminal A runs in its own terminal, which you leave running; every other block goes in your working terminal, called B here. Stop a terminal with Ctrl-C when the text says so.

0. Setup

You need Node 22.13 or newer, Python 3, curl, openssl and shasum. The psql client is optional (section 9). Manifests, a policy (an approval rule, secret detection and a retention: section) and one API key per role:
The keys, the environment every terminal shares, and a few helpers: rv is the CLI, j calls the API with a key, post calls a console page and prints its status and text, login signs a key in to the console and keeps the cookie in try/ops/jar-NAME. Each new terminal starts with . try/ops/.env.sh.
Expect:

1. Console modes

The console is off by default. Choose a mode with REVERSI_CONSOLE on the server, --console on the gateway, or console: { mode: … } in reversi.config.yaml:

1.1 Off

Expect: reversi-server listening on http://127.0.0.1:7800 (6 tools, files store, console off).
Expect:
What this proves: with no setting the console does not exist, while the API works.

1.2 Open: no login, this machine only, never admin

Expect … (6 tools, files store, console open). Give the console something to show: a charge over 100 waits for approval.
Expect:
Open http://localhost:7800/console in a browser too: there is no login page, and the top bar says “local-console (operator) - open console, no login”. Now abuse it. Open mode answers only requests addressed to localhost and made from the same origin:
Expect:
The open console is never admin, so the kill switch and publishing are refused even from the right origin with a valid CSRF token. (The token sits on any page with a form; the approvals page has them.)
Expect:
What it can do is what its role allows. As operator it can approve, and the ledger names it local-console:
Expect:
The server also refuses to start open on a non-loopback address, or open as admin:
Expect:
reversi console is the shortcut for your own laptop: it opens the engine on your data directory and serves the open console (default port 7421, here 7802). Its --role is auditor, approver or operator.
Expect:
Expect the terminal to print console: http://localhost:7802/console (open mode as auditor; Ctrl-C to stop).
What this proves: open mode needs no credential but refuses any request that is not plainly your own browser talking to localhost; it can never become admin; and it cannot be started where other people could reach it.

1.3 Login: paste a key, roles apply, a disabled key ends the session

Expect … console login). In a browser, open http://localhost:7800/console: it sends you to a sign-in form. Here the same with curl. Wrong keys and agent keys are refused; then sign in as the auditor, approver and admin:
Expect:
Roles differ. The auditor has no approve button and no kill-switch form; the approver can approve; only the admin has the kill switch:
Expect:
The cookie is HttpOnly; SameSite=Strict, and it follows the key. Disable the auditor’s key and the session ends at once:
Expect:
Leave Terminal A running for section 2. What this proves: login mode needs a credential with a console role (agent keys are refused), pages and buttons follow the role, a forged POST from a lower role is refused even with a valid CSRF token, and disabling a key ends its sessions without a restart.

1.4 The same switch on the gateway

The gateway serves the console on its own port, so it needs a listen: section. Build its config and manifests:
Expect:
Restart it with --console open and with --console login:
Expect:
Expect:
The gateway also refuses an unsafe open console at start:
Expect:

2. API keys: file format, console, API, rotation, expiry

A key file is a JSON list. Each entry has a name (it becomes the actor in the ledger), role or roles, and key_sha256 (the hex SHA-256 of the key; the key itself is never stored). Optional: max_permissions (agents), groups, tenant, not_before, expires_at, disabled, allow_observe. The server re-reads the file on SIGHUP or when it changes (checked every two seconds). Terminal A from section 1.3 is still running.
Expect:

2.1 Create, use, expire and remove a key over the API

POST /v1/keys is admin only. The new key is shown once:
Expect:

2.2 The same in the console Settings page

Sign in as the admin and create, disable and remove a key from Settings. The new key is shown once, on the page after the form:
Expect:

2.3 Rotation and expiry by editing the file

Rotate “carol” without downtime: add her new key beside the old one, give the old entry an expires_at, and the server follows the file. A file that does not parse is ignored and the old keys stay in force.
Expect:
What this proves: keys can be created, expired, disabled and removed from the API, the console or the file; only admins can; expired and disabled keys stop working without a restart; a broken key file never locks everyone out or in.

3. OIDC with the mock identity provider

examples/oidc/mock-idp.mjs is a throwaway OpenID provider that mints signed tokens. It is for trying this out, never for production. It listens on PORT (default 9000; here 7810).
The bearer token works on the API. The audience, expiry and role map are checked; a token with no mapped role is refused:
Expect:
API keys keep working next to OIDC (the last line is the auditor’s key). Now paste a token into the console login. The session never outlives the token:
Expect:
What this proves: OIDC bearer tokens authenticate the API and the console login, roles come from the claim map, the principal in the ledger is the token’s sub (bot-1), and a console session ends when its token does. Redirect (browser SSO) login is not built.

4. Notifications

Notifications are configured under notifications: in reversi.config.yaml (see Notifications). Three fakes stand in for the services, all on localhost. First the scripts. The fake HTTP endpoint prints one line per POST and checks the webhook signature; the fake SMTP server accepts a message and prints it decoded:
Start the fakes and the server. The example webhook sink (examples/webhook-sink.mjs) takes the older single approval webhook, REVERSI_APPROVAL_WEBHOOK, which works beside the channels:

4.1 A test message from the CLI and from the console

reversi doctor --send-test sends one message through every channel in the config, and checks each URL against the egress rules.
Expect:
In the console, Settings lists the channels and, for an admin, has a Send a test notification button:
Expect:

4.2 An approval request

Expect:
The notification names the tool, the run, the argument names (here amount) and a console link, never argument values.

4.3 Verify the webhook signature

The webhook channel signs the body with HMAC-SHA-256 and sends x-reversi-signature: sha256=<hex>. The fake endpoint saved the last signed delivery. The fake already printed signature: valid; check it yourself with the Python helper in the SDK:
Expect:

4.4 A dead channel never changes a decision

Stop the three fakes (Ctrl-C in terminals C, D and E), then ask for another approval. Because notify.yaml has log: { format: json }, the server writes its warnings as JSON lines on stderr (the logger redacts credential-looking fields and values; section 5.5 shows it):
Expect:
What this proves: the six channel types send the documented payloads (Slack blocks, a Teams card, a PagerDuty event, an Opsgenie alert, an SMTP message, a signed webhook); the signature verifies and breaks if the body or secret changes; messages carry names, never values; and a channel that is down is a logged warning, not a failed or allowed call.
Slack, Teams, SMTP, PagerDuty and Opsgenie were tested only against these local fakes, which check that a request arrives and what it says. They were not tested against the real services, so real-world details (rate limits, TLS and AUTH on a real mail relay, Slack’s response format, PagerDuty routing keys) are untested.

5. Alerts, tracing, logs and metrics

One config turns on alerts every two seconds with a denial threshold of 3, sends only alert events to the webhook, and exports traces to an OTLP endpoint. It uses its own data directory so the numbers are easy to read.

5.1 A denial spike and an unclassified tool

Expect:
The threshold of 3 fired a notification (“4 calls blocked”). The live views, GET /v1/alerts and the console Alerts page, use the same alerts.denial_spike, so they list the spike too:
Expect:

5.2 An alert that resolves

Alerts are sent once, repeated as reminders (alerts.remind, default one hour) and resolved when the condition clears. A kill switch is the simplest condition to clear:
Expect:

5.3 An unresolved step

An outcome that stays unknown for more than one hour raises “Outcome still unknown”. The hour is fixed, so this guide cannot wait for it. Leave one behind and look again later:
Expect:
The empty list is right for now (the step is a minute old). An hour later the same query lists Outcome still unknown: create_order in al-3. This part is not verified here.

5.4 OpenTelemetry traces

Spans are posted as OTLP JSON to <endpoint>/v1/traces every five seconds. Terminal D received them:
Expect:
The last number is 0: none of the argument or result values (o1, c1, B) appear in any span. Spans carry names and ids only.

5.5 Structured logs and Prometheus metrics

REVERSI_LOG=json writes one JSON line per request. Bodies and credentials are never logged:
Expect:
The library logger (createLogger, used by embedders) redacts credentials by field name and by pattern. Try it directly:
Expect:
Two logs exist. REVERSI_LOG=json writes the per-request line above (server only). Warnings from the engine itself (a failed notification, anchor or trace export) go through the log: section of reversi.config.yaml: format: json makes them redacted JSON lines on stderr, as section 4.4 showed, and format: pretty writes reversi [warn] message, and level is debug, info, warn or error. The code above is the same logger, for embedders.
What this proves: alerts are computed live and also pushed once, with a resolve when they clear; traces leave as OTLP/HTTP JSON without argument values; logs and metrics carry no credentials; /metrics needs an auditor key.

6. Evidence: ledger, bundles, reports, anchors

This section runs on the main data directory. The config adds one anchor sink, a file, and a data region label (section 8). A server start writes one anchor.
A run with a success, an approval and a charge:
Expect:

6.1 Verify the ledger and make signed bundles

Expect:

6.2 HTML and PDF run reports

Expect:
Open try/ops/report.html in a browser, and the PDF in a viewer; the PDF is a one-page summary. The console links the same files on the run page:
Expect:

6.3 The period report

With no run id, reversi report summarises a period for an auditor: what agents did, what was blocked, approvals, rollbacks. Formats are md, html or json.
Expect:

6.4 Anchoring

An anchor is a signed note of the ledger’s length and head hash, written somewhere you control. It lets you prove later that history was not cut back or rewritten: the check compares the anchored hash with the ledger’s record at that position, so a tail that was cut off or a chain that was rebuilt differently both fail. The server already wrote one when it started; write another through the API and the CLI, then verify:
Expect:
A truncated copy of the data directory still passes reversi verify (the chain is intact up to where it stops) but fails the anchors:
Expect:
A rewritten history, here a different but internally valid ledger (the one from section 5) put in the copy’s place, also passes reversi verify and fails the anchors:
Expect:
With no --file, anchor publish uses the anchor: sinks of the config (the CLI reads anchor:, notifications: and alerts: from the project file). Sign it with --key:
Expect (the record count and head depend on your run):
What this proves: the ledger verifies end to end; a signed bundle verifies against a key you trust and fails if one character changes or the wrong key is used; reports exist as HTML and PDF (a real %PDF file) and are linked in the console; reports show names and notes, never argument values; anchors catch a truncated or rewritten history that a chain check alone accepts. Retention deletes the working data around a run (run state, approval requests and captured arguments). The ledger is never deleted; a purged record is appended instead. The policy’s retention: section sets default_days (at least 1) and optional rules by tool, tenant, environment or data class. A manifest can raise a run’s retention with audit.retention_days and can require an evidence bundle first with audit.evidence_export: required (our charge_card does). We work on a copy so the main data survives.
Expect:
Everything is new, so nothing is due. To test without waiting a month, override the policy with --days 0, which makes every run due:
Expect:
A legal hold blocks deletion. Hold c-1, then do a dry run, a real run without an archive, and a real run with one:
Expect:
ev-1 uses charge_card, whose manifest requires an evidence bundle, so it was kept in the dry run (no archive directory given) and deleted only once a directory and key were. With them, every deleted run gets a signed bundle, not only the ones that require it. The held run c-1 survived and says why. Release the hold and apply again:
Expect:
The same is available on the server: GET /v1/retention/plan, POST /v1/holds, POST /v1/retention/apply (admin), and the console Settings page with Dry run and Apply buttons. Against the main data (nothing is due under the 30 day policy):
Expect:
What this proves: retention follows the policy and manifests; a hold wins over retention and names the reason; required evidence is exported and signed before deletion (and deletion is refused without it); deleted runs leave a purged ledger record and the chain still verifies.

8. Egress and data residency

Every outbound call Reversi makes (notifications, anchor webhooks, trace export, remote manifests, upstreams) passes the egress rules in reversi.config.yaml. allow lists host patterns; unset means any host. block_private: true refuses loopback, private and link-local addresses (off by default so local development works). Cloud metadata addresses such as 169.254.169.254 are always refused. region is only a label: it appears in refusal messages, in signed anchors (section 6.4) and in the PDF report (section 6.2).
reversi doctor checks every configured destination without sending anything:
Expect:
Now for real, from a running server. The fake endpoint is on 127.0.0.1 (listed); the same fake reached as localhost is not in allow, and the metadata address is refused even though it is listed:
Expect:
What this proves: an allow list stops data going to hosts you did not name, block_private stops it going to internal addresses, metadata endpoints are never reachable, and the refusal is a warning that does not affect any decision. The same guard covers anchor webhooks, OTLP export, remote manifest URLs and gateway upstream URLs; only notifications are exercised here.

9. The three stores, with the same scenario

The same four calls against each store: open a run, create an order, ask for a charge over 100 (blocked for approval), and try a tool out of scope. Then a second server on the same store approves the charge and the first server lets it through, which shows that the ledger and approvals are shared.

9.1 Files (the default)

Expect:

9.2 SQLite

SQLite needs Node 22.13 or newer and prints an experimental-feature warning.
Expect:

9.3 PostgreSQL

Use a throwaway database. The server creates its own tables (ledger, runs, approvals, kv, reversi_migrations) in the default schema on first start. This guide used a local PostgreSQL 15 instance at a unix socket (the project’s test database); any URL works.
Expect:
With psql you can read the tables (reading only):
Expect:
What this proves: the same behaviour on all three stores: policy, approvals and rollback state do not depend on the store; the ledger head is identical from two processes; a second process can decide an approval the first one asked for. The append-only triggers on SQLite and PostgreSQL (UPDATE and DELETE on the ledger are rejected) are described in Storage; this guide does not try to delete ledger rows.

10. Deployment artifacts

Docker, Compose and Helm were not run. What can be checked without a Docker daemon or a cluster:
Expect:
On the machine this guide was written on, the Docker daemon was not running, so the image was not built or run. A build would also download a base image and npm packages from the internet, which this guide does not do. Parse the YAML files at least (this is not a render, a lint or a dry run):
Expect:
All five files parse as plain YAML. That is all this checks: the Helm templates ({{ }}) cannot be parsed as plain YAML and were not rendered, and helm was not available. Treat the chart as unverified until you have run helm lint and helm template.
The compose file requires POSTGRES_PASSWORD (${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}), publishes only on 127.0.0.1 and turns on the login console. To try the stack yourself, follow the comments at the top of deploy/docker-compose.yml and the Deployment page. The workflows in .github/workflows/ run on GitHub and were not executed here.

Not covered here

  • Real third-party services. Slack (including interactive Approve and Deny buttons), Teams, PagerDuty, Opsgenie, a real SMTP relay (STARTTLS, AUTH, implicit TLS), and a real OpenTelemetry collector. Only localhost fakes were used. The anchor webhook sink, remote manifest URLs and gateway upstream URLs through the egress guard were not run either.
  • Alerts that need time or a failure: an unresolved step (needs more than an hour), approval timeout and escalation, a failed compensation, an unhealthy gateway upstream.
  • Browser sign-in with SSO redirects (not built: the console takes a pasted key or token). The console was exercised with curl; nothing was clicked in a browser.
  • TLS and REVERSI_SECURE_COOKIES=1, which need HTTPS in front.
  • Docker image build and run, Docker Compose, Helm (lint, template, install), the GitHub workflows.
  • PostgreSQL over the network, with TLS, or with a restricted role; append-only triggers were not provoked.
  • A Terraform provider and a Kubernetes operator. They are not built; use reversi apply and the Helm chart.

Cleanup

PostgreSQL tables from section 9 stay in your throwaway database; drop that database yourself when you are done.