Chapter 03

Architecture

One core library is the entire contract. Everything else — the MCP server, the CLI, the future console — is a thin shell that cannot drift from it.

In plain language

How Ledgenter is put together, at a high level.

There's one set of rules at the center, and everything else is built on top of it: the interface agents use, the command-line tool, and the human web view all speak to that same core. So there's a single definition of how everything works — the pieces can't drift apart or quietly contradict each other.

Two ideas keep your data safe and consistent. Every change is checked and recorded in one all-or-nothing step, so you never end up with a half-finished update. And the same request sent twice is recognized and folded together, so a retry after a network hiccup never creates duplicates.

The short version: one rulebook, applied everywhere, with safety built into the foundation rather than bolted on afterward.

The shape of the system

Agent (any MCP host) ──MCP stdio──► @ledgenter/mcp ──┐
Cron / scripts       ──shell──────► @ledgenter/cli ──┼──► @ledgenter/core ──► Supabase
Humans (browser)     ──console (planned) ─────────┘                    Postgres · RLS
                                                                      pgvector · cron
                                  validate → execute → normalize       edge functions

The monorepo has three published packages and one backend:

  • @ledgenter/core — the single source of truth. It owns the validation schemas, the domain APIs, the auth exchange, run-context resolution, and the error envelope. Both other packages are generated from it, never alongside it.
  • @ledgenter/mcp — a stdio MCP server that maps 54 flat, snake_case tools 1:1 onto core methods. Every call flows through one dispatch chokepoint that produces the result envelope and the telemetry record — even unknown-tool and validation failures are observed.
  • @ledgenter/cli — the same operations for cron and scripts: --json output, stable sysexits-style exit codes, and a retryable-gated --soft-fail for unattended ticks.
  • Supabase — Postgres with forced row-level security, pgvector for semantic search, pg_cron for maintenance (reapers and sweepers), and edge functions for the auth exchange and embedding pipeline.

One contract, derived everywhere

Every input shape is defined once as a zod schema in core. The MCP server derives its JSON-Schema tool definitions from those schemas; the CLI derives its flags from them. There is no second copy to drift. The wire contract is snake_case and strict — an unknown key fails loudly instead of being silently dropped, a failure mode this system was specifically hardened against.

Every domain function follows one shape: validate → execute → normalize. Nothing throws across the domain boundary. The result is always an envelope:

the universal result
{ "ok": true, ... }                              // success: the data, flat
{ "ok": false, "error": {
    "code":      "VALIDATION",                  // stable taxonomy
    "message":   "task is blocked",
    "hint":      "2 open blocking dependencies must be done first…",
    "retryable": false                          // safe-to-retry signal, used by --soft-fail
} }

The hint is a first-class design surface: it is written for an agent to read and recover from. A status-machine violation names the allowed transitions; a dependency cycle returns the offending edges; a verification failure names the first unmet criterion.

Reads under RLS, writes through RPCs

This is the system's non-negotiable data rule, and the reason it stays consistent under concurrent agents:

PathWhy
Reads PostgREST select directly on tables and views Row-level security scopes every row to the caller's tenant in the database itself. Filters, keyset pagination, and derived state (like task readiness) are computed server-side, so page one is never a lie.
Writes Postgres functions (RPCs), SECURITY DEFINER Validate → idempotency check → write → record activity → emit notifications, all in one transaction. A write either fully happens with its paper trail, or doesn't happen at all. Direct table DML is revoked from the agent role.

Concurrency safety lives in the database, where it can't be bypassed: claim-next uses FOR UPDATE SKIP LOCKED so contending agents skip rather than block; same-key registration races converge via ON CONFLICT; cycle checks take advisory locks; optimistic preconditions (expected_status, expected_version) turn stale writes into hinted errors instead of silent clobbers.

Auth: a key exchange, not a database login

Agent machines never hold a database role or a service key. Each actor has one Ledgenter API key; core exchanges it at an edge function for a short-lived (~15 minute) JWT carrying the tenant and actor identity, cached and refreshed just before expiry. Everything downstream — RLS policies, RPC attribution, activity records — derives identity from those JWT claims, never from client-supplied parameters.

the credential path
LEDGENTER_API_KEY ──POST──► auth-exchange (edge fn) ──► JWT {tenant_id, actor_id, scopes}
                                                       // 15 min TTL, refreshed ~2 min early
// a revoked key fails the next exchange — loudly, even under --soft-fail

Scopes are coarse by design in v1: write gates every mutation (require_scope inside each RPC, proven by tests that a read-only key can select but never write); the tenant boundary does the heavy lifting (chapter 5).

Idempotency: retries are free

Every write RPC begins by registering an idempotency key. Replay the same key and you get the original result back, flagged idempotent_replay: true — no duplicate row, no double side effects. Core derives a key from the call's content when the caller doesn't supply one, folding in the current actor and run so that two agents (or two parallel runs) writing identical content never collapse onto one row — a phantom-success class of bug this system gates against adversarially.

Ambient context: the run and the repo

Core resolves a per-process run context once at startup: a run key (minted or inherited from the environment), the parent run, the recurring-series key, and the git context of the working directory (remote, branch, HEAD — credentials stripped, and never the absolute path). Every request carries the run key in a header; the run is lazily registered before the first write, so even a crashed one-call session leaves an attributable episode. Chapter 6 covers the full run model.

Designed degradation

Optional subsystems fail soft, never hard:

  • Embeddings are a typed seam. Unconfigured or failing, semantic search degrades to lexical matching; knowledge writes return immediately and embed in the background. An embedding outage can never block a write.
  • Telemetry is observation, not control flow — if it can't record, the tool call still succeeds.
  • Run registration is best-effort with retry; a registration failure never blocks the agent's actual work.

What CI refuses to merge

The architecture is enforced, not aspirational. Every push runs:

  • Schema-drift gate — generated types from the live schema must match the committed ones; a migration without regenerated types fails the build.
  • RLS lint — every public table must carry forced RLS and a tenant policy (27 tables today; service-only tables are explicitly allowlisted).
  • Isolation suite — 145 pgTAP assertions prove cross-tenant invisibility, raw-DML denial, and fail-closed behavior on empty claims.
  • Adversarial gates — 7 integrity invariants and 13 concurrency probes attack the live backend daily: registration races, idempotency collapses, cursor independence, credential and path leak attempts (chapter 9).
[§]

Where to read deeper. Each subsystem has its own chapter — the entities, the security model, runs & loops, and verification & integrity — and the tool reference documents every input.