Charon docs · Full Run: End to End
Full Run: Charon + Lethe Memory Git
This document walks one complete run of the system that was built: from a cold
machine to deployed services, generated credentials, three agent roles, a
reviewed merge into protected shared memory, and the operational drills
(restart, rotation, backup) that prove the guarantees.
It is also the release smoke-test script. Every phase ends with an
expected result — if any phase deviates, stop and investigate before
continuing.
What was built
- Lethe (git mode) — the canonical, versioned memory store. Semantic
changesets, refs with compare-and-swap, protected refs, context projection
with manifests, conflict records. Every changeset is validated per operation
(
memory_git/v1 contract) before it becomes immutable history.
- Charon — the MCP authorization and governance gateway in front of Lethe.
Principals with scoped grants, owned refs, proposals, independent review,
protected merges authorized by an expiring single-use signed envelope
(
memory-git-merge/v2), fail-closed audit ledger, OAuth (S256 PKCE) and
Obol credentials.
The guarantees the run below exercises:
- exactly-once writes (idempotency-keyed changesets, digest-bound replays);
- no malformed operation ever enters immutable history (validation at Charon
and again at Lethe);
- no principal reviews or merges its own proposal;
- protected refs move only through a signed, expiring, single-use,
proposal-digest-bound authorization — replays are rejected even if the ref
cycles back to the same head;
- every mutation is ledgered; consumed merge nonces survive restarts;
- coordinated backup and verified restore of both databases.
Topology and ports
| Service |
Port |
Auth |
Exposure |
| OpenLethe (legacy, optional) |
18483 |
LETHE_API_KEY bearer |
loopback, pre-existing |
| Charon primary MCP gateway |
18484 |
OAuth only (S256 PKCE) |
loopback, or HTTPS via tunnel |
| Lethe Git |
18485 |
LETHE_API_KEY bearer |
loopback only, never public |
| Charon reviewer MCP gateway |
18486 |
Obol only |
loopback only |
Both Charon gateways share one policy database (charon.db in the
charon-data named volume): principals, grants, proposals, findings, Obol
digests, idempotency records, and the audit ledger. Lethe Git owns changesets,
refs, accepted memory, manifests, and conflicts in its own SQLite database on
a host bind mount.
Roles
Three roles cover everything an agent (or operator) does with this system.
The role skills under skills/ are the per-role playbooks; this document is
the shared narrative.
| Role |
Principal profile |
Scopes |
Connects via |
| Maintainer (operator) |
— (holds deployment secrets, no memory principal needed) |
— |
host shell, Docker, Charon CLI |
| Proposer (author) |
propose |
memory.read, memory.search, memory.branch, memory.commit, memory.propose |
Obol on 18486, or OAuth on 18484 (capped) |
| Reviewer |
review |
memory.read, memory.search, memory.review, memory.merge, proposal.review |
Obol on 18486 |
A fourth profile, readonly (reader), gets memory.read, memory.search,
thread.read and is provisioned by the maintainer for consumers that must
never write.
Separation of duties is enforced in code, not by convention: a proposal
originator cannot review (denied: self review) or merge (denied: self
approval) its own proposal, wildcard project grants are read-only, and
refs/shared/* rejects direct commits from everyone.
Credentials: what exists, who makes it, what a restart does
The operator generates credentials for both services at bootstrap. Nothing
else in the system can mint authority.
| Credential |
Generated by |
Lives in |
Survives restart? |
LETHE_API_KEY |
scripts/prepare-local-memory-git-env.sh (or ./lethe keygen, openssl rand -hex 32) |
Lethe .env.git (mode 0600) and Charon .env (same value) |
Yes — env-supplied at every start; never stored in any DB |
CHARON_MERGE_HMAC_KEY |
same script |
Lethe .env.git and Charon .env (must be identical) |
Yes — rotate on both services together |
CHARON_OBOL_HMAC_KEY |
setup.sh (or openssl rand -hex 32) |
Charon .env |
Yes — rotating it invalidates all Obols |
CHARON_OAUTH_HMAC_KEY |
setup.sh |
Charon .env |
Yes — rotating it invalidates all OAuth access tokens |
| OAuth pairing key ("authorization key") |
Charon itself at every start (CHARON_OAUTH_GENERATE_PAIRING_SECRET=true, the default) |
printed once in a boxed startup banner (docker compose logs charon) |
No — regenerated on every restart. Old pairing keys die immediately; already-issued access tokens remain valid |
| OAuth authorization code |
Charon, after the operator pastes the pairing key into the browser page |
in-memory only, 10-minute TTL, single-use |
No — dies on restart |
| OAuth access token |
POST /oauth/token after PKCE exchange |
client side; 24h HS256 JWT |
Yes, while CHARON_OAUTH_HMAC_KEY is unchanged |
Obol (obol_<id>_<secret>) |
charon obol mint <principal> |
mint output → mode-0600 file; only an HMAC digest is stored in charon.db |
Yes — until expiry, revocation, or Obol-key rotation |
So: a container restart gives you a new browser pairing key by design — and
only that. API keys, HMAC keys, Obols, and OAuth access tokens all persist.
You never need to regenerate the deployment after a restart; you only need the
new pairing key the next time a browser OAuth flow is run.
Phase 0 — Prerequisites
- Docker with Compose v2,
openssl, and both repositories checked out side by
side (lethe/ and charon/).
- Nothing from a previous experiment may reuse these data directories. Never
point Lethe Git at the OpenLethe data directory.
Phase 1 — Start Lethe Git (maintainer)
In the lethe repository:
sh scripts/prepare-local-memory-git-env.sh # writes .env.git (mode 0600); refuses to overwrite
docker compose -f docker-compose.git.yml --env-file .env.git up -d
The script generates LETHE_API_KEY and CHARON_MERGE_HMAC_KEY
(openssl rand -hex 32 each) without printing them. The compose stack binds
127.0.0.1:18485 and fails closed if either value is missing. The data
directory must be owned by UID 1000 (the container user); Lethe refuses to
run against a group/other-accessible database directory.
Verify (a 401 proves the HTTP stack is up and the key is enforced):
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18485/api/health
# expect: 401
set -a; . ./.env.git; set +a
curl -s -H "Authorization: Bearer $LETHE_API_KEY" http://127.0.0.1:18485/api/health
# expect: {"status":"ok"} (or equivalent 200)
Expected result: container lethe-git-local healthy; unauthenticated
request rejected; keyed request succeeds.
Phase 2 — Bootstrap Charon (maintainer)
In the charon repository, with the Lethe values present in the
environment:
set -a; . ../lethe/.env.git; set +a # provides LETHE_API_KEY + CHARON_MERGE_HMAC_KEY
export CHARON_OAUTH_REDIRECT_URIS="https://chatgpt.com/connector/oauth/*" # required in OAuth mode
./setup.sh
setup.sh (no secret value is ever printed):
- writes a mode-0600
.env with freshly generated CHARON_OBOL_HMAC_KEY,
CHARON_OAUTH_HMAC_KEY, and a pairing-secret placeholder, carrying over
LETHE_API_KEY and CHARON_MERGE_HMAC_KEY from the environment;
- builds the hardened image (non-root UID 10001,
cap_drop: ALL, read-only
root filesystem);
- reconciles three principals for the project —
Local Memory Author
(propose), Local Memory Reviewer (review), Local Memory Reader
(readonly) — and records their IDs in secrets/principal-ids.txt;
- sets
CHARON_OAUTH_DEFAULT_USER to the author principal;
- mints 7-day Obols into mode-0600 files under
secrets/;
- launches both gateways via
start.sh.
Verify:
docker compose ps # both services healthy
curl -s http://127.0.0.1:18484/livez # primary (OAuth) gateway
curl -s http://127.0.0.1:18486/livez # reviewer (Obol) gateway
docker compose logs charon 2>&1 | grep -i -A6 'authorization' # the current pairing-key banner
Expected result: two healthy gateways; .env and secrets/ all mode
0600; a current pairing key visible in the primary gateway's startup banner.
Minting working local credentials (important)
Obols are audience-bound: a token minted against one gateway's public URL
is rejected by another listener. The default primary gateway speaks OAuth
only, so local agents (author, reviewer, reader) authenticate through the
Obol gateway on 18486 — and their Obols must be minted through that
gateway:
set -a; . secrets/principal-ids.txt; set +a
docker compose exec charon-reviewer charon obol mint --expires 7d "$AUTHOR_ID" > secrets/author-obol.txt
docker compose exec charon-reviewer charon obol mint --expires 7d "$REVIEWER_ID" > secrets/reviewer-obol.txt
docker compose exec charon-reviewer charon obol mint --expires 7d "$READER_ID" > secrets/reader-obol.txt
chmod 600 secrets/*-obol.txt
Phase 3 — Connect the agents
Local MCP clients (OpenClaw example; any streamable-http MCP client works).
Extract tokens without leaving them in shell history:
AUTHOR_TOKEN=$(grep -oE 'Token: obol_[A-Za-z0-9_-]+' secrets/author-obol.txt | sed 's/Token: //')
REVIEWER_TOKEN=$(grep -oE 'Token: obol_[A-Za-z0-9_-]+' secrets/reviewer-obol.txt | sed 's/Token: //')
openclaw mcp add charon-author \
--url http://127.0.0.1:18486/mcp \
--transport streamable-http \
--header "Authorization=Bearer ${AUTHOR_TOKEN}"
openclaw mcp add charon-reviewer \
--url http://127.0.0.1:18486/mcp \
--transport streamable-http \
--header "Authorization=Bearer ${REVIEWER_TOKEN}"
openclaw mcp reload
openclaw mcp probe charon-author
openclaw mcp probe charon-reviewer
Remote browser clients (ChatGPT connector) instead point at the primary
gateway through the HTTPS tunnel and complete the S256 PKCE flow, pasting the
current pairing key from the startup banner. Their tokens are automatically
capped to memory.read, memory.search, thread.read, and memory.propose
— no branch, commit, review, merge, or write — because
CHARON_OAUTH_ALLOW_AUTHOR_SCOPES=false by default.
Expected result: both probes list the thirteen Memory Git tools; no client
has a direct route to Lethe Git.
Phase 4 — Propose memory (proposer)
As the author principal, through charon-author:
memory_repo_init({"project":"<project>"}) — once per project; creates the
synthetic root and protected refs/shared/main.
memory_context_at({"project":"<project>","ref":"refs/shared/main",...}) —
orient on accepted memory.
memory_branch_create — one owned ref, e.g.
refs/agents/<actor>/main or refs/topics/<topic>.
memory_changeset_create — one CAS commit with sequential ordinals, a
unique idempotency_key, and semantic operations (add_memory,
correct_memory, attach_evidence, …). Payloads are closed-key validated
at Charon and again at Lethe before anything becomes immutable.
memory_merge_propose — propose the owned head into refs/shared/main.
Retry the identical request with the same idempotency key after a network
failure — you get the original changeset back, never a duplicate. Change any
field and the same key fails closed instead of silently returning stale work.
Expected result: changeset committed on the owned ref (verify with
memory_show); proposal pending; refs/shared/main unchanged.
Phase 5 — Review and merge (reviewer)
As the reviewer principal, through charon-reviewer:
- Discover pending proposals (maintainer shell:
docker compose exec charon charon proposal ls) and resolve exact state.
- Inspect with
memory_show, memory_log, and memory_diff against the
exact changeset IDs — never a branch name.
- Apply the review criteria (durable content, no credentials or private
reasoning, valid ordinals, no blocking conflicts, not stale).
memory_merge_review with approve / request_changes / reject. The
verdict is bound to the exact proposal snapshot: if the proposal changes,
the approval no longer applies.
memory_merge — Charon re-validates that the approval is current, computes
the strategy (fast-forward / two-parent / cherry-pick), signs a
memory-git-merge/v2 envelope (2-minute TTL, single-use nonce, bound to
project, ref, heads, proposal digest, reviewer, and merger), and Lethe
independently verifies it and consumes the nonce atomically with the CAS.
- Verify: proposal completed,
refs/shared/main advanced to the returned
changeset, memory_context_at serves the new accepted memory, and
docker compose exec charon charon ledger tail shows the review and merge.
Negative checks worth running once: the author calling memory_merge_review
on its own proposal is denied (self review); calling memory_merge is
denied (self approval); replaying a captured merge authorization is
rejected by Lethe's consumed-nonce table.
Expected result: protected ref advanced via a reviewed merge; both
databases and the ledger agree.
Phase 6 — Read accepted memory (reader / any role)
memory_context_at on refs/shared/main from any principal with read
grants — including an OAuth-capped ChatGPT connector — returns exactly the
accepted state at the resolved head. Optional manifest pinning requires
exact-project commit/write authority.
Expected result: readers see the merged memory; the author's unmerged
branch work is invisible on refs/shared/main.
Phase 7 — Restart persistence (maintainer)
docker compose -f docker-compose.git.yml --env-file .env.git restart # in the lethe repo
docker compose restart # in the charon repo
Then confirm:
refs/shared/main head, changesets, proposals, and ledger are intact;
- author and reviewer Obols still authenticate (digests persisted);
- a previously consumed merge nonce is still rejected (replay protection
survives restarts);
- the OAuth pairing key in the startup banner changed — this is expected
and is the only credential that rotates on restart.
Expected result: full state persistence; new pairing key; nothing else
regenerated.
Phase 8 — Rotation drill (maintainer)
./scripts/rotate-tokens.sh
Revokes every active author/reviewer/reader Obol and mints fresh 7-day tokens
into secrets/ (values never printed). Old tokens are rejected immediately.
Key-level rotation semantics (Obol key, OAuth key, merge key — which must
rotate on both services together) are in docs/operations.md.
Expected result: old Obol rejected with 401; new Obol works; MCP clients
re-registered with the new token.
Phase 9 — Backup and restore drill (maintainer)
Coordinated snapshot of both databases with the CLI (both database files must
be reachable by the one charon process that runs it):
charon backup --charon-db <path-to-charon.db> --lethe-db <path-to-lethe.db> --out <backup-dir>
In the Compose deployment charon.db lives inside a named volume, so use the
zero-downtime runbook instead: sqlite3 .backup against the host-mounted
Lethe database, and docker cp the Charon database out after stopping both
gateways — exact commands in docs/local-memory-reviewer.md. Validate before
trusting a restore:
docker compose exec charon charon ledger verify
charon reconcile --charon-data <restored-charon-data-dir> --lethe-db <restored-lethe.db>
CHARON_RECOVERY_READONLY=1 keeps a restored instance inspectable until
reconciliation passes.
Expected result: integrity checks pass; restore procedure documented and
rehearsed before it is needed.
Where to go next
docs/operations.md — runbook: health, rotation, backup/restore, incidents.
docs/observability.md — metrics, SLOs, alert thresholds.
docs/local-memory-reviewer.md — concrete reviewer deployment example.
skills/charon-maintainer/SKILL.md — operator playbook.
skills/charon-proposer/SKILL.md — author playbook.
skills/charon-reviewer/SKILL.md — reviewer playbook.
skills/charon/SKILL.md — deployment-neutral tool reference for agents.
- Lethe repository:
docs/memory-git-v1.md (protocol), docs/local-memory-git.md
(boundary rules), docs/memory-context-bridge.md (projection).