The ArgonDB developer guide

ArgonDB v1.5.7.21 — The ArgonDB developer guide — cut v1.5.7.21 at a1ddf5f153868d1cbec935fc6a15ea80234f5370

The ArgonDB developer guide

GENERATED — never hand-edit. Regenerated at the release cut only (make regen-at-cut, [D-2026-09-26-6bea57]); the stamp above names the cut and the source commit. The drift gate (python3 scripts/generate-developer-guide.py --check, a make lint-nocargo leg) proves this file byte-identical to the guide of that STAMPED commit, naming any chapter that differs; make lint-at-cut proves it against HEAD at the cut. The seven chapters below are assembled, byte for byte apart from heading demotion and in-document anchors, from docs/2026-09-20-guide-00-… through -04-…, -07-… and docs/2026-10-04-guide-08-… — those files stay the cited sources (path:line citations elsewhere in the repo point into them), so edit a chapter there, never here.

Table of contents


Developer guide 0 — the themes that run through ArgonDB

Register: internal technical reference, written against ArgonDB v1.5.6. Every claim below carries a path:line into this repository, a section number (§4.0) into one of the guides that follow, or a ruling id [D-...] into the decision registry; nothing here was run against a box by this chapter. Naming: "sidecar" names only a tenant's own application; the process that turns the write log into the lake is the tail (docs/programs.md:106-112).

The five guides that follow this one each go deep on one region of ArgonDB: one database per tenant, and the wall, the per-tenant unit's gate, wake and stand-down, the lake and its process model, continuous materialized views, and agents and the MCP endpoint. This chapter does not summarize them one at a time — each already summarizes itself, in its own opening section. What follows instead is the six ideas that keep reappearing across those five regions: read together, they are what makes ArgonDB one mechanism rather than five, and they are the shape of what an application developer actually builds and experiences on top of it.

Each theme below has the same four parts: a plain-language paragraph first, then what the five guides say about it, then what it means for how you build an application, then what you experience running one — the commands, the things you stop having to do, and the refusals you will meet along the way.


1. One database per tenant, on its own timeline

In plain terms: every customer gets their own database, not a shared table with a customer-ID column. It's the difference between everyone keeping their money in one drawer with a label on each bill, versus everyone getting their own separate drawer. A bug in the label-reading code can empty the wrong drawer; a bug that reads the wrong drawer only ever reads an empty one, because the money was never in there.

Across the chapters. Guide 01 states the resulting invariant directly: "There is no query in this system that could return another company's row, because another company's rows are not in the database being queried" (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §2.1). That single-database-per-tenant topology was amended twice in September — first to give each tenant its own compute that sleeps and wakes on connect [D-2026-09-20-0d5ba3], then to give each tenant its own Postgres instance, own WAL and own timeline, with only the storage plane still shared (DESIGN.md:391 [D-2026-09-21-4107c5]) — and an operator-only switch, [compute] per_tenant, controls whether a given box actually turns the per-tenant compute on; it ships off, and no proof or gate flips it [D-2026-09-23-5122a6]. As of v1.5.6 this is the shape the product ships on one box, not a design: a tenancy (one multi-tenant system in the Estate — the CLI's word for it is system) and the tenants inside it are created by two provisioning commands (argondb system create, argondb tenant create; crates/argondb-ctl/src/main.rs:1059-1082, :610-640), and with per_tenant on, a company's compute is started at tenant create on a port recorded then, so a new company runs on its own compute from its first minute. Turning it on under companies that already hold data is not yet a complete move: the data half of provisioning — carrying an existing company's roles and rows onto its own branch — is not built, and DESIGN.md says so rather than implying otherwise (DESIGN.md:387). Guide 02 is the lifecycle mechanism this topology runs on: a unit is one compute plus at most one sidecar, sized so it is exactly what can be destroyed and recreated with no loss (docs/2026-09-20-guide-02-the-per-tenant-unit-gate-wake-and-stand-down.md §1, §2.4) — a stood-down unit measures 137 MB and about 0.6 s to wake (docs/2026-09-14-argondb-process-model-preliminary-design.md:114-119). The sidecar is the company's own application: one process per company under that company's own uid and cgroup [D-2026-09-21-7c4769], started only if the company was created with --sidecar-command — "a company with no application is a complete tenant" (crates/argondb-ctl/src/main.rs:640-647). Guide 03's tail is what makes the topology affordable: past one box it is a single process multiplexed across every tenant's timeline, never co-resident with one tenant's own instance, with a per-stream memory budget so the Nth tenant cannot starve the first (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §2.12 [D-2026-09-21-25c7c6]; §3.5 [D-2026-09-23-d17cbd]).

Building on ArgonDB. You never design multi-tenancy into your own schema. There is no tenant-ID column to remember on every table and no WHERE clause standing between a bug and a cross-customer leak — a company's rows live in a database that other companies' queries cannot reach at all. What ArgonDB does keep uniform across tenants is its own per-company schema, and in v1.5.6 it keeps it uniform across upgrades too: argondb estate migrate [--to <version>] walks every company in the estate directory, reads each one's version from that company's own argon.schema_version, skips a company already at the target, and brings the rest up with the same versioned apply tenant create uses (crates/argondb-ctl/src/estate_migrate.rs:1-50, crates/argondb-ctl/src/main.rs:563-575). Be precise about what that schema is: today it is ArgonDB's own identity table and its one write door (UNIFORM_SCHEMA_VERSION, crates/argondb-ctl/src/tenant_create.rs:1717-1748) — it does not migrate your application tables, which stay yours to version. The connection pooler, for its part, is optional: the box uses a platform PgBouncer when a supported one (1.25.2 or newer) is on PATH and serves every tenant on the direct port when it is not (DESIGN.md:389 [D-2026-09-24-f0e068]).

What you experience. Creating a tenancy is one command, argondb system create <system_key>, and so is creating a company inside it — argondb tenant create acme --display-name "Acme" --legal-name "Acme Holdings LLC" --system distributors --identity "tax_id=12-3456789" — which runs the timeline, compute, database, login role and grants, uniform schema, wall pass, identity record and (if you named one) the sidecar in a fixed order, is safe to re-run if it is interrupted, and finishes by minting the company's own MCP token (Theme 6) (crates/argondb-ctl/src/tenant_create.rs:206-215, :2955-2975; docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §4.0 [D-2026-09-21-e781f5]). system create now publishes the new system's Identities to every other registered system in the same call, creating the Identities namespace itself when nothing has been published into it yet; it used to defer that grant and leave it to be run by hand later (crates/argondb-ctl/src/system_create.rs:1499-1510). After an upgrade, argondb estate migrate is the one command that brings every existing company to the new build's schema version; one company it cannot migrate is named as REFUSED, the pass carries on for the others, and the command exits non-zero naming how many were refused (crates/argondb-ctl/src/main.rs:4133-4148); the pass is a command you run, not something the box does on its own when a new build is deployed (crates/argondb-ctl/src/estate_migrate.rs:19-32). You never write idle-detection logic yourself: bytes at the gate are the only signal a unit uses to decide it is still awake, and the whole control is one config key, [compute] idle_stop (docs/2026-09-20-guide-02-the-per-tenant-unit-gate-wake-and-stand-down.md §2.1). The command REFUSES to adopt a database it did not create itself, rather than silently taking it over (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §4.0), and a client that connects while a tenant is mid-wake gets Postgres's own 57P03 cannot_connect_now — a signal every driver already knows to retry (docs/2026-09-20-guide-02-the-per-tenant-unit-gate-wake-and-stand-down.md §2.1, §3.6). A missing or too-old PgBouncer is never a boot refusal in any profile: the box starts on the direct port and says so, on the banner and in argondb status, with a pooler: NOT RUNNING line naming what it found and the minimum it wants; the pooled port, when a pooler runs, is the direct port plus 200 (5632 at the defaults), and [pooler] enabled = "off" runs none (crates/argondb-ctl/src/pooler.rs:79, :813-832). The release installer writes [pooler] enabled = "on" into the box's <data>/argon.toml only when the key is absent, and on, auto and an unset key are one code path (scripts/release/install.sh:98-106). The one pooler setting that still refuses the boot is a misspelled enabled value (crates/argondb-ctl/src/pooler.rs:221-240).


2. The wall — the thing every surface is measured against

In plain terms: imagine every tenant's data behind its own locked door, with its own key. A feature doesn't get to say "trust me, I checked the badge" — it has to actually not have a key to the other doors. That's the difference between a rule a programmer could forget and a wall a programmer cannot walk through by accident.

Across the chapters. Guide 01 rules the wall as three parts together, never a choice among them — catalog authorization, login, and a warehouse plus bucket plus credential scoped per tenant database (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §1, §2.6 [D-2026-09-11-21a6d7]) — and states plainly that the wall is "not a filter inside a query" but a different database, a different place in storage, a different credential (deploy/publish/architecture-site/content.md:374-381). One predicate, wall::decide, gates every object-store operation (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §3.4), and the same discipline reappears wherever a boundary is checked: Guide 04 evaluates a view's database with the identical predicate the pin uses, and refuses a cross-database join by name rather than letting the query planner narrow it away with a WHERE clause (docs/2026-09-20-guide-04-continuous-materialized-views.md §2.13, §3.4); the lake's read plane refuses a read of another database's table the same way, by name, unless that database has granted it (Theme 5; cross_database_refusal, crates/argondb-tail/src/lib.rs:967-983). Row-level security is explicitly a different, narrower mechanism: it stays inside one tenant's database and separates roles, not companies, so a defective policy is an over-permissive grant inside one company rather than a cross-customer breach; and a table's lake copy carries no row policies, so the read plane refuses a scan of a protected table's lake copy rather than serving it (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §2.9). Guide 01 states the one limit on all of this plainly: the box operator stands inside every wall, and a tenant never does (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §2.7, §6). It is equally direct about the storage plane under the databases: every company is a timeline of the box's one pageserver tenant, the pageserver refuses a caller with no token, and each compute's token names its one timeline, so the pageserver refuses a company's valid token on any other company's timeline — built [D-2026-09-23-b822c8] and asserted by the W5± cases of testing/box/cross-company-isolation-eight-surfaces.sh. With the default [compute] per_tenant = false, one compute serves every company's database and holds the box's own timeline, so the storage token is not what separates companies there; the connect wall and the password rule are (§2.6).

Building on ArgonDB. You do not write an authorization predicate to keep tenants apart — there is nothing to get wrong, because there is no code path that reaches across in the first place. The corollary is the design constraint that matters most when you build a feature that legitimately needs two parties' data in view together: it cannot be a query that spans the wall on its own. It has to be a component that stands with a foot on each side and makes an explicit, recorded copy — which is exactly what Theme 5 (consent and grants) exists to do, and Theme 4 (views) is how you keep that copy usable once it has landed.

What you experience. Nothing to configure — the wall holds without a setting once tenant warehousing is on by default, and the catalog authorization engine it needs (OpenFGA) ships inside the release artifact on both Linux and macOS, so a multi-database box never meets a boot refusal for a missing engine and there is nothing for you to install (scripts/release/build-artifact.sh:593-611, scripts/release/build-artifact-macos.sh:519-523; docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §2.6). You probe the wall, you don't build it: argondb lake grant, argondb lake attach-clause and argondb lake revoke hand out and take back a reader's token (crates/argondb-ctl/src/main.rs:1235-1275; docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §4.3); a revoke removes the catalog permission, and a token already issued still logs in until it expires but can no longer read anything (crates/argondb-ctl/src/main.rs:1246-1251). The refusals are specific and are part of the interface, not an edge case: a bucket access across the boundary answers 403 AccessDenied, naming the key and both buckets; a catalog warehouse you cannot see answers 404, never 403, and does not disclose its name at all (docs/2026-09-20-guide-01-one-database-per-tenant-and-the-wall.md §3.4, §3.5, §4.6); a view joining tables from two databases refuses cross-database-join, naming the remedy instead of a bare error (docs/2026-09-20-guide-04-continuous-materialized-views.md §3.4).


3. The lake as the shared read side, the tail as what feeds it

In plain terms: every change you make in the transactional database shows up, seconds later, in a second read-only copy built for big questions — without you writing a sync job. It's like a photocopier standing next to your filing cabinet that copies every page the moment you file it, so someone else can flip through the copies without ever touching your originals.

Across the chapters. The thesis is stated once, in one sentence: "every Postgres table is automatically an Iceberg table, at a known LSN, with sub-second freshness and zero impact on transactional performance" (DESIGN.md:36). Guide 03 is the mechanism: the tail decodes the write log once — "the single decode path" — and the ring, the changelog, the lake copy and every reader all feed from that one decode, which is what makes "the lake cannot drift" a structural fact rather than a hope (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §2.3). A read at position L is Iceberg@W ∪ tail(W, L] — the durable copy up to the watermark W, plus the tail's own decoded changes above it (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §1, §2.9). Guide 02 explains why the tail can sit resident beside a stood down tenant without waking it: with the compute asleep there is no new WAL to decode, so the tail idles at baseline (docs/2026-09-20-guide-02-the-per-tenant-unit-gate-wake-and-stand-down.md §2.3). Guide 04's view maintainer is a second consumer of the exact same watermark — it advances a view's answer only when the tail's own freshness action reports the watermark moved, never on a timer of its own (docs/2026-09-20-guide-04-continuous-materialized-views.md §2.2). The changelog the tail writes beside a published table is not optional bookkeeping — it is the same decode path, so an audit trail read from it is complete by construction rather than by discipline (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §2.6). And the lake has exactly one writer per territory, which v1.5.6 now enforces per table as well as per namespace: the committer alone writes the mirrored br_<branch> tables, ArgonDB's lake writer alone writes the derived dv_<branch> namespace (continuous views, lake-landed transforms and appends), and inside dv_<branch> each table has exactly one writer — a view's table is written only by the view maintainer, and the view maintainer never writes a table that is not a view (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §2.7; table_writer_refusal, crates/argondb-committer/src/lake_writer.rs:2310).

Building on ArgonDB. A reporting feature is not a project with its own ETL job, its own schema mapping and its own reconciliation step. It is a SELECT over a copy that is guaranteed structurally consistent with the transactional table, because both descend from one decode of one write log. The design question that actually matters is which tables to publish and at what freshness tolerance you can afford to read them, not how to keep a second system in sync with the first. A result you compute from the lake — a transform or a view — can land straight back into it, in dv_<branch>, with no Postgres transaction and no --pg-url (argondb transform run --landing lake, crates/argondb-ctl/src/main.rs:2051-2066); pick a name no other door already owns.

What you experience. Publishing a table is one command with no connector to configure: argondb lake publish <table> sets an argon:publish marker, backfills once at a pinned position, then follows incrementally (DESIGN.md:406; the same command run, docs/2026-09-20-guide-03-the-lake-and-its-process-model.md Example 1). You never write a CDC connector, define a Kafka topic, or run a reconciliation job. argondb lake list shows you exactly where each table stands — lake @ 0/1BD2748, backfill pending, or QUARANTINED @ ... — and freshness is a number you read, ingest_lag_seconds, never a promise you take on faith (crates/argondb-ctl/src/lake.rs:300-318; ingest_lag_seconds, crates/argondb-tail/src/metrics.rs:1557). The one refusal worth knowing in advance: a column type the decoder cannot handle is accepted and stored durably but never arrives in the lake, silently, with no SQL error — which is exactly why a publish_status check exists as a separate, explicit thing to run (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §4.7). The second: landing into a dv_* table another door owns — a lake-landed transform onto a view's table, say — refuses not-owner and writes nothing: "every dv_* table has exactly ONE writer; nothing was written. Land under another name, or remove {target} through the door that made it" (crates/argondb-committer/src/lake_writer.rs:1477-1480).


4. Views as a statement of what should stay current

In plain terms: instead of writing a script that re-runs a big report every night and hoping it finishes before someone asks for it, you write the report's definition once — "this is the number I want" — and the system keeps that number accurate for you, forever, updating only the parts that actually changed. You stop being the one who has to remember to press refresh.

Across the chapters. Guide 04 is the primary mechanism: a continuous materialized view keeps its answer as of a coordinate L_v and advances it by applying the change rows in (L_v, L_1] rather than re-running the query, and a reader gets the exact current answer or a named older one with its distance stated, never a silent guess (docs/2026-09-20-guide-04-continuous-materialized-views.md §1, §3.10). The definition surface is deliberately a function call, argon.create_view(name, sql, options), not a native Postgres MATERIALIZED VIEW — because Postgres owning the refresh is exactly the manual-refresh problem this exists to remove (docs/2026-09-20-guide-04-continuous-materialized-views.md §3.1). A classifier decides, from the query alone, which of four maintenance strategies applies — a keyed projection, an algebraic aggregate, a join, or (when none of those hold) a full recompute — and it over-approximates toward the safe, expensive option rather than the reverse (docs/2026-09-20-guide-04-continuous-materialized-views.md §2.6-§2.9, §3.5). The second mechanism, a transform, is the one-shot sibling of a continuous view — a SELECT computed once at a pin and landed with a mode (create, append, replace, drop), rather than kept current forever (argondb transform run, crates/argondb-ctl/src/main.rs:2044-2066; the same run over MCP, docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §4.5). Because it is pinned, re-running it at the same coordinate gives the same output, where a hand-written batch that buckets against now() silently rewrites history when re-run after the fact. Both land in one of two places: the derived Postgres schema, reaching the lake through the tail, or the lake-owned dv_<branch> namespace directly; a view lands in dv_<branch> by default (docs/2026-09-20-guide-04-continuous-materialized-views.md §2.10, §2.11). The two compose directly: Guide 04's own worked example is a continuous view created over a table landed into a tenant's database from another party (docs/2026-09-20-guide-04-continuous-materialized-views.md §4.9) — Theme 5 and Theme 4 meeting in one example.

Building on ArgonDB. Per output, you choose one of three postures: an ordinary application table your own code writes; a transform, a pinned, reproducible one-shot SELECT for something tied to a business event like a period close; or a continuous view, a SELECT stated once that the system keeps current on its own. The fork that matters is whether the coordinate itself is the meaningful artifact (a transform, pinned at month-end) or the answer must never be allowed to go stale between requests (a view). Neither requires you to reason about incremental maintenance by hand — the classifier does, and it never guesses wrong in the unsafe direction.

What you experience. argon.create_view('name', 'SELECT ...', options) from SQL, argondb view create --name ... --sql ... from the CLI, or the create_view tool over MCP — the same body, three doors (docs/2026-09-20-guide-04-continuous-materialized-views.md §3.2). You never write a refresh cron job, never decide by hand whether a deleted row needs its group total decremented. Sixteen refusal kinds are closed and specific — cross-database-join, non-deterministic (a view over now() or random() is refused even for a recompute-class view), self-cycle, and others — each naming the offending construct and a remedy (ViewRefusalKind::ALL, crates/argondb-query/src/view_classify.rs:486; docs/2026-09-20-guide-04-continuous-materialized-views.md §3.4). A reader who wants an exact answer gets it or a stated refusal to the base data; the default staleness tolerance is zero (docs/2026-09-20-guide-04-continuous-materialized-views.md §3.10). A view's lake table belongs to the view: a lake-landed transform or append aimed at it refuses not-owner and writes nothing, so nothing but the maintainer can move a view's answer (Theme 3). Guide 04 keeps its own list of what is still red on this program, and names it rather than rounding it off (docs/2026-09-20-guide-04-continuous-materialized-views.md §9).


In plain terms: a company's data never leaves its own room by an automatic back door. If another company is allowed to see part of it, that permission is written down — as a row in a table, in the room it came from — so anyone can later see exactly what was allowed, when, and to whom, and can take it back.

Across the chapters. Consent is a row that lives in the database of the party whose data crosses, written with that party's own ordinary Postgres privileges — never a config file and never an operator setting — and published, so the consent record reaches the lake beside the rows it governs. The general form, new since v1.5.5, is the mount grant: a database that wants another database to read one of its lake tables writes hub.mount_grant (grantee_database, source_schema, source_table), one row per table, with no wildcard, and a later CREATE TABLE is not shared by it (MOUNT_GRANT_DDL, crates/argondb-transform/src/hub_consent.rs:298-313). The lake's read plane then serves that one table to that one grantee out of the owner's warehouse, and refuses every other cross-database read by name (DatabaseScope, crates/argondb-tail/src/lib.rs:960-973). The state is not a boolean: grant_state() returns one of four facts — granted, no grant, revoked (with the instant), or granted under a contract version this build does not serve — because "not granted" and "revoked" and "granted under an unserved version" are different things for a reader of the log to know (GrantState, crates/argondb-transform/src/hub_consent.rs:104-121). The same predicate decides the older, flow-shaped grant table, hub.share_grant, which scheduled landings (argondb publication land|serve, argondb hub run) consult before they move rows from one party's database into another's (crates/argondb-transform/src/hub_consent.rs:40-48; crates/argondb-ctl/src/main.rs:2365-2400). The component that moves the data holds two identities without ever mixing them — it reads every tenant under one cross-tenant, read-only identity and writes as the box, and there is no flag that lets it read as the box on a walled database (crates/argondb-ctl/src/hub.rs:69-86). Guide 07 shows the same doctrine one layer up, at the level of what an agent is allowed to do rather than what data it can see: the admin mount's destructive calls require an explicit confirm=true and refuse, naming what would be lost, without it — "an agent proposes; the operator confirms" (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §2.3, §4.6; crates/argondb-mcp/src/admin.rs:17-20).

Building on ArgonDB. You never design an outbox table, a webhook, or a second replication path to move data between parties — that door is closed by design, not left to you to build carefully. Instead you decide what crosses: which table, to which database — and the owning party writes a consent row, in its own database, using its own ordinary write privileges. Because consent is data, it travels through the tail like any other row, so it is queryable, auditable and versioned the same way any other business fact is, not a side channel nobody can see.

What you experience. INSERT INTO hub.mount_grant (grantee_database, source_schema, source_table) VALUES (...) in the owner's database to grant a read; UPDATE hub.mount_grant SET revoked_at = now() ... to revoke it. A read of another database's table with no grant refuses cross-database lake read refused, naming the owner, the asker and the exact INSERT that would grant it; a read after a revoke refuses with a different sentence that says when the grant was revoked (cross_database_refusal, revoked_mount_refusal, crates/argondb-tail/src/lib.rs:924-960). Two limits, stated directly: a revoke takes effect within one schema-refresh cycle of the read plane, not at the instant of the UPDATE, so a read in between can still succeed (crates/argondb-tail/src/lib.rs:942-960); and revocation is forward-only — rows already landed by a flow grant stay where they landed. Pulling them back is a separate, deliberately destructive operator command, argondb hub recall, and it refuses while the grant is still in force, naming the UPDATE the granting party runs first (crates/argondb-ctl/src/hub.rs:1440-1458). A flow grant made at a contract version the running build does not serve refuses by name, ContractVersionUnserved, rather than landing nothing silently: the grantor did consent, just to something else (crates/argondb-transform/src/hub_consent.rs:115-120). argondb hub grants prints the current state of every flow grant and always exits 0 — it reports consent, it does not judge it (crates/argondb-ctl/src/main.rs:2554-2576).


6. Agents read through the same doors as people

In plain terms: an AI agent connecting to ArgonDB does not get a special side entrance. It gets handed a key to one specific door — the same kind of key you would hand a human analyst, a dashboard, or a batch job — and which door decides everything it can and cannot do from then on.

Across the chapters. Guide 07 is the primary mechanism: the MCP surface is ArgonDB's API, split into four mounts by trust tier — analyst, dba, admin, write — each with its own bearer token, its own port, its own bind default and its own backing Postgres role (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §1). "An operator composes an agent's capability by choosing which mounts it connects, so the risk analysis happens at connect time rather than by curating a tool list" (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §1). On the analyst mount, read-only is architectural, not a parser's opinion: the Postgres role is the boundary, and a write attempt through it fails with Postgres's own 42501 insufficient_privilege, the same error a human would get with the same grant (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §2.1, §4.9). v1.5.6 carries that same principle down to the company: every company now has its own MCP token for the analyst mount, and a request carrying it runs as that company's own read role, <key>_mcp, which can connect to that company's database and no other, and holds no write privilege at all (crates/argondb-mcp/src/company_token.rs:194-203; crates/argondb-ctl/src/mcp_company_token.rs:45-83). The box token is no longer the only credential an agent can be handed; it stays the operator's, with operator scope, by name (crates/argondb-mcp/src/lib.rs:6484-6490). Guide 04's rewrite rule makes no exception for who is asking: it is an optimizer rule installed by pin(), so the FDW scan on an analyst's compute, the MCP query tool, the CLI's own query path, and a transform runner's compute all inherit the identical view-rewrite treatment (docs/2026-09-20-guide-04-continuous-materialized-views.md §2.5). And Guide 03's lake-facing MCP mount is stated to be "always" reading through the same query plane as everything else, never a side channel of its own (docs/2026-09-20-guide-03-the-lake-and-its-process-model.md §2.11).

Building on ArgonDB. You never build a separate, elevated access path for AI. You compose an agent's entire blast radius by choosing which mount its process connects to — the same lever an operator already pulls to scope a human analyst, a read replica, or a batch job. An agent given the analyst mount is bound by exactly the tenant wall (Theme 2), the exact pin discipline (Theme 3), and the exact view-rewrite machinery (Theme 4) that bind a person using the same mount; there is no seventh theme reserved for "what agents get to skip." When the agent works for one company, hand it that company's token rather than the box's: its reach is then one database by construction, not by a prompt's good behavior.

What you experience. One claude mcp add per mount, each its own URL and token; a session runs schema_overview → pin → query (against that pin) → changes(since=<lsn>) to learn what has moved since — the same pin_id carried across the whole burst (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §7.2). You never write a SQL parser to keep an agent read-only — the grant system already does the whole job. A company's token is minted by argondb tenant create and written, never printed, to a 0600 file, <data>/tenants/<key>/mcp-token; argondb tenant mcp-token <key> re-issues it (the old one stops working) and argondb tenant mcp-token <key> --revoke revokes every token of that company and sets <key>_mcp to NOLOGIN (crates/argondb-ctl/src/tenant_create.rs:2079-2081, :2955-2975; crates/argondb-ctl/src/main.rs:677-686; crates/argondb-ctl/src/mcp_company_token.rs:90-94). The token is checked on every request against the live record, so a re-issue or revoke takes effect with no restart, and every response names the scope it ran under in an x-argondb-mcp-scope header, operator or company:<key> (crates/argondb-mcp/src/company_token.rs:53-56, :265-270). The limits, directly: a company token works on the analyst mount only — the dba, admin and write mounts keep the one operator token (crates/argondb-mcp/src/lib.rs:6240-6248) — and the two analyst tools that read box-wide state, search and publish_status, refuse under it by name, pointing at the operator (crates/argondb-mcp/src/lib.rs:4611-4621). A wrong bearer token on any mount refuses with 401 before any tool body runs — a forged, revoked or superseded company token each with its own sentence (crates/argondb-mcp/src/company_token.rs:102-124) — and a non-loopback bind on the admin or write mount with no token configured refuses to start the process at all, rather than starting it open (docs/2026-09-20-guide-07-agents-and-the-mcp-endpoint.md §3.1).


How the six fit together

   one write log
        |
        v
   two shapes of one truth  --------->  the lake (theme 3)
   (Postgres table, Iceberg table)           |
        |                                    v
        v                              views state currency (theme 4)
   one database per tenant (theme 1)         |
        |                                    v
        v                              consent moves a copy
   the wall (theme 2)  <------------------- across it (theme 5)
        |
        v
   every reader goes through it the same way --- people and agents alike (theme 6)

Every theme above is a different vantage point onto the same handful of mechanisms: one write log, one wall, one pin, one classifier, one shape of consent row. An application built on ArgonDB is not five separate integrations — it is one mechanism, read from five directions, and this chapter is the map between them. Guides 01, 02, 03, 04 and 07 are where each direction goes deep; start with whichever theme above matches the feature in front of you.


Guide 1 — one database per tenant, and the wall between tenants

Audience: a senior developer who has to USE this. It explains the mechanism, states its limits and its failure modes, and gives worked examples. Every claim carries its source by path:line; a claim without one is a defect. Register: internal technical reference.

How to read the examples. Each one is labeled VERIFIED (run, with its real output pasted) or FROM SOURCE (the exact signature, flag, message or table quoted from the code or a design by path:line). This lane's box had no vendored PostgreSQL install and no built storage tier, so no box gate could run here; every example below is FROM SOURCE except where it says otherwise, and each names the gate that proves it on a box. The path:line coordinates are at commit c9415ae0a, except in §4.0 and §4.9-§4.12 and the rows and gotchas that cite them, which were re-read for v1.5.6 at commit e90da8c0c.

Labels the pictures use, and this guide uses with them. A solid outline is built and shipped. A dashed outline is designed and measured but not built. OPEN marks a decision still to be made. Where a source says UNCERTAIN or NOT MEASURED, this guide repeats it rather than rounding it off.


1. The diagram

One database per tenant, and the wall between tenants

The left panel is one PostgreSQL cluster on one box. A tenant is a DATABASE in it — Block 4 B [D-2026-09-11-1fc4b3], since amended twice over to B′ and then B′ (amended) [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5] — see §2.1 for the current wording — and the three columns are three member databases with the same shape: each has its own tables, its own SQL credential and its own lake key, its own place in the lake's address space, and its own published set. Two of the columns are distributors and one is a manufacturer, and the picture deliberately draws them identically: they are the same kind of thing, told apart by WHICH SCHEMA they publish in, not by a flag (crates/argondb-query/src/all_tenants.rs:91-112). Along the bottom of that panel are the three facts a box diagram cannot show: the provisioner runs a pass every 30 seconds and gives every new database its whole wall within one pass; the primary database is never a tenant and is never limited; and row-level security separates ROLES inside a database, never companies (docs/2026-09-04-erp-spec.md:328-351).

The right panel is the wall itself — Block 5, ruled D: A + B + C [D-2026-09-11-21a6d7], which means all three parts and not a choice among them. A is catalog authorization, so the catalog refuses to list or read a warehouse a caller is not entitled to and does not even disclose its name. B is a login in front of that catalog, because authorization with no authenticated subject authorizes nobody and the box would not be able to bootstrap its own catalog (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:611-622, :790-794). C is a warehouse, a bucket and a credential per tenant, which is the only one of the three whose boundary is also a STORAGE boundary — the measurement behind that is that an Iceberg namespace has no storage prefix of its own, so there is nothing below the warehouse for a key to be scoped to (:168-189). Below those three sit the five principals and what each holds, and the bottom band is the one way rows cross: the box reads every tenant under one read-only identity, makes a COPY into the receiving party's own area, and the receiving party reads that copy under its own key. No query reaches across.


2. What it is

2.1 One PostgreSQL cluster, and a tenant is a database in it

The topology is a ruling, not an implementation detail: hundreds of tenant databases share one compute, one write-ahead log, one lake arm and one catalog (docs/2026-09-04-erp-spec.md:89-100). What that boundary buys is stated exactly at docs/2026-09-04-erp-spec.md:135-142: "There is no query in this system that could return another company's row, because another company's rows are not in the database being queried." That is a property of the topology, not of every predicate in every code path.

The topology ruling has been amended twice since, and the amended wording is the current standing rule — not what a box runs by default. [D-2026-09-20-0d5ba3] ratified "B′": each tenant database is served by its OWN compute that stops when idle and wakes on connect, rather than sharing the box's one compute. [D-2026-09-21-4107c5] amended B′ again, the same evening: a tenant is a database that is its own Postgres instance on its OWN TIMELINE with its own WAL; "one cluster" now names the SHARED STORAGE PLANE — one pageserver, one safekeeper and one lake serving every tenant's timeline — not one shared write-ahead log. So of the four things this section's first paragraph names as shared, two no longer are: compute and the write-ahead log are ruled PER TENANT database. That is the standing topology; it is ratified and built, default off (§5; crates/argondb-ctl/src/tenant_compute.rs:105, HISTORY#per-tenant-compute-table), and the [compute] per_tenant switch that turns it on is the operator's own setting, never a system default [D-2026-09-23-5122a6]. Everything else in this chapter — the worked examples in §4 included — describes the box with that switch off, which is what ships today.

Two coordinates the picture could not carry. First, what counts as a database is one WHERE clause, spelled once: datallowconn AND NOT datistemplate (crates/argondb-ctl/src/lake_tenant.rs:787), and the tenant wall, the estate provisioner and publish discovery all use it, so the three cannot disagree about what a database is (crates/argondb-ctl/src/transform_estate.rs:59-63). Second, the primary database — the one --source-url names, postgres on a box — is not a tenant: it keeps the box warehouse argon and the box credential, it is never given a connection limit, and it is excluded from every tenant principal by the same predicate that refuses to grant on it (crates/argondb-ctl/src/lake_grant.rs:50-68).

Formerly OPEN, now ruled and built behind an operator's switch — the picture still draws it open. Whether hundreds of tenants keep sharing one compute or each tenant unit gets its own is no longer undecided: B′ (amended) rules that each tenant gets its own compute on its own timeline [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5]. What stays a live question is WHEN a given box turns that on — and that is the operator's call, never the system's [D-2026-09-23-5122a6]. Deep dive 2 covers the per-tenant unit's gate, wake and stand-down.

2.2 The member database, and what makes one a manufacturer

Each column of the left panel is a member database. A distributor's ERP tables live in its own schemas (public, and whatever the application uses). A manufacturer's published tables live in mfr. The hub's control tables live in hub, and what the hub lands back lands in derived. That is the whole distinction: all_tenants.<schema>__<table> unions every member database's copy of a name, so without a schema rule a manufacturer holding public.sales_order_lines would be read as a distributor by every flow (crates/argondb-query/src/all_tenants.rs:91-102).

The rule has a sharp edge worth stating at the start. The mfr schema is ESTATE: the per-database provisioning pass creates it in EVERY member database, distributor and manufacturer alike, because there is one provisioner path and no manufacturer-shaped branch in it (crates/argondb-ctl/src/transform_estate.rs:28-34). So holding the schema makes nothing a manufacturer; PUBLISHING in it does, and the product asks that question of the copies a pin composed rather than of a policy list that could disagree with the data (crates/argondb-query/src/all_tenants.rs:104-111).

2.3 Its role and its key — one SQL credential, one lake key, 20 connections

The "20 connections (fixed limit)" in the picture is DEFAULT_CONNECTION_LIMIT = 20 (crates/argondb-ctl/src/lake_tenant.rs:596), and it is a ruling: every tenant database gets the SAME number, the operator raises it per tenant, and nothing re-fits it as tenants come and go — the even-share alternative ((max_connections - reserved) / tenants) was considered and not chosen (crates/argondb-ctl/src/lake_tenant.rs:119-125). It is PostgreSQL's own ALTER DATABASE … CONNECTION LIMIT, so its semantics are PostgreSQL's: superusers are exempt, and the count is of connections to THAT DATABASE, not of the role (:136-141).

The lake key is argon-tenant-<bucket>, minted by the provisioner into lake-store-credentials.json under the data directory. The access key is readable on purpose and is not a secret — it appears in the store's refusal lines and in a SigV4 credential scope, and an opaque one would make every refusal a lookup (docs/reserved-identifiers.md:6732).

2.4 Its identity in the lake — database.schema.table

A published table's address has FOUR parts — branch, database, schema, table — and the database is always present, the box's own postgres included (docs/lake-foreign-read.md:430-439). SQL gives a reader three name parts, so the first three are written as one quoted identifier: lake."br_main.shop.sales".orders. That is not a style choice: without the quotes the engine sees four name parts and stops at the parser with Parser Error: NameListToString NOT IMPLEMENTED, which reads like a broken query rather than a quoting mistake (docs/lake-foreign-read.md:449-456, and the measurement it comes from is docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:220-226).

2.5 Its published set — a table comment, and the default is nothing

Publication is opt-in per relation and the marker is a table comment: COMMENT ON TABLE sales.orders IS 'argon:publish' (docs/upc-hub-recipe.md:470-478). Nothing is published by default. The consequence for this guide is the ordering one: a database that exists and has published nothing still gets its whole wall on the next provisioner pass, because provisioning is keyed on the DATABASE and not on the first publish (crates/argondb-ctl/src/lake_tenant.rs:22-32).

2.6 The wall's three parts, and which of them is on today

part what it enforces state today
A — catalog authorization (OpenFGA behind the Iceberg catalog) the catalog refuses list/read on a warehouse the caller is not entitled to, and answers 404 rather than 403 so the NAME is not disclosed built; DEFAULT is openfga on a box holding more than one database, off on a single-database box, explicit always wins (docs/2026-09-15-cross-tenant-read-design.md:502-504)
B — a login in front of the catalog every catalog request carries a verified subject; the box holds the signing key and serves the two documents itself, so no identity provider is needed built 2026-09-11 under Block 9 [D-2026-09-11-b5b145] (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:846-854)
C — a warehouse, a bucket and a credential per tenant a key opens one bucket, so the STORE enforces the wall even for a reader that bypasses the catalog entirely and speaks s3:// built; warehouse_per_database = true (crates/argondb-lake-store/src/wall.rs:4-10)

A and B are ONE landing, not two phases, and the reason is upstream's own sentence: "Authorization is only effective if Authentication is enabled" (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:616). With AUTHZ_BACKEND=openfga and no authentication the catalog is not a wall with a door — it is a locked door with no key cut, and the measurement says so exactly: anonymous bootstrap 401, anonymous warehouse creation 403, anonymous listing 403 (:780-794).

Both platforms bundle it, named rather than implied: the release artifact stages bin/openfga on Linux — scripts/release/build-artifact.sh refuses the build if its license text is missing (:243-246) and then stages the binary itself (:584-591) — and on macOS — scripts/release/build-artifact-macos.sh runs the same license gate (:223-226) and stages the binary through the shared scripts/release/lib/openfga-bundle.sh leg (:519-523) against the openfga-darwin-arm64 content pin. So neither platform's stock multi-database box meets a boot refusal for a missing engine (docs/2026-09-15-cross-tenant-read-design.md:513-516).

A fourth surface, not one of these three: the storage plane underneath them. Parts A–C wall the LAKE. The OLTP side has its own wall, and on 2026-09-23 the isolation gate measured a real gap in it: the box's pageserver ran with no credential check at all, so a credential-less process on loopback read any company's timeline under the box's one pageserver tenant. The box now signs a NeonJWT for every compute, the tail and its own management calls, and the pageserver refuses a caller carrying no token or the wrong one — closing that credential-less read. Every company is still a TIMELINE of the box's ONE pageserver tenant, so the token also names a timeline: the pageserver token carries an ARGON: timeline_id claim (storage/pageserver/src/auth.rs), compute_token(tenant, timeline) mints each compute's token for its one timeline (crates/argondb-ctl/src/pageserver_auth.rs, called at crates/argondb-ctl/src/serve.rs:13567), and the pageserver refuses that token on any other company's timeline — HTTP 403 on the pageserver's HTTP port and a JWT authentication error on the page service (basebackup and pagestream) — and logs pageserver auth: timeline mismatch. This is built [D-2026-09-23-b822c8], merged 2026-09-24 (b2743bdc9). The W5− and W5+ cases of testing/box/cross-company-isolation-eight-surfaces.sh assert it: A's compute token is refused on B's timeline on both ports, and the same token reads A's own timeline. Two limits, stated plainly. With [compute] per_tenant off, the default, one compute serves every company's database and its token names the box's own timeline, so what separates companies there is the connect wall and the password rule, not the storage token. And the box's own processes — the tail and the management client — hold a pageserverapi token with no timeline, which reaches every timeline; no company is ever given it. The pageserver must be built with this change: an older pageserver treats the new token as tenant-wide.

2.7 The five principals

Full detail, including which operation each may perform on which bucket, is sub-diagram 1b below (§7.2). In one line each:

What never exists (the red box in the picture): a principal spanning two tenants with write, and a crossing that is not a copy the box made.

2.8 all_tenants.<schema>__<table> — the reader's view

Under the cross-tenant reader, every same-named table across tenant databases is served as ONE view: the union of every tenant's copy plus a tenant column naming the tenant DATABASE (never the warehouse name — the two differ under a [lake.warehouse_overrides]) (docs/reserved-identifiers.md:39). It is a view, not a copy: each query expands to one scan per tenant of that tenant's own Iceberg files, the WHERE is pushed into every scan, and branches run in parallel.

Two properties the picture had room only to name. There is no tenant list anywhere — the roster IS the estate, re-derived on every provisioner pass from the same predicate the writer resolves a database to its warehouse with, and a config key naming tenants was explicitly withdrawn (docs/2026-09-15-cross-tenant-read-design.md:207-218). And an unwalled database is NAMED absent, never silently included or silently dropped: the roster carries it as absent: <db> — <reason> and every session banner prints it (:232-238).

2.9 Row-level security, and where it stops

RLS stays inside a database and separates roles, not companies (docs/2026-09-04-erp-spec.md:328-334). Its blast radius changed with the topology: a defective policy predicate used to be a cross-customer breach and is now an over-permissive grant inside one company (:344-351).

The residual the picture draws is real and current. A table's LAKE COPY has no row policies — PostgreSQL will not let a policy be attached to a foreign table at all, and Iceberg has no row-policy concept (docs/lake-foreign-read.md:644-649). Since 2026-09-12 the READ PLANE refuses a scan of a protected table's lake copy rather than serving it, naming the table, the role and the remedy (:651-661) — but that wall is on the Flight door only. A reader holding the catalog URL and the store credentials reads every row of every published table, row security or not (:670-680). Block 6 ruled that the Postgres role will travel into the read plane [D-2026-09-11-c64ea4]; it has not traveled into the hub's in-process read yet, and the picture lists that as one of its two residuals.

2.10 What is box-granular, and what is per tenant

The granularities below are what a box with [compute] per_tenant OFF gives you — today's shipped default. The ratified target topology moves the first three rows to PER TENANT once an operator turns per-tenant compute on [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5] [D-2026-09-23-5122a6].

capability granularity why
OLTP point-in-time recovery the box one storage timeline per box
a rehearsal or upgrade branch the box a child timeline forks the whole cluster
lake freshness the box with the switch off every tenant is on the primary's one timeline, so one tenant's write volume gates every other's watermark
a D12 pin per scope a scope is a table set, and a tenant is a database's tables
lake time travel per table a retained snapshot plus T__changes replay
the tenant wall (bytes; and names under the catalog login) per database v1.5.1

(docs/2026-09-15-cross-tenant-read-design.md:585-600.) So "a tenant cannot be rewound on its own" is true of the OLTP side and false of the lake side: a scoped pin reads one tenant at any LSN in the retention window today, without touching the timeline. Per-tenant timelines need a separate Postgres instance per tenant, because PostgreSQL has no per-database write-ahead log to branch (:576-583) — which is exactly what the topology ruling has since made the standing design, and it is built, default off: each tenant database is its own Postgres instance on its own timeline, sharing only the storage plane [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5], under [compute] per_tenant (crates/argondb-ctl/src/tenant_compute.rs:105), with the tail following every tenant timeline as its own stream (crates/argondb-tail/src/streams.rs; HISTORY#tail-multiplexes-tenant-timelines). The switch is the operator's, never a system default [D-2026-09-23-5122a6]; with it off, every row above still holds.


3. How it works

3.1 One provisioning pass, start to finish

One provisioning pass, and the table that arrives before its wall

Who runs a pass. Two callers, one function. The box's cadence loop runs ensure_all every DEFAULT_INTERVAL_SECS = 30 (crates/argondb-ctl/src/lake_tenant.rs:155, :2303), and argondb lake tenant provision runs the same function on demand (:2575). It matters that it is one function: on 2026-09-16 a hand provision run raced the box's own cadence pass over the same 72 new databases and produced 409s on the reader's catalog grants and a non-zero CLI exit while the box's pass finished the estate (:157-170).

The pass lock. Every cluster pass now holds one SESSION-LEVEL PostgreSQL advisory lock on the primary for its whole duration (crates/argondb-ctl/src/lake_tenant.rs:1458-1466). The key is i64::from_be_bytes(b"argon_tw") = 7021788497314804855 — the eight ASCII bytes read big-endian, so the number is derivable by hand and is not a magic literal (:172-176; registered at docs/reserved-identifiers.md:6734). Three properties follow from choosing a session lock: a dead holder frees it without a reaper, pg_locks joined to pg_stat_activity names the holder so a waiter can say who it is waiting for, and the primary is the one coordinate both parties already reach. A waiter waits PASS_LOCK_WAIT_DEFAULT_SECS = 900 by default (:183), overridable with ARGONDB_TENANT_WALL_LOCK_WAIT_SECS (:179), and refuses on the deadline naming the holder and stating that nothing was provisioned. Fifteen minutes is not arbitrary: an 80-tenant pass under the wall was measured in the minutes on a loaded box (:180-182).

The order, per principal, and why it is this order (crates/argondb-ctl/src/lake_tenant.rs:34-50):

  1. The credential is minted and written FIRST. The store watches that file and creates a bucket directory for every bucket it names, so the credential is also the bucket's birth certificate — one owner of the store's layout.
  2. The box creates the bucket directory itself too, on this process's clock, so step 3 does not race the store's reload. Both paths are create_dir_all and idempotent.
  3. Only then the warehouse, because Lakekeeper VALIDATES a storage profile by writing and reading a probe object in the bucket — into a bucket that does not exist, that validation fails.
  4. Success is asserted from POSITIVE EVIDENCE — the warehouse is read back out of GET /management/v1/warehouse — never from the POST falling through, which a re-provision answers 400 to.

The warehouse profile carries the ROOT credential, not the tenant's (:70-78). Lakekeeper holds the storage credential for the whole warehouse and uses it for its own probe writes and maintenance; a profile carrying a bucket-scoped key would work today and break the moment Lakekeeper needed to touch anything else. The tenant key is for the tenant's READER — DuckDB, Trino, a raw s3:// client — which is exactly the reader part C exists to wall, because the catalog vends an ENDPOINT and never key material.

Every row is a statement, and a refusal is per database. Outcome has four arms and nothing falls through silently: AlreadyProvisioned, Provisioned, BoxWarehouse, Refused(reason) (crates/argondb-ctl/src/lake_tenant.rs:425-435). One unprovisionable tenant never stops the others — that is written into the function's error contract (:1451-1457) and gated at both polarities by testing/box/tenant-wall-warehouse.sh P9, which puts two legal databases and one illegal name (zz_bad_name) into one pass and asserts two walled, one named with its remedies, and a non-zero exit for the named one only (testing/box/tenant-wall-warehouse.sh:58-61).

Two principal kinds, one function. ensure_principals (crates/argondb-ctl/src/lake_tenant.rs:2199) is what a database and a manufacturer group both go through; ensure_all is it over the cluster's databases and hub::provision is it over the ownership table's manufacturers (:52-62). The credential file's database field carries the principal's STORE KEY — a database's own name, a manufacturer's prefixed one — so the two kinds cannot collide in it, and WarehouseMap::resolve_estate refuses an estate in which they would (:64-69).

3.2 The two cluster-wide halves of the same pass

Both are keyed on DATABASES rather than on principals, which is why they live in ensure_all_locked and not in ensure_principals — the hub also calls ensure_principals over manufacturer groups, which have no pg_database row to limit (crates/argondb-ctl/src/lake_tenant.rs:1480-1484).

The OLTP half — ensure_connection_limits (:830). The decision is pure and unit-testable without a PostgreSQL (plan_connection_limit, :755), and it is three rules in order (:749-753):

  1. an explicit override is applied EXACTLY, whatever is there;
  2. the default is applied only over NO_LIMIT_SET (-1, PostgreSQL's own sentinel, :600);
  3. everything else is kept as found — a limit an operator set by hand is never lowered, and never raised either.

Three rungs resolve the wanted value, top wins: --connection-limit <db>=<n>, then [lake.tenant_connection_limit].<db> in argon.toml, then the default (ConnectionLimits::resolve, :651). An override naming a database that is not in the cluster is REPORTED rather than silently ignored (overrides_naming_no_database, :661-678). A value outside PostgreSQL's domain is refused by name at the boundary rather than handed to ALTER DATABASE to refuse in its own words (validate_connection_limit, :687), and the argument parser splits on the LAST = so a database name holding = still parses (parse_connection_limit_override, :706). LimitOutcome has four arms — Set, Overridden, Kept, Failed — and again nothing falls through (:730-744).

The reader half — ensure_reader (:1693), run AFTER the tenants are walled, so a tenant this pass minted is in the roster this pass writes (:1485-1491). Its roster is reader_plan (:1537), which asks WarehouseMap::resolve_estate_partitioned — the same predicate the provisioner walls with, called rather than re-stated — and keeps every database resolving to a NON-box warehouse, in database order (:1541-1554). Every database the resolver refused is carried with its reason (:1555-1562). The store half is one ReaderCredential in the same credentials file at version 2, its scope rewritten whenever the roster moves, so the key FOLLOWS tenants with no restart and no re-mint (the store re-reads the file by inode). The catalog half is one select assignment per tenant warehouse, DIFFED against the granted record the previous pass left in the file: a new tenant is granted on the pass that walls it, a dropped tenant is revoked on the pass that finds it gone, and every change is one log line (:103-107).

No wall, no reader (:108-112). With [lake].warehouse_per_database off, every database resolves to the box warehouse, so a reader would resolve nothing; the pass mints nothing and REMOVES a reader an earlier configuration left behind. reader_plan returns None for that case and its doc comment is explicit that None is a REFUSAL and not an empty roster (:1529-1532) — there is no cross-tenant read without a wall, and the product says so rather than handing out the lake under a read-only label.

A reader failure is a row, not the end of the pass. The tenants are already walled; the reader is retried next cadence, and the failure is one logged line (:1490-1500). Every outcome refreshes the roster cache, Unchanged included, because a pass that PROVED the record current is as good a reason to restart the window as a rewrite (:1589-1594).

3.3 Which warehouse a table publishes into, and what happens when it is not there yet

The committer resolves a catalog handle PER TABLE, from that table's own database (crates/argondb-committer/src/catalog_set.rs:226-239). When the resolution fails — the commonest reason being that the database was created seconds ago and its warehouse does not exist yet — the path refuses and defers by name. It never falls back to the box warehouse. The refusal text is one string, lifted out so it is testable without a catalog (unreachable_warehouse_message, :243-257), and it says why:

database "tenantc" publishes to lake warehouse "tenantc" (bucket "tenantc"); it is not
reachable. ArgonDB does NOT fall back to the box warehouse "argon" here: that would put
this database's published bytes behind the box credential, which is the tenant wall
inverted. If the database was just created, the box's tenant-wall pass provisions its
warehouse within one cadence interval — `argondb lake tenant list` names the state and
`argondb lake tenant provision` forces a pass.

The tail retries every cadence, so a deferral is a stable repeated log line and not a one-shot message. Within one 30-second pass the wall exists and the next retry publishes into the tenant's own bucket. And the ordering is stronger than the requirement: provisioning happens on first DATABASE, not on first publish, so in practice the wall is up before there is anything behind it — that substitution is named on the spot rather than made silently (crates/argondb-ctl/src/lake_tenant.rs:22-32).

3.4 The store's one predicate

The five principals, and what each one can reach

wall::decide(creds, access_key, op, target) is pure, total, and the only rule in the store about who may touch which bucket (crates/argondb-lake-store/src/wall.rs:117-191). It sits where it does for a measured reason: s3s 0.14.1 calls S3Access::check ONCE per request, after the signature is verified and after the route is resolved, before the operation's input is deserialized — so one implementation covers roughly a hundred operations including ones this store does not implement and ones a future s3s adds. Guarding each method by hand would be about twenty edits today and a silent hole on the next operation added (crates/argondb-lake-store/src/wall.rs:12-22).

The one thing the request path does not carry is a COPY's SOURCE bucket — s3_path is the destination. s3s generates a per-operation hook for that case and calls it with the deserialized input, so BucketWall::copy_object reads copy_source and applies the SAME predicate (:24-30). A cross-bucket copy is how a wall that guards only the destination is walked straight through.

The arms, in the order the predicate tests them:

READER_OPS is CLOSED and holds exactly seven names — GetObject, HeadObject, ListObjectsV2, ListObjects, HeadBucket, GetBucketLocation, GetObjectAttributes (:78-86). Anything not in the list is refused by name, so an operation s3s adds later does not arrive allowed. CopyObject is deliberately absent: a copy WRITES its destination, and the reader may write nothing (:71-77).

Why ListBuckets is filtered rather than refused (:32-40): refusing it outright would be a wall too, and it would break every S3 client that probes the bucket list before addressing a bucket. Filtering is the answer that is both a wall and usable. Every OTHER root-level operation is refused by name — a closed default.

3.5 The catalog's half

The catalog half is one select assignment per (subject, warehouse), written through POST /management/v1/permissions/warehouse/{warehouse_id}/assignments with {"writes":[{"type":"select","user":"oidc~<subject>"}],"deletes":[]} — read out of Lakekeeper's own OpenAPI document rather than guessed, and exercised end to end before it was built (crates/argondb-ctl/src/lake_grant.rs:13-20). select is READ access to the warehouse: the tenant lists and reads its own tables and cannot write them, which is the shape the wall is for, since the committer is the only writer and writes as the box (:35-41). Revoke is the same endpoint with the subject moved from writes to deletes (:22-25).

Four measured behaviors a developer will meet:

  1. A warehouse the caller may not see answers 404, not 403. The name is not disclosed (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:798-800, and the C7d/C7e rows of the measurement at :833-834).
  2. lakekeeper migrate with the backend set creates the OpenFGA store and writes the whole authorization model itself — no provisioning step of ours (:795-798).
  3. The box mints its own tokens. Lakekeeper never calls an identity provider; it fetches <provider>/.well-known/openid-configuration, then the JWKS, then verifies RS256 signatures. The box holds the signing key and serves those two documents, so the wall costs one new service (the policy engine) and no identity provider (:813-822, :846-849).
  4. Revoking a grant does not invalidate a token. It removes the assignment, so the principal is refused from the next request onward; the JWT itself is valid until it expires and the box keeps no revocation list. With the grant gone the token authenticates and authorizes nothing, and --ttl bounds the other half (crates/argondb-ctl/src/lake_grant.rs:281-287).

And one refusal that is a design statement rather than a validation: lake grant REFUSES a database that resolves to the BOX warehouse, because the box warehouse holds every other database's tables as well and "granting" there would hand the caller the whole lake while reading like a per-tenant permission (:45-68).

3.6 A read session that resolves every tenant

pin is the one home, and converting it converted four surfaces at once: the transform runner, the hub, the MCP pin/redeem path and the Flight read plane; the FDW reads through Flight and follows (docs/2026-09-15-cross-tenant-read-design.md:356-360). What changed is the type: QueryOpts.lake became the principal's CatalogSet — the plural handle the WRITER already had — so a session registers every committer table across every warehouse its principal may read, each table's provider built from the catalog handle its own database resolves to (:290-296).

The principal decides the reach (:311-317): the box resolves every warehouse; a tenant resolves exactly its own; the cross-tenant reader resolves every TENANT warehouse — CatalogSet::warehouses minus the box's, with absent tenants named.

Read-only means no writer in the session (:345-354). hub run is one process holding TWO principals: the reader for reads and the box for writes, with the run banner naming both. Splitting them is the point.

The consistency claim, stated exactly (:324-343). A pin is (branch, LSN, scope, …) and the LSN is a position in the cluster's ONE write-ahead log, which every tenant database shares — so one LSN names one instant for every table in every warehouse. Per table the guarantee is unchanged. What is NOT claimed is cross-warehouse atomicity of a cut: a cut spanning two tenants is one transaction PER WAREHOUSE, so two warehouses' watermarks may advance at different moments. A cross-tenant pin therefore lands at the newest LSN every scoped table in every tenant warehouse serves, and the session discloses per warehouse which coordinate it resolved.

This claim's "one write-ahead log" premise is the shipped default, not the standing topology. With [compute] per_tenant on, the ruled target gives each tenant database its OWN write-ahead log on its OWN timeline [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5]; one LSN naming one instant for every table in every warehouse holds today because that switch is off by default and is the operator's to flip [D-2026-09-23-5122a6].

3.7 The view, and what it costs

all_tenants.<schema>__<table> is composed at pin time from the cached roster and the per-warehouse table listing. The name is the flat key minus its database, grouped STRUCTURALLY by (schema, table) rather than re-parsed out of the name — the tenant-relative key is not injective (crates/argondb-query/src/all_tenants.rs:152-159). Each branch is SELECT '<tenant>' AS "tenant", <the view's columns, or NULL AS "col" where this copy lacks it> FROM "<flat name>", UNION ALLed in tenant order, columns unioned BY NAME in first-seen order (docs/reserved-identifiers.md:39).

Pushdown is the engine's and is not re-implemented. A WHERE over the view is pushed by DataFusion through the Union into every branch and on into each branch's IcebergTableScan, where manifests, files and row groups are pruned against that tenant's own statistics. Because tenant is a per-branch CONSTANT, WHERE tenant = 'x' simplifies to false in every other branch and the optimizer drops those branches before a file is opened — that is how "subsetting is the query's WHERE" costs nothing over a named set (crates/argondb-query/src/all_tenants.rs:34-45).

Drift has a voice twice (:47-58). A missing column is NULL AS "col" on that tenant's projection line beside its 'tenant' AS tenant, so EXPLAIN names the tenant and the column together; and every drifted view is listed in the served warning rendered at the mint and on every result the session serves, so a client that never reads a plan still sees it. A tenant with NO copy contributes no branch and is named the same way. A column whose TYPE differs is named per tenant with both types: the engine's UNION coercion widens it when it can (Int32 with Float64 reads as Float64) and the view is REFUSED BY NAME when it cannot (Int32 with Boolean) — never silently narrowed. A copy that already carries a column named tenant is refused the same way, because the projected literal would shadow it (:60-64).

The measured cost (docs/lake-foreign-read.md:386-402, re-measured 2026-09-19 on testing/box/all-tenants-views.sh P5):

estate whole pin catalog listing table metadata
8 warehouses, 8 lake tables 2307 ms 1702 ms 261 ms
80 warehouses, 80 lake tables 22575 ms 16123 ms 2974 ms

The split is the finding: roughly 70 % of a pin is the catalog WALK — one namespace_exists plus a list_tables and a list_namespaces at each of the branch, database and schema levels, so seven round trips per warehouse against one metadata load per table (about 29 ms per round trip at 80 warehouses). Narrowing the table set buys the smaller half; narrowing the walk buys the larger one. Earlier figures on this line — 17122 ms at 8 and 152654 ms at 80 — DO NOT REPRODUCE, and nothing in between was a deliberate optimization of this path; they are left in that document as the record and are not a baseline (:404-409). A reader sizing a deployment should re-measure.

A scoped pin (argondb-query --scope) walks only the namespaces its names could live in — three round trips instead of seven for a warehouse holding no database the scope names. In a cross-tenant estate it prunes nothing, and that is measured rather than assumed: the scope a cross-tenant read can name is all_tenants.<schema>__<table>, which names no database, so every database level is admitted. The lever bites where a scope names FEWER DATABASES than the roster has (docs/lake-foreign-read.md:415-428).

3.8 The three spellings of one identity

One relation, its spellings, and the door that answers each

Four coordinates, three SQL name parts. The shape was ruled in three blocks: full nesting [D-2026-09-11-702e69], the primary regularized [D-2026-09-11-118b2e], __ kept as the separator [D-2026-09-11-f73865] (crates/argondb-ctl/src/lake_address.rs:19-21). So postgres.public.orders on branch main has a catalog identifier of namespace ["br_main","postgres","public"] with table orders, an engine address of lake."br_main.postgres.public".orders, a flat lake key of postgres__public__orders, and a dotted log label of br_main.postgres.public.orders.

Those are four different jobs and no one of them can stand in for another (:47-78). address is what a DuckDB user TYPES, and its namespace is one quoted identifier because a nested namespace is readable no other way. lake-table is the FLAT key the tail's tables action, the FDW mount and a pin session use. leaf is the bare relation name a caller talking to the REST catalog directly needs. label is what a HUMAN and a LOG read — no catalog alias, no quotes — and it exists because address's quoting makes it useless as a log matcher.

Why this is a product subcommand and not a library function (:4-15): a shell gate, a shipped starter and a customer doc cannot call lake_table_name_for. The census measured what that costs — 452 lines in 133 files spell the lake address, and three of the consumer groups (12 lines of shipped starters, 132 lines in 57 gate files, 148 lines in 35 docs) are shell, SQL and prose. argondb lake address is the door they call, and testing/lib/lake-table-name.sh is its first caller.

What has no database coordinate stays at the branch level and in the box warehouse: _argon_gaps, _argon_timeline, and the lake writer's own dv_<branch> territory (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:354-370; docs/lake-foreign-read.md:186-188). A tenant key does not reach them either.

A catalog namespace is a NAMING hierarchy, not a directory hierarchy — measured, and it is the reason part C exists. Every namespace the probe created reported the SAME storage location, and a table's location is the warehouse prefix plus the table's own UUID; the warehouse storage profile carries "storage-layout":null. A credential scoped to a namespace-shaped prefix would open nothing, because no such prefix exists (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:168-189). One thing stays UNVERIFIED in that measurement and this guide repeats it rather than rounding it: whether 0.13.x offers a non-default storage-layout that nests objects by namespace — the field exists in the profile and was not probed (:187-189).


4. How to use it

Thirteen worked examples. Each states its label and, where it is FROM SOURCE, the path:line the command, flag, message or sample output is quoted from, plus the gate that proves it on a box.

4.0 argondb tenant create — the whole tenant, in one ordered command — FROM SOURCE

This is the command to reach for when you are creating a COMPANY, rather than just a database. §4.1 below is still exactly right and still supported — it creates the database and lets the wall pass find it — but it leaves the tenant's timeline, its compute row, its login role, its uniform schema, its identity record, its application process and its MCP token to you. argondb tenant create runs all of those in the order the ERP spec §13.4 forces — the project owner's ruling on who creates a company and in what order [D-2026-09-21-e781f5] — and it is safe to re-run:

argondb tenant create acme \
  --display-name "Acme" \
  --legal-name   "Acme Holdings LLC" \
  --system       distributors \
  --identity     "tax_id=12-3456789" \
  --identity     "contact=ops@acme.example" \
  --data-dir "$DATA" --port 5433
flag what it is required
<name> the tenant's stable, never-reused key — and, on a box, its database name. Lowercase ASCII letters, digits and underscores yes
--display-name §13.1's display_name: the company's name as people write it no — see below
--legal-name §13.1's legal_name no — same
--system which tenancy the tenant belongs to (distributors, manufacturers) — a tenancy is one multi-tenant system in the Estate, and the CLI's word for it is system no (distributors)
--identity KEY=VALUE the rest of the company's identity. Repeatable. The key must be one of address, tax_id, contact no
--sidecar-command COMMAND the command that runs the company's APPLICATION as its own process (step 8) no — omitted means the company runs no application
--sidecar-uid UID the unix uid that process runs under no — defaults to the argon-t-<db> user
--direct also print the DIRECT connection string; the POOLED one is what a tenant is handed by default no
--data-dir, --port the box, resolved by the same precedence every other subcommand uses no

(crates/argondb-ctl/src/main.rs:611-670.)

The two name flags are OPTIONAL, and a missing name is written EMPTY, never invented and never refused. This changed in v1.5.5: an earlier build refused a create with no name. A company whose human has not answered yet is written with its key and empty names, the transcript names the flags that were not supplied, and the tenant still reaches live (crates/argondb-ctl/src/tenant_create.rs:1862-1873). What is still refused, before anything is created, is an --identity key the identity table has no column for — country=US, for example — because an unknown key cannot be stored and dropping it quietly is how an address goes missing (IDENTITY_EXTRA_FIELDS, crates/argondb-ctl/src/tenant_create.rs:2024; the refusal, :424-438). tenant create --help marks both name flags OPTIONAL (crates/argondb-ctl/src/main.rs:624-634), as the code does.

The eight steps, and what each one is. The run prints them as it goes; the one place their order is written down is STEPS (crates/argondb-ctl/src/tenant_create.rs:206-215):

# step what exists afterwards
1 its timeline a branch of the box timeline, recorded in <data>/tenants/acme/identity.json
2 its compute row a row in the per-tenant compute table — its own slot in the tenant port band and its own directory
3 its database acme in pg_database
4 its role and grants acme_owner in pg_roles, granted on the database and on public
5 its uniform schema §13.1's identity table, applied through the versioned path §4.9 drives across every tenant, with its version recorded in acme's own argon.schema_version
6 the wall pass exactly what §4.1 does — warehouse, bucket, scoped credential, catalog grant, connection limit
7 its identity row your answers in acme's identity table, WRITTEN THROUGH to its system's Identities table in the same call
8 its sidecar the company's application process, under its own uid and cgroup — or nothing at all, when no --sidecar-command was given

Measured (2026-10-30, compose-2's node, [compute] per_tenant on, the estate pass and maintainer running): the whole command took 3.4 s on a fresh box with one system and 4.4 s at 300 tenants, with the compute start (step 2) about 1 s and the wall pass (step 6) under 1.1 s at every count, no step ever reaching 60 s (docs/lane-reports/product-measure-2026-10-30-tenant-create-timed-on-compose-2-at-the-reference-estates-tenant-count-against-a-fresh-box-and-the-954-second-idle-wait-named.md).

After the eight steps the run mints the company's own MCP token and writes it to a 0600 file, <data>/tenants/acme/mcp-token (§4.11). A failure there is named and does not undo the tenant; argondb tenant mcp-token acme is the remedy (crates/argondb-ctl/src/tenant_create.rs:2950-2975).

The identity table has one door. Step 7 writes the tenant's row and then its system's Identities row with the tenant commit's LSN as source_lsn; a trigger refuses a row write to identity from any role but the tenant API's own, and tenant_key is not editable at all. A rename goes through argondb tenant identity acme --display-name …, the same door (crates/argondb-ctl/src/main.rs:725-744).

RE-RUNNING IT IS SAFE, AND THAT IS THE POINT. Every step probes before it acts, so a second run creates nothing and says so (crates/argondb-ctl/src/tenant_create.rs:2244, :2938-2948):

[argondb]   step 1/8 timeline — already present (§13.4 step 5); not applied
...
[argondb] tenant create: acme is live — 0 step(s) applied, 8 already present. Journal: <data>/tenants/acme/create-journal.json
[argondb] tenant create: NO-OP — this run created nothing (every step probed present). §13.4 idempotence.

AND A RUN THAT DIES HALFWAY IS RESUMED BY RE-RUNNING IT. Progress is recorded in a step journal at <data>/tenants/acme/create-journal.json, and a tenant that is half-built says so in its own status column (provisioning, not live). Re-run the same command and it continues where it stopped:

[argondb] tenant create: RESUMING at step 4 (roles) — the journal names step 3 (database) as the last completed step

WHAT IT WILL NOT DO: adopt. If a database of that name already exists and this command did not create it, it REFUSES and points you at the provisioner. That is not an obstacle — the wall pass adopts it within one cadence, which is §4.1's whole story:

refusing: a database of this name already exists and this sequence did not create it
  (there is no step journal beside it). `argondb tenant create` CREATES; it does not adopt.

What this box does NOT give you. The run's own banner says it: on one development box there is one safekeeper, no WAL backup and no zone diversity. A quorum of one is not redundancy. Every §13.4 step that has no meaning on one box — the tenant's EC2 instance, its subnet and security group, its volume, its elastic address and the three-zone safekeeper member set — is printed as a SKIPPED step with the one-box substitute that stands in for it, so nothing is silently absent (skipped_cloud_steps, crates/argondb-ctl/src/tenant_create.rs:318). The Identities write and the application sidecar were on that list before v1.5.5; both are now real steps (7 and 8 above).

Where the estate control plane, the estate directory and Identities actually run — never co-resident with a tenant. [D-2026-09-21-8b9d22] ruled this on the model of estate instances, following the storage plane's own practices: at least two estate instances across two availability zones (three is the natural number, since the safekeeper quorum already needs three) run the provisioning API (one leader, the rest forwarding rather than serving), the Identities projection, and the multiplexed tail's own lease — never on a tenant's own instance, because a control plane sharing a tenant's instance dies with that tenant and cannot be the thing that recreates it. It does not scale to zero: the API must answer a tenant create call that can by definition arrive when nothing else is awake, so the estate control plane is always-resident, like the storage plane, not stop-when-idle like a tenant compute. On one development box every one of those coordinates collapses to a single process and a schema in the box's own primary (argon_estate) — which is why the bar for "one-box complete" now also requires showing that control plane's leader and its forwarding running as two processes on the box, not just the wall [D-2026-09-23-21780c].

Gate: testing/box/tenant-create-sequence.sh (both polarities, on ms-01). Its v1.5.5 run measured a created tenant reaching its system's Identities — all_tenants.public__identity, read through the cross-tenant reader — in 59 seconds with no administrator act (docs/releases/v1.5.5.md §15).

Fixed: the uniform schema's target is a step it defines. The v1.5.6 release shipped UNIFORM_SCHEMA_VERSION at 2 with a sequence that defined only version 1, so a fresh tenant create (step 5) and a default argondb estate migrate (§4.9) both stopped at the uniform schema has no step defining version 2. The sequence now carries step 2 — the identity table's one door (uniform_schema_steps in crates/argondb-ctl/src/tenant_create.rs) — a unit test holds the target to the last step, and testing/box/uniform-schema-fresh-create-and-migrate-from-v1.sh runs both commands on a box. Post-mortem: docs/postmortems/2026-09-28-uniform-schema-version-declared-without-its-step.md.

4.1 Create a tenant database and watch the provisioner wall it — FROM SOURCE

Nothing about creating a tenant is special. Create the database; the pass does the rest within one cadence.

# 1. the database. Nothing else is special about it.
createdb -p 5433 -h localhost -U cloud_admin tenanta
#   (or: psql -c 'CREATE DATABASE tenanta')

# 2. wait for the box's own pass (<= 30 s), or force one now:
argondb lake tenant provision --data-dir "$DATA" --port 5433

# 3. read the estate back
argondb lake tenant list --data-dir "$DATA" --port 5433

The listing's shape is the format string at crates/argondb-ctl/src/lake_tenant.rs:2384-2392 — seven columns on a box with a catalog login, six without (the CATALOG GRANTS column is ABSENT rather than a row of "none", because "none" would read as "this box does not have the feature"; :2377-2382). A worked listing, quoted from the customer recipe at docs/lake-foreign-read.md:141-145:

$ argondb lake tenant list
DATABASE       WAREHOUSE      BUCKET         ACCESS KEY               STATE
postgres       argon          argon          (the box credential)     primary — the box warehouse
shop           shop           shop           argon-tenant-shop        walled
tenantb        tenantb        tenantb        argon-tenant-tenantb     walled

Every STATE is a statement with a producer named (crates/argondb-ctl/src/lake_tenant.rs:2411-2424):

(credential, warehouse) STATE
both present walled
neither not yet — the provisioner walls it within 30s, or run `argondb lake tenant provision`
credential only credential minted, WAREHOUSE MISSING — the last pass could not reach …
the resolver refused the name REFUSED — <reason>

Then the per-database estate, which is a different provisioner path with the same cadence. Check it rather than assume it (docs/upc-hub-recipe.md:453-462):

psql -d tenanta -c "SELECT to_regnamespace('derived') IS NOT NULL
                      AND to_regnamespace('mfr')     IS NOT NULL
                      AND to_regclass('derived.transform_runs') IS NOT NULL
                      AND to_regclass('argon.view_state')       IS NOT NULL"

That is exactly the probe transform_estate::is_provisioned runs (crates/argondb-ctl/src/transform_estate.rs:127-147), and a unit test asserts the probe asks for precisely what deploy/sql/provision-transform-role.sql creates, so the two cannot drift (:298-317). Note the cluster/database split that made this gap invisible once: CREATE ROLE argon_transform LOGIN is a CLUSTER object, visible from every database, so "does the role exist?" asked alone returns a false CLEAN (:19-26).

Gate: testing/box/tenant-wall-warehouse.sh P1, P3, P4 and P7 (testing/box/tenant-wall-warehouse.sh:19-26, :41-57) — the credential, the bucket directory, the warehouse, a distinct key per tenant, a REFUSAL for the primary, and which bucket the published parquet actually lands in, every leg re-run after a box restart.

4.2 The three spellings of one identity — FROM SOURCE

Ask the box; do not assemble it. The examples below are quoted verbatim from the module's own doc comment, crates/argondb-ctl/src/lake_address.rs:25-36 and :105-110:

$ argondb lake address postgres.public.orders
lake."br_main.postgres.public".orders
$ argondb lake address --field namespace postgres.public.orders
br_main.postgres.public
$ argondb lake address --field lake-table postgres.public.orders
postgres__public__orders
$ argondb lake address --field qualified postgres.public.orders
"public"."orders"
$ argondb lake address --field label postgres.public.orders
br_main.postgres.public.orders

$ argondb lake address --changes postgres.public.orders
lake."br_main.postgres.public".orders__changes
$ argondb lake address --changes --field lake-table postgres.public.orders
postgres__public__orders__changes

--branch moves the branch coordinate, and --format json answers a machine (crates/argondb-ctl/src/lake_address.rs:278-281 for the two formats; docs/lake-foreign-read.md:462-466 for the branch form):

$ argondb lake address --branch dev shop.sales.orders
lake."dev.shop.sales".orders

Which field you want, by consumer: address for a DuckDB user and for a doc; namespace for a starter's LAKE_NAMESPACE constant; leaf for a caller talking to the REST catalog directly; lake-table for a pin scope, the FDW mount and the tail's tables action; label for a log matcher; qualified for the tail's own <pg> left the published set lines; bucket for "which bucket do this table's files land in" (crates/argondb-ctl/src/lake_address.rs:156-196; testing/box/tenant-wall-warehouse.sh:53).

The failure you will hit if you assemble it (docs/lake-foreign-read.md:453-456):

D SELECT count(*) FROM lake.br_main.postgres.public.orders;
Parser Error: NameListToString NOT IMPLEMENTED

Four name parts, and SQL has three. The quotes are not optional.

4.3 Hand an analyst a tenant token, and see what it can and cannot list — FROM SOURCE

# mint + grant in one call; the TOKEN is the only thing on stdout
TOKEN="$(argondb lake grant tenanta --data-dir "$DATA" --ttl 30)"

# the clause a reader pastes, minted and granted through the same path
argondb lake attach-clause --database tenanta --data-dir "$DATA"

# take it back
argondb lake revoke tenanta --data-dir "$DATA"

The lines on stderr are the format strings at crates/argondb-ctl/src/lake_grant.rs:256-275:

[argondb] granted `select` on warehouse tenanta (<uuid>) to oidc~argondb-tenant-tenanta, valid 30 day(s).
[argondb] the analyst reads it with:
    ATTACH 'tenanta' AS lake (TYPE iceberg, ENDPOINT '<catalog>/catalog', AUTHORIZATION_TYPE 'oauth2', TOKEN '<the token on stdout>');
[argondb] revoke it with: argondb lake revoke tenanta

What that token can do, measured (the C-series rows at docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:826-838):

C4a/C4b tenant principals BEFORE any grant          HTTP 403 / HTTP 403
C6a/C6b grant select on one warehouse each          HTTP 204 / HTTP 204
C7a tenant-a lists warehouses                       HTTP 200  -> tenanta ONLY
C7b tenant-b lists warehouses                       HTTP 200  -> tenantb ONLY
C7d tenant-a reads tenantB config                   HTTP 404
C7e tenant-b reads tenantA config                   HTTP 404
C7f/C7g committer reads BOTH configs (the control)  HTTP 200 / HTTP 200
C7h ANONYMOUS reads tenantA config                  HTTP 401
C8a FORGED token, right shape wrong signer          HTTP 401

Read the 404s: a warehouse this subject may not see is not merely refused, its NAME is not disclosed. And read the negative controls as controls — each is only one because its positive twin passed in the same run on the same box.

The refusal you will meet if you point it at the primary (crates/argondb-ctl/src/lake_grant.rs:59-67):

database "postgres" resolves to the BOX warehouse "argon", which holds every other
database's tables as well — granting there would hand the caller the whole lake while
reading like a per-tenant permission. Turn on `[lake] warehouse_per_database` (it is on
by default) and let the provisioner wall this database first: `argondb lake tenant provision`.

Revoke does not invalidate the token — it removes the assignment, so the subject is refused from the next request onward while the JWT stays valid until it expires; --ttl is what bounds that half (crates/argondb-ctl/src/lake_grant.rs:281-287).

4.4 Read every tenant at once under the reader, and see an unwalled database named — FROM SOURCE

argondb lake reader credential --data-dir "$DATA"            # the roster, the grants, a token
eval "$(argondb lake reader credential --data-dir "$DATA" --env)"   # exports ARGONDB_LAKE_WAREHOUSES
argondb-query "SELECT tenant, count(*) FROM all_tenants.public__orders GROUP BY tenant ORDER BY tenant"

The credential surface, quoted from docs/lake-foreign-read.md:228-241:

$ argondb lake reader credential
principal    cross-tenant reader (read-only over every tenant warehouse, never the box warehouse)
access-key   argon-reader
secret-key   <48 hex characters>
endpoint     http://127.0.0.1:9011
region       local
catalog      http://127.0.0.1:8181/catalog
warehouses   shop, tenantb
buckets      shop, tenantb
granted      shop, tenantb
pass-unix    1789514071
token-ttl    30 day(s)
token        <a JWT for the subject argondb-reader>

And the session, quoted from docs/lake-foreign-read.md:322-336 — note that the banner, the census line and the drift note all arrive before the rows:

$ eval "$(argondb lake reader credential --env)"
$ argondb-query "SELECT tenant, count(*) FROM all_tenants.public__orders GROUP BY tenant ORDER BY tenant"
-- pinned at 0/1C2A3F0 (min watermark 0/1C2A3F0)
-- note: this session resolves 4 warehouses — tenanta, tenantb, tenantc, tenantd — …
-- pin built in 412 ms over 4 warehouse(s), 5 lake table(s)
-- all_tenants: 2 view(s) served over 4 tenant(s) [tenanta, tenantb, tenantc, tenantd]: all_tenants."public__orders", all_tenants."public__other"
-- note: 2 cross-tenant view(s) DRIFTED — a row from a named tenant is unioned by column name with NULL for what that tenant lacks, and a tenant with no copy contributes no rows: all_tenants."public__orders" — tenant tenantc lacks column(s) "v" (served as NULL); tenant(s) tenantd have no copy of this table (no branch) | all_tenants."public__other" — tenant(s) tenanta, tenantb, tenantc have no copy of this table (no branch)
+---------+----------+
| tenant  | count(*) |
+---------+----------+
| tenanta | 3000     |
| tenantb | 3000     |
| tenantc | 50       |
+---------+----------+

An unwalled database is ABSENT BY NAME, in the list line and in the roster. The lake tenant list reader line is built at crates/argondb-ctl/src/lake_tenant.rs:2484-2498 and renders the absent entries as <database> (<reason>):

cross-tenant reader: argon-reader covers [shop, tenantb] catalog grants [shop, tenantb] absent [zz_bad_name (<the resolver's reason>)]

The other two states of that line are statements too (:2469-2476):

cross-tenant reader: none — [lake].warehouse_per_database is off (no wall, no reader)
cross-tenant reader: not yet minted — the provisioner mints it on the pass that walls the first non-primary database (within 30s), or run `argondb lake tenant provision`

Subsetting is the query's WHERE, and it is free. WHERE tenant IN ('tenanta','tenantb') folds to false in every other branch and those branches are dropped before a file is opened (crates/argondb-query/src/all_tenants.rs:40-43). There is no named tenant set to configure, and the config key that would have been one was withdrawn (docs/2026-09-15-cross-tenant-read-design.md:214-218).

The primary is not in the view. The session never resolves the box warehouse, so postgres__public__house answers "table not found", and naming the box warehouse in the roster by hand is refused by the catalog under the reader's login with the session saying which warehouse refused (docs/lake-foreign-read.md:277-281).

Gates: testing/box/cross-tenant-reader.sh (8 phases — the store wall, the catalog, a session, a tenant joining and leaving the roster, a restart; testing/box/cross-tenant-reader.sh:17-57) and testing/box/all-tenants-views.sh (8 phases — the view, pushdown read off EXPLAIN, zero bytes written, the cost measurement, a memory bound, a DuckDB reader; testing/box/all-tenants-views.sh:18-80).

4.5 Set, override and read back a connection limit — FROM SOURCE

# the default (20) is applied only where datconnlimit = -1
argondb lake tenant provision --data-dir "$DATA" --port 5433

# an explicit override, applied EXACTLY, on this pass and every later one
argondb lake tenant provision --data-dir "$DATA" --port 5433 --connection-limit tenantb=50

or, durably, in argon.toml:

[lake.tenant_connection_limit]
tenantb = 50

The CLI rung wins over the file rung, and both win over the default (crates/argondb-ctl/src/lake_tenant.rs:648-659). Read it back on the operator surface — lake tenant list carries a CONN LIMIT column precisely so an unset or hand-raised limit is visible outside the box log (:2366-2370), and the primary's cell reads unlimited (testing/box/tenant-wall-warehouse.sh:633-639).

The three rules, and both polarities of each, are what testing/box/tenant-wall-warehouse.sh P10 proves (testing/box/tenant-wall-warehouse.sh:62-74): 20 held sessions on tenant A as a NON-superuser role and the 21st refused NAMING THE DATABASE; tenant B still connecting (the positive twin); a superuser still connecting (PostgreSQL's documented exemption); a CLI override winning and printed with the sibling untouched; a hand-set value surviving a plain pass, neither lowered nor raised; an argon.toml override winning, printed, and printed again as "in force" on the next pass; a malformed value and an override naming no database refused by name with nothing applied; and the UPGRADE shape — a tenant with no limit getting 20 on the next pass, with the pass saying so.

Two edges worth knowing before you rely on the number. It is PostgreSQL's own limit, so superusers are exempt and the count is of connections to that DATABASE, not of the role (crates/argondb-ctl/src/lake_tenant.rs:136-141). And default = -1 is a legal setting meaning "set no default" — an operator who wants PostgreSQL's unlimited behavior back says so with -1 (:626-630).

4.6 Turn the catalog login on, and read a refused cross-tenant access — FROM SOURCE

# argon.toml — but note the DEFAULT already resolves this way on a
# multi-database box, and an explicit value always wins
[lake]
catalog_authz = "openfga"

The default was ruled on 2026-09-15: openfga on a box holding more than one database, off on a single-database box, explicit always wins (docs/2026-09-15-cross-tenant-read-design.md:502-504). The box says which one it is running, and says when the count has crossed since boot — one predicate, called by both the cadence loop and the list surface so they cannot disagree (crates/argondb-ctl/src/lake_tenant.rs:2344-2356, :2460-2465):

catalog authz: <what this box runs, and why> (as decided at boot)
<…and, when the database count has crossed 1 since boot, that the change takes effect at the next boot>

With the login OFF, the served refusal text states the position exactly — authenticates nobody and authorizes everybody (crates/argondb-ctl/src/lake_grant.rs:356, quoted at docs/2026-09-15-cross-tenant-read-design.md:520-521). Data is walled; metadata is not.

What a refused cross-tenant read prints, at each of the two doors. At the store, the exact Deny strings are the format strings at crates/argondb-lake-store/src/wall.rs:148-151, :166-170, :173-177 and :182-186:

access key "argon-tenant-tenanta" is scoped to bucket "tenanta" (database "tenanta") and GetObject addresses bucket "tenantb"
access key "argon-reader" is the cross-tenant READER over ["tenanta", "tenantb"] and GetObject addresses bucket "argon", which is not a tenant bucket in its roster (the box bucket never is)
access key "argon-reader" is the cross-tenant READER and is READ-ONLY: PutObject on bucket "tenanta" is refused (allowed: GetObject, HeadObject, ListObjectsV2, ListObjects, HeadBucket, GetBucketLocation, GetObjectAttributes)
access key "argon-reader" is the cross-tenant READER (read-only over 2 tenant bucket(s)) and CreateBucket addresses the whole store

Each surfaces to the client as 403 AccessDenied and is logged by the store with the key, the operation and both buckets named (docs/lake-foreign-read.md:190-194, :243-249). At the catalog, the refusal is a 404 and does not disclose the name (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:798-800).

Gate: testing/box/tenant-wall-warehouse.sh P5 runs the whole matrix with both polarities and a restart afterwards — key A on bucket A 200, key A on bucket B 403 on list AND on get, the symmetric pair, key A on the BOX bucket 403, root on every bucket 200 as the control, ListBuckets filtered per key, anonymous 403 (testing/box/tenant-wall-warehouse.sh:27-40).

4.7 Stand up a manufacturer, and see the party rule bite — FROM SOURCE

A manufacturer is a member database beside the distributors; the SCHEMA is what keeps the parties apart (docs/upc-hub-recipe.md:430-441).

createdb mfracme           # the box's pass walls it exactly as it does a distributor
psql -d mfracme -c "SELECT to_regnamespace('mfr') IS NOT NULL"   # estate; you do not create it
CREATE TABLE mfr.price_list(
  upc             text PRIMARY KEY,
  list_price      double precision,
  effective_from  date,
  effective_to    date,
  manufacturer_id text);
COMMENT ON TABLE mfr.price_list IS 'argon:publish';

CREATE TABLE mfr.share_grant(
  flow             text NOT NULL,
  manufacturer_id  text NOT NULL,      -- a recipient, or '*'
  contract_version int  NOT NULL,
  granted_at       timestamptz NOT NULL DEFAULT now(),
  revoked_at       timestamptz,        -- NULL = in force
  PRIMARY KEY (flow, manufacturer_id, contract_version));
COMMENT ON TABLE mfr.share_grant IS 'argon:publish';
INSERT INTO mfr.share_grant(flow, manufacturer_id, contract_version)
  VALUES ('upc_prices', '*', 1);

(Both DDL blocks quoted from docs/upc-hub-recipe.md:470-496.) Then ask the product which parties it sees — the output is quoted from docs/upc-hub-recipe.md:508-514:

$ argondb hub parties
pin 0/3A1B2C8 — 4 member database(s), 1 manufacturer(s)
hub: party hubcontrol: distributor (publishes no mfr table at this pin)
hub: party mfracme: manufacturer (publishes 2 mfr table(s) at this pin)
hub: party tenanta: distributor (publishes no mfr table at this pin)
hub: party tenantb: distributor (publishes no mfr table at this pin)

Every member database gets a line, distributor included — so "no manufacturers" and "nothing looked" can never read the same (docs/upc-hub-recipe.md:516-517).

The failure mode, and its one remedy. A manufacturer database publishing outside mfr becomes a branch of the distributors' own cross-tenant views. The product names it and exits non-zero (docs/upc-hub-recipe.md:524-533):

hub: CROSSES PARTIES mfracme.public — served as "mfracme__public__products", a branch of all_tenants.public__products

…and a hub run whose source scans that branch is REFUSED before anything lands. It is a REFERENCE question and deliberately not a pruning one: the LOGICAL plan is walked before optimization, so a WHERE tenant IN (…) over the view does NOT clear it. The remedy is the one the design states — publish it in mfr. A source over a distributor's own table references no manufacturer copy and is unaffected (docs/reserved-identifiers.md:6735).

Gates: testing/box/upc-hub-warehouse.sh P20 and testing/box/all-tenants-views.sh P8, both polarities each (docs/reserved-identifiers.md:6735).

4.8 Find out who is holding the provisioner pass lock — VERIFIED (the key) + FROM SOURCE (the SQL)

The list surface prints the lock line itself (crates/argondb-ctl/src/lake_tenant.rs:2501-2506, rendered by pass_lock_list_line, :2512):

provisioner lock: pg advisory key 7021788497314804855 on the primary — free / HELD by …

To ask PostgreSQL directly you need the key's two halves, because pg_locks stores a single-bigint advisory key as classid (high 32 bits) and objid (low 32), with objsubid = 1 (crates/argondb-ctl/src/lake_tenant.rs:262-274). The derivation is reproducible by hand, which is the whole reason the key is eight ASCII bytes and not a magic literal — VERIFIED, run in this lane:

$ python3 -c 'k=int.from_bytes(b"argon_tw","big",signed=True); print(k, hex(k>>32), hex(k & 0xffffffff))'
7021788497314804855 0x6172676f 0x6e5f7477

which matches the registered values at docs/reserved-identifiers.md:6734 (classid = 0x6172676f, objid = 0x6e5f7477, objsubid = 1). The query the product itself runs is PASS_LOCK_HOLDER_SQL (crates/argondb-ctl/src/lake_tenant.rs:276-281) — FROM SOURCE, quoted:

SELECT a.pid, coalesce(a.application_name, ''), coalesce(a.state, ''),
       to_char(a.backend_start, 'YYYY-MM-DD HH24:MI:SS TZ')
  FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
 WHERE l.locktype = 'advisory' AND l.granted AND l.objsubid = 1
   AND l.classid = (7021788497314804855 >> 32)::oid
   AND l.objid   = (7021788497314804855 & x'ffffffff'::bigint)::oid
 ORDER BY a.pid LIMIT 1;

The holder's application_name tells you which caller it is: the box's own pass wears <INTERNAL_APP_PREFIX>_tenant_wall and the CLI wears argondb_lake_tenant_cli (crates/argondb-ctl/src/lake_tenant.rs:240-242). Raise the wait with ARGONDB_TENANT_WALL_LOCK_WAIT_SECS if an 80-tenant pass is in front of you (:179-183).

Gate: testing/box/provisioner-exclusive-up-after-tail.sh, 6 phases, both polarities (docs/reserved-identifiers.md:6734).

4.9 Migrate every tenant's schema in one pass — FROM SOURCE

One database per tenant has a cost that shows up the first time the schema changes: a company created last month and a company created today are each at whatever version their own tenant create shipped. argondb estate migrate closes that gap. It brings EVERY tenant on the box to one version of the uniform schema in one pass, through the same per-database function step 5 of tenant create calls (tenant_create::apply_uniform_schema), so no tenant is migrated by a path the others were not (crates/argondb-ctl/src/estate_migrate.rs:1-17).

# bring every tenant to the version this build ships (the default --to)
argondb estate migrate --data-dir "$DATA" --port 5433

# or to a version you name
argondb estate migrate --to 2 --data-dir "$DATA" --port 5433

(crates/argondb-ctl/src/main.rs:555-572.) What one pass does, in order (crates/argondb-ctl/src/estate_migrate.rs:290-315):

  1. It enumerates the estate directory, argon_estate.tenant, and asks pg_database which of those databases really exist. A directory row whose database is gone is REFUSED by name. A database with no directory row is not migrated at all: adopting it is a decision, not a side effect of a pass (:34-40, :342-353, :372).
  2. It asks each tenant its own version, from that tenant's own argon.schema_version — never from the directory row. "Never applied" and "version 0" are different answers and are kept apart (:42-49).
  3. It decides apply, skip or refuse for every tenant before changing anything (plan, :175). A tenant at or above the target is SKIPPED and counted; nothing is ever rolled back. A target above the highest version this build ships is refused for every tenant before any schema moves.
  4. It applies, and a failure is that tenant's refusal, never the end of the pass. One company whose migration fails does not leave the other N-1 un-migrated (:80-91).
  5. It records. The version is read BACK out of the tenant and only then written to the directory row, so the row can lag its database and can never run ahead of it (directory_value, :216); and one row per tenant per pass goes into argon_estate.schema_migration, outcome applied, at-target or refused with the reason (JOURNAL_DDL, :54-70).

Every pass ends with one count line, zeros included, so "nothing refused" is said rather than implied (summary_line, :277-286):

[argondb-box] estate migrate --to 2: 2 tenant(s) applied, 1 already at target (skipped), 0 refused (named, pass continued), 3 row(s) walked

The exit code is non-zero if any tenant was refused, after the pass has migrated every other tenant and named every refusal on stdout (crates/argondb-ctl/src/main.rs:4133-4147). A second run at the same target applies nothing.

What it does not do. It is a command an operator runs, not a reconciler arm on a timer: a schema change to every company at once needs an operator in the loop (crates/argondb-ctl/src/estate_migrate.rs:19-32). And the spec's rehearsal step — try the migration on a branch of that company's timeline first — is NOT built; the pass goes straight from reading the version to applying it (docs/2026-09-04-erp-spec.md §16). Read the defect paragraph at the end of §4.0 before you rely on the default --to.

Gate: testing/box/uniform-schema-migration-across-tenants.sh (P0-P10 — three tenants at two versions, a planted refusal in the middle tenant with the tenants on both sides still migrated, the journal, a re-run as a no-op by count, and an unshipped target refused).

4.10 Create a tenancy, and publish its Identities to every other tenancy — FROM SOURCE

A tenant belongs to a TENANCY — one multi-tenant system in the Estate, the Distributor Tenancy distributors or the Manufacturer Tenancy manufacturers (--system in §4.0; the CLI's word for a tenancy is system, and the command name stays argondb system create). Creating the tenancy is the other §13.4 API, and like tenant create it is ordered, journaled, probed and safe to re-run (crates/argondb-ctl/src/main.rs:1060-1083):

argondb system create manufacturers --data-dir "$DATA" --port 5433
# step what exists afterwards
1 register the system's row in the estate directory
2 Identities the system's own identities-<system> database and its lake namespace
3 reader the system's cross-tenant reader holds select on the system's tenant warehouses — never the box warehouse
4 publish every OTHER registered system's reader holds select on THIS system's Identities namespace
5 live the system is marked live

(crates/argondb-ctl/src/system_create.rs:246-257.)

New in v1.5.6: step 4 grants in the same call. It used to grant only on a namespace the tail had already published into, so a system created moments ago printed DEFERRED, granted nothing, and journaled itself done — leaving the operator to run argondb system grant-identities later with nothing to say when. Step 4 now creates the Identities namespace itself when it is absent (the same act the tail's first publish performs) and grants in the same call (crates/argondb-ctl/src/system_create.rs:1486-1512, system_identities_grant::ensure_namespace_id). And the probe now reads an absent namespace, with another system registered, as step 4 UNFINISHED, so a create killed after step 3 runs step 4 again on resume instead of skipping it (:931-959).

Step 4 publishes in ONE direction, and this is the part to get right. It grants the OTHER systems' readers on THIS system's Identities. An earlier system's Identities is not granted to a system created later — the later system's create grants in the opposite direction only. So the first system you create publishes to nobody. Create the Distributor Tenancy first: each Manufacturer Tenancy created after it then grants the Distributors' reader its Identities, which is the Distributors' estate-wide read for discovery.

New in v1.5.7: a Manufacturer tenant sees a Distributor's identity once that Distributor invites it, with no operator step [D-2026-09-28-65b0a2]. The invitation is the shared-data grant you already know. The Distributor's pub.publication row names the Manufacturer's database: that is PENDING. The Manufacturer's own pub.subscription to it makes it ACCEPTED. Either one is enough. On every pass the box writes each inviting Distributor's Identities row into the Manufacturer's own derived.identities, which lists exactly those Distributors and no others (invited_by and invitation say who invited it and in what state). When the Distributor revokes (argondb publication revoke), the refresh stops. The row already landed stays, and the box log names it: revoked at <ts> — refresh stopped, the landed row is RETAINED (crates/argondb-ctl/src/invited_identities.rs).

grant-identities is still there for boxes that already depend on it. It opens the publishing system's WHOLE Identities namespace to the reading system's reader, which is wider than any invitation, so use it only when the reading system really needs every identity of the publishing one (crates/argondb-ctl/src/main.rs, SystemCmd::GrantIdentities):

argondb system grant-identities  --from distributors --to manufacturers
argondb system identities-grants                       # every recorded grant, LIVE or REVOKED
argondb system revoke-identities --from distributors --to manufacturers

Mind the first system's own message: it says "a system registered later gets its grant from its own create", which reads as if the later create covers both directions; it covers the later system's Identities only (crates/argondb-ctl/src/system_create.rs:1470-1478). A revoke refuses the foreign reader from its next request onward; what that system already landed into its own schema stays — it is that system's data (crates/argondb-ctl/src/main.rs:1145-1149).

What is not built, and printed as such rather than faked. Step 4's CROSS-ACCOUNT half — a bucket policy and an assumable role — has nothing to name on one box with no second account or region; every step 4 run prints CROSS-ACCOUNT: NOT BUILT on this box. And a system's reader is a CATALOG principal only: the credentials file still carries one box-wide reader key, not a key per system (crates/argondb-ctl/src/main.rs:1069-1073, crates/argondb-ctl/src/system_create.rs:1513-1520).

Gate: testing/box/system-create-sequence.sh — P3 asserts step 4 created the Identities namespace, P5 asserts a resume killed after step 3 runs exactly steps 4 and 5, and the system boundary is proven by a refused read.

4.11 Give each company its own MCP token — FROM SOURCE

Before v1.5.6 the box's one MCP token, ARGONDB_AUTH_TOKEN, was the only credential the MCP endpoint took, so anything you handed a company's agent could read every company on the box. Now every company carries its own token, and a session under it reads ONLY that company's database (crates/argondb-ctl/src/main.rs:672-687):

# tenant create mints the first one, into a 0600 file (§4.0)
cat "$DATA/tenants/acme/mcp-token"

# re-issue: prints a fresh token on stdout; every earlier token of acme is refused from the next request on
TOKEN="$(argondb tenant mcp-token acme --data-dir "$DATA")"

# revoke every token of acme
argondb tenant mcp-token acme --revoke --data-dir "$DATA"

What the token is and what it reads. A company token starts amc1. (company_token::PREFIX, crates/argondb-mcp/src/company_token.rs:46) and is signed by the box. A session under it reads through the company's own read role, <key>_mcp, on the company's own database: LOGIN, CONNECT on that one database, and SELECT on public including tables created later (crates/argondb-ctl/src/mcp_company_token.rs:45-87). So a read of another company's table is refused by PostgreSQL at the company's own connection, not by a filter. Exactly one token per company is live: a re-issue supersedes the last one, and --revoke removes the record and makes <key>_mcp NOLOGIN (:217-229).

What it refuses, by name. A bad token is an HTTP 401 whose body says why (crates/argondb-mcp/src/company_token.rs:102-122):

company token refused: its signature does not match its claim company="acme" (forged or re-labelled)
company token refused: company "acme" has no live MCP token (revoked, or never minted)
company token refused: company "acme"'s token was revoked by a re-issue (this is not its live token)

Two tools read box-wide state and refuse under a company token: search (crates/argondb-mcp/src/lib.rs:5028) and publish_status (:4767), both through one refusal, company_refusal (:4613-4625) — a company session gets no text search at all, not a company-scoped index. Every response carries x-argondb-mcp-scope: operator or company:<key>, so a client can see which scope served it (crates/argondb-mcp/src/company_token.rs:52-56).

Where it applies. Company tokens are accepted on the ANALYST surface only; the admin mount keeps its one operator token (crates/argondb-mcp/src/lib.rs:6244-6248). argondb serve turns them on by setting ARGONDB_MCP_COMPANY_AUTH_DIR; with it unset, company tokens are not accepted at all (crates/argondb-mcp/src/company_token.rs:48-50). The box token keeps operator scope — treat it as the operator credential it is, and hand a company's agent the company's token instead. A company session reads PostgreSQL only: the lake half of carrying the role into the read plane (Block 6 [D-2026-09-11-c64ea4]) is not built, so a company token reads no lake table — with one exception: its query reads the company's OWN <table>__changes companions, read-only and under the tenant wall [D-2026-10-03-ada8e3] (guide 4, §3.4).

Gate: testing/box/cross-company-isolation-eight-surfaces.sh, surfaces S4+ and S4- (:738-795).

4.12 Hand each manufacturer its own copy of the global aggregate — FROM SOURCE

A hub run's global aggregate (--aggregate <name>) always landed in the BOX warehouse — which no manufacturer's key opens. Now each manufacturer also gets its OWN COPY, in its own hub-<m> warehouse, so the manufacturer reads it under its own credential and the wall is not crossed (crates/argondb-ctl/src/hub.rs:870-945):

argondb hub run --source "$SRC" --ownership "$OWN_TBL" --flow upc_prices \
  --hub-table upc_hub --aggregate upc_hub_totals \
  --data-dir "$DATA" --port 5433

(The shape testing/box/upc-hub-warehouse.sh P14b runs, :1464-1468.)

A copy counts only what that manufacturer was granted. Each manufacturer's copy is computed over exactly the tenants whose grant of the flow to that manufacturer is in force at the run's pin — the run's own consent plan, not a second reading of the grants. Two states land NOTHING, not even an empty table, and say so on the run and in the ledger (hub.run_ledger.aggregate_copies):

hub: aggregate copy for <m>: NOT LANDED — no-flow: this run names no --flow, so no consent plan exists and no tenant granted "<m>" anything; a copy of the aggregate is handed only under a grant
hub: aggregate copy for <m>: NOT LANDED — no-consent: no tenant's grant of flow "upc_prices" to "<m>" is in force at pin <lsn>, so "<m>" receives no copy of the aggregate

The copy lands like the box's aggregate: created on first sight and replaced after, under the token <run>:__aggregate:<m>, so a re-run at the same token changes nothing. An aggregate whose columns would carry a tenant or a retailer identity — tenant, customer_id, customer_name, customer, name — is refused by name (check_aggregate_columns, crates/argondb-transform/src/hub.rs:184). One stale line: the --aggregate help still says the aggregate lands "in the BOX warehouse" only (crates/argondb-ctl/src/main.rs:2710-2711).

The open question, stated plainly. A copy counted over a SINGLE granting tenant shows that tenant's total volume across every manufacturer, competitors included. Whether that is acceptable is waiting on a ruling (docs/history/folded/2026-09-24-each-manufacturer-receives-its-own-copy-of-the-global-aggregate.md).

Gate: testing/box/upc-hub-warehouse.sh P14b (:1444-1549) — the granted manufacturer receives a copy and the ungranted one receives none, with the ledger saying why.


5. Built today versus designed

feature status what proves it
tenant = a database in one shared cluster BUILT Block 4 B [D-2026-09-11-1fc4b3]; docs/2026-09-04-erp-spec.md:89-100
part C — a warehouse, a bucket and a scoped store credential per tenant database BUILT (v1.5.1) crates/argondb-ctl/src/lake_tenant.rs (the provisioner); crates/argondb-lake-store/src/wall.rs:120 (the predicate); gate testing/box/tenant-wall-warehouse.sh P1-P7
the committer resolving a catalog handle PER TABLE, so tenant bytes land in the tenant bucket BUILT crates/argondb-committer/src/catalog_set.rs:226-239; gate P7, re-run after a restart (testing/box/tenant-wall-warehouse.sh:41-57)
deferral by name when a table's warehouse does not exist yet BUILT catalog_set.rs:243-257
parts A + B — catalog authorization and a login, with the box minting its own tokens BUILT 2026-09-11 Block 9 [D-2026-09-11-b5b145]; docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:846-854; crates/argondb-ctl/src/lake_grant.rs
catalog_authz DEFAULT = on with more than one database BUILT 2026-09-15 ruled by question block 9/15; docs/2026-09-15-cross-tenant-read-design.md:502-516
bundling OpenFGA in the Linux release artifact BUILT scripts/release/build-artifact.sh:243-246 (license-text gate), :584-591 (staging bin/openfga)
bundling OpenFGA in the macOS release artifact BUILT scripts/release/build-artifact-macos.sh:223-226 (license-text gate), :519-523 (staging bin/openfga against the openfga-darwin-arm64 pin)
three-level identity database.schema.table in the catalog, primary regularized BUILT 2026-09-11 Blocks 1 A / 2 B / 3 A [D-2026-09-11-702e69] [D-2026-09-11-118b2e] [D-2026-09-11-f73865]; crates/argondb-ctl/src/lake_address.rs:19-36
the identity version gate — migrate or refuse, never guess BUILT 2026-09-11 docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:470-511
the OLTP half — a per-database connection limit, fixed at 20 BUILT 2026-09-15 ruled 9/15; crates/argondb-ctl/src/lake_tenant.rs:114-141, :596; gate P10
the provisioner pass lock (one pass at a time) BUILT 2026-09-16 crates/argondb-ctl/src/lake_tenant.rs:157-183; docs/reserved-identifiers.md:6734
the cross-tenant reader — one read-only key, one catalog login, roster = the estate BUILT 2026-09-15 Block A, ruled 9/15; lake_tenant::ensure_reader; gate testing/box/cross-tenant-reader.sh
all_tenants.<schema>__<table> views with pushdown, drift named twice BUILT 2026-09-16 crates/argondb-query/src/all_tenants.rs; gate testing/box/all-tenants-views.sh
hub run reading under the reader and writing as the box BUILT 2026-09-16 docs/reserved-identifiers.md:40; docs/2026-09-15-cross-tenant-read-design.md:653
the mfr schema and the party rule BUILT 2026-09-19 docs/reserved-identifiers.md:6735; crates/argondb-query/src/all_tenants.rs:91-112
the hub return flow — a manufacturer's price list landing in each ACCEPTING distributor's derived.mfr_price_list BUILT (argondb hub return, shipped 2026-09-19, box gate green on the merged tree) — the main diagram still draws it dashed, and that is stale crates/argondb-transform/src/hub_return.rs; docs/2026-09-17-distributor-manufacturer-data-sharing-design.md §3.4; HISTORY#hub-return-landing-box-gate-on-the-merged-tree
the Postgres role travelling into the read plane (Block 6) PARTLY — refusal built, propagation into the hub's in-process read NOT [D-2026-09-11-c64ea4]; docs/lake-foreign-read.md:651-668 (the refusal) and :670-691 (the limit)
per-tenant storage timelines (per-tenant PITR, per-tenant freshness) — a tenant database as its own Postgres instance on its own timeline RATIFIED, BUILT, default OFF — each tenant compute runs on a branch cut at tenant create, and the tail follows every tenant timeline as its own stream B′ (amended) [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5]; docs/2026-09-04-erp-spec.md §2; crates/argondb-tail/src/streams.rs; HISTORY#tail-multiplexes-tenant-timelines
one compute per tenant unit RATIFIED, BUILT, default OFF under [compute] per_tenant — the switch is the operator's, never a system default; once a tenant has its compute, its writes go there [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5] [D-2026-09-23-5122a6] [D-2026-09-25-a2d0a3]; crates/argondb-ctl/src/tenant_compute.rs:105, tenant_data.rs; deep dive 2
the estate control plane — provisioning API, Identities projection and the multiplexed tail's lease, on their own estate instances, leader plus forwarding DESIGNED, NOT BUILT on the multi-instance shape; on one box it collapses to a single process and the argon_estate schema [D-2026-09-21-8b9d22]; "one-box complete" also requires showing the leader and its forwarding [D-2026-09-23-21780c]; docs/2026-09-04-erp-spec.md §13.3
a multi-tenant control plane provisioning N cloud instances (not "N boxes" — that framing was corrected) DESIGNED, NOT BUILT docs/2026-09-04-erp-spec.md §13, disposition §18, cited at docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:892-894; the cloud-not-boxes correction [D-2026-09-21-6e436d]
storage-prefix-scoped short-lived credentials (STS vending) NOT AVAILABLE with the bundled store — a hosted/BYO-S3 option docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:876-883
a cross-party mount — one tenant reads exactly the tables another tenant GRANTED it, one row per table; it CANNOT write, read an ungranted table, or reach a third party's (or any other database's) warehouse BUILT, box-proven — both polarities (granted / ungranted), both tenant-wall states (on / off) hub.mount_grant (grantee_database, source_schema, source_table, granted_at, revoked_at), published by MOUNT_GRANT_DDL (crates/argondb-transform/src/hub_consent.rs:304) and decided by grant_state (:204) through mount_grant_state (:353); DatabaseScope::Granted { owner } (crates/argondb-tail/src/lib.rs:908-918); the one named refusal on the read plane, two sentences — never-granted keeps the cross-database anchor, revoked carries the instant (crates/argondb-tail/src/flight.rs:655-677); gate testing/box/fdw-cross-party-mount.sh: fdw-cross-party-mount: PASS — 11 checks, tenant wall ON (revoke lag 28 s) and the same line, tenant wall OFF (revoke lag 29 s), ms-01
argondb estate migrate — one pass brings every tenant's uniform schema to one version, skip-at-target, a refused tenant never stops the pass BUILT 2026-09-27 — but see the version defect at the end of §4.0 crates/argondb-ctl/src/estate_migrate.rs; gate testing/box/uniform-schema-migration-across-tenants.sh
rehearsing a tenant's migration on a branch of its timeline first (ERP spec §16 step 2) NOT BUILT docs/2026-09-04-erp-spec.md §16
argondb system create step 4 publishing a new system's Identities to every other system in the same call BUILT 2026-09-27; one direction only, and the cross-account bucket policy is NOT BUILT crates/argondb-ctl/src/system_create.rs:1454-1522; gate testing/box/system-create-sequence.sh
a per-company MCP token reading only that company's database BUILT 2026-09-27 (PostgreSQL only; the lake half of Block 6 is NOT BUILT) crates/argondb-ctl/src/mcp_company_token.rs, crates/argondb-mcp/src/company_token.rs; gate testing/box/cross-company-isolation-eight-surfaces.sh S4+/S4-
each manufacturer's own copy of the global aggregate, counted over its granted tenants only BUILT 2026-09-27; the single-granting-tenant disclosure is OPEN crates/argondb-ctl/src/hub.rs:870-945; gate testing/box/upc-hub-warehouse.sh P14b
a view defined over a table the database holds only through a mount grant REFUSED BY NAME (view-input-granted) until the reader scope is ruled crates/argondb-query/src/view_classify.rs:2206-2218; gate testing/box/view-over-granted-table.sh

6. Gotchas

A tenant table published before its wall exists is DEFERRED, and the log line repeats. That is the design, not a stall: the alternative is publishing the bytes behind the box credential, which is the wall inverted. Wait one cadence or run argondb lake tenant provision (crates/argondb-committer/src/catalog_set.rs:243-257).

A database whose name is not a legal object-store bucket name is REFUSED, by name, and the pass continues. Nothing rewrites the name to fit, because a rewrite would merge tenant_a and tenant-a into one bucket — which is the wall this feature exists to build. The remedy is [lake.tenant_warehouse].<database> = { warehouse, bucket } in argon.toml, the only door for such a database (docs/reserved-identifiers.md:6732). Gate P9 proves both polarities (testing/box/tenant-wall-warehouse.sh:58-61).

warehouse_per_database = false removes the wall AND the reader. Every database resolves to the box warehouse, so reader_plan returns None and the pass REMOVES a reader an earlier configuration left behind. There is no cross-tenant read without a wall (crates/argondb-ctl/src/lake_tenant.rs:108-112, :1529-1532). An explicit true against a bring-your-own object store is also refused: the box can mint a bucket-scoped key only in a store it runs (docs/reserved-identifiers.md:6732).

With catalog_authz = "off", names are public even though bytes are not. Anything that can reach the catalog port lists every warehouse and every table name with no credential at all. The served text says so in those words. Bind the catalog to loopback and treat its port as privileged (docs/lake-foreign-read.md:196-202).

OpenFGA ships in both platforms' release artifacts. scripts/release/build-artifact.sh stages bin/openfga on Linux (license gate :243-246, staging :584-591) and scripts/release/build-artifact-macos.sh does the same on macOS (license gate :223-226, staging :519-523), so neither platform's stock multi-database box meets a boot refusal for a missing engine (docs/2026-09-15-cross-tenant-read-design.md:513-516).

A continuous view over a table you hold only through a mount grant is REFUSED at create, by name. A lake-landed view is scoped to its inputs' OWNER, so a view a grantee defined over the grantor's table would be created and then be unreadable in the grantee's own database; whether a grantee may build one at all is an open ruling (views design OPEN-V3). Until it is ruled, the create door refuses before the pin and before anything is enrolled, on both landings, with kind view-input-granted and this text (crates/argondb-query/src/view_classify.rs:2206-2218; crates/argondb-transform/src/view_door.rs:493-507):

view `<v>`: input <db>.<schema>.<table> is a mounted grant from <grantor>; views over granted tables are not ruled — [ruling pending]. A lake-landed view is scoped to its inputs' OWNER, so this one would be created and then unreadable here; define the view in <grantor>, or over tables this database owns

The remedy is in the last clause: define the view in the grantor's database, or over tables yours owns. Two edges. The check runs only when the create request names a database (crates/argondb-transform/src/view_door.rs:504), so a request with none is not checked. And the lane that built the refusal escalated three defects it did not fix, reproduced with an OWNED input as well: the tail panics on a view that was declared and never refreshed (crates/argondb-tail/src/lake_owned.rs:114), a view created through argon.create_view fails every refresh on its recorded lake_main. qualifier, and under the tenant wall the create door cannot plan any tenant database's table (docs/history/folded/2026-09-24-a-view-over-a-granted-table-is-refused-by-name-until-the-reader-scope-is-ruled.md). Gate: testing/box/view-over-granted-table.sh.

Hand a company's agent its own MCP token, never the box token. The box's ARGONDB_AUTH_TOKEN keeps operator scope over every company; the company token reads that company's database only (§4.11). A company session has no search and no publish_status — both read box-wide state and refuse by name — and reads no lake table, because the lake half of the role's propagation is not built (company_refusal, crates/argondb-mcp/src/lib.rs:4750-4762).

argondb system create publishes one way. A system's Identities reaches the systems that existed when it was created, not the ones created after it. A Manufacturer tenant does not need that grant to see the Distributors that invited it: since v1.5.7 an invitation lands their identities in its own derived.identities with no operator step [D-2026-09-28-65b0a2]. argondb system grant-identities --from <earlier> --to <later> opens the whole namespace and is wider than an invitation (§4.10).

Revoking a grant leaves the token valid until it expires. Nothing here keeps a revocation list. Use --ttl (crates/argondb-ctl/src/lake_grant.rs:281-287).

The operator is inside every wall. The root store key and the argondb-box catalog subject are plural over everything, the box warehouse included, and they write. That is drawn in the main picture and listed as one of its two residuals; it is not something the wall is intended to stop (docs/2026-09-15-cross-tenant-read-design.md:190-195).

Row-level security does not reach the lake copy, and the Flight-door refusal does not cover the catalog door. A reader holding the catalog URL and the store credentials reads every row of every published table, row security or not. Those credentials are the only thing standing between such a reader and the data (docs/lake-foreign-read.md:670-680). Two related surprises: a table already protected the first time the lake mounts it gets no mount at all (the type-learning read is subject to the same wall; run SELECT argondb_lake_sync() as a role row security does not apply to), and toggling row security reaches the wall on the schema cadence rather than instantly (:693-705).

The read plane's assertion is believed, not verified. The compute asserts "this read is for role R and row security does not apply to R here", and the read plane cannot check it, because the only credential on the wire is one process-wide bearer token. Anyone holding ARGONDB_AUTH_TOKEN can assert any role: do not hand it to analysts (docs/lake-foreign-read.md:682-691).

SHOW ALL TABLES does not list a nested table. DuckDB enumerates GET /namespaces with no ?parent and never recurses; a table you have not NAMED is absent from the listing. USE lake."br_main.<db>.<schema>" first and it appears (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:246-250; the catalog-walk recipe is docs/lake-foreign-read.md:468-520).

A copy that already has a column named tenant cannot be unioned, and neither can a column whose types across tenants the engine cannot coerce (Int32 with Boolean). Both are refused BY NAME and the view is absent from the namespace — never silently narrowed (crates/argondb-query/src/all_tenants.rs:53-58, :60-64).

A session with no roster has no all_tenants namespace at all. The namespace is composed at pin time from what the session registered, so a roster-less session is not a session with an empty view set (docs/lake-foreign-read.md:315-318).

Do not size a deployment from the numbers in §3.7 without re-measuring. The earlier figures on that same line do not reproduce, and the document that carries them says so (docs/lake-foreign-read.md:404-409).

Two connection-limit surprises. Superusers are exempt, and the limit counts connections to the DATABASE rather than by role (crates/argondb-ctl/src/lake_tenant.rs:136-141). A limit an operator set by hand is never touched by a plain pass — neither lowered nor raised — so a tenant sitting at a hand-set 5 stays at 5 until an explicit override says otherwise (:749-753).

Cross-warehouse atomicity of a cut is not claimed. A cut spanning two tenants is one transaction per warehouse; a cross-tenant pin lands at the newest LSN every scoped table in every tenant warehouse serves, and says per warehouse which coordinate it resolved (docs/2026-09-15-cross-tenant-read-design.md:324-343).

Lake freshness is box-granular — with per-tenant compute off, the shipped default. Every tenant is then on the primary's one timeline, so one tenant's write volume gates every other tenant's watermark (docs/2026-09-15-cross-tenant-read-design.md:591). Per-tenant freshness needs a separate Postgres instance and timeline per tenant; that is the ratified topology [D-2026-09-20-0d5ba3] [D-2026-09-21-4107c5], built default off, with the tail running one stream per tenant timeline (HISTORY#tail-multiplexes-tenant-timelines), and the operator turns it on, not the system [D-2026-09-23-5122a6].

The catalog's REST {prefix} is the warehouse's UUID, not its name. /catalog/v1/argon/namespaces answers 400 {"error":{"type":"WarehouseIdIsNotUUID"}}; the resolver is the config response's defaults.prefix, read from GET /catalog/v1/config?warehouse=<name> (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:191-196).

Not measured, and named as such: Trino. No Trino was stood up. Its Iceberg REST connector is documented to map a namespace to a schema with the same three-part budget, so the same arithmetic is EXPECTED to apply — that is an inference, not a measurement (docs/2026-09-10-multi-database-identity-and-tenant-isolation-design.md:257-262).


7. Sub-diagrams

Three deeper pictures of parts the main diagram compresses. Each is generated, not drawn: the source is a Python script beside the others, using the same palette and legend, and python3 <script>.py regenerates the SVG.

7.1 One provisioning pass, and the table that arrives before its wall

Source docs/diagrams/argondb-and-the-erp/generate/d01a_provisioner_pass.py → docs/diagrams/argondb-and-the-erp/01a-provisioner-pass.svg. Embedded at §3.1. It carries the two callers and the pass lock; the four artifacts in the order they are made and why that order; the two cluster-wide halves (the connection limit and the reader); and the deferred-by-name path end to end, with the refusal text's own reasoning beside it.

7.2 The five principals, and what each one can reach

Source docs/diagrams/argondb-and-the-erp/generate/d01b_principals_and_buckets.py → docs/diagrams/argondb-and-the-erp/01b-principals-and-buckets.svg. Embedded at §3.4. The store's predicate arm by arm with its refusal wording, the catalog's four subjects and the grant verb, and a reach table reading every principal against every bucket and two warehouses.

7.3 One relation, its spellings, and the door that answers each

Source docs/diagrams/argondb-and-the-erp/generate/d01c_identity_three_spellings.py → docs/diagrams/argondb-and-the-erp/01c-identity-three-spellings.svg. Embedded at §3.8. Where an identity starts (a PostgreSQL relation plus a publish marker), the four spellings it has, which argondb lake address field answers each, the two failures of assembling it by hand, and what the regularization of the primary changed.


8. References

Read in full for this guide; cited above by path:line.


Guide 2 — the per-tenant unit: the gate, wake and stand-down

Audience. A senior software developer who has to USE this system: set the knobs, predict what a client sees, size a box, run an application beside a tenant's database, and know which of these capabilities exist today.

Register. Internal technical reference. Every claim carries its source by path:line or by document section; a claim with no source is a defect in this document. Where the picture says a thing is designed and not built, this guide says so in the same words and does not soften it.

Two names, ruled. Our WAL-to-Iceberg process is the tail (argondb-tail). "Sidecar" names only the tenant's own application — block S4, RATIFIED [D-2026-09-18-07c9d9] (docs/2026-09-17-process-sidecar-design.md:1177). The bare word never names our process anywhere below.


1. The diagram

The per-tenant unit: the gate, wake and stand-down

Read the picture left to right along the top and you have the control path. A visitor — a browser, an application's API client, an SQL client, an AI agent — reaches the gate, a single process that fronts both the tenant's SQL port and the tenant's application port (the second listener is built: see the HTTP 503 paragraph at the end of §3.6). The gate is the only component that sees every byte to either, and that is what entitles it to decide who is awake. Its trigger is the first byte, never the accept(): a connection that arrives and sends nothing is dropped after thirty seconds with nothing spawned (crates/argondb-ctl/src/idle_stop.rs:101, :517). Below the gate is the supervisor's process table, and the table has two halves. On the left is the resident plant — broker, pageserver, safekeeper, the lake's object store and catalog, the policy engine, the tail, the maintenance worker, the agent endpoint — which never stops and is paid for once per box, measured at 265 MB (docs/2026-09-12-tenant-runtime-sidecar-design.md, §9.1, the M1 storage+supervisor line, 231 116 kB). In the middle is one tenant's unit: compute:<db> and at most one sidecar:<db>, and nothing else. That unit is the thing that stops.

The second thing the picture is saying is about its own outlines. Solid means built and shipped; dashed means designed, measured and not built. The picture's dashed unit box is stale. When it was drawn the tenancy topology B′ — one compute per tenant database — had been put to a block on 2026-09-12 and NOT selected [D-2026-09-12-44e647]. It was RATIFIED on 2026-09-20 [D-2026-09-20-0d5ba3] and amended [D-2026-09-21-4107c5], and the N-computes-one-per-tenant shape is BUILT: [compute] per_tenant gives every tenant database its own compute:<db> row, its own port pair, its own idle clock and its own gate (crates/argondb-ctl/src/tenant_compute.rs; §2.4, §2.8). It is default OFF and it stays the operator's setting, never the system's [D-2026-09-23-5122a6]. With the knob off the box runs the v1.4 shape, one primary compute per box, with [compute] idle_stop default OFF (ratified [D-2026-09-08-c60bce]). The per-company application process is built too, in a different shape from the row this chapter's §2.5 designs (§2.5 says how). So the reading this guide takes is that the mechanism (the gate, stand-down and wake) is shipped and proven, and the multiplicity is shipped behind an operator's switch, with the limits §2.7 and §6 name. The three boxes on the right that this diagram draws OPEN are RULED now, two of them against this design's own recommendation: G1 — the on-prem box keeps rung 1, but the one-click cloud VM requires rung 2, a container per tenant, AGAINST the recommendation [D-2026-09-20-3425d9]; G2 — a tenant's application is always a SEPARATE OS PROCESS in the box supervisor's table, never inside the database's process, as recommended [D-2026-09-20-4faa8c]; G4 — bytes are the only idle signal, AGAINST the recommendation: a silent WebSocket does not hold a tenant awake [D-2026-09-20-78f71e]. §2.6, §2.8 and §5 carry the detail; the picture's own OPEN styling on these three boxes is stale.


2. What it is

One paragraph per box in the picture, carrying the coordinates, states, limits, defaults and failure modes a block diagram cannot show.

2.1 The gate

The gate is idle_stop::serve_gate (crates/argondb-ctl/src/idle_stop.rs:693) and it is about ninety lines. With [compute] idle_stop ON the supervisor owns the public PG port and Postgres is moved to public + 100 (PG_INTERNAL_DELTA, idle_stop.rs:89; for the default box that is 5432 → 5532, and an overridden ARGONDB_PG_PORT shifts both by the same amount, so two boxes with distinct primaries get distinct internals). It binds exactly the address Postgres would have bound with the knob off (GateConfig::bind, idle_stop.rs:410), so a client sees no difference in its connection string. Per connection it spawns one tokio task (handle_conn, idle_stop.rs:822) and does exactly four things: buffer the first bytes, ask the supervisor to wake if the compute is down, connect to the internal port, and splice. The connect goes to the internal port ON THE ADDRESS THE CLIENT REACHED THE GATE ON (upstream_addr), never a fixed 127.0.0.1: the box hba trusts its own roles on loopback, so a gate that dialed loopback for everyone would present an off-box client as a trusted local one — which it did until 2026-09-24 (testing/box/bind-policy.sh Arm E is the gate). It parses no protocol — a SSLRequest, a GSSENCRequest, the startup packet and a cancel request all reach the real backend unaltered, because the gate never speaks Postgres to the client on the happy path. Two failure modes, both with real messages: a client that sends nothing inside FIRST_BYTES_TIMEOUT = 30 s (idle_stop.rs:101) is closed silently and wakes nothing; a wake that fails gets a genuine Postgres wire ErrorResponse carrying SQLSTATE 57P03 cannot_connect_now (idle_stop.rs:425, :448). A per-connection accept() error (EMFILE, ECONNABORTED) is logged and the loop continues (idle_stop.rs:486-492) — abandoning the port would leave the box's only client surface dead with the supervisor still up; only the initial bind failure is fatal.

2.2 The supervisor's process table

serve_inner builds ONE Vec<(&'static str, Child)> at boot and never reshapes it. Three parallel vectors are keyed on the same index: policies[i] (the restart policy), tripped[i] (the breaker), and stood_down[i] (crates/argondb-ctl/src/serve.rs:8866). The table is already heterogeneous — four inherited Rust binaries, two PostgreSQL clusters, a Go policy engine, a Rust catalog server and three of our own workers (docs/2026-09-12-tenant-runtime-sidecar-design.md, §2.1's row table) — and every row shares four things: a command rebuilt identically on respawn by respawn_command, a port resolved by the one predicate child_port, a log through spawn_logged (stdout AND stderr into one O_APPEND file, Fresh on first spawn and Append on respawn, so a panic outlives its own recovery), and an ownership stamp — ARGONDB_BOX_INSTANCE in the child's environment plus (name, pid, start-time, port) in the children ledger, which is how the reaper proves what belongs to this box without matching a process NAME. Every child leads its own process group (.process_group(0)), which is what makes kill(-pid, SIGTERM) on a compute_ctl also reach its postmaster. An application sidecar is not a new kind of row; it needs those four plus two the others do not — a health probe and an isolation spec. It also settles where the row lives: a tenant's application runs as a SEPARATE OS PROCESS in this same table, under its own uid and cgroup, never inside the database's process — RULED [D-2026-09-20-4faa8c] (block G2); the in-process Rust embedding tier this rules out is reconsidered only on a measurement showing the loopback round trip moves a real workload, and that measurement is not commissioned.

The supervisor's process table, row by row

2.3 The resident plant

Broker, pageserver, safekeeper, lake-store, catalog-pg, openfga, lakekeeper, argondb-tail, argondb-maintenance and argondb-mcp are resident: they are never stood down and a second tenant does not pay for them again. Measured on the box with one database, lake off, pool off: the whole box is 368 205 kB PSS, of which 231 116 kB is storage plus supervisor (docs/2026-09-12-tenant-runtime-sidecar-design.md, §9.1's verbatim M1 block). Two rows deserve their own note. catalog-pg is the bundled catalog's OWN Postgres, and it exists because until 2026-09-08 the bundled Lakekeeper stored its Iceberg metadata IN the primary that idle-stop stops — so idle_stop ON REFUSED that shape at config load. That refusal is gone, and its removal is the whole point of [D-2026-09-08-ca2e96] option B (crates/argondb-ctl/src/serve.rs:5759-5772). argondb-tail is resident by decision, not by omission: when the tenant's compute is stood down there is no WAL to decode, the tail sits at baseline, and its only use of the primary is schema introspection over the internal port with the internal application name — so it can never wake a stood-down tenant and never resets its idle clock (docs/2026-09-17-process-sidecar-design.md:254-265, :757-770). This residency — one process per tenant BOX, never idle-stopped, not moved into the supervisor's own process and its cache not moved to the pageserver — is RULED, not just built: Block S1, the process boundary [D-2026-09-18-515279]. Its row carries a stated memory bound, 6 144 MiB with the alarm line at 5 632 MiB (docs/2026-09-17-process-sidecar-design.md, §4.2's delta table); the budget per buffer, the 6 GiB cgroup memory.max bound alarmed at 5.5 GiB, and the 512 MiB floor a low share derives down to before the box refuses are Block S2, adopted as all three [D-2026-09-18-ff217a]. One tail per tenant DATABASE, as opposed to per box, is not claimed here: the ruled row is one tail per box, and it stays one tail per box with per_tenant on.

2.4 compute:<db> — the tenant's database

compute_ctl plus its postmaster, with its own pgdata, its own spec and its own port. The property that makes stopping it a memory decision rather than a durability decision is that a compute is stateless over shared storage — analyst_pool.rs wipes pgdata before every slot spawn precisely so durability lives in the storage tier and nowhere else (docs/2026-09-12-tenant-runtime-sidecar-design.md, §2.3, citing analyst_pool.rs:1664). Measured: one idle compute returns 133 906 kB to the box when it stands down (box PSS 361 561 kB UP → 227 655 kB DOWN), and is re-paid in 619 ms p50 (min 613, max 636, ten cycles) when someone comes back (§9.3a and §9.3 of the same document). Failure mode: a compute that never accepts TCP fails the wake loudly with its own log tail, charges the wake governor with healthy_reset = false, and — after six failures in 120 s — opens the wake breaker, after which every connection is refused by name until the box is restarted (crates/argondb-ctl/src/serve.rs:9161-9166, :9250-9262).

A company's compute starts at tenant create, on a port recorded at create. Ruled by block 2026-09-25 ~11:1xam ET, verbatim: "(a), a compute started at tenant create with ports assigned and recorded at create so a new company runs on its own compute from its first minute, is chartered now and is a ship item for the release after v1.5.5." Step 2 of tenant create assigns the company the lowest free slot of the tenant band and records it in tenants/<db>/compute-slot.json (crates/argondb-ctl/src/tenant_compute.rs fn assign_slot), then asks the RUNNING supervisor to start its compute: the request and the answer are two files under <data>/tenant-compute-start/, and the supervisor adds the row, its per-child state, its idle clock and its wake gate to the live table and publishes the port to the estate directory before it answers (crates/argondb-ctl/src/serve.rs fn start_tenant_row_at_create). The create returns only once that is true, so the company's first write already goes to its own compute and the primary refuses it. A compute that cannot start fails the create BY NAME at step 2, taken back out of every table — no database, roles, wall or published port exist yet — and re-running the command resumes onto the same slot. The slot, not a sorted position, is the port: inserting a company into the sorted list used to move every later company's port. Companies that predate the record keep the port they had — the boot's one-time migration records each one's sorted position once (fn migration_plan). v1.5.5 does carry this. The start at create is in the tagged release, and the v1.5.5 notes as cut say a company created while the box runs gets its own compute at creation (docs/releases/v1.5.5.md, section 26, item 9). An earlier draft of those notes, written on 2026-09-25 before the start at create merged, said "at the next restart", citing the data-half ruling [D-2026-09-25-f80e7e], and this chapter had repeated that draft. What remains of "the next restart" is a company created under v1.5.4: its pool entry comes at the upgraded box's first estate pass.

Rows written before the compute existed are copied once, and then the primary refuses the company's writes. Once a company has its own compute, the pass installs a refusal on the primary for that company's database: a statement trigger on every table refuses INSERT, UPDATE, DELETE and TRUNCATE, and an event trigger refuses DDL. Both name the company, its compute's port and the ruling (crates/argondb-ctl/src/tenant_data.rs fn refusal). Reads on the primary are unchanged. Only after that refusal is in place does fn copy_once copy the rows that had landed on the primary onto the company's own compute, once, rolled back whole on failure. The company's roles are replayed onto its compute by the same pass, from the primary's catalog (crates/argondb-ctl/src/tenant_roles.rs fn ensure_on_computes), so a connection routed there logs in as itself. §2.7 states the one known defect in the copy.

2.5 sidecar:<db> — the tenant's application

A per-company application process is BUILT, but not in the shape this section designs. What ships (crates/argondb-ctl/src/tenant_sidecar.rs, box gate testing/box/erp-sidecar-per-company.sh) starts one process per company, from a step of tenant create that runs after the company's compute is up. The command is read from the estate directory's sidecar_command column for that company, never from a path convention. A company whose column is empty is a complete tenant with no process at all. The process runs under the company's own recorded uid and its own cgroup, and its state is the tenant's own <data>/tenants/<db>/sidecar.json. A later pass adopts a live one, restarts a dead one, and refuses a second restart inside the breaker window. It is not a row in the supervisor's table. Under [compute] per_tenant the tenant's gate fronts its recorded APPLICATION port (§3.6): a request there to a stood-down tenant wakes it and is told to retry. It does not yet hold the tenant awake — bytes on the application port do not reset the idle clock (§3.7). Only the application's own SQL connections do that, because they pass through the compute's gate like any other client. Its limit, stated plainly: two companies need two distinct uids, creating a uid is a root step (useradd), and the product refuses to share the supervisor's uid between companies. Since v1.5.7.2 that root step is the sudoers-scoped argondb-useradd helper's, not a human's [D-2026-10-03-37002a]: root runs sudo <prefix>/bin/argondb box install-useradd-helper --user <supervisor-user> once per box, and the sidecar then calls sudo -n /usr/local/sbin/argondb-useradd <key> itself when argon-t-<key> is absent. On a box without the helper the company's application refuses by name, naming that install command.

The rest of this section, and §4.5, is the supervisor-row design, which is still designed and not built. One more row per tenant, whose command is whatever <data>/tenants/<db>/run names. The supervisor runs what is there and does not build it, deploy it, update it, version it, roll it back, or fetch it; a command that does not resolve is a refusal at spawn with the path named, exactly like a missing compute_ctl today — never a silently absent row (docs/2026-09-12-tenant-runtime-sidecar-design.md:364-372). Its environment is .env_clear() plus an explicit set, which is the single most important line in its spawn, because Command inherits the parent's environment by default and the supervisor's holds ARGONDB_AUTH_TOKEN, the lake credentials and ARGONDB_BOX_INSTANCE. ARGONDB_BOX_INSTANCE is the one variable re-added BY HAND: a sidecar that cleared it would be unreapable, which is worse than the leak the stamp exists to prevent (§4.2c of the same document). It gets exactly one secret — its own tenant's SQL credential — and a health probe that is an HTTP GET or a TCP accept. Its restart policy is an ordinary RestartPolicy::new(), and a tripped sidecar cannot take the tenant's database down: the trip arm sets tripped[i] for that row, records DEGRADED, alarms, and explicitly keeps supervising the rest.

2.6 The isolation box

Rung 1 of a four-rung ladder: one unix user per tenant plus one cgroup carrying MemoryMax, CPUQuota and TasksMax. The cap half needs zero privilege and is measured; the uid half needs one root command per tenant at provisioning and none at run time. What it does not cover is stated rather than left for the word "isolation" to carry: the tenant's own data (the app reads it, that is what it is for), a kernel-level escape (a uid is not a VM), and what the application does with data it is entitled to. §4.6 has the recipe and §6 has the gotchas.

Which rung each deployment target gets is now RULED, block G1 [D-2026-09-20-3425d9] — against this design's own recommendation. The on-prem box keeps rung 1 as above (rung 0, process-only, when no sidecar is configured); the one-click cloud VM requires rung 2, a container per tenant, on top of rung 1 — designed in docs/2026-09-20-rung-2-container-per-tenant-on-the-cloud-vm-design.md, not built, with its own blocks R1–R5 unput. Hosted multi-tenant — our hardware running code we did not write — is NOT covered by this ruling; a uid is not proportionate to mutually hostile tenants, and its tier is decided when that product is designed. The kernel-level-escape gap stated above is unchanged by G1: it is stated as uncovered wherever the isolation is described, on every rung this ruling names.

2.7 The pooler

Built and shipped 2026-09-20, no longer dashed. One PgBouncer process per box, resident, transaction mode, one pooled port for the box routed by the startup packet's dbname, with the direct port still there — block P1, option A, RULED [D-2026-09-20-d5c0f2]. Pool size follows the tenant's own connection limit at one quarter, otherwise option A's defaults (reserve_pool_size 0, max_db_client_connections 200, box-wide max_client_conn 10000, server_idle_timeout = the tenant's idle_secs, min_pool_size 0, max_prepared_statements 200) — block P2, option C, RULED [D-2026-09-20-5b6a85]. The pooled port is the DEFAULT connection string the provisioner prints; the direct port is printed beside it, labeled for migrations, LISTEN/NOTIFY, session SET, session advisory locks and desktop tools — block P3, option A, RULED [D-2026-09-20-5f9e67]; the box's own tools (argondb, the tail, --pg-url defaults) use the direct port regardless. The pooled port is a derived sidecar of the PG port it fronts (PG_PORT + 200, pooler::POOL_PORT_DELTA), not a separate band entry, so it moves with whatever port the primary is on. crates/argondb-ctl/src/pooler.rs is the one home of the shape (sizing, the ini generator, the auth lookup, the two connection strings); the supervised child is pooler in serve.rs; the box gate is testing/box/pgbouncer-pooler-gate.sh.

The pooler is PgBouncer specifically, pinned at 1.25.2 (a source build — Ubuntu's packaged 1.25.1 predates the SCRAM fixes the pin needs). The pooler is OPTIONAL — AMENDED 9/24 [D-2026-09-24-f0e068]: it is used when a supported binary is present, and the box boots on the direct port and says so when one is absent; no profile refuses to boot over it. [pooler] enabled is on, auto or off, and on and auto now share one behavior — pool if a working binary is found, otherwise decline loudly and serve the direct port only; the installer writes enabled = "on" as intent, not as a refusal switch. A binary below the 1.25.2 floor (pooler::MIN_VERSION), or one that cannot report its own version, is treated the same as absent.

What that means for a developer, key by key (pooler::Enabled::parse):

[pooler] enabled what the box does
"on", "auto", true or no key runs the platform's PgBouncer when a supported one resolves (ARGONDB_PGBOUNCER_BIN, else the first pgbouncer on PATH), and prints its path and version on the boot line; otherwise boots on the direct port and prints one pooler: NOT RUNNING line on the banner and in argondb status, naming the binary, what it found, the 1.25.2 minimum and the Ubuntu install line
"off" or false runs no pooler: pooler: OFF ([pooler] enabled = "off"); tenants reach Postgres on the direct port only
anything else REFUSED at config load, naming the three values. A misspelling never falls back to a value the operator did not write

The connection string a tenant is handed is the pooled one only when a pool actually came up; otherwise it is the direct one. The pooler serves non-superuser login roles only, so cloud_admin is refused on the pooled port by design. The release installer (scripts/release/install.sh, write_pooler_intent) writes [pooler] enabled = "on" into <data>/argon.toml only when the key is unset, and never over a value already there.

With [compute] per_tenant on, each tenant's pool dials that tenant's own compute. The one routing predicate is pooler::upstream_port. With per_tenant off, every [databases] entry dials the box's gate port, exactly the v1.4 bytes. With it on, each entry dials the compute_port the estate directory records for that database. A database with no recorded port gets no entry at all, and the box log names it (pooler::unrouted_line, "it is NOT routed to the primary"). It is never quietly sent to the primary. Because a company's compute now starts at tenant create (§2.4), the create's own pass writes its pool entry. The no-entry case is left for a company whose compute is not up yet, which is at most one pass's cadence.

What per-tenant pooling does NOT prove today, stated plainly. The routing is proven in both directions: the pooler's box gate (testing/box/pgbouncer-pooler-gate.sh) lands each company's pooled session on its own compute under per_tenant on (inet_server_port() is the company's port, never the primary's), and on the primary under off. The routing, the roles replay and the data half are all in v1.5.5. What has not passed on main is the cross-company isolation gate with per_tenant on. The lane that set out to prove it, attempt 3 of pooler-ini-dials-each-tenants-own-compute-port-when-per-tenant-is-on, merged with a FAIL verdict: its dependency measured cross-company-isolation-eight-surfaces: FAIL (25/26 assertions passed [per_tenant=on], with S2+ failing on relation "ledger" does not exist because at that moment a company's data was not yet on its own compute. The data half landed later. A fourth attempt then reached 26/26 [per_tenant=on] only on its own branch, and that branch is not on main. On a tree equal to main it first measured a defect that is therefore still there. The publish-status writer can create argon.publish_status on a company's newly awake compute before the one-time copy runs, and the copy then fails and is rolled back whole. The log says tenant data: <company> — the one-time copy NOT done this pass, and every later pass refuses the copy because the compute already holds that table. That company's pre-compute rows stay on the primary, readable there, and are not on its own compute. A box that has hit this is not repaired by any later pass. With per_tenant off (the default) none of this applies: the isolation gate reads cross-company-isolation-eight-surfaces: PASS (26/26 assertions [per_tenant=off]. Whether that binary is vendored into the release artifact or required from the platform was its own, separate block: RULED [D-2026-09-23-520b66] — require it from the platform, rather than bundle a third-party binary into the release — this half stands. That ruling's OTHER half — refuse to boot if the binary is absent — is SUPERSEDED by the 9/24 amendment above, and the release artifact already matches the platform-required half: scripts/release/build-artifact.sh refuses a --pgbouncer flag by name ("the artifact bundles NO pgbouncer"), so no un-vendoring work remains owed.

2.8 The TODAY vs DESIGNED column, and the tenancy question under it

B′, one compute per tenant database, is RATIFIED and BUILT. It was put to a block on 2026-09-12 and not selected then [D-2026-09-12-44e647]. It was ratified on 2026-09-20 [D-2026-09-20-0d5ba3] as "each tenant database served by its own compute that stops when idle and wakes on connect", and amended on 2026-09-21 [D-2026-09-21-4107c5]. The code is crates/argondb-ctl/src/tenant_compute.rs, switched by [compute] per_tenant (§3.1). Every tenant database in the provisioner's record gets a compute:<db> row. On Linux and Mac there is no count cap [D-2026-09-28-715d12]: each new tenant compute takes the next free pair of ports at or above tenant_port_base (default 25450, tenant_compute::DEFAULT_PORT_BASE), skipping every port the box reserves for itself and every port another listener already holds (tenant_compute::next_free_pair). The first port of the pair is the public one, owned by that tenant's own gate; the second is the port its Postgres really listens on. The pair is recorded in tenants/<db>/compute-slot.json (SlotRecord) and reused on every restart; if another listener holds a recorded port at boot, that tenant alone is refused, by name, naming the port and its holder, and the box keeps serving every other tenant (recorded_pair_conflict). A compute that cannot start for lack of memory is refused the same way (memory_refusal). The AWS/Azure approach is a separate v1.5.7 question and is not built. Each row has its own idle clock and stands down and wakes exactly as §3.3 and §3.4 describe for the primary.

The amendment [D-2026-09-21-4107c5] puts each tenant compute on its OWN TIMELINE with its own WAL, a branch cut at tenant create, and "one cluster" means the shared storage plane: one pageserver, one safekeeper, one lake (HISTORY#tenant-timeline-per-database). The tail follows every tenant timeline as its own stream inside one process (crates/argondb-tail/src/streams.rs; HISTORY#tail-multiplexes-tenant-timelines). The ERP spec's open item 7 was closed by the rework against this topology (HISTORY#erp-spec-topology-rework, HISTORY#erp-spec-reworked-to-per-tenant-timelines).

Separately, whether the box's SHIPPED DEFAULT ever flips to per-tenant compute is settled, and the answer is no system default at all: "Per-tenant compute is the operator's setting, never the system's" — RULED [D-2026-09-23-5122a6] (the amended B-MT1). Whatever [compute] per_tenant resolves per box, it is something an operator turns on, never something the box defaults to on its own.


3. How it works

The complete walk of the control and data paths, with what is atomic, what is idempotent, what is refused and with which message, what is disclosed, and what is measured.

Wake and stand-down, with every timeout

3.1 Configuration resolution, at boot

The [compute] section of argon.toml has three keys (ComputeSection, crates/argondb-ctl/src/serve.rs:106-133). The two per-tenant keys come first, because they depend on the third:

[compute] idle_stop itself is a string, deliberately: "off" (the default) or a whole number of seconds, one spelling for both arms so that one parser — idle_stop::parse_idle_stop (crates/argondb-ctl/src/idle_stop.rs:142) — serves both the file key and the environment knob and the two cannot drift. idle_stop = 300 as a TOML integer is refused by serde with the type in the message and the remedy is quotes. Precedence is ARGONDB_PRIMARY_IDLE_SECS > argon.toml > default OFF (resolve_idle_stop, idle_stop.rs:166), matching the box's existing shape for port and memory_budget_mb, and the env spelling wins including when it is the disabling one: an operator who exports off has turned it off whatever the file says (idle_stop.rs, the env_wins_over_toml test). Two values are refused loudly rather than defaulted, because a knob that silently becomes its default is a trap and this one decides whether the database stops:

The whole section is deny_unknown_fields, as is every section of argon.toml (serve.rs:48-57): a typo'd key is refused with the accepted set named, on the doctrine that a knob the operator believes in and that does nothing is the worst outcome a config surface can produce.

Resolution happens at serve.rs:5752, with the other config MUSTs, because it decides which ports the box binds and the preflight plans them from it.

3.2 The idle predicate

The supervisor runs ONE long-lived probe connection to the primary's internal port and executes ONE query per tick — ACTIVITY_QUERY (crates/argondb-ctl/src/idle_stop.rs:205) — at PROBE_CADENCE_SECS = 2 (:95). One round trip on a held connection, because a fresh backend every two seconds would itself perturb pg_stat_activity, which is the thing being measured. The query returns five numbers: busy client backends, the age of the most recent idle client backend, walsenders (excluding walproposer), logical subscriptions with a live pid, and autovacuum workers. Every arm mirrors the inherited compute_ctl monitor and is cited to it at the arm.

Two exclusions are load-bearing, and they are in the SQL itself rather than in the caller: application_name NOT LIKE 'argondb_internal%' (INTERNAL_APP_PREFIX, idle_stop.rs:79 — every connector the box itself makes, the probe included, which names itself argondb_internal_idle_probe, :82) and NOT LIKE 'compute_ctl%'. Without the first, the monitor would count as activity and hold the box awake forever.

Reading::hold() (idle_stop.rs:267-286) returns the first of four AwakeHold reasons in the monitor's own precedence order — BusyClient, Walsender, LogicalSubscription, Autovacuum — each of which resets the idle clock to now. A hold is a reason the database cannot be stopped. decide (:307) is pure and takes last_active by &mut, and it only ever advances it, never retreats it, so a stale reading cannot pull the clock backwards; a negative age (clock skew inside the database) is treated as "now", which is the conservative direction because it keeps the box awake. The window boundary is inclusive (recent_activity, :288): exactly idle_secs since the last activity still counts as recent, one second past does not — the identical rule, for the identical reason, as analyst_pool::recent_activity (crates/argondb-ctl/src/analyst_pool.rs:540).

A primary the probe cannot measure is never stood down. A failed probe connect logs and continues, leaving the clock alone (crates/argondb-ctl/src/serve.rs:9016-9024); a failed read drops the probe, reconnects next tick and leaves the primary up (:9029-9036). A measurement that cannot tell "zero" from "I failed to look" must fail toward AWAKE, and here it does.

3.3 Stand-down, in five steps, and the order is the fix

The arm is serve.rs:9049-9144. It runs only when idle_secs.is_some() and the compute is not already stood down (:9007).

  1. Drop the probe's own session first. Otherwise Postgres waits on us to disconnect during its own shutdown.
  2. compute_up.store(false) BEFORE any signal (:9079). This is the step whose ORDER is the whole fix. A connection arriving between the SIGTERM and the port's release would otherwise be spliced into a Postgres that is shutting down and the client would get FATAL: the database system is shutting down instead of a wake. Flipping the flag first makes that window a wake request, which the supervisor serves as soon as it is back in select. This was witnessed on the gate's second run; it is not theoretical.
  3. kill(-pid, SIGTERM) — the process GROUP, not the leader, because compute_ctl's postmaster is its process-group child and signaling compute_ctl alone can leave Postgres behind.
  4. A double-bounded wait: STAND_DOWN_GRACE = 60 s (idle_stop.rs:112) AND the child's own exit. Child::wait also REAPS, which is what lets the child be excluded from the supervisor's wait set without leaving a zombie. On expiry: SIGKILL the group, and print the log tail.
  5. Wait for the PORT to be free — poll connect(127.0.0.1:<internal>) every 100 ms for up to another 60 s (:9115-9140). This is the step a naive implementation omits: compute_ctl can exit before its Postgres has finished its shutdown checkpoint and released the socket, and a wake that spawned into that window would lose the bind to a corpse. On expiry the group is SIGKILLed with a WARNING naming the port.

Then stood_down[compute_idx] = true. stood_down is a second vector, never a third state on tripped, and the distinction matters: a stood-down child is not a tombstoned one — it is respawnable, it has charged no breaker, and the "every child tripped ⇒ the box is dead" bail must not fire because the primary is asleep.

Measured cost: 34 s against a 20 s window. The extra 14 s is Postgres's shutdown checkpoint plus step 5, and it lands on the stand-down, never on the wake (docs/2026-09-12-tenant-runtime-sidecar-design.md, §9.3a).

3.4 Wake, from the first packet

  1. Bytes arrive at the gate — and bytes, not accept(), are the trigger (idle_stop.rs:517). This is load-bearing twice: argondb status's reachability probe connects and drops, so waking on accept would make the box's own status command hold it awake forever; and every modern browser opens speculative pre-connects that send nothing, so a sidecar-fronting gate that woke on accept would hold every tenant awake forever. A read timeout or a zero-byte read returns immediately and spawns nothing.
  2. The gate checks compute_up (an AtomicBool, SeqCst) and, if it is false, sends a WakeRequest on a 64-deep channel (serve.rs:8860) and holds the client connection open while awaiting the reply, bounded by WAKE_DEADLINE = 180 s (idle_stop.rs:107).
  3. The supervisor acts (serve.rs:9152 onward), because child handles live there and nowhere else — which is what keeps ONE owner of the compute process across boot, crash-respawn and wake. It rebuilds the command with respawn_command (not a second, drifting recovery path) and spawns with LogMode::Append, so the previous incarnation's shutdown and any failure that preceded it outlive their own recovery.
  4. wait_tcp_accept, 120 × 500 ms, double-bounded on the compute pid (serve.rs:11253): it fails IMMEDIATELY with the compute's log tail when the producer dies rather than running out the clock. The deadline is the backstop, not the mechanism.
  5. On success: stood_down = false, compute_up = true, policies[compute_idx].note_spawn(now), last_active = now, a mark in the child log reading woken by a client connection on the public PG port, and the reply goes back to the gate.
  6. The gate splices: set_nodelay(true) on both halves (idle_stop.rs:584-585 — Nagle off, because a 40 ms coalescing delay per round trip is exactly what a splice-overhead measurement would find), the buffered first bytes are forwarded, and then copy_bidirectional (:590). After that the gate speaks no protocol again.

Three idempotence and race properties, each deliberate. A wake request that arrives when the compute is already up replies Ok(()) immediately — it raced another connection's wake or a crash-respawn (serve.rs:9154-9159). A wake inside the governor's backoff is refused cheaply rather than spawning, which is what turns a client retry storm into one spawn per backoff step instead of one spawn per connection. And a failed wake cleans up before it charges the governor: SIGTERM the group, wait up to 10 s, set stood_down back to true, then on_exit_healthy(now, false).

3.5 The wake governor is the restart policy, not a second breaker

wake_policy (serve.rs:11603) is built from the same RestartPolicy::new() every child respawn uses — its third consumer, after the supervised children and the analyst pool's slot recovery. The constants are one definition (crates/argondb-ctl/src/restart_policy.rs:35-43): BASE_BACKOFF_SECS = 2, MAX_BACKOFF_SECS = 30, HEALTHY_RESET_SECS = 60, BREAKER_MAX_RESTARTS = 6 in BREAKER_WINDOW_SECS = 120. Per-row constants are a supported shape: argondb-mcp trips on the THIRD exit inside sixty seconds (MCP_BREAKER_MAX_RESTARTS = 2, restart_policy.rs:67-68), because its characteristic failure is a deterministic CONFIGURATION refusal — it resolves and fail-closes every configured surface before it binds any of them, so a bad knob makes it exit in milliseconds, forever, and retrying re-reads the same environment and reaches the same conclusion.

For N tenants the consequence is the one that matters: a tenant whose compute cannot start burns its own breaker and nobody else's.

A trip does four things and no more: stop respawning that row, alarm loudly, record a DEGRADED box state readable by argondb status, and keep supervising everything else. Never a silent infinite loop — CLAUDE.md's expensive-automatic-recovery rule, N recoveries in M minutes ⇒ stop and alarm.

3.6 What a failed wake says, and why it says it in Postgres

The gate answers with a real Postgres wire ErrorResponse built by error_response (idle_stop.rs:656), written against the protocol 3.0 message grammar: byte 'E', an Int32 length counting itself but not the type byte, then NUL-terminated field entries (S=FATAL, V=FATAL, C=the SQLSTATE, M=the message), then a final zero byte. The SQLSTATE is 57P03 cannot_connect_now (:448) — the code Postgres itself uses while it is starting up, which every driver already classifies as "retry later" rather than "your credentials are wrong." There are four distinct messages, and each names its own remedy:

condition message (verbatim from the code)
supervisor shutting down the ArgonDB supervisor is shutting down; the primary compute cannot be woken
wake returned an error the primary compute could not be started: {why}
wake deadline / channel dropped the primary compute did not start within 180s; see <data>/logs/compute.log
internal port refuses the connect the primary compute is not accepting connections on its internal port {n}: {e}

Two more come from the supervisor as the {why} of the second row: the rate limit (the primary failed to start and is in a {n}s backoff; retry shortly) and the open breaker (the primary's wake breaker is OPEN after repeated start failures ({n} attempts); see logs/compute.log. It will not be retried automatically — restart the box once the cause is fixed).

The application port has the other vocabulary: HTTP 503 with Retry-After. Under [compute] per_tenant each tenant's gate owns a SECOND listener, the tenant's APPLICATION port, in front of its sidecar. The port is RECORDED, like the tenant's other ports: tenants/<db>/compute-slot.json carries app_public_port (the gate's) and app_internal_port (where the sidecar listens, loopback), each the next free port the allocator found at create — in the same pass and under the same lock as the PG and compute_ctl HTTP pairs, starting where the PG pair starts — and reused as recorded on restart [D-2026-09-28-715d12] (tenant_compute::TenantTable::app_pair_of). A record written before the pair existed gets one at boot and keeps every other port it had. The vocabulary is chosen by WHICH LISTENER accepted, never by sniffing the first bytes, because sniffing would be a grammar and a grammar is machinery (docs/2026-09-12-tenant-runtime-sidecar-design.md §5). The first-bytes rule is the SQL port's, unchanged: a browser's pre-connect wakes nothing. What differs is that the application port does NOT hold the client through the wake. First bytes to a stood-down tenant begin the wake and are answered at once (idle_stop::handle_app_conn):

HTTP/1.1 503 Service Unavailable
Retry-After: 1
Content-Type: text/plain; charset=utf-8
Connection: close

tenant acme: the database compute is waking (4 ms in; its last start took 640 ms); retry after 1s

Retry-After is computed from a MEASUREMENT, never a constant (idle_stop::retry_after_secs). It is what remains of the tenant's last measured start, rounded up and never below 1. The row's WakeClock is seeded with the compute's measured start (spawn to accepting TCP, at boot or at create — the same two steps a wake runs) and overwritten by every successful wake the gate times, on either port. If a wake fails, the next 503 names why. Once the compute is up, the connection is spliced to the sidecar's internal port exactly as the SQL port splices to Postgres. A sidecar that is not listening gets 502 Bad Gateway naming the APPLICATION and no Retry-After, because no wake can fix it. The SQL port's four messages above are unchanged. Gate: testing/box/erp-sidecar-per-company.sh P12-P15. Not built: bytes on the application port do not yet reset the tenant's idle clock (design §7.2's last_sidecar_bytes), so a tenant used ONLY through its application stands down after its window and its next request is a 503 and a wake. The supervisor also does not yet start or stop the sidecar with the compute (§3.8); the provisioning pass (argondb tenant sidecar) runs it. The sidecar learns its internal port from its own command line; the box prints it at boot (tenant compute <db> application gate listening on …).

3.7 The idle predicate with an application in it

Designed, and its shape is now RULED, block G4 [D-2026-09-20-78f71e] — AGAINST this design's own recommendation, which would have added a fourth term for an open-but-silent sidecar connection. Bytes are the only idle signal. No connection-count hold, no second, longer window for a held-open socket, no busy flag the application can raise. The rule is one component sees all the traffic, and it is a rule rather than a preference because an application that keeps its own idle timer is a second control loop over one lifecycle, and it fails in the expensive direction: an app with a 30-second poll loop is never idle by its own reckoning, so the tenant's database never stands down and per-tenant scale-to-zero buys nothing. The gate fronts both ports, so:

tenant_idle  =  sql_reading.hold().is_none()
             && now - last_sql_activity      >  idle_secs
             && now - last_sidecar_bytes     >  idle_secs

last_sidecar_bytes is the gate's OWN number — it is already counting first bytes — so this needs no probe, no agent inside the application, and no cooperation from it at all. The idle signal for foreign code is measured at our socket, not inside their process. A fifth AwakeHold is deliberately NOT added: a hold is a reason the DATABASE cannot be stopped and a busy application is not one; it is a reason the TENANT is not idle, which the predicate's third term already carries. The consequence of G4's ruling: a browser holding a WebSocket open, silent for the idle window, is not a hold — the tenant stands down under it, the client reconnects on its own next bytes and the gate wakes the tenant, and server-push over a silent socket does not survive a stand-down. Every WebSocket client must already handle a reconnect, which is the ruling's own argument for it.

3.8 Wake and stand-down order with an application

Wake: compute first, then the application. wait_tcp_accept proves the compute is serving, then the sidecar is spawned and its health probe is waited on under the same double-bounding rule — an explicit deadline AND kill -0 on its pid, failing loudly with its own log tail. Stand-down is the mirror: the application first (SIGTERM to its process group, its own grace), then the compute's existing five-step sequence unchanged. The asymmetry is the design: the app must never be up with its database down, at either end.

A SQL-port connection is released at the compute step, not the application step. A psql session does not block on somebody's web server coming up. A connection that arrived on the application port waits for the health probe, because there is nothing for it to be spliced to until the app answers. This is stated because it is the kind of thing that otherwise gets decided by accident in code.

The application half of the wake latency is not ours to bound. A Go binary answering /healthz in 20 ms and a JVM taking 8 seconds are both legitimate applications. The obligation is to MEASURE and DISCLOSE the total, not to bound it.

3.9 Routing, when there is more than one tenant

The shipped gate maps one port to one compute. A multi-tenant gate needs exactly one new thing: routing, and there are three places that information can come from — a port per tenant, the startup packet's database field, or TLS SNI. The recommendation is a port per tenant: zero protocol parsing, zero new grammar, it is what the analyst pool already does, and it composes with the application row without extra thought because that row gets a port in the same band. Parsing the startup packet would put our code in front of the TLS handshake; SNI means terminating TLS at the gate. The routing problem is already solved by the operating system.

That recommendation is what shipped. With [compute] per_tenant on, each tenant's gate owns its own public port in the tenant band (§2.8), and the pooler's one pooled port routes by the startup packet's dbname to that tenant's port (§2.7). PgBouncer does that parsing, and the gate still parses nothing.

3.10 What is measured, and under which conditions

quantity figure conditions, stated because a number without them is not a measurement
wake, connect-to-first-row on the CLIENT's clock min 613 / p50 619 / max 636 ms, 10 cycles small empty database, fast NVMe box, pageserver warm, lake off
spread across those 10 cycles 23 ms the path is repeatable, which a latency budget needs more than a low absolute
memory returned by one stand-down 133 906 kB (361 561 → 227 655 kB PSS) idle_stop = 20s; UP reading taken while an active backend holds AwakeHold::BusyClient, DOWN after the pid is proven gone
stand-down wall time 34 s against a 20 s window the extra 14 s is the shutdown checkpoint plus the wait-for-port-free step
whole box, one database 368 205 kB PSS lake OFF, pool OFF, idle_stop OFF
shared plant a second tenant does not re-pay 231 116 kB same run
per resident compute, three computes 80 280 kB two of the three were read-only trailing-head replicas
per resident compute, lone primary 137 089 kB written-to, holding two idle client backends — use this number
a booted box held idle for one hour, with one continuous view landed 0 view-maintainer WARN lines, 0 refreshes, 0 cuts, 0 landing writes; maintainer CPU 0.199 % of one core on average (max 0.617 %) 3600 s hold, zero writes, sampled every 60 s; then one write gave exactly one refresh, and a forced gap record fired gap-behind, so the zeros are not missing instrumentation (testing/t3/view-idle-hour.sh, VIEW-IDLE-HOUR: PASS (24 assertions, 0 failed; 3600s idle hold …))

What an idle box still does. The hour above is the measurement behind a plain statement: a booted box with nothing to do raises nothing and advances nothing in the view maintainer. Two things still move, and neither is a fault. The decoded WAL head creeps forward with no write and no cut (1,205,552 bytes over the hour, about 335 B/s), so watch cuts, not the head. And the maintenance sweep in the same tail process WARNs `maintenance pin

: CONSTRAINED to lsn … while head is …` about every 120 s, 21 times in the hour, on the view's own landed table (`crates/argondb-tail/src/maintenance.rs`, counted by the `maintenance_pin_constrained_total` counter in `crates/argondb-tail/src/metrics.rs`). That WARN was escalated when it was measured and is not fixed. Do not page on it for an idle box (§6).

PSS and not RSS, because every Postgres backend maps the same shared_buffers segment and summing RSS counts it once per process.

The 630-resident-computes figure that follows from 87 GB of available memory is arithmetic, not a measured ceiling, and the source says so: PSI stayed flat at three computes, so the pressure knee was never approached. Memory is also unlikely to be the binding constraint — 600 computes is 600 listeners, 600 pgdata directories, 600 timelines on one pageserver and 600 × max_connections file descriptors, and none of those is measured. The per-tenant table no longer sets a count of its own on Linux and Mac [D-2026-09-28-715d12]: the number of tenant computes on one box is bounded by free ports and by memory, and a compute refused for either is refused for its tenant alone (tenant_compute::next_free_pair, tenant_compute::memory_refusal).


4. How to use it

Every example below is labeled VERIFIED — run on the merged tree or on the agent node this guide was written on, with its real output pasted — or FROM SOURCE — the exact signature, flag, message or table quoted from the code or the design by path:line. An example that could be grounded neither way is not in this document.

4.1 Configure [compute] idle_stop, and both ways it refuses — FROM SOURCE

The section's idle_stop key, quoted from crates/argondb-ctl/src/serve.rs:106-133 (the per-tenant keys follow at the end of this section):

# argon.toml — the box's whole file surface.
# Every section is deny_unknown_fields: a typo'd key is REFUSED with the
# accepted set named, never warned about and ignored.

[compute]
# "off" (the default: the primary never stands down), or a whole number of
# seconds as a STRING. A TOML integer is refused by serde with the type in
# the message; the remedy is quotes.
idle_stop = "300"

[resources]
memory_budget_mb = 2048

Precedence, from idle_stop::resolve_idle_stop (crates/argondb-ctl/src/idle_stop.rs:166) — env beats file beats default:

# 1. the file's 300 s applies
argondb serve --data-dir /srv/argon

# 2. the env wins: 60 s, whatever argon.toml says
ARGONDB_PRIMARY_IDLE_SECS=60 argondb serve --data-dir /srv/argon

# 3. the env wins EVEN WHEN IT IS THE DISABLING SPELLING.
#    An operator who exports `off` has turned it off.
ARGONDB_PRIMARY_IDLE_SECS=off argondb serve --data-dir /srv/argon

The two refusals, verbatim from parse_idle_stop (crates/argondb-ctl/src/idle_stop.rs:142-165). Expected output for idle_stop = "5 minutes":

[compute] idle_stop="5 minutes" is neither `off` nor a whole number of seconds.
Use `off` (the default: the primary never stands down) or a positive idle window
in seconds, e.g. 300.

And for idle_stop = "0":

[compute] idle_stop=0 would stand the primary down the instant it goes idle,
which is a restart loop rather than a policy. Use `off` to disable idle-stop,
or a positive idle window in seconds.

Note the source name in the message. Set the same bad value through the environment and the first token is ARGONDB_PRIMARY_IDLE_SECS instead — one parser, two sources, and the message tells you which one you are fighting.

Turning on a compute per tenant. per_tenant is a boolean and needs an idle window beside it (§3.1):

[compute]
idle_stop        = "300"   # REQUIRED with per_tenant: each tenant's compute stands down after this
per_tenant       = true    # default false; ARGONDB_COMPUTE_PER_TENANT=on|off wins over it
tenant_port_base = 25450   # the default; slot i = base+2i public (its gate), base+2i+1 internal

At boot the box prints one line per recorded company slot and then one summary line: per-tenant computes: ON, the number of tenant databases taken from the provisioner's record, the band as <lo>-<hi>, and the idle window each compute stands down after (serve.rs, after resolve_boot_slots). A company created while the box runs gets its slot and its compute from tenant create (§2.4). Point its clients at the pooled port or at its own public port in the band, not at the primary's port. The primary still answers reads of that database, but it refuses every write to it, by name.

4.2 Observe a stand-down and a wake, with timestamps — FROM SOURCE

What the box prints, in order, with the emitting site. Every string below is quoted from the code; the wall-clock stamps are the ones a reader will see in their own log and are shown as HH:MM:SS placeholders rather than invented values.

# Turn it on with a short window and watch the supervisor's stderr.
ARGONDB_PRIMARY_IDLE_SECS=20 argondb serve --data-dir /srv/argon 2>&1 | ts

Boot — the gate announces the port split (idle_stop.rs:478-482):

[argondb-box] primary gate listening on 127.0.0.1:5432 → postgres :5532 (idle_stop ON)

A client connects and runs a query; the predicate holds the box awake and says which of the four reasons it is (serve.rs:9039-9047, AwakeHold::why() at idle_stop.rs:242-250). The line is printed only when the reason CHANGES, so a long query does not spam the log:

[argondb-box] primary held awake: a client query is running (idle_stop 20s)

The client disconnects. Twenty seconds later the stand-down arm runs (serve.rs:9061-9071), writing one mark into the compute's own child log and one line to the supervisor's stderr:

[argondb-box] primary idle 20s — standing it down (storage stays up; the next client connection on :5432 wakes it)

and, in <data>/logs/compute.log:

standing the primary down after 20s idle ([compute] idle_stop); the next client connection on :5432 wakes it

If the compute does not stop in STAND_DOWN_GRACE = 60 s, or if the internal port still accepts 60 s after the signal, the box says which and SIGKILLs the group (serve.rs:9104-9108, :9129-9137):

[argondb-box] the primary did not stop within 60s of SIGTERM — SIGKILLing its process group. Log tail:
[argondb-box] WARNING: :5532 still accepts connections 60s after the primary's stand-down — SIGKILLing its process group

The next connection's first byte wakes it (idle_stop.rs:526, serve.rs:9180, :9226):

[argondb-box] primary gate: 127.0.0.1:41922 asked for a stood-down primary — waking
[argondb-box] waking the primary (attempt 1)
[argondb-box] primary awake (attempt 1)

and the compute's child log carries the mark woken by a client connection on the public PG port.

To see the split with your own eyes rather than in a log, the box gate's phase A/B assertion is the shape to copy — with the knob OFF the PUBLIC port is owned by a postgres process and the internal port is not bound at all; with it ON the supervisor owns the public port and postgres owns public + 100 (testing/box/idle-stop-wake.sh:17-24):

ss -tlnp | grep -E ':(5432|5532)\b'
# idle_stop OFF:  127.0.0.1:5432  users:(("postgres",...))      and :5532 absent
# idle_stop ON :  127.0.0.1:5432  users:(("argondb",...))       and :5532 owned by postgres

The measured wake, ten cycles, quoted from docs/2026-09-12-tenant-runtime-sidecar-design.md, §9.3:

  cycle  1:    636 ms   cycle  6:    616 ms
  cycle  2:    625 ms   cycle  7:    617 ms
  cycle  3:    634 ms   cycle  8:    613 ms
  cycle  4:    618 ms   cycle  9:    620 ms
  cycle  5:    621 ms   cycle 10:    619 ms
M4  wake latency over 10 cycles: min 613 ms · p50 619 ms · max 636 ms

4.3 What a client sees while waking, and how a driver should retry — FROM SOURCE

On the happy path a client sees nothing. The gate holds the connection for ~619 ms and then splices; there is no error to handle and no pre-warm ping is needed, which is a property of the gate rather than of the number — the gate holds the first connection instead of failing it. (The published guidance for platforms whose control plane is NOT on the connection path is the opposite: send a lightweight SELECT 1 to pre-warm. That guidance does not apply here.)

On a failed wake the client gets SQLSTATE 57P03. Handle it as "retry later", not as an authentication or configuration error. The retry budget should respect the supervisor's own governor: backoff doubles 2 s → 30 s and the breaker opens after 6 failures in 120 s, so a client retrying faster than that gains nothing — the refusals inside the backoff window are answered without a spawn.

Python, psycopg 3 — sqlstate is 57P03:

import time, psycopg

def connect_with_wake_retry(dsn, attempts=6, base=1.0, cap=30.0):
    """57P03 cannot_connect_now == 'the tenant is waking, or its wake failed'.
    Anything else is a real error and is re-raised immediately."""
    delay = base
    for n in range(attempts):
        try:
            return psycopg.connect(dsn)
        except psycopg.OperationalError as e:
            if getattr(e, "sqlstate", None) != "57P03":
                raise
            if n == attempts - 1:
                raise
            time.sleep(delay)
            delay = min(delay * 2, cap)

Go, pgx v5 — the same classification on *pgconn.PgError.Code:

func connectWithWakeRetry(ctx context.Context, dsn string, attempts int) (*pgx.Conn, error) {
    delay := time.Second
    var err error
    for n := 0; n < attempts; n++ {
        var c *pgx.Conn
        c, err = pgx.Connect(ctx, dsn)
        if err == nil {
            return c, nil
        }
        var pgErr *pgconn.PgError
        if !errors.As(err, &pgErr) || pgErr.Code != "57P03" {
            return nil, err // not a wake failure — do not retry
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(delay):
        }
        if delay < 30*time.Second {
            delay *= 2
        }
    }
    return nil, err
}

JDBC exposes the same string through SQLException.getSQLState(). The point of choosing 57P03 rather than inventing a code is exactly this: the classification already exists in every driver (crates/argondb-ctl/src/idle_stop.rs:445-448).

What you will NOT see is a bare socket close on a failed wake, and that is what error_response exists for. The field codes it writes are S, V, C, M — severity, severity-non-localized, SQLSTATE, message — so e.diag.message_primary (psycopg) / pgErr.Message (pgx) carries the box's own sentence, including the path to logs/compute.log on a deadline failure.

One trap, stated because it is the failure mode a reader will hit. A silent connection is not a wake. A TCP health check that connects and drops, or a browser's speculative pre-connect, will be closed after 30 s having woken nothing (idle_stop.rs:101, :517-522). If you want a monitor to keep a tenant awake, it must SEND something.

4.4 The analyst pool: lazy mode, cap and port band — FROM SOURCE

The analyst pool is the shipped precedent for several computes on one box with their own idle rule, and you configure it entirely through the environment (crates/argondb-ctl/src/analyst_pool.rs:168-250):

# mode: off | lazy | always | auto (the default).
#   auto resolves by detected RAM: `always` at or above 16 GiB, `lazy` below
#   (analyst_pool.rs:55, :300-317).
#   lazy  — computes start on the first analyst query and idle-stop after the
#           analyst idle window (DEFAULT_IDLE_SECS = 300, analyst_pool.rs:103)
#   always— cap computes warmed at boot
#   off   — no replica computes, no static computes, no pin-redemption endpoint
ARGONDB_ANALYST_POOL=lazy \
ARGONDB_ANALYST_PG_PORT=25440 \
  argondb serve --data-dir /srv/argon

The port band is arithmetic from one base, and this is the shape a per-tenant compute band copies (analyst_pool.rs:124-129, :251-264):

member port source
replica compute i port_base + i port_for, analyst_pool.rs:251
static compute j port_base + cap + j static_port_for, :257
pin-redemption endpoint port_base + 9 REDEEM_PORT_OFFSET, :129; redeem_port, :262

Defaults: DEFAULT_PORT_BASE = 25440 (:124), DEFAULT_CAP = 2 (:107), DEFAULT_STATIC_CAP = 2 (:118), RECONCILE_CADENCE_SECS = 2 (:113).

Three refusals you can provoke, each at config load so the box never binds a compute whose coordinates are wrong (analyst_pool.rs:173-230):

# ARGONDB_ANALYST_POOL=lazy with a cap of 0:
… ARGONDB_ANALYST_POOL=lazy asks for one — set the pool to `off`, or a cap ≥ 1

# ARGONDB_ANALYST_PG_PORT=0 or a non-port:
ARGONDB_ANALYST_PG_PORT=0 is not a valid TCP port (1-65535)

# a band that would overflow, or whose members would collide:
ARGONDB_ANALYST_PG_PORT=<n> + --port-offset <k> overflows …

validate_port_band(cap, static_cap) (:269) is called before anything binds, and static_ports_never_collide_with_the_redeem_port is a unit test rather than a comment (:2950).

Two behaviors of lazy that surprise people. want_ready(mode, cap, recent_activity) (:522) returns cap while there was recent activity and 0 otherwise — that IS per-compute scale-to-zero. And a lazy pool reports its computes as DOWN in argondb status, which is the configured steady state, not a fault; the status walk says so in its own words (crates/argondb-ctl/src/serve.rs:5169-5172):

the pool is `lazy` — computes start on the first analyst query and idle-stop after the analyst idle window

The pool's computes are REPLICAS of one timeline, not databases of their own. That single sentence was the whole distance between the analyst pool and a per-tenant compute table; the table is now built separately, each tenant compute on its own timeline (§2.8).

One coupling to know: the pool's only consumer is the MCP child, so ARGONDB_MCP_PORT=0 disables the pool regardless of ARGONDB_ANALYST_POOL, and status says which switch did it (serve.rs:5158-5163).

4.5 The application sidecar row, as designed — FROM SOURCE

Design; a first version is built (§2.5): one process per company, recorded in <data>/tenants/<db>/sidecar.json (crates/argondb-ctl/src/tenant_sidecar.rs), with the command read from the estate directory rather than the run path below, and without the per-company uid. The rest is the contract a tenant's application is held to, quoted from docs/2026-09-12-tenant-runtime-sidecar-design.md §4.1–§4.3 and docs/2026-09-14-argondb-process-model-preliminary-design.md §3.2.

The supervisor reads exactly one path per tenant:

<data>/tenants/<db>/run        # a command line. The supervisor runs what is there.

A small HTTP application as a tenant's sidecar — the whole contract is: bind the port you are given, answer the health path, read your DSN from the environment, and exit on SIGTERM.

# /srv/argon/tenants/acme/run      (mode 0755, owned by argon-t-acme)
#!/bin/sh
exec /srv/argon/tenants/acme/bin/acme-app \
     --listen "127.0.0.1:${ARGONDB_SIDECAR_PORT}" \
     --health-path /healthz
# acme-app, in ~20 lines. Nothing here is ArgonDB-specific except the two
# environment variables it reads.
import os, http.server, psycopg

DSN  = os.environ["ARGONDB_SIDECAR_DSN"]      # the tenant's OWN credential, and the only secret
PORT = int(os.environ["ARGONDB_SIDECAR_PORT"])

class App(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/healthz":           # the supervisor's health probe
            self.send_response(200); self.end_headers(); self.wfile.write(b"ok"); return
        with psycopg.connect(DSN) as c:
            n = c.execute("select count(*) from orders").fetchone()[0]
        self.send_response(200); self.end_headers()
        self.wfile.write(f"{n} orders\n".encode())

# SIGTERM to the process GROUP is the stop signal. Default handling (terminate)
# is correct; install a handler only if you need a drain.
http.server.HTTPServer(("127.0.0.1", PORT), App).serve_forever()

The row the supervisor builds for it, field by field (docs/2026-09-12-tenant-runtime-sidecar-design.md, §4.1's table):

field value new?
command <data>/tenants/<db>/run new: read, not built
environment .env_clear() + an explicit set new
port the tenant's slot band, resolved by child_port mechanism exists
health probe HTTP GET on that port, or a TCP accept new
log spawn_logged — stdout AND stderr, one O_APPEND file exists
ownership stamp ARGONDB_BOX_INSTANCE + the children ledger row exists
restart policy RestartPolicy::new() — 2 s → 30 s, trip 6-in-120 s exists
isolation own unix user + own cgroup new

The environment it receives is a CLOSED set. It gets its DSN, its port, and ARGONDB_BOX_INSTANCE re-added by hand. It does not get ARGONDB_AUTH_TOKEN, the lake credentials, or anything else the supervisor was started with.

What the supervisor will not do for it: build it, deploy it, update it, version it, roll it back, or fetch it. A run that does not resolve is a refusal at spawn with the path named — exactly like a missing compute_ctl today — never a silently absent row.

4.6 The isolation rung-1 recipe: one root command per tenant — VERIFIED (the probes) and FROM SOURCE (the two root commands)

Run on the primary agent node on 2026-09-20 as an unprivileged user, no sudo anywhere. Verbatim:

$ stat -fc %T /sys/fs/cgroup
cgroup2fs

$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/session-63939.scope

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cgroup.controllers
cpu memory pids

$ loginctl show-user "$(id -un)" -p Linger
Linger=yes

$ systemd-run --user --scope -p MemoryMax=32M --unit=guide02-cap-probe \
      bash -c 'cat /sys/fs/cgroup/$(awk -F: "{print \$3}" /proc/self/cgroup)/memory.max'
Running as unit: guide02-cap-probe.scope; invocation ID: 3616a8fc799a4d44b15f60245641f8d5
33554432

$ systemctl --version | head -1
systemd 259 (259.5-0ubuntu3.4)

Read the cap probe. The scope was created with no privilege, and the process inside it read its own memory.max back as 33554432 — 32 MiB, the value that was asked for. That is a positive control, not an absence of an error: MemoryMax, CPUQuota and TasksMax for a tenant's application cost zero privilege on a cgroup2 box with cpu memory pids delegated.

The uid half is one root step per box, then none per tenant [D-2026-10-03-37002a]. Root installs the helper and its sudoers drop-in once:

# once per box, as root — writes /usr/local/sbin/argondb-useradd (root, 0755)
# and /etc/sudoers.d/argondb-useradd (root, 0440, checked by visudo -cf),
# letting the supervisor's user run exactly that helper with NOPASSWD:
sudo <prefix>/bin/argondb box install-useradd-helper --user <supervisor-user>

From then on the sidecar creates argon-t-<key> itself — sudo -n /usr/local/sbin/argondb-useradd <key>, which validates the key against the product's key grammar and runs the one useradd below and nothing else. The hand command stays the FALLBACK for a box where the helper is not installed, and the sidecar's refusal prints both. The fallback and the separate setcap step, quoted from docs/2026-09-12-tenant-runtime-sidecar-design.md:663-673:

# once per tenant, at provisioning, as root:
useradd --system --no-create-home --shell /usr/sbin/nologin argon-t-<db>

# once per box, at install, as root — ONLY if the supervisor is to switch uid
# itself (it does not run as root and should not start as root):
setcap cap_setuid,cap_setgid+ep /usr/local/bin/argondb

setcap cap_setuid on the supervisor binary is a real privilege grant and the source argues for it rather than slipping it in. The alternatives are a setuid-root helper (worse) or systemd-run --user under a user manager that is itself the tenant's user, which moves the problem to "who creates the user" and lands back at useradd. There is no path to a per-tenant uid that does not cross root once.

Putting them together, the shape of a rung-1 spawn:

# per tenant, at run time, no privilege beyond the uid switch:
systemd-run --uid=argon-t-acme --gid=argon-t-acme \
  --scope --unit=argon-sidecar-acme \
  -p MemoryMax=512M -p CPUQuota=50% -p TasksMax=64 \
  /srv/argon/tenants/acme/run

The isolation ladder, rung by rung

An anomaly this guide found and did not resolve. The source designs both record, from 2026-09-12 on this same node, that the unprivileged-user-namespace route is CLOSED — unshare -U -r failing with write failed /proc/self/uid_map: Operation not permitted because kernel.apparmor_restrict_unprivileged_userns = 1 (docs/2026-09-12-tenant-runtime-sidecar-design.md:685-699, docs/2026-09-14-argondb-process-model-preliminary-design.md, §2.3). Re-run on 2026-09-20 it is now OPEN:

$ sysctl -n kernel.apparmor_restrict_unprivileged_userns
0

$ unshare -U -r /bin/sh -c 'id -u; cat /proc/self/uid_map'
0
         0       1000          1

$ grep "^$(id -un):" /etc/subuid /etc/subgid     # account name redacted below
/etc/subuid:<box-user>:100000:65536
/etc/subgid:<box-user>:100000:65536

The AppArmor restriction has been lifted on this node since the measurement the designs rest on. This does not change the recommendation — the source's argument against the namespace route was never only that it failed here, it was that lifting the restriction is itself a root step and that the policy differs across distributions, so useradd is the portable answer. But the two documents state the refusal as a current measurement and it is no longer true on this node. Escalated, not resolved: changing those documents is outside this lane. Re-measured 2026-09-22, the namespace route still yields no second uid, for a different reason: newuidmap/newgidmap are not installed, so the /etc/subuid range above cannot be mapped (HISTORY#erp-sidecar-per-company; docs/2026-09-04-erp-spec.md §15.7).

4.7 The restart policy, and how a DEGRADED row reads — FROM SOURCE

The constants are one definition and both consumers' profile tests assert against it (crates/argondb-ctl/src/restart_policy.rs:33-43):

pub(crate) const BASE_BACKOFF_SECS:    u64   = 2;
pub(crate) const MAX_BACKOFF_SECS:     u64   = 30;
pub(crate) const HEALTHY_RESET_SECS:   u64   = 60;
pub(crate) const BREAKER_MAX_RESTARTS: usize = 6;
pub(crate) const BREAKER_WINDOW_SECS:  u64   = 120;

So the sequence a crash-looping row produces is: exit, wait 2 s, respawn; exit, wait 4 s; 8 s; 16 s; 30 s; 30 s — and on the sixth restart inside 120 s, Decision::Trip { count }. A row that stays up longer than 60 s since its last respawn clears its streak, so a genuinely flaky-but-working child is not tripped by a one-off crash.

One row has its own, tighter pair, and the reason is a design rule rather than impatience (restart_policy.rs:45-68):

pub(crate) const MCP_BREAKER_MAX_RESTARTS: usize = 2;   // trips on the THIRD exit
pub(crate) const MCP_BREAKER_WINDOW_SECS:  u64   = 60;  // inside sixty seconds

argondb-mcp is the only supervised child whose characteristic failure is a deterministic CONFIGURATION refusal: it resolves and fail-closes every configured surface before it binds any of them, so a bad knob makes it exit in milliseconds, forever, and each retry re-reads the same environment and reaches the same conclusion. The default profile would take seven exits and ~90 s of accumulated backoff to reach the same verdict — 90 s of a box presenting as healthy with its agent surface missing.

How to read it in status. A stood-down or lazily-cold component is reported with an expected_down note — the configured steady state, not a fault (crates/argondb-ctl/src/serve.rs:5084-5096):

primary Postgres (internal; idle_stop ON)   [down]
  the primary compute is stood down after `[compute] idle_stop` — the next
  client connection on the public PG port wakes it

A tripped row is different: the supervisor has stopped respawning it and the box carries a DEGRADED state. A DEGRADED box boots, says so on every surface, and keeps serving what it can — the catalog-authz arm is the worked example of the shape, and it prints its cause, its since-stamp and its retry policy on one line (serve.rs:7801-7808), tripping its own breaker after a bounded number of recovery attempts rather than looping:

[argondb-box] catalog authz: BREAKER TRIPPED — 10 recovery attempts over 300s all failed;
no further automatic retry. The box stays DEGRADED …

When the application sidecar row lands, the one edit the design names is to the DEGRADED record's text, so that argondb status names WHICH TENANT's sidecar was given up on — the mechanism is already there.

4.8 The refusal that protects the lake, both polarities — FROM SOURCE

Removing the old bundled-catalog refusal made a configuration reachable that never had been: a lake-ON box with idle-stop ON. A narrower MUST replaces it (crates/argondb-ctl/src/serve.rs:5789-5800, crates/argondb-common/src/schema_refresh.rs:80-108).

The negative polarity — [lake] enabled = true, schema refresh on, and a window shorter than the tail's 30 s refresh cadence (TAIL_REFRESH_CADENCE_SECS, schema_refresh.rs:45). Refused at preflight with nothing spawned:

idle_stop is 20s, which is SHORTER than the tail's schema-refresh cadence (30s), so this
lake-ON box would silently stop enrolling newly published tables. The tail discovers a new
relation by reading the PRIMARY (`refresh_tables`, once per 30s cadence) over an INTERNAL
connection that deliberately cannot wake the primary and cannot hold it awake — so at a 20s
window the primary is always stood down when the tick fires. Tables already tracked keep
materializing (their rows come from the safekeeper and the pageserver, not the primary),
which is why this looks like a healthy box. Remedy, pick one: raise `[compute] idle_stop`
(or ARGONDB_PRIMARY_IDLE_SECS) to at least 30; or set `[lake] enabled = false` if this box
does not publish to the lake; or set ARGONDB_SCHEMA_REFRESH=off to declare the published set
STATIC, after which new tables are never discovered by your instruction rather than by
accident.

The positive polarities, all three of which boot:

# (a) EXACTLY the cadence — the boundary-equal must-NOT-apply control.
#     The rule is "strictly shorter"; 30 is not shorter than 30.
ARGONDB_PRIMARY_IDLE_SECS=30 argondb serve --data-dir /srv/argon

# (b) the same short window, with the published set DECLARED static.
ARGONDB_PRIMARY_IDLE_SECS=20 ARGONDB_SCHEMA_REFRESH=off argondb serve --data-dir /srv/argon

# (c) the same short window with the lake off entirely ([lake] enabled = false).
ARGONDB_PRIMARY_IDLE_SECS=20 argondb serve --data-dir /srv/argon

Both polarities of that boundary are unit-tested against the shared constant (schema_refresh.rs, boundary_is_strictly_shorter) and gated live in both directions by testing/box/idle-stop-wake.sh phase I (testing/box/idle-stop-wake.sh:48-54). The predicate lives in argondb-common and not in either consumer because the crate that owns the cadence (argondb-tail) and the crate that refuses the box (argondb-ctl) do not depend on each other, and the same constant is passed to the tail on --cadence-secs at its spawn — so the check can never be against a number the running tail does not sleep on.


5. Built today versus designed

feature status what proves it
one primary compute stands down when idle and wakes on connect SHIPPED, v1.4.0, default OFF ratified [D-2026-09-08-c60bce]; crates/argondb-ctl/src/idle_stop.rs; gate testing/box/idle-stop-wake.sh PASS 6/6 twice
the byte-triggered gate, the splice, the SQL failure vocabulary SHIPPED idle_stop.rs:462, :506, :584-590, :425-448
the wake governed by the SAME RestartPolicy as child respawns SHIPPED wake_policy, serve.rs:11603; constants restart_policy.rs:35-43
the five-step stand-down including wait-for-port-free SHIPPED serve.rs:9049-9144
several computes on one box with their own idle rule SHIPPED as the analyst pool — but its computes are REPLICAS of one timeline, not databases of their own analyst_pool.rs:103, :107, :522, :540, :1727
idle-stop on a bundled-catalog box SHIPPED since 2026-09-08 — the catalog has its own resident Postgres [D-2026-09-08-ca2e96] option B; serve.rs:5759-5772; gate phase G
the refresh-cadence guardrail, both polarities SHIPPED schema_refresh.rs:80; serve.rs:5789; gate phase I
both compute_ctl HTTP control servers bind loopback unconditionally SHIPPED 2026-09-13, one resolver, ten call sites serve::control_http_bind(); gate testing/box/bind-policy.sh Arm B′
ARGONDB_SHARED_PRELOAD_EXTRA accepts only names the build installs SHIPPED 2026-09-13 crates/argondb-ctl/src/pg_ext_set.rs; argondb extensions --list/--census
the tail's resident row and its stated memory bound RATIFIED, in implementation blocks S1–S7, docs/2026-09-17-process-sidecar-design.md:1061-1301
N computes, one per tenant DATABASE, in the process table SHIPPED in v1.5.5 behind [compute] per_tenant, default OFF; refused without idle_stop B′ [D-2026-09-20-0d5ba3], amended [D-2026-09-21-4107c5]; crates/argondb-ctl/src/tenant_compute.rs; §2.8, §3.1
each tenant compute on its own timeline; the tail follows every timeline as its own stream in one process BUILT [D-2026-09-21-4107c5]; crates/argondb-tail/src/streams.rs; HISTORY#tenant-timeline-per-database, #tail-multiplexes-tenant-timelines
a company's compute started at tenant create, on a recorded slot SHIPPED in v1.5.5 tenant_compute::assign_slot; serve.rs fn start_tenant_row_at_create; docs/releases/v1.5.5.md section 26, item 9
the primary refuses a company-with-compute's writes; pre-compute rows copied once; roles replayed SHIPPED in v1.5.5, with one open defect in the copy (§2.7) crates/argondb-ctl/src/tenant_data.rs, crates/argondb-ctl/src/tenant_roles.rs
each tenant's pool dials its own compute under per_tenant on SHIPPED in v1.5.5; the routing is gated both ways, but the cross-company isolation gate under per_tenant on has NOT passed on main pooler::upstream_port; gate testing/box/pgbouncer-pooler-gate.sh; §2.7
a per-company application process, under its own uid and cgroup SHIPPED in v1.5.5, started by tenant create, NOT as a supervisor row; the second company needs a root-created uid crates/argondb-ctl/src/tenant_sidecar.rs; gate testing/box/erp-sidecar-per-company.sh; §2.5
the application sidecar as a supervisor row, with its lifecycle DESIGNED, NOT BUILT docs/2026-09-12-tenant-runtime-sidecar-design.md, §4.1–§4.3
the tenant-keyed WakeRequest: one channel, one gate per tenant compute SHIPPED with the per-tenant table idle_stop::WakeRequest (tenant: Option<String>, None = the box primary)
the gate's second listener, for an application port BUILT 2026-09-29 — HTTP 503 + measured Retry-After while waking, splice once up, 502 when the application refuses; the port is recorded in compute-slot.json idle_stop::handle_app_conn; tenant_compute::SlotRecord; gate testing/box/erp-sidecar-per-company.sh P12-P15
HTTP 503 + Retry-After as the second failure vocabulary BUILT 2026-09-29, on the application port only; the SQL port's 57P03 messages unchanged idle_stop::http_503, idle_stop::retry_after_secs; design §5
per-tenant uid + cgroup (isolation rung 1) uid half NOT BUILT — the cgroup half needs no privilege, the uid half needs one useradd; the namespace route lacks newuidmap §8.1–§8.3 and §9.4; re-measured in §4.6 above
the pooler, one per box, transaction mode, PgBouncer specifically SHIPPED 2026-09-20 P1 [D-2026-09-20-d5c0f2], P2 [D-2026-09-20-5b6a85], P3 [D-2026-09-20-5f9e67]; crates/argondb-ctl/src/pooler.rs; gate testing/box/pgbouncer-pooler-gate.sh
pgbouncer vendored in the release artifact, vs required from the platform RULED AND RECONCILED — required from the platform, never bundled; the pooler itself is OPTIONAL, AMENDED 9/24 (boots on the direct port and says so when absent, no boot refusal) [D-2026-09-23-520b66] (platform-required half stands), superseded in its refusal half by [D-2026-09-24-f0e068]; scripts/release/build-artifact.sh refuses --pgbouncer by name
the tenancy topology B′ that all of the above serves RATIFIED 2026-09-20 and amended 2026-09-21, after it was put on 2026-09-12 and not selected then [D-2026-09-20-0d5ba3], [D-2026-09-21-4107c5]; earlier [D-2026-09-12-44e647]
the pooler is optional; on and auto are one behavior; the installer writes the key SHIPPED [D-2026-09-24-f0e068], [D-2026-09-24-093abd]; pooler::Enabled, pooler::resolve_binary; scripts/release/install.sh write_pooler_intent
a booted box held idle for an hour raises nothing and advances nothing in the view maintainer MEASURED 2026-09-24, 3600 s; one unrelated WARN every ~120 s is escalated, not fixed testing/t3/view-idle-hour.sh; §3.10
which isolation tier each deployment target gets RULED — block G1, against the recommendation B, rung 2 (container) required for the one-click cloud VM [D-2026-09-20-3425d9]; rung 2 itself DESIGNED, NOT BUILT
does the sidecar share the VM or the process RULED — block G2, as recommended A, always a SEPARATE OS PROCESS, never in-process [D-2026-09-20-4faa8c]
which cloud the per-tenant model targets first RULED — block G3, as recommended A, AWS first; Azure follows once its lake config keys ship, which are PROPOSED [D-2026-09-20-a7fb70]
does a WebSocket hold a tenant awake RULED — block G4, against the recommendation B, bytes are the only signal — a silent WebSocket does not hold a tenant awake [D-2026-09-20-78f71e]

What is still open, stated once. B′ is ruled and the per-tenant compute table is built, so the old blocking dependency is gone. Three things remain. The cross-company isolation gate has not passed under per_tenant on at main's tip (§2.7). The application's own port has no gate, so a request to it neither wakes nor holds its tenant (§2.5). And per_tenant stays the operator's switch, default off [D-2026-09-23-5122a6].


6. Gotchas

Every refusal, limit and residual gap a developer will hit, with its remedy.

On v1.5.7, a box that has run tenant create may refuse its next boot. tenant create cuts each tenant its own timeline in the box's storage tenant, whether or not a compute is ever bound to it, and on v1.5.7 the box's argondb-tail bound the FIRST timeline the pageserver listed rather than the box primary's. The listing order changes from boot to boot, so a restart can bind a tenant's timeline: the boot quarantines the estate's own tables (scan failed: nblocks), the safekeeper answers Timeline … was not found in global map, and the box says argondb-tail exited before it bound Flight … refusing the boot. A tenant removed with a hand DROP DATABASE … WITH (FORCE) leaves its timeline behind and triggers the same refusal. Fixed in v1.5.7.1: argondb serve names the primary's timeline to the tail (--primary-tenant/--primary-timeline, from the box's identity.json), the tail binds exactly that one, and every other timeline is named once in the tail log as a tenant's (named by its tenants/<key>/identity.json) or as an ORPHAN and is not streamed (crates/argondb-tail/src/service.rs, fn choose_primary_timeline). Remedy on an existing v1.5.7 box that holds a tenant: do not restart it on the v1.5.7 binaries. Stop it (SIGTERM to the box pid, wait for it to exit), install v1.5.7.1, and start it on the same data directory — nothing in the data directory needs changing, and a box v1.5.7 already refused boots the same way. To remove a tenant, use argondb tenant delete <key>, never a hand DROP DATABASE: it removes the estate rows (through destroy-tenant's sequence), the database, the tenant's timeline on the pageserver and on the safekeeper, and its tenants/<key>/ directory, each step probed first so a re-run removes nothing twice (crates/argondb-ctl/src/tenant_delete.rs). It refuses a tenant with its own compute bound (per_tenant on), with nothing removed, and it leaves the tenant's lake data for argondb lake drop-orphan.

A connection that sends nothing wakes nothing. FIRST_BYTES_TIMEOUT = 30 s and then a silent close. A TCP-connect health check, a browser pre-connect, or nc -z will never wake a tenant and will never keep one awake. Remedy: make the monitor send a real query. This is a feature — it is what stops argondb status from holding every box awake forever (idle_stop.rs:101, :517-522).

The internal port is public + 100 only while idle-stop is ON. With the knob off there is no internal port at all and Postgres owns the public one. Anything that hard-codes 5532 breaks when the knob is turned off, and anything that hard-codes "postgres owns 5432" breaks when it is turned on. Remedy: resolve the port through argondb status, whose walk carries both shapes (serve.rs:5084-5096), and remember that an overridden ARGONDB_PG_PORT shifts both by the same 100 (idle_stop.rs:84-89). A tenant compute's split is + 1, not + 100: slot i is tenant_port_base + 2i public and the next port internal (§2.8).

per_tenant = true without idle_stop does not boot. It is refused at config load, with both knobs named. Remedy: set idle_stop to a number of seconds, or leave per_tenant off.

Under per_tenant, a company with no known compute port has no pooled entry at all. Its pooled connection fails at PgBouncer, and the box log names the company and says it is "NOT routed to the primary". That is deliberate: the alternative is a company's traffic quietly landing on the primary. Remedy: wait one estate pass after tenant create, or read the log line. How long that is: the estate member runs a pass every --pass-interval-secs (default 300 s) AFTER the last one ends, so the real gap is 300 s plus the pass's own run time; it runs on the leader only, /v1/estate/pass runs one on request, tenant create and tenant destroy run their own, and the member's log says "next pass at

Under per_tenant, the one-time copy of pre-compute rows can be refused for good. If the publish-status writer creates argon.publish_status on a company's compute before the copy runs, the copy rolls back and every later pass refuses it by name (§2.7). The fix exists only on an unmerged branch. Remedy: none on main today. Do not turn per_tenant on for a box whose companies already hold rows on the primary until that fix lands, or check the log for the one-time copy NOT done after the first pass.

An idle box WARNs every ~120 s, and it is not a fault. The tail's maintenance sweep logs maintenance pin <table>: CONSTRAINED … on a landed view's table even with no writes: 21 times in the measured idle hour (§3.10). It is escalated, not fixed. Remedy: do not alert on that line alone for an idle box; alert on a view-maintainer WARN, which stayed at zero.

idle_stop = 300 (a TOML integer) is refused. The key is a string so that one parser serves both the file and the environment. Remedy: quotes.

idle_stop = "0" is refused, not treated as "always off" or "always on". Remedy: "off" to disable, or a positive window.

A lake-ON box refuses a window shorter than 30 s. Not a warning — the box does not boot. Remedy: one of the three the message names (raise the window to ≥ 30; turn the lake off; declare the published set static with ARGONDB_SCHEMA_REFRESH=off). §4.8 has both polarities.

The default is OFF, and that is deliberate. On a laptop or a single VM a stopped compute saves no money, so the knob buys nothing there and costs a wake latency; it is the multi-instance estate that wants it (idle_stop.rs:115-117).

A stand-down takes longer than its window. 34 s against a 20 s window, measured. The extra is the shutdown checkpoint plus the wait-for-port-free step. Consequence: do not write a test that asserts the pid is gone at window + epsilon; assert it is gone within window + STAND_DOWN_GRACE.

A stood-down compute is not a tripped one. stood_down is a separate vector; a stood-down child has charged no breaker and the "every child tripped ⇒ the box is dead" bail deliberately does not fire. Do not infer health from "asleep".

An open wake breaker is not retried automatically. Six failed wakes inside 120 s and every subsequent connection is refused by name until the box is restarted. Remedy: fix the cause, read logs/compute.log, restart the box. The refusal is deliberate — an expensive automatic recovery sits behind a circuit breaker, never a loop.

The 619 ms wake is conditional. Small empty database, fast NVMe box, pageserver warm, lake off. A large database with a cold pageserver is not this number, and the source states the conditions for exactly this reason.

The application half of the wake is not measured and is not bounded. It is the app's number. The obligation is disclosure, not a bound.

~630 resident computes is arithmetic, not a ceiling. The pressure knee was never approached. The first real wall is more likely the pageserver's per-timeline cost, the port band, or file descriptors — none of them measured.

The gate has one failure vocabulary. An application-port client whose wake fails today would receive a Postgres ErrorResponse over what it believes is HTTP. The second vocabulary is proposed and not built; do not front an application with this gate yet.

A busy application does not create an AwakeHold. Deliberately — a hold is a reason the DATABASE cannot be stopped. It shows up as the predicate's third term instead, and that term does not exist in shipped code.

A silent WebSocket does not hold a tenant awake — RULED, not unsolved. Block G4 [D-2026-09-20-78f71e]: bytes are the only idle signal, against this design's own recommendation. A browser holding a socket open with no bytes crossing for the idle window is standing its tenant down like anything else; the client reconnects on its own next bytes and the gate wakes the tenant, but server-push over that silent socket does not survive the stand-down. Remedy: a client that depends on server-push must already handle a reconnect — there is no configurable hold to raise instead.

A box without a supported pgbouncer boots on the direct port — it does not refuse. The pooler is OPTIONAL (B-MT5 amended [D-2026-09-24-f0e068]): a platform with no pgbouncer, one below 1.25.2, or one that cannot report its own version boots the box on the direct port only, under [pooler] enabled = "on", "auto" or no key alike, and the tenant is handed the direct connection string. It is never SILENT: the banner and argondb status print pooler: NOT RUNNING, naming the binary, what was found and the install line. Remedy: read that line (or the boot line's resolved path and probed version when it says pooler: ON) before assuming the pooled port is live.

pgbouncer does not ship inside the release artifact. The platform provides it [D-2026-09-23-520b66]; the release census fails a tarball carrying bin/pgbouncer, and scripts/release/build-artifact.sh refuses a --pgbouncer flag by name. The installer writes [pooler] enabled = "on" into the recipient's argon.toml as a record of intent, never over an existing value. Remedy: a provisioning script that wants the pooled port installs a PgBouncer 1.25.2 or newer on the host (the Ubuntu line is on the banner).

Rung 1 alone is not enough on the one-click cloud VM. Block G1 [D-2026-09-20-3425d9], against the recommendation: the cloud VM target needs rung 2, a container per tenant, on top of rung 1 — and rung 2 is DESIGNED, NOT BUILT. §4.6's recipe below is the on-prem shape only.

Rung 1 does not cover a kernel escape. A uid is not a VM. If tenants are ever mutually hostile rather than one customer's own tenants, rung 1 is not sufficient and the microVM question reopens on different evidence.

Rung 1's uid half crosses root once, per tenant. There is no path that does not. Budget one useradd per tenant at provisioning.

The user-namespace escape hatch is kernel-policy dependent, in both directions. It was measured CLOSED on this node on 2026-09-12 and measures OPEN on 2026-09-20 (§4.6). Do not build a provisioning path that assumes either state; probe it, and keep useradd as the portable answer.

ARGONDB_MCP_PORT=0 silently disables the analyst pool — not silently, in fact: status says so by name. But the coupling surprises people, because the pool's only consumer is the MCP child.

A lazy pool's computes read as DOWN in status. That is the configured steady state. The note in the walk says so.

A sidecar that clears ARGONDB_BOX_INSTANCE would be unreapable. The .env_clear() is right and the stamp must be re-added by hand. This is the one exception to the closed environment and it is worse to forget than the leak the stamp prevents.

compute_ctl's INTERNAL HTTP server has no authorization layer. It carries /extensions, /extension_server/{*filename}, /grants and /refresh_configuration, and it answered an unauthenticated POST /extensions with a 422 naming the field it wanted. The hole is CLOSED by control_http_bind(), which returns 127.0.0.1 and takes no argument so no caller can widen it. The gotcha that remains: the SQL port still honors ARGONDB_LISTEN_ADDRESS, so widening it widens the SQL surface and nothing else — verify with testing/box/bind-policy.sh Arm B′ rather than by reading.


7. Sub-diagrams

Three deeper pictures of parts the main diagram compresses. Each is generated by a script beside it; python3 <script>.py from docs/diagrams/argondb-and-the-erp/generate/ rewrites its SVG.

diagram generator what it shows that the main picture cannot
2a — wake and stand-down, with every timeout generate/d02a_wake_standdown.py the two sequences as four lanes (client, gate, supervisor, compute), every constant on the path (30 s first bytes, 180 s wake deadline, 120 × 500 ms accept wait, 60 s grace, 60 s port-free wait, 2 s probe cadence), the four AwakeHold reasons in precedence order, the two application_name exclusions, and each refusal message at the step that emits it
2b — the supervisor's process table generate/d02b_process_table.py every resident row that exists today with its restart rule and memory cap, the one row whose breaker differs and why, and where the tenant's two rows attach — with the four shared fields spelled out
2c — the isolation ladder generate/d02c_isolation_ladder.py all four rungs side by side in four columns — what each is, buys, costs, and does not cover — plus the per-target recommendation and the distinction that a cloud VM is rung 3 for the box and rung 1 for the tenants inside it

They share the main picture's palette and legend: solid outline = built and shipped, dashed = designed and measured but not built, OPEN = a decision still to be made.

The earlier process-model set is still current and covers overlapping ground from the design's own angle: docs/diagrams/process-model/01-tenant-unit.svg, 02-wake-path.svg, 03-isolation-ladder.svg, 04-sidecar-reach.svg, 05-cloud-topologies.svg.


8. References

Diagrams

Designs

Code

Gates

Build and operation

Decisions cited

[D-2026-09-08-c60bce] primary idle-stop and wake · [D-2026-09-08-ca2e96] the bundled catalog's own Postgres · [D-2026-09-12-44e647] tenancy B′ put and not selected then · [D-2026-09-20-0d5ba3] B′ adopted · [D-2026-09-21-4107c5] B′ amended, a timeline per tenant · [D-2026-09-25-a2d0a3] a company's compute owns its writes · [D-2026-09-18-07c9d9] block S4, the two names · [D-2026-09-11-21a6d7] the tenant wall in the lake · [D-2026-09-15-917bb4] the catalog-authz default · [D-2026-09-18-515279] block S1, the process boundary · [D-2026-09-18-ff217a] block S2, the tail's budget and bound · [D-2026-09-20-3425d9] block G1, the isolation tier per target · [D-2026-09-20-4faa8c] block G2, a separate OS process · [D-2026-09-20-a7fb70] block G3, AWS first · [D-2026-09-20-78f71e] block G4, bytes are the only idle signal · [D-2026-09-20-d5c0f2] block P1, one pooler per box · [D-2026-09-20-5b6a85] block P2, pool size at one quarter · [D-2026-09-20-5f9e67] block P3, the pooled port is the default · [D-2026-09-23-520b66] B-MT5, pgbouncer required from the platform · [D-2026-09-23-5122a6] the amended B-MT1, per-tenant compute is never a system default · [D-2026-09-20-0d5ba3] B′ ratified · [D-2026-09-21-4107c5] B′ amended · [D-2026-09-24-f0e068] B-MT5 amended, the pooler is optional · [D-2026-09-24-093abd] the installer writes the key · [D-2026-09-25-f80e7e] the data half, a company created while serve runs.


Guide 3 — the lake and its process model

Audience: a senior developer who has to build against this, operate it, or debug it. Every claim carries its source by path:line. Every worked example is labelled VERIFIED (run, with its real output pasted) or FROM SOURCE (the exact signature, flag, schema or constant quoted from the code or the design). Nothing here is labelled neither.

Naming, once: our process is the tail (argondb-tail). The word "sidecar" in this system names the tenant's application process and nothing of ours (block S4 [D-2026-09-18-07c9d9], docs/2026-09-17-process-sidecar-design.md:1177).

1. The diagram

Deep dive 3 — the lake and its process model

The picture is four frames, and the order matters. The top frame is the write path: an ordinary INSERT commits in Postgres, the WAL goes to the safekeepers, argondb-tail reads that WAL as a physical standby, decodes heap and catalog pages, CLOG and WAL records into typed row changes as Arrow, holds them in an in-memory ring, and on a cadence lands everything the ring holds as one atomic multi-table commit into Iceberg. Nothing in that path is a pipeline you configure: there is no connector, no topic, no sink, no second copy of the schema. The left-middle frame is what lands — a standard Iceberg table per published relation, optionally a __changes changelog beside it, and two branch-level system tables. The right-middle frame is who reads it, and its whole content is one rule: every read names one position L, and every scoped table is served at exactly that position or the read is refused. The bottom frame is the process model: five supervised rows on one box, each with a memory cap and a restart rule.

The two coordinates carry the design. H is the head — the newest commit fully decoded and available in memory. W is the watermark — the newest position durably committed to Iceberg, stamped on every snapshot the cut touches. W ≤ H always, and the interval (W, H] is the tail: decoded rows that exist only in the tail's memory, served over Arrow Flight, evictable the instant W advances past them (DESIGN.md:481-483). A read at L is Iceberg@W ∪ tail(W, L] merged by primary key, which is why the lake never drifts from the database: the lake copy and the un-landed remainder are two halves of one decode, not two systems that have to be reconciled. The picture's dashed outlines mean designed, measured, not built yet (docs/diagrams/argondb-and-the-erp/generate/blocklib.py:4); on this diagram the D18 Iceberg-v3 line and the D19 speed trigger are the two claims carrying that status, and §5 states each one's evidence.

2. What it is

2.1 The application and the primary Postgres

The application writes to real Postgres and nothing about that changes. G1 — Postgres fidelity (MUST): any SQL, type, extension behaviour or driver behaviour the Neon Postgres fork supports works unchanged, and ArgonDB's additions MUST NOT alter the transactional write path or the OLTP read path (DESIGN.md:391). There is no trigger, no logical-replication slot, no publication, and by default no REPLICA IDENTITY FULL. The decode is physical — heap pages, catalog pages, CLOG and WAL records — which is exactly why the customer's schema, WAL settings and writes stay untouched (DESIGN.md:152). The one exception is an operator's per-table opt-in: argondb lake publish <table> --old-row-from-wal sets that table's REPLICA IDENTITY FULL so the tail takes the old row from the WAL instead of the pageserver (crates/argondb-ctl/src/lake.rs:1131, opt_in_old_row_from_wal at :1194; HISTORY#tail-old-row-from-the-wal). It is off unless asked for, and §3.9 has its numbers. G1 is deliberately kept separate from G7 — Isolation (MUST): G1 forbids altering the write path's behaviour; G7 forbids loading it. A change can satisfy one and violate the other (DESIGN.md:391).

2.2 The safekeepers and the pageserver

Each tenant database is its own Postgres instance on its own timeline with its own WAL; the shared storage plane — one pageserver, one safekeeper set, one lake — serves every tenant timeline (B′ amended [D-2026-09-21-4107c5]). The WAL is durable in the safekeepers (Paxos, ×3 in the separated topology) and the pageserver serves versioned pages by GetPage@LSN (DESIGN.md:83-97). The tail is a consumer of both: each timeline's WAL comes from the safekeeper and its pages from the pageserver (crates/argondb-ctl/src/serve.rs:8025-8031), one stream per tenant timeline (crates/argondb-tail/src/streams.rs; §2.12). On the box the pageserver asks for a token on both of its ports: the tail and the management path carry the box-wide pageserverapi token, and each tenant compute's token names its one timeline and is refused on any other (storage/pageserver/src/auth.rs check_permission_for_timeline; HISTORY#pageserver-token-auth-on-the-box, HISTORY#pageserver-token-timeline-claim). It also holds one Postgres connection per stream — to the primary on a single-timeline box, and to the tenant's OWN compute for a tenant timeline's stream, since the primary's catalog does not hold a tenant's tables (crates/argondb-tail/src/streams.rs:120-128) — and that connection is the one thing about the tail that is easy to get wrong when reasoning about isolation. Its only use is schema introspection: at boot, once per cadence in refresh_tables, and at the write-floor flip. It uses an internal port and an internal application_name precisely so it never wakes the primary and never resets its idle clock, and a refresh that fails while the primary is stood down is a warn! and a retry next cadence — no schema can change while the compute that would change it is stopped (crates/argondb-ctl/src/serve.rs:8034-8050).

The tail is also a term of the safekeeper's WAL removal horizon, which is the mechanism most likely to surprise you. It reports its resume point as the flush_lsn/apply_lsn of the standby status update it already sends, under application_name=argondb-tail; the safekeeper takes the min over live argondb-tail sessions, persists it to argon_tail_hold.json beside safekeeper.control, and mins it into the removal horizon — capped at ARGONDB_TAIL_WAL_HOLD_CAP_BYTES (default 4 GiB) below remote_consistent_lsn (docs/reserved-identifiers.md:76). The persistence is the point: a box restart is exactly when no live session exists, and the file is loaded before the first removal pass. Over the cap, removal advances to rcl - cap and one ARGON tail WAL hold OVER CAP line per advanced floor segment names the forfeit the next restart will report as RESUME POINT UNSERVABLE at boot. It is not a lease: a removed tail pins up to the cap until the sidecar is deleted with the box stopped.

2.3 argondb-tail: decode

The decoder turns physical storage into logical, schema-typed row changes in Arrow — inserts, updates and deletes, with MVCC visibility resolved so uncommitted and aborted transactions never appear in any output, lake or tail (DESIGN.md:417, DESIGN.md:98). This is the single decode path: the ring, the changelog, the lake copy and every read all feed from it. There is no second derivation anywhere in the system, which is what makes "the lake cannot drift" a structural statement rather than a quality claim.

Row identity is the primary key when there is one (D7, DESIGN.md:148). A table with a PK gets full incremental insert/update/delete semantics via a RowDelta commit — data and equality-delete files in one snapshot. A table without one is a supported contract, not a gap: accuracy is identical, but the lake copy is maintained by full-table scan-replace in any cadence in which the table changed, so a missing PK costs update performance in both time and whole-system load. Scan-replace is also the path during catch-up and the automatic per-table fallback on any error (DESIGN.md:492). The operational guidance is one line: put a primary key on every published table.

2.4 The ring (W, H]

The ring holds decoded, committed rows that are not yet in Iceberg. Its budget is ARGONDB_TAIL_DRAIN_QUEUE_MB, 2048 MiB by default (crates/argondb-tail/src/lib.rs:133). Two things about it are worth knowing before you size anything.

First, what is counted is allocated bytes, not rows × logical width — RecordBatch::get_array_memory_size(), which walks every column's ArrayData and sums the allocated buffer capacity (crates/argondb-tail/src/lib.rs:206). Deliberately: the ring's problem is RSS, and a batch of three rows whose builders rounded twenty columns up to 64-byte buffers costs the process what it allocated. The ring holds one RecordBatch per (commit, table) pair, and at the ERP mix's ~36 rows per transaction spread over ten published tables, that is mostly single-digit-row batches — which is where the cost comes from. Measured 2026-09-19 (crates/argondb-tail/tests/ring_per_row_cost.rs): per-commit batches 364.2 B/row, the same rows coalesced into one batch 76.7 B/row, ratio 4.7×, 78.9 % of the ring's accounted bytes being batch overhead at ~1,150 B fixed per batch (docs/2026-09-17-process-sidecar-design.md:294-340). That measurement is what ARGONDB_TAIL_RING_COALESCE_ROWS (default 512, crates/argondb-tail/src/lib.rs:215-238) exists to remove.

Second, accounted bytes are not resident bytes. The relationship was measured directly: drain_queue_bytes_high_water 2,064.4 MiB against tail VmHWM 3,217.2 MiB — a ratio of 1.56× (docs/2026-09-17-process-sidecar-design.md:340). Size against the process bound, not against the budget. The same paragraph withdraws the 22 KiB per-ring-row figure the design used to carry: it was arithmetic over an RSS peak divided by a row count sampled by a different loop, the two numbers were never a paired reading, and nothing in the current design rests on it.

0 is refused on this knob, not read as "unbounded" — unbounded is the behaviour the budget exists to remove — and an unparseable value falls back to the default with a WARN saying so, never to off (crates/argondb-tail/src/lib.rs:165-187). It is read once at ingest-loop start, so the bound cannot move under a running loop.

2.5 The cut

A cut is one pass of the cadence loop. It (1) chooses a target commit-boundary LSN W', never splitting a Postgres transaction; (2) computes the net per-PK delta and prepares a RowDelta commit per PK-bearing table, or a scan-replace for the rest; (3) prepares the D9 changelog appends; (4) lands everything touched in (W, W'] as one atomic multi-table commit via the REST /transactions/commit endpoint, carrying Argon.watermark-lsn = W'; and (5) on success advances W, after which the tail evicts ≤ W' (DESIGN.md:489-495).

Atomic multi-table commit is a catalog requirement, not a preference. G3 cannot be honoured without it and no weaker mode is offered; the rejected alternative — per-table commits letting tables trickle independently, which is what every CDC pipeline ships — would make the consistency contract silently depend on which catalog the customer chose, and a guarantee that holds only on some catalogs is not a guarantee. A startup capability probe refuses clearly against catalogs lacking it (DESIGN.md:494; the probe is crates/argondb-committer/src/catalog_probe.rs).

One transaction per warehouse: under D30's tenant wall each non-primary database has its own Lakekeeper warehouse, so per-tenant atomicity is what the single commit buys, not cluster-wide atomicity across tenants (DESIGN.md:382).

A cut normally runs once per [lake] cadence_secs (default 30 s), but a caller can ask for one sooner. A publication landing's data pin must read at or after the landing's run start. When its first pin reads below that point, it sends the tail's cut_now Flight action. The tail does not have a second cut path: the request wakes the same cadence loop early, through the kick the drain budget uses, and the landing waits for the pass numbered after its request. The pin then re-reads the watermark and pins the coordinate that cut landed. Requests that arrive together share one pass. A request that arrives while a pass is already running is answered by that pass, with no extra wake-up, when its cut reaches the requested coordinate. A loop that nobody asks keeps cutting on its normal cadence. If the requested cut is refused, the refusal comes back to the pin with the database and warehouse named. The pin then waits for the next cadence, as it did before cut on demand existed, and says why on its publication data: line. A tail too old to have the action, or a pass that ended before cutting, is handled the same way. freshness serves the loop's pass, cut, on-demand-pass and request counts under cut_on_demand (crates/argondb-tail/src/cut_on_demand.rs; the pin's side is cover_data_floor in crates/argondb-ctl/src/landing_runner.rs).

2.6 The published table, and the __changes changelog

A published relation lands as a standard Iceberg table at branch / database / schema + the bare relation name as the leaf — br_main/postgres/public + orders (DESIGN.md:521). Each cut that touches it writes one snapshot carrying Argon.watermark-lsn, Argon.branch and Argon.txn-range (DESIGN.md:482).

The changelog is opt-in per table (D9, DESIGN.md:156), configured by --changelog / ARGONDB_CHANGELOG on the tail (crates/argondb-tail/src/main.rs:39-43) — a comma-separated list of flat lake keys, not relation names. It is an append-only table of rows: no row is ever modified or removed except by retention, and its schema is the source columns followed by four appended fields (crates/argondb-committer/src/iceberg_sink.rs:10204-10226):

column type nullability meaning
_argon_lsn long required the commit position of this row change
_argon_op string required I, U or D; a delete tombstone carries the key
_argon_seq long required per-table ordering inside one commit
_argon_ts timestamptz optional commit wall clock, μs; optional so pre-existing changelogs evolve cleanly and old rows read null

It is emitted from the same single decode path and committed in the same watermark-stamped atomic transactions as the base table, which is why a changelog can never disagree with its table about what happened at a position. Retention floor is the pin window (48 h, configurable upward): time travel reconstructs an instant from a retained snapshot plus changelog replay, so a changelog that expires early strands valid pins.

It is not append-only at the snapshot level: D10 compaction may commit a Replace. Rows are preserved exactly and every ArgonDB consumer is unaffected, but a foreign engine doing standard Iceberg incremental reads must fall back to a full re-read on a replace snapshot (DESIGN.md:156).

2.7 dv_<branch>, and the single-writer rule

Three writers, three territories, and the rule is MUST. The committer alone writes br_<branch>; the lake writer alone writes dv_<branch> — continuous views, run_transform(landing: lake) and append_rows; and argon_transform alone writes the derived Postgres schema, enforced by grants (DESIGN.md:383, D29 ruling 2). The reason dv_ exists as a separate namespace is ownership: the tail's maintenance plane reads ownership at the namespace root, so a foreign writer nesting under our root is still Foreign, and a lake-owned table is never a thing the committer might also decide to write (DESIGN.md:521).

Inside dv_<branch> the rule goes one level finer: every dv_* table has exactly one writer, and the lake writer enforces it with one predicate, table_writer_refusal (crates/argondb-committer/src/lake_writer.rs:2310), asked on the staging path and on the transform landing (crates/argondb-transform/src/land.rs:642). A continuous view's table is written only by the view maintainer. A table that is not a view is written only by run_transform(landing: lake) or append_rows, and those two count as one owner, so an append into a transform output is allowed. Any other door is refused with the wire kind not-owner, and nothing is written:

lake-writer/not-owner: <target> is a continuous view — its one writer is the view maintainer, and the `<door>` door is not its writer — every dv_* table has exactly ONE writer; nothing was written. Land under another name, or remove <target> through the door that made it

(crates/argondb-committer/src/lake_writer.rs:1477-1480). A table that ensure_table has just created, with no snapshot and no view declaration yet, has no owner, and every door may take it. Otherwise the refusal would block the maintainer's own first refresh. The four polarities (a transform landed in dv_main is readable by DuckDB; not-owner in both directions; a br_ landing is refused; dv_* is enrolled in maintenance and kept out of the committer's territory) are proven on a box by testing/box/dv-lake-landing-polarities.sh.

Nothing may enter a published br_<branch> table from outside the WAL — ruled never, and enforce it: "No analytics-side write ever lands in br_*. ArgonDB detects a foreign snapshot on a published table, refuses to treat that table as unmaterialized, and reports it. Bulk-load needs are served by a fast path through Postgres, so the rows have real LSNs" (DESIGN.md:719-770, §12.22 [D-2026-09-20-4693b1]). A row with no LSN and no Postgres transaction is refused, never re-baselined into place; where a watermark is recoverable the table keeps flowing, and where none is, the table is held (quarantined) with a gap record.

2.8 _argon_gaps and _argon_value_notes

_argon_gaps sits at the branch level of the namespace and records every re-baseline the tail performs. Schema (crates/argondb-committer/src/iceberg_sink.rs:2833-2842, :2684-2700):

column type nullability meaning
start_lsn long required the gap covers (start_lsn, end_lsn] — half-open, like the tail
end_lsn long required
ts timestamptz required when the gap was recorded
reason string required the sentence; pre-forfeits rows carry their count inside it
table_name string optional NULL = every tracked table; else the one lake table
forfeits long optional commits an undecoded-window barrier episode coalesced into this row. None is not Some(0)

_argon_value_notes records D14's lake-unrepresentable values — NaN in bounded numeric(p≤38,s), ±infinity in date/timestamp[tz] — which map to NULL in the lake with a durable record and a disclosure on reads on both serving paths (DESIGN.md:181-189). Floats are not in that class: IEEE 754 on both sides, NaN/±Inf pass through untouched. Both tables ride along in every pin session, scoped or not, because a digest must see recorded gaps and a value note must reach every read it affects (crates/argondb-query/src/lib.rs:4157-4160).

2.9 The query plane

argondb-query implements the unified read as a DataFusion TableProvider: Iceberg@W ∪ tail(W, L] merged by primary key (DESIGN.md:126). G3 — LSN-pinned snapshot consistency (MUST): a unified read at L returns, for every table in the pin's scope, exactly the committed state Postgres would show at L. Effects of transactions with commit LSN ≤ L are fully visible; all others fully invisible, across the scoped tables. Consistency claims never extend beyond the scope that was pinned (DESIGN.md:417).

G3 is MUST while G4 (freshness — tail head within ~1 s of the compute's flush LSN; Iceberg watermark trailing by the commit cadence, default target ≤ 60 s) and G5 (read-your-writes) are SHOULD, and the asymmetry is the contract: correctness is absolute, timeliness is negotiable. A slow tail degrades G4 and G5 and that degradation is visible and bounded; a read that mixes LSNs violates G3, and there is no acceptable degraded form of it (DESIGN.md:419-427).

2.10 The pin

A pin is a signed value token, not a server-side resource handle: (branch, LSN, scope, minted_at, coordinate-provenance), where provenance records whether the LSN was server-chosen at the protected default or caller-named (D12, DESIGN.md:163). That last field is what decides whether a later redemption may refuse.

2.11 The MCP endpoint and the analytical Postgres

argondb-mcp on :8018 serves four mounts, one token each — argondb (read-only), argondb-dba, argondb-admin, argondb-write (DESIGN.md:195, D15/D16). The analytical Postgres reads the lake through lake_<branch>.* foreign tables, mounted by the argondb_lake FDW extension and always reading through the query plane (crates/argondb-lake-fdw/src/catalog.rs:1-18). The analyst computes are a different surface again: they read pages, not the lake, so the publish choice does not constrain them (DESIGN.md:393).

argondb_lake is one of a fixed, ratified set — the box's compute is a separate OS process whose only entry is the SQL wire protocol, which is false if a client can CREATE EXTENSION its own C code, so the shipped extension set is narrowed to a rule rather than left to what the build happens to install. The final set: core PostgreSQL (vendored), pgcrypto, btree_gin, btree_gist, our pure-SQL uuid-ossp shim, the neon preload with its required neon_rmgr (the preload loads it internally; a tier without it spawns no compute at all), pg_trgm (required because Lakekeeper's own catalog migration creates it and the box will not boot without it), and the lake reader, argondb_lake — ten of the eighteen extension-shaped members the build used to install are removed from the build entirely, not merely from the preload allowlist (docs/2026-09-12-tenant-runtime-sidecar-design.md:523-618, [D-2026-09-20-6b4639] amended [D-2026-09-21-c514c3]).

2.12 The five processes on the box

argondb serve supervises: argondb-tail (decoder, committer, the Flight ring — resident, durability per cut); argondb-maintenance (compaction, clustering, Puffin statistics, snapshot expiry, orphan cleanup — every 120 s, no off switch); Lakekeeper + catalog-pg + OpenFGA (the Iceberg REST catalog, with atomic multi-table commit probed at boot); argondb lake-store (the bundled S3 surface); and argondb-mcp + the analyst pool (crates/argondb-ctl/src/serve.rs:8225, :8332-8360, :8380). Scale-to-zero applies unevenly and for stated reasons: the tail no — a tail at zero is a stale lake; maintenance yes, on a schedule; the agent endpoint technically yes but in practice no (cold start ~35–49 s); the catalog no.

Past a single box, the tail is one process, multiplexed across every tenant's timeline, not one tail per tenant and not one tail per box: it runs on a shared estate instance, holds N streams — one per tenant timeline, each with its own member set, its own watermarks and its own replay budget — and never runs on a tenant's own instance, because a tail co-resident with one tenant could decode every other tenant's WAL. Under the deployment's three-member safekeeper quorum, "redundant" was ratified to mean one multiplexed tail, warm standby: a second tail on an estate instance in another availability zone, taking over on a fenced lease with no committed transaction lost, rather than one tail process per tenant (docs/2026-09-04-erp-spec.md:2447-2609, block E3″ [D-2026-09-21-25c7c6], reasoning trail E3 [D-2026-09-21-8bb144] and E3′ [D-2026-09-21-5a7dc6]). The multiplexing itself is built — one tail process following every tenant timeline as its own stream on one box; the warm standby and its fencing lease are not yet built.

3. How it works

3.1 The ring, the cut, and the coordinates over time

3a — the ring, the cut, and the coordinates W and H over time

A cut fires at whichever comes first of four conditions, and knowing all four is the difference between predicting the snapshot rate and guessing at it.

  1. The clock. --cadence-secs, declared by the box at 30 s rather than left to the tail's own default, because the number the box checked [compute] idle_stop against at preflight must be the number the running tail sleeps on, and "both defaults happen to be 30" is a coincidence, not an agreement (crates/argondb-ctl/src/serve.rs:8065-8075).
  2. The ring's high-water, 50 % of the budget — 1 GiB at the default. The cadence loop is woken and cuts now; its sleep becomes a wait on (clock, high-water), whichever first, and nothing downstream changes (docs/2026-09-17-process-sidecar-design.md:373).
  3. The writer's byte trigger, per table: DEFAULT_CADENCE_BYTES = 128 MiB of staged parquet (crates/argondb-committer/src/lake_writer.rs:148). The writer's own due check "can fire between two interval ticks".
  4. The floor, which is the one that says no: ARGONDB_TAIL_MIN_CUT_SECS, default 5 — the writer's own DEFAULT_CADENCE_SECS (crates/argondb-committer/src/lake_writer.rs:148). It bounds the snapshot rate, and the reason is downstream: the maintenance worker's compaction thresholds are cadence multiples (crates/argondb-tail/src/maintenance.rs), so an unfloored byte-triggered cut every ~2 s at peak would be 15× the shipped manifest rate.

There is a fifth threshold that is backpressure, not cadence: the writer's staged_ceiling_bytes, 1 GiB per table (crates/argondb-committer/src/lake_writer.rs:153, :164-179). At the ceiling nothing is written and the refusal names the ceiling, the current staged bytes and the next cadence tick.

At 100 % of the ring budget the decode loop stops pulling from the WAL stream until the ring is back under high-water. Never a drop, never a batches.clear() outside the shipped trip/re-baseline arms; the safekeeper's TCP window backs up and the WAL stays under the hold (docs/2026-09-17-process-sidecar-design.md:375).

The subtle part is that backpressure is a named phase, not a stall. Three consumers ask "is this decoder stuck?" — ReplayVerdict::NotAdvancing, which feeds the lag governor's forfeit; heal_decision; and the liveness watchdog's StallVerdict::Wedged ("no WAL event and no WAL bytes for 60 s"). A decoder that is not reading because the ring is full is not stuck, and none of the three may see it as stuck. So the ingest loop prints ingest phase=backpressure on its liveness line beside await-event and the others, a backpressured window is excluded from the "did not advance" measurement, and the stall verdict's "no WAL bytes" clock does not run while backpressured — one predicate, in the place the three already share (docs/2026-09-17-process-sidecar-design.md:546-570). Deadlock is ruled out by construction and asserted: the cadence loop that cuts the ring runs on its own task and never waits on the decode loop, and the decode loop holds no lock the cut needs.

3.2 What is atomic, what is idempotent, what is refused

Atomic. One cut = one multi-table commit = one Iceberg snapshot per table touched, base table and changelog together, all carrying the same Argon.watermark-lsn. There is no partial step and tables never trickle (DESIGN.md:494).

Idempotent. Every stage is idempotent by LSN (G8, MUST, DESIGN.md:438). The durable source of truth is the Iceberg snapshot metadata itself — there is no sidecar progress store. On startup the committer reads each table's Argon.watermark-lsn and resumes from the min over them. Not the max, and the reason is specific: per-table watermarks are not guaranteed equal at a crash, because scan-replace fallbacks land per table, outside the atomic cut, so resuming from max would silently skip a laggard's unapplied window. Each table then applies only changes above its own watermark, so the overlap deduplicates by construction. Commits are conditional (catalog CAS), and a crashed half-commit is retried and deduplicated by watermark check (DESIGN.md:499).

Refused, and with which message. Only five things can refuse a read, and each names the blocking table and the remedy:

A server-chosen coordinate has no refusal doors left. When latest_servable finds no coordinate every scoped table can serve, the pin is still granted and each table that cannot serve it is served at its own newest retained watermark at or below the grant, with the mint disclosing, per table, the older coordinate it reflects (DESIGN.md:167). The disclosure is what separates this from silent staleness, and the read path prints it verbatim (crates/argondb-query/src/lib.rs:1914-1926).

Disclosed. Reads without an explicit pin MUST disclose when the session serves meaningfully behind the live database, measured against the pageserver position (the tail's freshness action) rather than the decoded head alone, because catch-up parks the head at the watermark. Threshold 16 MiB, a constant since 2026-08-02 [D-2026-08-02-5a35c8]; an active catch-up always discloses (DESIGN.md:167).

3.3 The unified read, per table

![3b — the unified read: Iceberg@W ∪ the changes in (W, L]](diagrams/argondb-and-the-erp/03b-unified-read.svg)

The protocol is five steps (DESIGN.md:503-509):

  1. Pick L — default the current H, or client-supplied for read-your-writes — and a scope.
  2. Per scoped table, select the Iceberg snapshot with the greatest W ≤ L. Any retained snapshot qualifies, not only the current one; that is what makes time travel the same code path as a fresh read.
  3. Fetch the changes in (W, L] — from the ring over Arrow Flight when L is above the table's current watermark, else from the __changes changelog, whose rows each carry _argon_lsn and _argon_op.
  4. Merge: upserts and deletes applied over the Iceberg scan keyed by PK; append-only tables concatenate.
  5. Refuse only in the five cases above.

The important structural fact is in step 3: the ring and the changelog are two sources for one merge, not two read paths. The merge does not know or care which one fed it, which is why time travel and read-your-writes have identical semantics and identical code.

There is an ordering subtlety worth naming because it looks like a bug when you hit it. A cadence commit can move a table's W past a pin taken moments earlier. For an implicit "latest" pin the answer is to re-pin at the fresh head and rebuild, not to fail; an explicit --at-lsn below W is served by time travel (crates/argondb-query/src/lib.rs:4278-4281). And a time-travel pin folds the tail's current pending D14 value notes into a historical read — so a note about a value that landed after the pinned coordinate can be disclosed on an answer that does not contain it. That is over-disclosure, named in the code as deliberate: the failure this mechanism prevents is an affected value served with no note, never a note with no value (crates/argondb-query/src/lib.rs:4270-4276).

3.4 The gap record and the rescan

3d — the gap record and the rescan

Four doors lead to a re-baseline: a resume point unservable at boot (the hold went over cap); a startup laggard, per table, further behind head than ARGONDB_MAX_LAG_BYTES (256 MiB, the shipped default — docs/reserved-identifiers.md:77); an undecoded-window barrier episode; and a watchdog stall escalation on ReplayVerdict::NotAdvancing.

A fifth outcome is not a gap and is easy to confuse with one: a table whose decode fails is quarantined alone at its last-good watermark, alerted with the row's evidence and the remedy named, while the cadence, the catch-up rescan set and the read plane for every other table carry on unaffected (D14 containment, MUST, DESIGN.md:186). Nothing was lost — the table simply stopped advancing, and argondb lake list renders it as QUARANTINED @ <lsn> (frozen; cause=<c>: <reason>) (crates/argondb-ctl/src/lake.rs:316).

The ordering is the guarantee. The gap record MUST land before any of the rescan's table commits. A crash between the two can then only leave a conservative false positive — a gap recorded over a window that turns out to be fine, which replay-from-watermark heals — never an advanced watermark with an unrecorded gap (DESIGN.md:170). The reverse order produces a lake that is silently missing history and confident about it, and this ordering is what makes that state unreachable. It is listed as an invariant of every stage of D13's sharding design, not just of today's single pipeline (DESIGN.md:191).

Two consequences reach the reader, and neither is silence. A changes() digest whose window intersects a gap is still served, annotated: its counts are declared lower bounds over the intersecting range(s). A time-travel read inside a gap is refused permanently, naming the range and the reason (DESIGN.md:170).

Note also the defensive guard in the replay path. Structurally, a re-baseline resets snapshot history, so no base below a gap should survive; the gap check in travel_parts exists to convert any future violation of that invariant into a refusal instead of wrong rows (crates/argondb-query/src/lib.rs:6006-6011).

3.5 The memory budget, and where it turns into lag

3c — the tail's memory budget as a stack

The bound is a sum of seven named buffers plus allocator slack (docs/2026-09-17-process-sidecar-design.md:392-455):

   baseline              1,024 MiB   tokio, the Flight server, the schema, the pagestream connections
   ring budget           2,048 MiB   ARGONDB_TAIL_DRAIN_QUEUE_MB
   open-txn budget         512 MiB   ARGONDB_MAX_OPEN_TXN_BYTES
   scan-buffer budget      512 MiB   ARGONDB_MAX_SCAN_BUFFER_BYTES
   TOAST-index budget      256 MiB
   old-tuple cache         128 MiB   ARGONDB_TAIL_OLD_TUPLE_CACHE_MB
   writer staging        1,024 MiB   one table's ceiling
   ------------------------------------------------------------------
                         5,504 MiB
   + 25 % of the 4,480 MiB of BUDGETS (not the baseline)   1,120 MiB
   ------------------------------------------------------------------
   stated bound          6,624 MiB   ->  memory.max 6,144 MiB, memory.high 5,632 MiB

Ratified as block S2 [D-2026-09-18-ff217a]. Two things about that table are measured rather than assumed, and one thing in it did not ship.

The bound has a floor, and the box does not refuse under it. The tail's floor is 4,704 MiB. When a box's own memory budget would give the tail a smaller share than that, the share clamps up to the floor at boot — the boot line names the share, the budget and the floor — and the box refuses only when an operator sets ARGONDB_TAIL_MEMORY_MB explicitly below the floor. Read literally, the "refuses only below it" wording of block S2 item (3) would stop every budgeted box in the estate today from booting at all; this is that read corrected (block S5 [D-2026-09-19-1d3fca]).

The Flight scan budget sits outside this cap, not inside it. ARGONDB_TAIL_FLIGHT_SCAN_MB (default 8,192 MiB per process) bounds Arrow Flight scan reads on its own process-wide budget; the cgroup cap above does not cover them, and the boot line and the v1.5.4 limits line say so. Raising the 6,144 MiB cap to cover the read path (to roughly 16.5 GiB) was one of the three shapes considered and was rejected: it would undo the "≥ 24 GiB replaced by 6 GiB" limits-line claim, and this is the only one of the three that changes no ratified number (block S7 [D-2026-09-19-7a272e]).

Measured (the baseline). 2026-09-19, lane tail-rss-bound-gate on ms-01, the idle arm — box booted, seeded, published, drained, then one 120 s cadence at zero clerks: tail VmHWM 172.5 MiB, VmRSS 72.4 MiB, drain queue 0 bytes, backpressure 0 ms, ingest lag 0 bytes. That is 17 % of the 1,024 MiB constant, and the bound is not restated on it, for three reasons the design states explicitly: recomposing would move 6,624 → 5,772 MiB and with it a ratified cap, which one reading does not do; the reading is at ERP_SCALE=1 with ten published tables and the baseline's composition scales with the tenant, so one point is not the curve; and a baseline under the constant makes the bound conservative, which is the safe direction (docs/2026-09-17-process-sidecar-design.md:415-437).

Measured (the fail-before). The same 10-clerk level with the cache on and no ring budget: 19,007 MiB sampled, VmHWM 19,013 MiB (testing/box/tail-rss-bound.sh:12-17). That reading is what the budget removes, and the gate cites it character-for-character rather than spending 19 GiB of a shared node nightly to re-prove it.

Did not ship. The design asked for a tail memory: over the alarm line log line; L2 shipped the cap and the alarm without it, and the v1.5.4 notes §11 say so in those words (docs/2026-09-17-process-sidecar-design.md:451-455). Do not grep for that line.

Memory, lag and the cap are one system (docs/2026-09-17-process-sidecar-design.md:583-600):

   memory held by the tail      ≤  the bound          6,144 MiB cap
   WAL the tail has not read    ≤  the hold cap       4 GiB
   replay after any restart     ≤  the same cap       resume_replay_budget(cap, ARGONDB_MAX_LAG_BYTES) takes the LARGER

Backpressure moves bytes from the first line to the second. The second is where a tenant faster than its tail eventually forfeits. At 10 clerks, ~2.2 KiB of WAL per transaction, so 4 GiB ≈ 1.9 M transactions; at ~560 txn/s under backpressure, a tail whose offered rate exceeded its decode rate by 100 txn/s would take **~5 hours** to reach the cap.

At 50 clerks the picture is different and the design says so plainly: the tail decodes 659.6 txn/s against 2,900.4 tps offered and never drains (904 s, residual 497 MB). Doubling the cap doubles the runway — 13 minutes becomes 26 — and changes nothing, because the deficit is structural at that level. The cap is not what forfeits the tenant; the decode rate is (docs/2026-09-17-process-sidecar-design.md:1143-1165, block S6 [D-2026-09-19-9c0383]).

Under the multiplexed tail (§2.12), this budget is per stream, not shared across every tenant. ARGONDB_MAX_LAG_BYTES (256 MiB) is the constant each tenant timeline's stream owns on its own — ruled "per-stream, the current constant each. Simple, and the Nth company cannot be starved by the first; the estate's worst case is N × 256 MiB of headroom that must exist" (block B-MT2 [D-2026-09-23-d17cbd]) — never one 256 MiB budget divided across N companies. Because the resume budget actually applied is the larger of ARGONDB_MAX_LAG_BYTES and the WAL hold cap, and the hold is on by default at 4 GiB, the constant in force per stream is 4 GiB, not 256 MiB, so the estate's real worst case is N × 4 GiB of headroom — and under a three-member quorum that hold is held on all three members, tripling a lagging stream's storage cost relative to a single box (docs/2026-09-04-erp-spec.md:2654-2723).

3.6 Restart

There is no shutdown path, and that is deliberate. argondb-tail installs no signal handler, pinned as a source fact by service::tests::there_is_no_shutdown_path_so_every_exit_is_a_crash, which asserts the tail's crates/argondb-tail/src/main.rs never mentions SIGTERM, ctrl_c, signal_hook or SignalKind. The restart point is durable per cut, never at exit, because a clean LSN written at shutdown is produced only by the polite exit path — exactly the artifact that is missing when it is most needed. A drain-on-SIGTERM was considered and is not proposed: it would re-open the crash-between-write-and-exit class that test exists to keep unreachable (docs/2026-09-17-process-sidecar-design.md:687-700).

The sequence (docs/2026-09-17-process-sidecar-design.md:701-736):

  1. The process dies where it stands. Ring, open-transaction buffers and cache are gone; the resume point is the last cut's.
  2. The supervisor's exit arm asks RestartPolicy: Restart(backoff) 2 s → 30 s, or Trip at 6 in 120 s → DEGRADED naming the tail, alarm raised, and the tenant's database keeps serving. Under a cgroup cap the exit is the kernel's kill inside the cap and the DEGRADED text says so.
  3. respawn_command rebuilds the same command and environment; the log is appended, so the panic that killed the previous incarnation is still there.
  4. Boot: restart_start_lsn reads the per-table resume points, the replay budget is max(hold cap, ARGONDB_MAX_LAG_BYTES), prove_resume_servable probes the safekeeper as argondb-resume-probe before choosing, and the boot line names the arm. Servable → decode from the resume point, no re-baseline, no gap. Unservable → fresh baseline with a named gap.
  5. Replay cost: at most the cap's worth of WAL at the decode rate — up to ~1.9 M transactions, ~45 min at ~700 txn/s. With the ring budget in place a crash mid-window re-decodes at most ~4 s of peak decode plus whatever lag the tail carried.
  6. The cache is cold; the hit rate reads low for the first minutes. Nothing else differs.

A binary upgrade is the same sequence with step 1 chosen: stop the tail row (SIGTERM to its process group), swap the binary the box resolves (find_bin("argondb-tail", "ARGONDB_TAIL_BIN")), let the respawn arm bring the new one up from the same resume point. No state crosses between the two incarnations except through the lake and the hold, so there is nothing for a version to migrate.

argondb stop SIGTERMs every child's process group together with a 10 s budget for the set and a 10 s group grace, then SIGKILL — a 35 s teardown bound. The tail gets the same SIGTERM as everything else and dies. At the next serve the hold, loaded from argon_tail_hold.json before the safekeeper's first removal pass, has kept the WAL back to the resume point (docs/2026-09-17-process-sidecar-design.md:737-755).

Everything above is restart-from-watermark, and on the estate's multiplexed tail (§2.12) it is still the baseline: a failure is a restart, and the estate's lake freshness pauses for every stream until it comes back — nothing is lost, because nothing above depends on there being only one tenant. What "warm standby" adds under the ratified redundancy answer is a second tail process, in another availability zone, that takes over on a fenced lease before the restart-from-watermark sequence would otherwise have to run (block E3″ [D-2026-09-21-25c7c6]) — not yet built.

The lake writer's own restart seam is measured. The same rule, "the durable coordinate is the Iceberg commit", covers the dv_<branch> writer. A lake view refresh killed after its Iceberg commit returns, and before anything else is recorded, restarts cleanly. The restarted run reads the committed coordinate, so the view reads whole at the new position. A replay of the dead refresh's idempotency token answers already-landed and adds no snapshot and no rows. The next refresh continues from that coordinate, and the rows equal an oracle built from the deltas. The kill is a real process abort, not a simulation: a child process lands the refresh and aborts at the after-lake-commit hook in land_view_lake (crates/argondb-transform/src/land.rs:1759). The test is a_refresh_killed_after_the_lake_commit_is_wholly_present_and_not_reapplied (crates/argondb-transform/tests/landing_view.rs:980), and bash testing/t3/view-restart-await-matrix.sh --kill runs it and prints the kill's run token on its verdict line (measured on ms-01, 2026-09-24). Candor: the test's catalog is in memory and is reopened from the newest committed metadata file. That stands in for a REST catalog's durable pointer; it is not Lakekeeper itself.

3.7 The maintenance plane

ArgonDB owns it (D10, DESIGN.md:158). Compaction (RewriteFiles), argon:cluster-by clustering, exact-NDV Puffin statistics, snapshot expiry under explicit retention, orphan cleanup. It runs whole in every deployment on a fixed 120-second cadence — MAINTENANCE_SWEEP_SECS: u64 = 120 (crates/argondb-tail/src/maintenance.rs:837) — a code constant with no switch, no cadence setting and no off state. Tests reach it only through the test-override seam [D-2026-08-02-6e83a2]. Safety comes from the operations' own guards, never from optionality.

Why ArgonDB owns it rather than the customer: ArgonDB is the single writer and already knows table state from the WAL, so an external maintenance job would be a second writer racing the committer — and it would hand the customer back the operational pipeline this product exists to delete.

Clustering defaults to the table's single primary-key column when a published table has exactly one PK column and sets no explicit marker (ratified [D-2026-08-05-5caa39]): the shape read() serves is a primary-key equality, and the mechanism sat unused while a benchmark spent 15.90 s scanning for one row. Precedence: explicit argon:cluster-by=<col> wins; argon:cluster-by=none opts out; composite-PK and PK-less tables get no default — a leading-column default for composite PKs is deliberately not implemented, because it would optimize a prefix-equality shape the surface never issues. A single uuid key — every ERP table's UUID v7 — is measured, not assumed, to take this default: it lands as Iceberg's native uuid (arrow FixedSizeBinary(16)) and sorts through the same cluster_order path, proven by cargo test -p argondb-committer --lib uuid_cluster_tests and on a box by testing/box/uuid-v7-cluster-default.sh (declared sort order, monotone row groups; the composite-key twin takes no default).

Because clustering is a cost every table now pays, the snapshot-replace path's sort is part of the same ruling: it writes rows in key order through the parquet writer's slice feed rather than materializing a sorted copy, for any table that fits ARGONDB_MAX_SCAN_BUFFER_BYTES; above the budget it writes budget-sized windows each sorted by the cluster key and declares no sort order at all, because a global SortOrder over window-sorted files is false metadata a foreign reader may act on (amended wording ratified [D-2026-08-12-7e936d]).

A pass that overruns the cadence backs off rather than overlapping: on the live demo at a 120 s cadence, measured pass durations exceeded the cadence (crates/argondb-tail/src/maintenance.rs:5985-6080), and the pass deadline is 30 cadences = one hour (:5107-5113).

3.8 Branches

A branch maps to a distinct Iceberg namespace, materialized lazily: the lake does no work for a branch until something reads or publishes on it, so an unread branch costs the lake nothing while the Postgres side stays Neon copy-on-write (DESIGN.md:515). Once materialized, a branch's lake tables share nothing at the file level — the storage cost of a materialized branch is a full copy of what it materializes.

The rejected alternative is instructive: copy-on-write file sharing in the lake would cut that storage cost but couple every branch's maintenance, because compaction rewrites and orphan cleanup could no longer prove a file dead by looking at one table's retained snapshots. Every operation would need cross-branch reference counting, and a bug in that coordination corrupts a branch you were not touching (DESIGN.md:517).

The br_ prefix is conditional, and this is the single most common wrong assumption about lake addressing. Only main, temp and system take it — they are the three names DuckDB reserves as schema names. Every other branch is its namespace, bare. A br_$BRANCH composed in a shell is therefore right on three branch names and silently wrong on all the others; the dv_<branch> sibling is unconditional. Ask argondb lake namespace instead (§4, example 8).

3.9 What is measured, and the numbers

reading number where
decode rate, 10 clerks, cache ON 666.65 txn/s (3.3× the 201 txn/s before the cache) docs/2026-09-17-tail-old-tuple-cache.md:178, :185
old-row reads served from memory 84.3 % hit rate (87.7 % at its peak) docs/2026-09-17-tail-old-tuple-cache.md:202
that level's drain 578 s, lag end 0.0 MB, jumps 0.0 docs/2026-09-17-tail-old-tuple-cache.md:178
tail RSS at idle (0 clerks, one cadence) VmHWM 172.5 MiB, VmRSS 72.4 MiB docs/2026-09-17-process-sidecar-design.md:420-424
ring accounted vs resident 2,064.4 MiB accounted vs 3,217.2 MiB VmHWM = 1.56× docs/2026-09-17-process-sidecar-design.md:340
ring per-row cost, ERP profile 364.2 B/row per-commit batches, 76.7 B/row coalesced, 78.9 % overhead docs/2026-09-17-process-sidecar-design.md:320-330
no-budget fail-before, same level 19,007 MiB sampled, VmHWM 19,013 MiB testing/box/tail-rss-bound.sh:14
50 clerks decode 659.6 txn/s against 2,900.4 tps offered; never drains (904 s, residual 497 MB) docs/2026-09-17-process-sidecar-design.md:612-614, :1153
WAL per transaction, ERP ~2.2 KiB → the 4 GiB cap ≈ 1.9 M transactions docs/2026-09-17-process-sidecar-design.md:597-600
decode ceiling, pre-cache, profiled oldpage_fetch (the pageserver round-trip for old tuples) is 96.5 % of charged decode time and bounds decode at 198.0 txn/s by itself; the other five stages together bound decode between 5,888 and 4.4M txn/s docs/2026-09-17-process-sidecar-design.md:1307-1340
decode ceiling, zero-WAL lever (the default) vs lever A, P1, one binary zero-WAL 10,009.7-10,388.9 txn/s, lever A 9,695.8-10,067.7, neither 748.0-754.8; old-page reads 0; WAL per UPDATE +0 B vs A's +31.6 docs/2026-09-17-process-sidecar-design.md:1527-1560

The 198 txn/s ceiling is one number with the 201 txn/s "before the cache" line above — the old-tuple cache exists precisely because oldpage_fetch is the whole cost, and serving old-row reads from memory is what turns 198-201 txn/s into 666.65. No tuning inside the tail moves the ceiling: every batching, allocation or lock change acts on the 3.5 % that is not oldpage_fetch, and sharding the fetch across more pagestream connections was already measured not to help (8 connections: 113-190 txn/s against 2,114 offered, no better than one) because the pageserver's per-tenant WAL-redo process is the serial resource. The fix therefore had to take the fetch out of decode's way or stop making it, not make it faster, and three levers have shipped on that basis. They are listed below in the order they landed. The question block that framed them is drafted in the design doc (docs/2026-09-17-process-sidecar-design.md:1391-1450, §11b).

  1. Decode decoupled from the old-page read [D-2026-09-23-212703]. The tail encodes the rows that need no old page while the fetch is in flight. ARGONDB_TAIL_DECODE_DECOUPLED defaults on; 0, false, off or no run the two legs in series again (crates/argondb-tail/src/lib.rs:4739-4745). Measured, it hid only 2.0-2.7 % of decode time, because oldpage_fetch stayed 93.6-97.8 % of decode in every arm. The release claims no throughput from it (docs/releases/v1.5.5.md:548).
  2. Lever A, the old row from the WAL [D-2026-09-23-dd9ca5]. This is a per-table opt-in and is off by default. argondb lake publish <table> --old-row-from-wal sets the table's REPLICA IDENTITY FULL, so Postgres writes the whole old row into the WAL and the tail never asks the pageserver for it. It is refused by name when wal_level is not logical, or when the table's identity was set to NOTHING or USING INDEX on purpose (crates/argondb-ctl/src/lake.rs:1131-1230). It costs WAL: ×1.047 at the ERP mix, 31.6-31.8 bytes per UPDATE, and a wide or TOASTed table pays its full row width on every UPDATE and DELETE (docs/releases/v1.5.5.md:530-547).
  3. The zero-WAL lever, now the default. Under the DEFAULT replica identity, on a table with a primary key, Postgres logs an UPDATE's old key only when the key changed. An UPDATE record that carries no old key therefore proves the key did not change. The tail applies it as a same-key update and reads no old page at all. It changes no table's identity and adds no WAL byte. One predicate decides it, record_proves_same_key (crates/argondb-tail/src/lib.rs:3876), and both the fetch plan and the decode pass ask it, so a page is never skipped by one and wanted by the other. ARGONDB_TAIL_ZERO_WAL defaults on, is read once per process and logged at first use; off restores the old-page read (crates/argondb-tail/src/lib.rs:3717-3745). The ruling that made it the default was taken by block on 2026-09-24, option C, and its id lands at the next registry regeneration (docs/2026-09-17-process-sidecar-design.md:1574-1583).

The zero-WAL lever refuses by name and falls back to the old-page read, which is always correct, in each of these cases (ZeroWalRefusal, crates/argondb-tail/src/lib.rs:3756):

Each refusal is counted by reason in argondb_tail_zero_wal_refused_rows (crates/argondb-tail/src/metrics.rs:1665). A DELETE never takes the lever: under DEFAULT identity its record already carries the key, so no page was read before this lever either. Lever A stays the answer for a table the zero-WAL lever cannot help, most importantly a table with no primary key, whose DELETEs otherwise need the page.

Measured on ms-01 at the P1 level, all arms on one binary. The zero-WAL decode ceiling was 10,009.7-10,388.9 txn/s against lever A's 9,695.8-10,067.7 txn/s in three clean pairs (zero-WAL ÷ A = 1.028-1.061), with 0 old-page reads and +0 B of WAL per UPDATE against lever A's +31.6. The same harness with neither lever read 748.0-754.8 txn/s (docs/2026-09-17-process-sidecar-design.md:1527-1560). Read these as decode ceilings, not end-to-end throughput. Each is transactions decoded divided by decode seconds. They say the old-page read is no longer what bounds decode on a primary-keyed, DEFAULT-identity table. They do not say a tenant sustains 10,000 txn/s through the whole path.

The diagram's bottom caption also carries "338 MiB drained in 578 s, zero gaps" and a "56-byte drain residual". That residual is argondb_tail_ingest_lag_bytes + argondb_tail_commit_lag_bytes (crates/argondb-tail/src/metrics.rs:1445, :1469), and it was not a floor: testing/box/tail-drain-residual-probe.sh at 10 clerks measured level c10 drain: drained after 31s (residual 0), trajectory shape=REACHED-ZERO, so the gate keeps residual 0 as the meaning of drained (docs/2026-09-17-process-sidecar-design.md:846-860). The 56 bytes were one standby-snapshot WAL record — a race, not a structural constant. Teaching the gate a floor would have been the re-baseline the design forbids, wearing a shared-predicate costume.

4. How to use it

Every example below is labelled VERIFIED or FROM SOURCE. VERIFIED means the command was run and its output pasted; the binary used for every VERIFIED example in this section is argondb built from this repo at /home/steveis/argon/target/release/argondb (2026-09-18 build), and the subcommands exercised are the pure ones — they open no Postgres connection, reach no catalog and need no running box, which is exactly what makes them runnable in a lane and callable from a gate. FROM SOURCE means the signature, flag, schema, constant or message is quoted from the code or the design by path:line and has not been executed here.

Example 1 — publish a table, and the marker that does it

FROM SOURCE. Publishing is one command and no connector configuration (G2, DESIGN.md:393):

$ argondb lake publish public.orders
published public.orders — the full table backfills at the tail's next cadence, then follows incrementally

The exact println! is crates/argondb-ctl/src/lake.rs:1190-1193. What the command actually does is set a Postgres table comment containing the marker argon:publish:

-- what `argondb lake publish` sets (crates/argondb-ctl/src/lake.rs:1176-1181):
COMMENT ON TABLE public.orders IS 'argon:publish';
-- an existing comment is preserved and the marker appended:
COMMENT ON TABLE public.orders IS '<existing comment> argon:publish';

Three consequences follow from the marker living in a comment rather than a config file, and all three matter in practice.

  1. The published set lives in the branch's catalog and branches with the database. Branch the database, and the branch starts with the same published set (DESIGN.md:393).
  2. Re-publishing is idempotent and says so: public.orders is already published (crates/argondb-ctl/src/lake.rs:1173).
  3. Any client can set that comment without going through the CLI, so the marker can land on things that are not publishable. That path is accepted and announced, never silently dropped and never refused at COMMENT time, because refusing would need an event trigger and therefore superuser on the primary — which cannot hold on a bring-your-own-Postgres deployment. See §6.3.

If the table has columns no Iceberg type can carry yet, the marker is still set and durable; the table auto-backfills when its columns become lake-eligible, with no re-publish, and the command says that explicitly instead of implying an imminent backfill (crates/argondb-ctl/src/lake.rs:1194-1201).

Example 2 — see it appear, from both directions

FROM SOURCE. There are two listings and they answer different questions. From Postgres — which published relations have a lake table, and at what watermark (crates/argondb-ctl/src/lake.rs:1543-1545, three TAB-separated cells: name, state, key):

$ argondb lake list --tail http://localhost:8817
public.orders   lake @ 0/1BD2748    postgres__public__orders
public.line_items   not mirrored    postgres__public__line_items
public.legacy_audit QUARANTINED @ 0/1A9F210 (frozen; cause=decode: numeric out of range)    postgres__public__legacy_audit

The state cell's spellings come from crates/argondb-ctl/src/lake.rs:305-316. Pass --tail or the D9 changelog configuration is not checked, and the command says on stderr that it did not look — rather than leaving you to infer a clean bill of health from silence (argondb lake list --help, run below in example 3).

From the catalog — what the lake actually holds, including tables no Postgres relation is behind:

$ argondb lake catalog-tables --branch main
postgres__public__orders    br_main.postgres.public.orders    nested
_argon_gaps                 br_main._argon_gaps               branch-system

Three cells: the flat lake key, the catalog address, and the shape — nested | old-shape | branch-system | unclassified. The shape cell is not decoration: an old-shape table's flat key is its bare leaf, which cannot match what the Postgres side is told, so a consumer that ignores cell 3 reads a live lake table as missing. A catalog that will not resolve is an error, never an empty listing; an unborn branch namespace is an empty listing (argondb lake catalog-tables --help).

From SQL, and where a company's rows live. Every database carries its own argon.publish_status and argon.gaps, written by the box on a cadence (crates/argondb-ctl/src/publish_status.rs). A company that has its own compute (B′, [compute] per_tenant) has them written ON THAT COMPUTE, and only while it is awake. The pass never wakes a sleeping company to refresh a status row, so a stood-down company's rows are brought current by the first pass after its next wake, seconds after the wake, not before the wake's own first read. Read as_of to see how old they are. The CLI reads from the right place and says which:

$ argondb publish-status --database alphaco
source: alphaco's own compute on :27132 (the estate directory gives it one; its rows are written there while it is awake, and this read wakes it if it was stood down)
database: alphaco
relations: 2
  public.identity  state=mirrored  watermark=0/36BF298  as_of=2026-09-25 19:28:18.285429+00
  public.ledger  state=mirrored  watermark=0/36BF298  as_of=2026-09-25 19:28:18.285429+00

(Measured on ms-01 by testing/box/publish-status-awake-compute-gate.sh, 2026-09-25.) A company with no compute of its own reads source: the primary (<db> has no compute of its own), and a database the writer has not reached yet says so instead of erroring.

Example 3 — argondb lake address: the three spellings of one table

VERIFIED. A relation has three spellings and since the three-level identity landed they are no longer the same string. Ask, do not assemble:

$ argondb lake address postgres.public.orders
lake."br_main.postgres.public".orders

$ argondb lake address --field namespace postgres.public.orders
br_main.postgres.public
$ argondb lake address --field lake-table postgres.public.orders
postgres__public__orders
$ argondb lake address --field qualified postgres.public.orders
"public"."orders"
$ argondb lake address --field label postgres.public.orders
br_main.postgres.public.orders
$ argondb lake address --field leaf postgres.public.orders
orders

Which spelling you want depends on who is asking:

The changelog sibling is a flag, not a --field family, because the changelog is a different relation carrying every one of those coordinates:

$ argondb lake address --changes postgres.public.orders
lake."br_main.postgres.public".orders__changes
$ argondb lake address --changes --field lake-table postgres.public.orders
postgres__public__orders__changes

Note which halves move: the lake-side answers become the sibling's, namespace does not (a changelog always sits in its base table's namespace), and the Postgres-side target does not either (a changelog has no pg_class row), which keeps the lake list | … | lake address round trip exact (crates/argondb-ctl/src/lake_address.rs:120-130).

A second database, and the JSON form a script should consume:

$ argondb lake address shop.sales.orders
lake."br_main.shop.sales".orders
$ argondb lake address --field lake-table shop.sales.orders
shop__sales__orders

$ argondb lake address --format json postgres.public.orders
{
  "box_warehouse": "argon",
  "branch": "main",
  "catalog": "lake",
  "changelog": false,
  "primary": "postgres",
  "tables": [
    {
      "address": "lake.\"br_main.postgres.public\".orders",
      "bucket": "argon",
      "changelog": false,
      "database": "postgres",
      "label": "br_main.postgres.public.orders",
      "lake_table": "postgres__public__orders",
      "leaf": "orders",
      "namespace": "br_main.postgres.public",
      "qualified": "\"public\".\"orders\"",
      "schema": "public",
      "table": "orders",
      "target": "public.orders",
      "warehouse": "argon",
      "warehouse_refusal": null
    }
  ],
  "warehouse_per_database": true
}

"changelog": true|false is carried so a consumer never has to infer which relation it was handed.

Example 4 — unpublish, and the two tiers

VERIFIED (the help text; the effects are FROM SOURCE via that text):

$ argondb lake unpublish --help
Stop publishing a table. Lake files and history are KEPT (re-publish re-baselines). With --purge, also drops the Iceberg table and its changelog and deletes their files — irreversible

Usage: argondb lake unpublish [OPTIONS] <TABLE>

Arguments:
  <TABLE>

Options:
      --pg-url <PG_URL>      Omitted = this box's primary (see `lake publish --pg-url`)
      --purge                Also delete the lake table, changelog, and all their files
      --yes                  Skip the interactive confirmation (scripts)
      --branch <BRANCH>      Branch whose lake table to purge [default: main]
      --data-dir <DATA_DIR>  This box's data directory (argondb-lake-list-port-defect: only consulted with --purge, which is the arm that reaches the catalog). Same default as `argondb serve`/`status` [env: ARGONDB_DATA_DIR=] [default: ~/argon]
      --port <PORT>          This box's Postgres port, for the SAME port-precedence `argondb status` uses (`--port` / `ARGONDB_PG_PORT` / argon.toml `port`; presence-based, lake-visibility-pair finding b)
  -h, --help                 Print help

The two tiers mirror DROP TABLE vs DROP TABLE PURGE exactly (DESIGN.md:393): plain unpublish stops capture and keeps every lake file and all history — instant and reversible, and re-publishing re-baselines; --purge additionally drops the Iceberg table and deletes its files after an explicit confirmation showing what will be destroyed. DROP TABLE in Postgres behaves like the default unpublish: the lake data is kept.

Two tables can never be published: TEMP and UNLOGGED. They write no WAL, so there is nothing to capture (DESIGN.md:393).

Example 5 — read the lake copy from Postgres, through lake_<branch>.*

FROM SOURCE. The argondb_lake FDW extension mounts every table this database published as a foreign table in the lake_<branch> schema (crates/argondb-lake-fdw/src/catalog.rs:1-18, docs/reserved-identifiers.md:4118-4127):

CREATE EXTENSION argondb_lake;                    -- superuser: it creates a FOREIGN DATA WRAPPER

CREATE SERVER argondb_lake_srv
  FOREIGN DATA WRAPPER argondb_lake
  OPTIONS (tail 'http://localhost:8817', branch 'main');

SELECT argondb_lake_sync();                       -- one idempotent pass: CREATE FOREIGN TABLE IF NOT EXISTS
SELECT argondb_lake_start_sync();                 -- the cadence background worker, so later publishes mount
                                                  -- without a reconnect

SELECT count(*) FROM lake_main.postgres__public__orders;
SELECT argondb_lake_freshness();                  -- the behind-note, as queryable JSON

Three properties of this mount are worth internalising.

Example 6 — read the same table from DuckDB, with nothing of ours running

FROM SOURCE (docs/lake-foreign-read.md:430-470, :468-530). This is G6 — open-format exit (MUST): the tables are standard, spec-compliant Iceberg, readable by any Iceberg-capable engine with no ArgonDB component running, and ArgonDB metadata rides in Argon.* snapshot properties that never break a foreign reader (DESIGN.md:434).

-- in DuckDB, after ATTACHing the Iceberg REST catalog as `lake`
SELECT count(*) FROM lake."br_main.postgres.public".orders;
SELECT count(*) FROM lake."br_main.shop.sales".orders;   -- database `shop`, schema `sales`

The quotes are not optional, and the failure does not look like a quoting mistake:

D SELECT count(*) FROM lake.br_main.postgres.public.orders;
Parser Error: NameListToString NOT IMPLEMENTED

Discovery is the part that is missing, and it is measured. DuckDB enumerates the catalog's top level only and never walks into a nested namespace, so a table you have not named is invisible to SHOW ALL TABLES, duckdb_schemas() and duckdb_tables():

D SHOW ALL TABLES;
┌──────────┬─────────┬───────┬──────────────┬──────────────┬───────────┐
│ database │ schema  │ name  │ column_names │ column_types │ temporary │
├──────────┴─────────┴───────┴──────────────┴──────────────┴───────────┤
│                            0 rows                                     │
└───────────────────────────────────────────────────────────────────────┘

Name the namespace once and it becomes visible for the rest of the session:

USE lake."br_main.postgres.public";
SHOW ALL TABLES;

To learn the namespaces without guessing, walk the REST catalog yourself — one level per call, which is exactly what DuckDB does not do for you (docs/lake-foreign-read.md:510-528):

curl -s "$CATALOG/v1/$WH/namespaces"                    | jq -r '.namespaces[][]'
curl -s "$CATALOG/v1/$WH/namespaces?parent=br_main"     | jq -r '.namespaces[][-1]'
curl -s "$CATALOG/v1/$WH/namespaces?parent=br_main%1Fpostgres" | jq -r '.namespaces[][-1]'
curl -s "$CATALOG/v1/$WH/namespaces/br_main%1Fpostgres%1Fpublic/tables" \
  | jq -r '.identifiers[].name'

%1F is the level separator the REST spec uses, and $WH is the warehouse's UUID, not its name — the catalog answers 400 WarehouseIdIsNotUUID for the name. Read it from GET /catalog/v1/config?warehouse=argon, field defaults.prefix.

What a foreign engine gets is the base table at its watermark. No ring, no pin, no merge. That is not staleness by accident — it is a different, simpler read than the unified one, and it is why example 5's FDW mount exists.

A dv_<branch> table reads the same way, but tells you which coordinate it is at differently. A continuous view's table, like a transform output, is plain Iceberg, and DuckDB reads it with nothing of ours running. Its snapshots carry Argon.view-current-to-lsn, the LSN the view is current to, and never Argon.watermark-lsn. The lake writer refuses a snapshot summary containing Argon.watermark-lsn, Argon.replay-from-lsn or Argon.changelog-through-lsn before it attempts the commit (FORBIDDEN_LAKE_SNAPSHOT_PROPS, crates/argondb-committer/src/iceberg_sink.rs:729, enforced at crates/argondb-committer/src/lake_writer.rs:1564). A view did not come from the WAL, so a reader that took its coordinate for a watermark would pin it wrong. A foreign consumer can therefore tell the two classes apart by which key the current snapshot carries: a published br_ table has Argon.watermark-lsn, and a view has Argon.view-current-to-lsn.

Proven with stock DuckDB v1.5.4 by testing/box/view-foreign-reader.sh (2026-09-24, ms-01, view-foreign-reader: PASS (ok=7 fail=0)). DuckDB read the view's 10 rows, equal to the maintainer's own count. The view coordinate on the current snapshot equaled argondb view list's, and the published control table showed the opposite pair. Two DuckDB v1.5.4 limits shape how you read those properties yourself. iceberg_snapshots() returns no summary column, so read the summary from the table's metadata.json (its location comes from iceberg_load_table_response). That function also refuses a multi-level namespace written as an identifier, so pass the address as a string literal.

Example 7 — a pinned read, and what the pin token carries

VERIFIED (the surface) / FROM SOURCE (the semantics). The bundled reader:

$ argondb-query --help
Usage: argondb-query [OPTIONS] <SQL>

Arguments:
  <SQL>  SQL to execute against the unified views

Options:
      --tail <TAIL>      The tail's Flight address. Read from `ARGONDB_TAIL_ADDR` when the flag is absent … [env: ARGONDB_TAIL_ADDR=] [default: http://localhost:8817]
      --branch <BRANCH>  [default: main]
      --at-lsn <AT_LSN>  Pin LSN (X/Y). Default: current tail head (freshest consistent view)
      --scope <SCOPE>    Pin scope (D12): comma-separated lake table names the pin covers. Default: all tables (the atomic cut)
      --csv              Print the result as CSV (header row first) …
  -h, --help             Print help
  -V, --version          Print version

So the three coordinates of a read are three flags:

# freshest consistent view, scope = all tables (the atomic cut)
argondb-query "SELECT count(*) FROM postgres__public__orders"

# the same question at a named position
argondb-query --at-lsn 0/1BD2748 "SELECT count(*) FROM postgres__public__orders"

# narrowed scope — the consistency claim now covers exactly these two tables
argondb-query --scope postgres__public__orders,postgres__public__line_items \
  "SELECT o.id, sum(l.qty) FROM postgres__public__orders o
     JOIN postgres__public__line_items l ON l.order_id = o.id GROUP BY 1"

# machine-readable, for a foreign consumer
argondb-query --csv "SELECT * FROM postgres__public__orders LIMIT 10"

Note that --scope takes flat lake keys — argondb lake address --field lake-table is the door that produces them. Narrowing the scope is also the remedy every scope-related refusal names, which is why it is worth knowing before you need it.

The token itself is (branch, LSN, scope, minted_at, coordinate-provenance), signed and stateless (DESIGN.md:163). The field that does the work is the last one: whether the LSN was server-chosen at the protected default or caller-named. A server-chosen coordinate has no refusal doors left — when no coordinate serves every scoped table, the pin is granted anyway and the tables that cannot serve it are served at their own newest retained watermark, with a per-table disclosure. A caller-named coordinate can refuse, because the caller named it and owns its lifetime.

The disclosure a non-atomic pin prints is verbatim from crates/argondb-query/src/lib.rs:1914-1926:

note: this pin is NOT one atomic cut — no coordinate serves every scoped table, so 2 scoped
table(s) are reflected as of an OLDER coordinate than 0/1BD2748: postgres__public__audit,
postgres__public__legacy. Rows committed to those tables after their own coordinate are NOT
visible here; every other scoped table is at 0/1BD2748. A table is listed here for one of two
reasons: it has no D9 changelog, so it can only be read exactly at a retained snapshot watermark
and it retains none at 0/1BD2748; or its lake watermark has run ahead of the live decoder, so its
changelog holds nothing between its own coordinate and this one and it is served base-only.
Enable the D9 changelog on the first kind for row-exact history at any coordinate, narrow the
scope to tables that share a coordinate, or repin — the second kind clears itself as soon as the
decoder catches up.

Example 8 — branches, and the br_ prefix that is not always there

VERIFIED. This is the one piece of addressing a shell script gets wrong, so there is a pure subcommand for it:

$ argondb lake namespace
br_main
$ argondb lake namespace postgres public
br_main.postgres.public
$ argondb lake namespace --branch feature-x
feature-x
$ argondb lake namespace --territory lake-writer
dv_main
$ argondb lake address --branch feature-x postgres.public.orders
lake."feature-x.postgres.public".orders

Read the third line again. feature-x has no br_ prefix. Only main, temp and system take it — the three names DuckDB reserves as schema names — so a br_$BRANCH composed in a shell is right on three branch names and silently wrong on all the others. The dv_<branch> lake-writer territory is unconditional, which a literal cannot carry either (argondb lake namespace --help).

Give levels below the branch to compose deeper; the result is the same string argondb lake address --field namespace answers for a relation in that schema. Use lake namespace when you have no relation to name and lake address when you do.

Operationally: branching the database branches the published set with it, the lake does no work for the branch until something reads or publishes on it, and once materialized the branch's lake tables share nothing at the file level (DESIGN.md:515).

Example 9 — time travel, and what a gap refusal prints

FROM SOURCE. Below the watermark, a read is served from the newest retained snapshot with W' ≤ L plus changelog replay over (W', L]:

argondb-query --at-lsn 0/1A00000 "SELECT count(*) FROM postgres__public__orders"

Four ways that can refuse, each with its exact text.

Inside a recorded gap — permanent, because the rows were never decoded (crates/argondb-query/src/lib.rs:6014-6026):

time travel of postgres__public__orders to 0/1A00000 refused: the changelog replay window
(0/19C0000 … 0/1A00000] intersects a recorded gap (0/19F1200 … 0/1A0C440] (watchdog
stall-escalation re-baseline) — the range is unreconstructable; pin at or after 0/1A0C440,
or repin for a fresh view

No changelog, and L is not one of the table's retained watermarks (crates/argondb-query/src/lib.rs:5790-5799):

postgres__public__orders has no changelog, so time travel can only land exactly on a retained
snapshot watermark (nearest at or below 0/1A00000 is 0/19C0000); enable the changelog for
row-exact history

This one is reachable only through freshness=head or an explicit at_lsn. The latest_servable default snaps down to a durably servable coordinate at mint precisely so the ordinary path never lands here.

Below a history restart, the one refusal whose remedy points forward (crates/argondb-query/src/lib.rs:5738-5777). The table, coordinates and cause below are the ones the unit test uses (the_refusal_below_a_history_restart_names_the_first_servable_coordinate_and_only_then):

time travel of postgres__public__vio_dim to 1/D54A4238 refused: a whole-table materialize
restarted this table's history at 1/D54B78F0 (schema-change-not-expressible-in-place) — this is
NOT retention expiry and repinning at an older coordinate will never succeed; the new history's
first servable coordinate is 1/D54B78F0: repin at 1/D54B78F0

Repinning older never helps here, and that is exactly the difference from retention expiry, which the message calls out. The cause is one of schema-change-not-expressible-in-place, format-version-downgrade or eligibility-probe-failed (SwapReason::why, crates/argondb-committer/src/iceberg_sink.rs:7111-7117). A caller that has to recover programmatically should parse the restart coordinate with the one parser, argondb_query::history_restart_of (crates/argondb-query/src/lib.rs:5810), rather than re-spelling the sentence.

A scope naming something that does not exist (crates/argondb-query/src/lib.rs:4102, :4218):

pin scope names unknown lake table(s) ["postgres__public__ordres"]; published tables:
["postgres__public__orders", "postgres__public__line_items"]

The misspelling is deliberate: the refusal prints the published set, which is what makes it self-correcting.

To record a gap that predates gap recording — known from logs — there is an ops escape hatch that only ever adds rows (VERIFIED, the help):

$ argondb lake gap-record --help
Record a changelog gap in `_argon_gaps` (D12). Ops escape hatch: the tail records its own gaps; this backfills gaps that predate gap recording (known from logs) so changes() stops being confidently wrong over those windows

Usage: argondb lake gap-record [OPTIONS] --start <START> --end <END> --reason <REASON>

Options:
      --start <START>        Gap range is (start, end]: LSNs as X/Y
      --end <END>
      --reason <REASON>      Why the range is unreconstructable (shown verbatim in digests and refusals)
      --table <TABLE>        Affected lake table; omit = every tracked table
      --forfeits <FORFEITS>  How many commits an undecoded-window barrier episode coalesced into this ONE record. Omit for every gap class that is not a barrier episode — omitted is NOT the same claim as 0
      --at <AT>              When the gap happened, RFC3339 (default: now)
      --branch <BRANCH>      [default: main]
      …

Note --reason is shown verbatim in digests and refusals: whatever you type is what a developer hitting that window six months from now will read.

Example 10 — the change log's columns, and a changes() digest

FROM SOURCE. Enable the changelog on the tail, by flat lake key (crates/argondb-tail/src/main.rs:39-43):

export ARGONDB_CHANGELOG=postgres__public__orders,postgres__public__line_items
# or, equivalently, on the tail's own argv:
argondb-tail --changelog postgres__public__orders --changelog postgres__public__line_items …

The tail accepts any string here without checking it, so an entry that names nothing produces no changelog. That is why argondb lake list --tail <addr> reads the configuration back and names every entry matching no published key — and why, without --tail, it tells you it did not look.

The table that results is queryable like any other, and its four appended columns are the schema in §2.6:

-- from a foreign engine
SELECT _argon_lsn, _argon_op, _argon_seq, _argon_ts, id, status
FROM lake."br_main.postgres.public".orders__changes
WHERE _argon_lsn > 0/19C0000
ORDER BY _argon_lsn, _argon_seq;

The replay the query plane itself issues is exactly that shape (crates/argondb-query/src/lib.rs:6037):

SELECT * FROM chg WHERE _argon_lsn > {w_base} AND _argon_lsn <= {lsn}

Through the agent endpoint, the digest tool takes a window in three interchangeable forms (crates/argondb-mcp/src/lib.rs:2658-2671):

{
  "name": "changes",
  "arguments": {
    "since": "now-1h",                       // or "0/1A2B3C", or "2026-07-08T14:00:00Z" (μs resolution)
    "until": null,                           // defaults to now (head)
    "tables": ["postgres__public__orders"],  // default: all tracked tables with history
    "budget_tokens": 4000                    // default
  }
}

If that window intersects a recorded gap, the digest is still served and its counts are annotated as lower bounds over the intersecting range(s) (DESIGN.md:170). A digest is never silently short.

Example 11 — the tail's environment knobs

FROM SOURCE. The tail's knobs, with what each actually changes:

variable default what it changes source
ARGONDB_TAIL_DRAIN_QUEUE_MB 2048 (MiB) the ring's byte budget. High-water (cut now) at 50 %; backpressure at 100 %. 0 is REFUSED with a WARN; unparseable → default with a WARN. Read once at ingest-loop start crates/argondb-tail/src/lib.rs:133, :165-187
ARGONDB_TAIL_MIN_CUT_SECS 5 the minimum interval between cuts, so a byte-triggered cut cannot outrun the maintenance worker's cadence-multiple compaction thresholds docs/2026-09-17-process-sidecar-design.md:374; the constant is lake_writer::DEFAULT_CADENCE_SECS, crates/argondb-committer/src/lake_writer.rs:148
ARGONDB_TAIL_RING_COALESCE_ROWS 512 how many rows one coalesced ring batch may hold. 0 disables coalescing. Removes ~4.7× of the ring's accounted bytes at the ERP profile. Unparseable → default and says so on the log crates/argondb-tail/src/lib.rs:215-238
ARGONDB_TAIL_WAL_HOLD_CAP_BYTES 4 GiB how much WAL the safekeeper KEEPS for the tail's resume point, and the tail's restart replay budget — one number, two jobs. 0 turns the whole mechanism off and restores the pre-2026-09-17 horizon exactly. Unparseable → default, printed; never unbounded, never off docs/reserved-identifiers.md:76
--cadence-secs (box-declared) 30 s how often the cadence loop cuts absent a byte trigger. Declared by the box rather than defaulted by the tail crates/argondb-ctl/src/serve.rs:8073-8074
ARGONDB_MAX_LAG_BYTES 256 MiB how far behind head a table may be at startup before it re-baselines individually; also the other term of resume_replay_budget(cap, …), which takes the LARGER DESIGN.md:499, docs/reserved-identifiers.md:77
ARGONDB_TAIL_MEMORY_MB derived (6,144 / 5,632) moves the cgroup cap (memory.max) and its alarm line (memory.high) together docs/releases/v1.5.4.md:723
ARGONDB_TAIL_ZERO_WAL on the zero-WAL lever: an UPDATE record with no old key, on a primary-keyed table under DEFAULT identity, is applied as a same-key update with no old-page read. off restores the read. Read once per process and logged at first use crates/argondb-tail/src/lib.rs:3717-3745
ARGONDB_TAIL_DECODE_DECOUPLED on encode the rows that need no old page while the fetch is in flight. 0/false/off/no runs the two in series; anything unrecognized keeps the default with a WARN crates/argondb-tail/src/lib.rs:4739-4770

A worked sizing pass, and the trade in each direction:

# A memory-constrained box: halve the ring. The cut rate RISES (high-water is
# hit sooner), backpressure milliseconds rise, and the tail holds less.
export ARGONDB_TAIL_DRAIN_QUEUE_MB=1024

# A box with headroom and a bursty writer: raise the ring and let the clock
# do more of the cutting. Snapshots get larger and less frequent; the tail
# holds more, and a crash re-decodes more.
export ARGONDB_TAIL_DRAIN_QUEUE_MB=4096

# Turn coalescing OFF to reproduce the pre-L6 ring cost (a measurement, not a
# configuration): expect ~4.7x the accounted bytes for the same rows.
export ARGONDB_TAIL_RING_COALESCE_ROWS=0

# Restore the pre-2026-09-17 WAL horizon exactly, hold and replay budget both.
# A restart under lag then re-baselines with a gap, as it did before the hold.
export ARGONDB_TAIL_WAL_HOLD_CAP_BYTES=0

The knob's effect is read back, not assumed: the box gate's N1 arm runs a second arm at ARGONDB_TAIL_DRAIN_QUEUE_MB=512 and requires drain_queue_budget_bytes read back as 512 MiB, a high-water at or under 512 MiB, a higher drain_queue_backpressure_millis_total than the default arm, and still drained (testing/box/tail-rss-bound.sh §7 table, N1).

What to read while it runs — the shipped metric names are drain_queue_bytes_high_water and drain_queue_backpressure_millis_total (crates/argondb-tail/src/metrics.rs). Two log lines carry them (crates/argondb-tail/src/service.rs:6753-6761, :6557-6562):

tail drain queue: bytes=1073741824 (1024 MiB) high_water=1024 MiB budget=2048 MiB rows=46355 backpressure_holds=3 backpressure_secs=1.8 backpressure_expiries=0

ingest phase=backpressure for 2s, ring_rows=46355, drain_queue=2048 MiB (budget 2048 MiB, 3 hold(s)), open_txns=1 holding 12 MiB (peak 40 MiB, budget 512 MiB), evicted_open_txns=0, toast 0 value(s) / 0 block(s) scanned + 0 hint block(s)

ring_bytes_peak and ring_backpressure_secs_total are retired spellings that no producer emits; they survive only in the design's record of what two lanes were asked for (docs/2026-09-17-process-sidecar-design.md:800-816).

Example 12 — the maintenance plane's cadence, and what it writes

FROM SOURCE. The cadence is a code constant:

// crates/argondb-tail/src/maintenance.rs:837
pub const MAINTENANCE_SWEEP_SECS: u64 = 120;

No switch, no setting, no off state; tests reach it only through the test-override seam (DESIGN.md:158). In the split production shape it is its own supervised process, spawned only when the tail is also spawned (crates/argondb-ctl/src/serve.rs:8332-8360) — a maintenance worker with no tail would be a second writer with no ingestion to coordinate with.

What a pass writes, per table: a compaction (RewriteFiles) snapshot when the file count crosses a cadence-multiple threshold; a clustered rewrite when argon:cluster-by (explicit, or the single-PK-column default) applies; a Puffin statistics file carrying exact NDV; a snapshot-expiry commit under explicit retention, which keeps the newest N snapshots plus the travel anchor plus, on a changelog-less table, every watermark-stamped snapshot inside the retention window; and an orphan-file deletion pass limited to data files unreferenced by any retained snapshot and older than a writer grace period (DESIGN.md:158, :174).

Statistics are re-scanned only when a table's rows moved. A statistics scan reads every data file of a table, so the pass first asks iceberg_sink::stats_freshness whether the statistics already registered still describe the table's current rows, and counts each table it does not re-scan as stats_skipped_fresh= on the maintenance pass cost: line. Fresh means registered at the current snapshot, or at an older snapshot when every snapshot after it carries Argon.rows-unchanged-since naming its own parent. That mark is what a quiet cut writes. A cut moves the lake watermark of every clean table through a property-only snapshot (the quiet advance), but it moves a table's row watermark only when it carries that table's rows, and statistics describe rows. Anything else ends the walk and owes a scan: a snapshot that carried rows, a compaction rewrite, a foreign writer's snapshot with no mark, or a statistics snapshot that expiry has already removed. Before 2026-10-05 the predicate asked only the first question. Every cut therefore staled every table, and a quiet 385-table box re-read all 20 MB of its tables on every pass (stats_scanned=385 stats_skipped_fresh=0). The profile reader calls statistics current by the same function, so it never calls a table stale that the sweep declined to re-scan.

Its log lines are keyed by the label spelling of example 3 (crates/argondb-ctl/src/lake_address.rs:72-76):

maintenance br_main.postgres.public.cas_zoo__changes: changelog compaction rewrote 7 data file(s) -> 1

A pass that overruns the cadence backs off rather than overlapping; on the live demo at the 120 s cadence, passes exceeding the cadence were measured and are kept as the back-off fixture (crates/argondb-tail/src/maintenance.rs:5985-6080). The pass deadline is 30 cadences — one hour (:5107-5113).

Clustering is a table comment, same idiom as the publish marker:

COMMENT ON TABLE public.orders IS 'argon:publish argon:cluster-by=customer_id';
COMMENT ON TABLE public.events IS 'argon:publish argon:cluster-by=none';  -- opt out of the PK default

Example 13 — force a clean re-derive of one table

FROM SOURCE (argondb lake --help, quoted in §6.7). The tail's own self-heal — snapshot-replace on a failed row-delta commit — only fires when a write fails, so a table left duplicated or drifted by an already-fixed cause never self-heals:

$ argondb lake rederive public.orders             # DRY RUN by default: prints the plan, touches nothing
$ argondb lake rederive public.orders --execute

It automates the proven path — unpublish (never --purge), then re-publish — which routes the table through the tail's own mid-run join, keeping the tail the single writer. It deliberately does not replace the table from the CLI process, which would be a second writer racing the cadence on the same table.

Example 14 — the whole path, end to end

FROM SOURCE. One change, and where the time goes. The architecture site traces one price change across six hops, from one company's database to another's (deploy/publish/architecture-site/content.md:563-565). Within a single tenant the path is shorter, and it is worth writing out because it is the shape every question about freshness reduces to:

1. an ordinary COMMIT in Postgres            <- the application's latency; unchanged (G1)
2. the WAL reaches the safekeepers           <- durability
3. the tail decodes it into the ring         <- H advances; typically well under a second behind (G4)
4. a cut lands it in Iceberg                 <- W advances; ≤ 30 s by clock, sooner on a byte trigger
5. a reader pins L and reads                 <- Iceberg@W ∪ tail(W, L], merged by PK

Between steps 3 and 4 the row is already readable at full consistency — that is what the ring is for. Step 4 is about durability and file health, not about visibility. A reader that pins at head sees a row the instant step 3 completes; a foreign engine reading the Iceberg table directly sees it after step 4.

5. Built today vs designed

Solid outlines in the diagrams mean built and shipped; dashed means designed, measured, not built yet (docs/diagrams/argondb-and-the-erp/generate/blocklib.py:4). This table is the same statement per feature, with the evidence.

feature status evidence
Physical decode → Arrow row changes, single decode path BUILT DESIGN.md:559 (M0–M5 shipped as v0.1.0-alpha); DESIGN.md:122
One atomic multi-table commit per cut, watermark-stamped BUILT DESIGN.md:489-495; the REST /transactions/commit path, iceberg-rust fork ahead of PR #2786
argon:publish gate, per-table, default empty (G2) BUILT DESIGN.md:393; crates/argondb-ctl/src/lake.rs:1176-1201
D9 __changes changelog, opt-in per table BUILT DESIGN.md:156; schema at crates/argondb-committer/src/iceberg_sink.rs:10204-10226
D12 pins, gaps, time travel, always-grantable BUILT DESIGN.md:559 ("the full D12 pin/gap/time-travel machinery"); crates/argondb-query/src/lib.rs:4234
D10 maintenance plane, 120 s, no off switch BUILT crates/argondb-tail/src/maintenance.rs:837
D14 value mapping + per-table decode isolation BUILT DESIGN.md:181-189, DESIGN.md:559
Branch → Iceberg namespace, lazy materialization BUILT DESIGN.md:515-519
D30 three-level identity (branch/database/schema + leaf) BUILT (shape); one step pending DESIGN.md:382 — design §7 steps 1–4 implemented; step 5, the per-table rename of pre-2026-09-11 tables, is pending its rehearsal on a scratch copy. Old-shape names boot and list through the compatibility read path: a deliberate, tested state
The ring byte budget + backpressure (L1) BUILT crates/argondb-tail/src/lib.rs:133-238; docs/releases/v1.5.4.md:609-625
Ring batch coalescing (L6) BUILT crates/argondb-tail/src/lib.rs:215-238; measurement crates/argondb-tail/tests/ring_per_row_cost.rs
The tail WAL retention hold, capped, persisted (one bound) BUILT docs/reserved-identifiers.md:76; both polarities proven by testing/box/tail-wal-hold-restart-proof.sh
The 6 GiB cgroup cap + 5.5 GiB alarm line (L2) BUILT block S2 [D-2026-09-18-ff217a]; docs/2026-09-17-process-sidecar-design.md:443-449
The tail memory: over the alarm line LOG LINE NOT BUILT the design asked for it; L2 shipped the cap and the alarm without it, and v1.5.4 §11 says so in those words (docs/2026-09-17-process-sidecar-design.md:451-455)
tail-rss-bound box gate proving the bound (L3) BUILT testing/box/tail-rss-bound.sh; three arms, P1–P5 / N1–N3
The lake FDW mount, database-scoped, plus tables granted to it BUILT crates/argondb-lake-fdw/src/catalog.rs; docs/reserved-identifiers.md:4118-4127; HISTORY#fdw-cross-party-mount-built
The tail, one stream per tenant timeline in one process BUILT crates/argondb-tail/src/streams.rs; HISTORY#tail-multiplexes-tenant-timelines
Pageserver token auth, a compute's token scoped to its timeline BUILT storage/pageserver/src/auth.rs; HISTORY#pageserver-token-timeline-claim
Sync drops a STALE cross-database mount BUILT, opt-in (argondb_lake.drop_stale_mounts, default off, superuser) crates/argondb-lake-fdw/src/catalog.rs:514-579; gate testing/box/lake-mount-scoped-wall-matrix.sh
Sync drops a REVOKED grant's mount NOT BUILT — OPEN the source is alive, so the mount is kept and warned; docs/2026-09-22-fdw-cross-party-mount-design.md §4.4
D29 derived tables — Postgres landing (v1) BUILT DESIGN.md:383 rulings (1)–(3)
D29 P2 lake-native landing (dv_<branch>, two doors) BUILT (v1.4) DESIGN.md:383 — ruling (4)'s further block was held 2026-09-07 and §12.20 amended
One writer per dv_* table, refused not-owner BUILT crates/argondb-committer/src/lake_writer.rs:2294; gate testing/box/dv-lake-landing-polarities.sh
Lake view refresh killed after its Iceberg commit restarts from the committed coordinate MEASURED (a test, against an in-memory catalog standing in for the REST catalog) crates/argondb-transform/tests/landing_view.rs:980; testing/t3/view-restart-await-matrix.sh --kill
History-restart refusal names the first servable coordinate BUILT crates/argondb-query/src/lib.rs:5738-5777, ruling [D-2026-09-26-544ae0]
Decode decoupled from the old-page read BUILT, default on — hides 2.0-2.7 % of decode, no throughput claim crates/argondb-tail/src/lib.rs:4739-4767; docs/releases/v1.5.5.md:548
Lever A, the old row from the WAL (--old-row-from-wal) BUILT, per-table opt-in, off by default crates/argondb-ctl/src/lake.rs:1131-1230; [D-2026-09-23-dd9ca5]
The zero-WAL lever (no old-page read for a same-key UPDATE) BUILT, the default crates/argondb-tail/src/lib.rs:3717-3745, :3876; docs/2026-09-17-process-sidecar-design.md:1452-1583
D29 lake landing upsert mode (fifth mode) BUILT, UNRATIFIED DESIGN.md:383 addendum 2026-09-15 — AGENT-DERIVED, "reported for a question block, not assumed ratified"
D18 Iceberg v3-capable writer RATIFIED, NOT BUILT DESIGN.md:320 — ships as a constant v2; the deployment default and the per-table argon:format-version=3 override return when the build starts
D19 HTAP offload SHELL BUILT; V1 limited DESIGN.md:333 — crates/argondb-offload, 4/4 box gates 2026-07-24. V1 runs the isolation trigger only; the speed trigger is pathless by ruling (logs, never executes) because the accelerator is MCP-tool-only in V1; offload_min_cost owes the T1 bench before any deploy
D13 Stage 1/2/3 decode sharding DESIGNED, NOT INDICATED DESIGN.md:186-194 — measured 2026-07-27: peak decode backlog 0.30 MiB against publish backlog 959.81 MiB. The bottleneck was downstream of what sharding helps; it is never taken on theory
Deriving the WAL hold cap from disk headroom (S3 option B) DEFERRED, and the reading recommends against it as posed block S3 [D-2026-09-18-31ae7e] selected C; block S6 [D-2026-09-19-9c0383] selected A (constant stays). If re-put, derive from a stated runway in minutes at the measured decode deficit, not from a share of free disk (docs/2026-09-17-process-sidecar-design.md:1143-1175)
Restating the memory bound on the measured baseline NOT DONE, deliberately one reading does not move a ratified number; ERP_SCALE=1 is one point, not the curve; and under-running the constant is the conservative direction (docs/2026-09-17-process-sidecar-design.md:415-437)
Helm maintenance workload TRACKED GAP DESIGN.md:158
Old-image capture for foreign incremental reads NOT BUILT, accepted DESIGN.md:156 — GetPage@LSN can always produce old images later; revisit if a CDC customer relies on incremental reads

6. Gotchas

6.1 br_ is conditional

Only main, temp and system take the prefix. br_$BRANCH composed in a shell is right three times and wrong every other time; dv_<branch> is unconditional. Remedy: argondb lake namespace [--branch B] [--territory lake-writer] (example 8).

6.2 A nested namespace needs one quoted identifier

lake.br_main.postgres.public.orders is four SQL name parts and DuckDB stops at the parser: Parser Error: NameListToString NOT IMPLEMENTED. It reads like a broken query, not a quoting mistake. Remedy: argondb lake address <target>, which quotes by construction (crates/argondb-ctl/src/lake_address.rs:57-63).

6.3 SHOW ALL TABLES does not list your tables

Measured, not theoretical: DuckDB enumerates only the catalog's top level. Reading a table you can name works perfectly; discovery is the only thing missing. Remedy: USE lake."br_main.postgres.public"; once per namespace per session, or walk the REST catalog, or argondb lake catalog-tables (docs/lake-foreign-read.md:468-530).

6.4 The publish marker is a COMMENT, so it can land on non-tables

A view, a materialized view, a foreign table, a partitioned parent (which holds no storage — publish its leaf partitions), a sequence and an index are none of them capturable, and any client can COMMENT the marker onto them without going through the CLI. Refusing the DDL would need a Postgres event trigger and therefore superuser on the primary, which cannot hold on a bring-your-own-Postgres deployment. So the marker is accepted and announced: one rate-limited WARN per (database, relation) naming the relation and its kind, and argondb lake list shows it as not-publishable (<kind>) (DESIGN.md:393). The WARN names the database unconditionally (postgres.pmv.v) because publish discovery reads the whole cluster and a relation name alone no longer identifies a relation.

Also: TEMP and UNLOGGED tables cannot be published at all — no WAL, nothing to capture. And new tables are not auto-enrolled: publishing is per table, by design.

6.5 No primary key means scan-replace, every cadence the table changed

Accuracy is identical either way — ArgonDB guarantees 100 % agreement between the database and the lake with or without a PK. What a missing PK costs is update performance, in time and in whole-system load, because the copy is maintained by full-table scan-replace (DESIGN.md:148). Remedy: put a primary key on every published table. Corollary for maintenance: composite-PK and PK-less tables get no clustering default, so the read-point-lookup win does not apply to them (DESIGN.md:158).

6.6 0 is refused on the ring budget, and on the open-txn budget

Neither reads as "unbounded" — unbounded is the behaviour the budget exists to remove. An operator who wants effectively-no-budget says so with a large number. Unparseable values fall back to the default and log why (crates/argondb-tail/src/lib.rs:165-187). By contrast, ARGONDB_TAIL_WAL_HOLD_CAP_BYTES=0 does mean off — it is the documented "restore the pre-hold horizon exactly" escape — so the same digit means opposite things on two knobs in the same process. Read the table in example 11 before setting either.

6.7 The tail's self-heal only fires when a write fails

A table left duplicated or drifted by an already-fixed cause never self-heals, because the snapshot-replace heal is triggered by a failed row-delta commit and nothing else. Remedy: argondb lake rederive <table> --execute, which automates unpublish-then-republish (never --purge) so the table goes back through the tail's own mid-run join rather than being rewritten by a second process (argondb lake --help, the rederive entry).

6.8 A time-travel refusal inside a gap is permanent

Retention will not fix it and neither will waiting: the rows were never decoded. The refusal names the recorded range and the reason, and the remedy it prints — pin at or after <end_lsn>, or repin for a fresh view — is the whole of what is available (crates/argondb-query/src/lib.rs:6014-6026). A changes() digest over the same window is different: it is served, annotated as a lower bound.

A refusal below a history restart looks similar and is not. The rows there were decoded, but a whole-table replace that could not commit in place dropped the base below its new watermark. That refusal is checked first, says this is NOT retention expiry, and names the one coordinate to repin at, the restart's first servable coordinate (§3.2, example 9). If you script around these refusals, go forward to that coordinate. Repinning older, which is the usual reflex, never succeeds here.

6.9 Read-your-writes is lost at the default on changelog-less tables

latest_servable snaps down to the newest watermark every changelog-less scoped table retains, so a pin on such a table is as fresh as the last lake commit, not the last write. That cost is accepted and named in the ruling (DESIGN.md:167). Remedies, in order of preference: enable the D9 changelog on the table; narrow the scope; or use freshness=head, which opts into head-or-refuse and discloses the transience at mint.

6.10 A pin token can outlive what it names — in exactly one direction

A token minted at the latest_servable default survives tail restarts and watermark advances for its whole window. A token minted at a caller-named coordinate — freshness=head above a changelog-less table's W, or an explicit at_lsn in that transient range — survives the restart but not the watermark advance past it. The mint discloses that when it happens, and the provenance field in the token is what later lets redemption tell the two apart (DESIGN.md:163-166).

6.11 Over-disclosure on a time-travel pin

An at_lsn pin folds the tail's current pending D14 value notes into a historical read, so a note about a value that landed after the pinned coordinate can be disclosed on an answer that does not contain it. Named in the code as deliberate and as the safe direction: the failure being prevented is an affected value served with no note (crates/argondb-query/src/lib.rs:4270-4276).

6.12 Do not read total-records to reconcile a table

It fails in the direction that looks like a broken lake. Count rows (docs/lake-foreign-read.md:556).

6.13 Backpressure is not a stall, and the metrics say so

If you are writing an alert, do not treat a non-advancing read position as stuck without excluding backpressured windows — the product already does this in one shared predicate, and a second copy in your monitoring will fire on healthy backpressure (docs/2026-09-17-process-sidecar-design.md:546-570). The lines to read are ingest phase=... and tail drain queue: ... (example 11).

6.14 Two metric names in the design do not exist

ring_bytes_peak and ring_backpressure_secs_total are retired spellings that no producer emits. The shipped names are drain_queue_bytes_high_water and drain_queue_backpressure_millis_total. The design keeps the old spellings only in its record of what two lanes were asked for (docs/2026-09-17-process-sidecar-design.md:800-816).

6.15 There is no clean shutdown, and that is the design

argondb-tail installs no signal handler; a unit test asserts crates/argondb-tail/src/main.rs never mentions SIGTERM, ctrl_c, signal_hook or SignalKind. Every exit is a crash and the restart point is durable per cut. Do not add a drain-on-SIGTERM: it re-opens the crash-between-write-and-exit class that test exists to keep unreachable (docs/2026-09-17-process-sidecar-design.md:687-700).

6.16 The decode rate, not the cap, is what forfeits a fast tenant

At 50 clerks the tail decodes 659.6 txn/s against 2,900.4 tps offered and never drains. Doubling the WAL hold cap doubles the runway — 13 minutes becomes 26 — and changes nothing, because the deficit is structural at that level. Size the decode, not the buffer (docs/2026-09-17-process-sidecar-design.md:1143-1165).

Profiled to its stage, the pre-cache ceiling is one cost: the pageserver round-trip for old tuples (oldpage_fetch) is 96.5 % of charged decode time and bounds decode at 198.0 txn/s by itself, no matter what else in the tail changes. On a table with a primary key and the DEFAULT replica identity, the zero-WAL lever (on by default) now skips that read for every UPDATE that keeps its key. A table without a primary key, or with identity NOTHING or USING INDEX, still takes the page. For those tables the remedy is lever A, argondb lake publish <table> --old-row-from-wal, which you pay for in WAL (§3.9). If argondb_tail_zero_wal_refused_rows climbs, its reason label names which case the refused rows are in; the counter is per reason, not per table.

6.17 Snapshot expiry keeps more than you might expect, and for a reason

The keep-list is: the newest N snapshots, plus the travel anchor (the newest snapshot at or older than the retention window, or the oldest snapshot when the table is younger than the window), plus, on a table with no D9 changelog, every watermark-stamped snapshot inside the window — because there the servable coordinates are those watermarks and replay cannot cover any distance from the anchor (DESIGN.md:174). If you are estimating metadata growth on changelog-less tables, that third term is the one that dominates.

6.18 A quarantined table is not a gap

A table whose decode fails is quarantined alone at its last-good watermark: the cadence, every other table and the read plane are unaffected, and nothing was lost. argondb lake list renders it as QUARANTINED @ <lsn> (frozen; cause=<c>: <reason>), and a read of it is served base-only at that watermark and says so (crates/argondb-ctl/src/lake.rs:305-357). Before that state had a voice on the list surface, the watermark simply stopped moving and the customer had to read the tail log to learn why.

6.19 Two things about the changelog that surprise foreign readers

It is append-only at the row level, not the snapshot level: D10 compaction may commit a Replace. Rows are preserved exactly and every ArgonDB consumer is unaffected, but a foreign engine doing standard Iceberg incremental reads must fall back to a full re-read on a replace snapshot (DESIGN.md:156). And a delete is a tombstone carrying the key, not the old image — old-image capture is not built, and is accepted as not built.

6.20 A too-small memory budget clamps up at boot, it does not refuse

The tail's floor is 4,704 MiB. A box whose own memory budget would give the tail less than that does not fail to boot: the share clamps up to the floor, and the boot line names the share, the budget and the floor. The box refuses only when an operator sets ARGONDB_TAIL_MEMORY_MB explicitly below the floor. Read the cgroup-cap rule (§3.5) as "refuses below the floor" and you will expect every small deployment to fail to boot; it does not (block S5 [D-2026-09-19-1d3fca]).

7. Sub-diagrams

Four deeper block diagrams of parts the main picture compresses. Each is generated by a Python script beside it, using the same drawing library, palette and legend as the main set; re-render any of them with python3 <script> from docs/diagrams/argondb-and-the-erp/generate/.

diagram what it opens up generator
3a — the ring, the cut, and W/H over time the admission predicate, the three thresholds on one budget, the four cut triggers, and the staircase of W under a continuous H docs/diagrams/argondb-and-the-erp/generate/d03a_ring_and_cut.py
3b — the unified read, per table the three cases (L above W, L below W, no changelog), the merge they share, and what the pin token carries docs/diagrams/argondb-and-the-erp/generate/d03b_unified_read.py
3c — the tail's memory budget as a stack the seven buffers, the 25 % slack, the cgroup cap and alarm, the measured baseline, and the three lines that make memory, lag and the cap one system docs/diagrams/argondb-and-the-erp/generate/d03c_tail_memory.py
3d — the gap record and the rescan the four doors to a re-baseline, quarantine as the fifth and different outcome, the ordering MUST, the row's columns, and the two things a reader is told docs/diagrams/argondb-and-the-erp/generate/d03d_gap_and_rescan.py

8. References

Design and doctrine

Code

Gates

Diagrams


Guide 4 — continuous materialized views, and a view across the tenant boundary

Who this is for. A senior developer who has to USE this subsystem: define views, read them, predict what they cost, and diagnose them when they stop advancing. It is one guide of a set, each drawn around one block diagram. It is an internal technical reference; it explains the mechanism and does not argue for it.

How to read a claim in here. Every statement is sourced by path:line to the code or to docs/2026-09-18-continuous-materialized-views-design.md (cited below as "the design", by section). Worked examples in §4 are labelled VERIFIED (run in this lane, with the real output pasted) or FROM SOURCE (the exact signature, flag, constant or table quoted by path:line). Where the picture is dashed, the guide says designed-and-not-built, and §5 has the table.


1. The diagram

Deep dive 4 — continuous materialized views, and a view across the tenant boundary

Read the picture as four horizontal bands, top to bottom, and read the top band left to right as one refresh happening. The tail decodes every committed change to every published table into a change row carrying _argon_lsn, _argon_op (I/U/D) and _argon_seq. Those rows are reachable from exactly two durable places — the tail's in-memory ring above the lake watermark W, and the D9 <t>__changes changelog at and below it — and which one a consumer should use is decided by when the consumer runs, not by preference. The view maintainer runs inside argondb-maintenance, notices that W has advanced past some view's coordinate L_v, and schedules that view at L_1 = W. It applies the class's arithmetic over the window (L_v, L_1] and lands ONE snapshot whose rows and whose coordinate stamp — Argon.view-current-to-lsn = L_1 — are in the same commit. A read pinned at some L may then be answered from that view: the rightmost box is dashed-on-the-shipped-legend only in the sense that the whole rewrite path is newer than the rest of the band, and §5 states exactly what of it has landed.

The three bands below are the three axes a developer actually chooses on. The CLASSES band is the arithmetic: whether a view can be brought up to date from what changed (C1, C1a, C2, C2m, C3) or has to be recomputed from its inputs (C4), and the classifier decides that at create and prints it. The LANDINGS band is where the rows physically sit: one Iceberg table in dv_<branch> read through the same FDW as every other lake table, or one real Postgres table in derived.* on the primary — one landing per view, never both. The bottom band is the tenancy answer, and its shape is the point: a view never reaches across a wall. It is an ordinary join of two LOCAL tables, one of which happens to be a reference table the hub COPIED into this database earlier. The "no query reaches across" box is not a policy bolted onto views; it is the same wall the FDW mount and every scan ticket already carry, evaluated once more by the maintainer.

Three judgement calls the picture makes and this guide inherits. Dashed outline means designed, or designed and partly built, on this tree — it is not a promise about a date. OPEN means a decision nobody has made; the picture carries OPEN-V2 (retention for view snapshots) and this guide does not resolve it. And the word "sidecar" in this subsystem names only the tenant's own application process; our process is the tail.


2. What it is

One paragraph per box in the picture, carrying the coordinates, states, defaults, limits and failure modes the picture had no room for.

2.1 The tail — the change rows, and the two things they do not carry

The tail decodes the WAL and emits, per published table, a row stream where each row is the table's own columns — the NEW image — plus _argon_lsn, _argon_op (I/U/D), _argon_seq (the order within one commit) and _argon_ts (the design, §3.1). Two absences shape everything downstream. A D row carries the key, not the old image, and a U row carries the new image, not the old; the tail's old-tuple cache is internal to decoding and is not on the wire. So any arithmetic that needs −f(old) — every aggregate class — has to go and fetch the old image, and §3.4 is where that fetch is costed. A third property is load-bearing and easy to miss: a primary-key UPDATE is not a special case. The tail emits it as a D on the old key and an I/U on the new one, because the merge plan treats a key as a partition; the classifier relies on that and the box gate asserts it (testing/box/view-maintainer-incremental-oracle.sh:66-68).

2.2 Two delta sources, and the rule that picks between them

Above W the change rows are in the tail's ring, served over Flight as (from_lsn, to_lsn] tickets — and evicted the moment a cut lands. At and below W they exist only if the table is enrolled in the D9 changelog <t>__changes, an append-only Iceberg table of exactly those rows, committed in the same atomic cut as the base table and retained at least the pin window (48 h). The design (§3.1) states the consequence as a rule: the ring is the right source for the READ-TIME compensation of §4, and the changelog is the right source for MAINTENANCE — because a refresh runs at a cut, and at a cut the ring has just been evicted below it. Therefore a view is maintained incrementally only over inputs whose changelog is enrolled, and a view whose inputs are not enrolled is recompute class whatever its shape. That is the first thing the classifier prints. The first refresh's own coordinate is PINNED AT OR AFTER the enrolment floor, never left floating — Block V2a [D-2026-09-23-fd53b9], amending V2 [D-2026-09-18-b2dc92]: a first refresh raises its pin to the HIGHEST of its inputs' changelog-enrolment floors (costing V1 and D12's protected latest_servable default), rather than pinning at the branch's own servable coordinate, which could sit inside an input's enrolment gap — a window that starts there is reconstructable by neither the changelog (the drain only starts at enrolment) nor the ring (evicted below a cut). A third source exists for a downstream view in a chain — the upstream's own committed snapshot — and §3.8 covers it. And a window that intersects a recorded gap (_argon_gaps) on any input, or that begins below an input's changelog retention, is not a delta: the view recomputes at L_1 with the cause recorded (V4, DESIGN.md:49-54 of §11). Both cases are read from one record, _argon_gaps, by one predicate, argondb_query::view_gap::gap_block, and the cause is named from the record's reason (GapCause::of — Rebaseline or Retention). A changelog retention floor is itself an _argon_gaps row (0, floor], written BEFORE the changelog is pruned (changelog_retention::prune_changelog_through), so a reader never sees rows gone without the floor that explains them (§4.12).

2.3 The view maintainer — where it runs, and the three things it will not do

The maintainer is a task inside the argondb-maintenance process, beside — never inside — the D10 sweep loop, because the two have different clocks: the sweep runs on a 120 s cadence and a view refresh runs on the CUT (crates/argondb-tail/src/view_maintainer.rs:1-16; spawned at crates/argondb-tail/src/bin/argondb-maintenance.rs:139-155). It is a cut follower: it reads the tail's shipped freshness action every ARGONDB_VIEWS_POLL_SECS (default 5 — the resolution at which it NOTICES an advance, not a refresh interval, view_maintainer.rs:85-93) and acts only on a watermark advance. A NEW VIEW REFRESHES ONCE AT CREATION even on an IDLE branch: creating a view moves no watermark, so "acts only on a watermark advance" would leave a view created after its branch's writers finished at zero refreshes forever — the follower therefore re-runs an already-passed cut while any view of the branch has never landed a refresh, and stops as soon as that first refresh lands. A view set that has ALL landed is still never recomputed on an unmoved watermark. Its queue is ordered by staleness: never-refreshed first, then by W − L_v in bytes, then by name, so the order is TOTAL and a gate can assert it (view_maintainer.rs:648-650, the doc comment on schedule at :651). ARGONDB_VIEWS_CONCURRENCY (default 1, :85-88) bounds how many refreshes run at once.

Under per-tenant compute ([compute] per_tenant = true) a tenant's views are maintained by the tenant's own tail, which runs this same maintainer for that database's views and cuts them at the tenant's own coordinate; the box maintainer skips a tenant database by name, one line per pass, and never crosses the wall [D-2026-10-03-f3cf87].

The three promises it makes are in its own module header (view_maintainer.rs:42-43): it holds no ring, it applies no backpressure to the tail, and it never blocks a cut. A maintainer that falls behind simply targets a later cut — nothing is skipped, the window (L_v, L_1] just grows, and the changelog holds it for the pin window. If it falls far enough behind, the views are behind, that is disclosed, and reads are still served.

Two breakers sit around it. The per-view breaker is a STREAK, not a count: ARGONDB_VIEWS_BREAKER_FAILURES (default 3, :79-83) CONSECUTIVE failures mark one view FAILED (<cause>); it stops being scheduled, keeps serving at its own L_v, one success clears the streak, and the rewrite rule stops using it (BreakerVerdict, :210-222). Outside it is the row's ordinary RestartPolicy — backoff 2 s → 30 s, breaker at 6 restarts in 120 s. A tripped maintainer means views stop advancing and reads keep serving at L_v with the disclosure; it is never a refused read.

Its memory is its own pool: ARGONDB_VIEWS_MEMORY_MB bounds a FairSpillPool spilling under ARGONDB_VIEWS_SPILL_DIR (crates/argondb-query/src/memory_pool.rs:104-110), resolved EAGERLY at boot so a bad value refuses the process rather than the first refresh (argondb-maintenance.rs:130-137). The pool is a fourth even part of the box reserve; the cost of adding that fourth claimant is stated rather than smoothed — the read plane's own share moved 3,641 → 2,730 MB on a 32 GB box, and 142 → 106 MB on the 1280 MB floor (the design, §12 L4).

2.4 One refresh = one snapshot, stamped in the same commit

A refresh pins at L_1 = W, plans the view's own definition text, computes under the views pool, and lands through argondb_transform::land::land_view_lake — which is the committer's LakeWriter and nothing else. The snapshot carries the coordinate in the SAME commit as the rows, which is V1's whole content: Argon.view-current-to-lsn = L_1, beside Argon.view-definition-sha256, Argon.view-class, Argon.view-inputs (each input with the W it was read at) and Argon.run-token (so a retry after state-unknown finds its own commit). A view MUST NOT carry Argon.watermark-lsn, Argon.replay-from-lsn or Argon.changelog-through-lsn — those are claims about WAL COVERAGE that only the committer may make (V1, DESIGN.md §11). That is the "one timeline, two clocks" line in the picture: the WAL LSN is the one timeline every ArgonDB datum sits on; an Iceberg snapshot id or committed-at is a clock of the STORAGE.

A refresh killed after its lake commit is wholly present and not re-applied. Because the coordinate rides in the same commit as the rows, there is no second write that a crash can leave behind: the one seam after the commit returns (after-lake-commit, land_view_lake in crates/argondb-transform/src/land.rs) is killed for real by a_refresh_killed_after_the_lake_commit_is_wholly_present_and_not_reapplied (crates/argondb-transform/tests/landing_view.rs), run as leg H of testing/t3/view-restart-await-matrix.sh --kill. After the kill the view is at the new coordinate and the maintainer treats it as current; replaying the dead refresh's run token lands no snapshot and no rows (AlreadyLanded); the next refresh continues from the committed coordinate; and at every step the rows equal an oracle built from the deltas alone. The limit, stated: that test's catalog is an in-memory one re-opened from the newest committed metadata file, standing in for a REST catalog's durable pointer — it is not Lakekeeper. A Postgres-landed view has no such seam (its rows and its argon.view_state row commit in one transaction), and its kill between COPY and COMMIT is not yet proven on a box (§5).

2.5 A read pinned at L — the rewrite rule

The rewrite rule is an OptimizerRule installed by pin(), so every caller of the read plane inherits it: the FDW scan on the analyst compute, the MCP query tool, argondb-query on the CLI, the transform runner's own compute. It is installed FIRST in the optimizer rather than appended, because DataFusion's own filter and projection pushdown rewrite the very sub-plans the rule matches, and a filter pushed under a table's alias would be invisible to a later match and dropped (the design, §4.6). A match replaces the sub-plan with a OneOf extension node; the choice is made at match time by construction (a matched view is by construction a subset of the base scan), so nothing plans the original twice. The rule's answers are the Coordinate enum (crates/argondb-query/src/view_rewrite.rs:1260-1272): Exact, Compensated { from, plan }, Disclosed { behind, age_secs }, Never(reason). Never is not an error — the base plan runs.

2.6 C1 / C1a — the keyed projection

Select-project-filter over ONE table with that table's key passed through (or declared). A refresh applies π σ to the change rows: I/U upsert by key, D delete by key. Exact, cost proportional to |Δ|, no old images (view_classify.rs:55-63, :140-149). C1a is the same view on a window whose delta holds I rows only — checked per refresh, never assumed: classify never returns C1a, and window_specialisation(class, delta_is_insert_only) is its one producer (view_classify.rs:198-203). The picture's "refuse today (oracle gate RED)" caption is the measured status of update/delete windows on this build; §6.1 has it with the remedy.

2.7 C2 / C2m — the algebraic aggregate

GROUP BY g with count, sum, avg (kept as the sum,count pair), min/max. Per group the refresh applies +f(new) for an I, −f(old) + f(new) for a U, −f(old) for a D; a group whose count reaches zero is DELETED. It needs old images for U/D rows, which is the one place a refresh touches base data other than the delta. C2m is the min/max case: when a D or U held a group's current extreme, the AFFECTED GROUPS are recomputed from the base at L_1 (WHERE g IN (…)) and the rest run incrementally. The class is static even though the trigger is per refresh, because the maintainer must carry the affected-group arm for the view at all (view_classify.rs:68-72, :174-186). An aggregate with no GROUP BY is keyed on the empty tuple — one row — and is fine (ViewKey::EmptyTuple, :223-225). A view's key names its OUTPUT columns, and a key that is an alias of a source column is supported (s.txn_month AS month, keyed (upc, month)): the maintainer follows each key column down the view's own plan to its GROUP BY term and reads every group set — the new side, the old side and an insert-only window's empty old side — through that projection, never by applying the key's names to the base (view_delta::group_keys, view_delta::empty_groups; the v1.5.7.9 regression that did is in HISTORY.md).

2.8 C3 — join, changed side known

Fact joined to dimension on an FK (N:1), optionally with C1/C2 on top. The delta is the standard two-term form ΔV = ΔF ⋈ D@L_1 ∪ F@L_0 ⋈ ΔD. As built (view_maintainer::refresh_join, :2077) it is one term per input: each input is rebound to its own collapsed new images while every other input is read at L_1, the view's own definition text is planned unchanged over each binding, and the terms are unioned as a SET. Every term is evaluated at L_1 — a named deviation from the literal F@L_0 ⋈ ΔD, and in the only safe direction: an upsert must land the fact row's image at L_1, and F@L_0 would silently revert a fact change the first term just computed. It is also what makes the two terms produce identical rows for a key both touched, so UNION (never UNION ALL) dedupes them. The delete set is the OLD side, not the tombstones, because a deleted DIMENSION row leaves no tombstone on the FACT and its fact rows must still leave the view.

The picture's "built, but a real FK still classifies as C4 through the box door" caption is STALE as of this guide's re-derivation — that reading turned out to be a gate artifact, and C3 through the box door is now proven, with a different, still-unfixed defect in the SQL door's own FK read; §6.2 has both the correction and what remains real. The diagram source has not been re-rendered for this; treat the caption as superseded by §6.2's text.

2.9 C4 — recompute

Everything else: DISTINCT on non-keys, window functions, non-algebraic aggregates (percentile, string_agg, array_agg), outer joins, semi/anti subqueries in WHERE, self-joins, HAVING over non-algebraic terms, keyless projections, and any input without a changelog. The whole SELECT is run at L_1 and landed as ONE atomic swap; cost is proportional to |inputs| and independent of |Δ| (view_classify.rs:76-78). C4 is CAPPED on this build, not uncapped. Block V7's initial ruling allowed it disclosed and uncapped pending measurement of the replace path [D-2026-09-18-82320c]; its FOLLOW-UP, ruled once L11 measured that path (776 k rows/s), supersedes it — ALLOWED WITH A CAP [D-2026-09-20-ce8d62]. create still prints the classifier's cost estimate by the door (view_maintainer.rs:26-30), and now also DERIVES min_refresh_interval from it, so a C4 view refreshes no more often than the smallest whole number of cut cadences under which its estimated cost is at most ARGONDB_VIEWS_C4_COST_SHARE % (default 10) of one cadence; argon.views() shows the interval it chose. Nothing refuses a refresh for being expensive — the cap bounds how OFTEN a C4 view may refresh, never whether it may run (§3.6, §4.4).

The classifier over-approximates toward C4. A shape it does not recognise is C4, which is always correct and sometimes expensive; the reverse error lands wrong rows in a served table (view_classify.rs:11-21).

2.10 The lake landing — dv_<branch>.<view>

The default. A view is a dv_* Iceberg table written through the argondb-committer crate's LakeWriter — the same code the transform runner lands through, so there is no second Iceberg WRITER IMPLEMENTATION. That is narrower than a single writer PROCESS: DESIGN.md §12.20, amended to reconcile its single-writer rule with V5 [D-2026-09-18-328120] [D-2026-09-20-bd1b20], restates single-writer-per-TABLE as a property of OWNERSHIP — the transform runner and the view maintainer are each ArgonDB's own dv_<branch> writer for the tables THEY OWN (transform outputs, view tables respectively), neither writes br_<branch>, and the committer remains br_<branch>'s only writer. The verbs per refresh are upsert (C1–C3: one row_delta snapshot, equality deletes on the view's key plus the new images), append (C1a) or replace (C4: one rewrite_files snapshot; readers see old-or-new). Which verb is not chosen by the maintainer: land::refresh_mode(class, first_refresh, landing) resolves it from the class's own landing_verb (crates/argondb-transform/src/land.rs:1875-1892), and the FIRST refresh of a lake-landed view is always an append into the table the create door already made empty. A reader tells a view from a transform output by the properties: a view carries Argon.view-current-to-lsn, a transform output carries Argon.origin/Argon.committed-at and no coordinate. The read door is the FDW foreign table lake_<branch>.<view>, mounted under the same tenant wall as every other lake table.

A foreign Iceberg reader sees the same table and the same coordinate. Nothing about a view table is ArgonDB-private: DuckDB's iceberg extension reads dv_<branch>.<view> like any Iceberg table, and the current snapshot's summary carries Argon.view-current-to-lsn and NOT Argon.watermark-lsn. The keys are written by view_provenance_capped (crates/argondb-committer/src/lake_writer.rs), and a lake snapshot that tries to carry any key in FORBIDDEN_LAKE_SNAPSHOT_PROPS (crates/argondb-committer/src/iceberg_sink.rs) is refused as LakeWriteError::ForbiddenCoordinate rather than written. This is proven from the reader's side by testing/box/view-foreign-reader.sh (phase T5 of testing/box/view-maintainer-recompute.sh): the DuckDB row count equals the maintainer's refresh line, the coordinate equals argondb view list's on the same snapshot id, the watermark key is absent, and a published base table shows the opposite — view-foreign-reader: PASS (ok=7 fail=0). §4.6 has the DuckDB SQL.

2.11 The Postgres landing — derived.<view> on the primary

Built (L7, proven against a real Postgres — §5). The C3 join arm's reachability defect is FIXED, at 72d745e02, merged 9/23 9:31am (bc6fdde66): refresh_join now lands through land_refresh, and declared_target_spec (crates/argondb-tail/src/view_maintainer.rs:2127-2136) is the one place either landing's target is spelled — see §9. Chosen per view at create, and a view has exactly one landing (V3); a second view with the other landing is how "both" is spelled, so that each has one writer and one state row. The rows go through the D29 landing transaction as the argon_transform role, with a PRIMARY KEY on the view's key and any secondary index the caller declares. Per refresh it is ONE transaction — INSERT … ON CONFLICT (key) DO UPDATE plus DELETE … WHERE key IN (…) plus the argon.view_state row plus COMMIT; or TRUNCATE + COPY for C4. The row cap (DEFAULT_ROW_CAP, 10,000,000 — crates/argondb-transform/src/lib.rs:53) applies to the VIEW SIZE for C4 and to the DELTA for C1–C3 (land.rs:1003-1006). What it buys: index lookups at OLTP latency, joins with live OLTP tables in one Postgres query, RLS and grants, and — publish-marked by default — a lake copy that arrives through the tail like any other table. That copy is a br_* table with a REAL Argon.watermark-lsn, because its rows went through the WAL; it is the one place a view's content legitimately appears on the committer's timeline. What it costs: heap, WAL, safekeepers and pageserver for every refreshed row, ON THE PRIMARY. This is the one exception to "the analytical side never loads the OLTP side", and V8 bounds it: the maintainer never loads the primary except through that transaction.

2.12 The bottom band — a view over a distributor's own rows and a landed reference table

public.invoice_lines is written by the distributor's own people all day and reaches its lake through the tail with a changelog. derived.mfr_price_list is a manufacturer's price list that the hub landed into this same database earlier — read-only, LOCAL, publish-marked, so it is in this distributor's lake copy too. A view over both is therefore an ordinary join of two local tables of one database. It classifies, refreshes, stamps and serves exactly like any other view; nothing about it is special-cased. The sharing model put the rows there; the view model reads them locally.

A table this database merely holds by mount grant is a different thing, and it is refused (§2.13): a landed copy is this database's own table, while a granted table still belongs to its grantor.

2.13 The wall — "no query reaches across"

The other party's area is unreachable, and rows mix only after the hub COPIED them. Mechanically this is not a new check. A view's database is its inputs' database, evaluated by the same tail_tables_in_database predicate the pin uses (crates/argondb-query/src/lib.rs:3197), which answers a database's OWN tables only; the FDW mount is scoped to the asking database — and, since 2026-09-23, also carries, read-only, the tables another database explicitly GRANTED it (hub.mount_grant, DatabaseScope::Granted, crates/argondb-tail/src/lib.rs:1392-1415; HISTORY#fdw-cross-party-mount-built), which a plain query may read but which is not an input of this database's own for a view; every scan ticket carries the database; and argon.create_view asserts it from the compute exactly as a scan ticket does (crates/argondb-lake-fdw/src/view.rs:341-345). Cross-database joins are refused by name (cross-database-join, view_classify.rs:673-681), and a view of another database is refused with the same words as a table of one.

A view over a table held by mount grant is refused by name — view-input-granted, raised by the create door before it pins or enrolls anything, on both landings (view_door.rs, refuse_granted_inputs; the predicate is view_classify::input_holding). A grant lets the grantee's mount READ the grantor's table, but the tail scopes a lake-landed view to the OWNER of its inputs, so the view would be created and then unreadable by the database that created it. Whether a grantee may build a view over a granted table at all is not ruled: it is OPEN-V3 in the design, and until it is ruled the door refuses rather than record something nobody asked for (§4.9, §6.17). The all_tenants.* views are a different object entirely: they are the READER's union over every walled database for the hub's census, not a tenant's view, and they are never reachable by a tenant.

A mounted table is never a view input [D-2026-09-27-f26f18]. A table another database granted you a MOUNT of (hub.mount_grant) is visible in your database — the tail lists it so catalog sync can mount what a scan serves — but a mount is a live read with no integrity guarantee: PostgreSQL allows no foreign key onto a foreign table, and the lake copy of a view over it would belong to the owner's scope, not yours. So the create door refuses it by name, before it takes a pin:

mounted-input-refused: dist__public__sales is a mount grant into this database, a live
read with no integrity; land it with a publication and read the landed copy

The remedy is the publication landing: the owner publishes the table with your database as a grantee, you subscribe, and the landed derived.<publication> is your own table — a view over it is admitted like any other (Guide 6, "One mechanism, both directions"). The refusal is the one body all four doors share (crates/argondb-transform/src/view_door.rs, mounted_input_refusal), fed the asking database's mount grants by the tail (crates/argondb-tail/src/view_action.rs); the operator doors (CLI, MCP) are no database's client and have no mounts.


3. How it works

The complete walk, control path first, then data.

3.1 The definition surface, and why it is a function call

The analytical-side instance is the analyst compute — a read-only Neon-storage compute that sees the page-native catalog AND the published lake tables through the argondb_lake FDW mounted as lake_<branch>.<table>. Two consequences fix the surface before any preference does (the design, §2.1). A read-only compute refuses DDL: CREATE MATERIALIZED VIEW on a hot standby fails with Postgres's own cannot execute CREATE MATERIALIZED VIEW in a read-only transaction, so an event trigger on that statement has nothing to fire on where the definition originates. And a Postgres MATERIALIZED VIEW is the wrong object: Postgres would own its refresh, it is not publishable (refused BY NAME as "a materialized view cannot be the unit of publication"), and its definition would be parsed by Postgres's planner against foreign tables whose semantics live in the read plane.

So the definition is captured by three SQL functions shipped by the argondb_lake extension and callable on the analyst compute (and on the primary, where the same extension is installed):

The options parameter is jsonb, not json, and the difference is load bearing: against the json form PostgreSQL answers function argon.create_view(text, text, jsonb) does not exist, because there is no implicit cast between the two (view.rs:48-57).

None of them writes locally, which is what lets them run on a read-only compute: each runs a Flight do_action against the read plane, exactly as argondb_lake_sync and argondb_lake_freshness already do. The wire key is argondb.view.v1 (crates/argondb-flight-proto/src/view_action.rs:47), on a THIRD surface — Surface::DoAction — inside the one envelope registry, so a scan ticket sent to the view door refuses WrongSurface naming both RPCs, and a view request in a do_get refuses the same way.

The argon schema is a precondition, never created here. Every ArgonDB box provisions it before anything installs an extension; the extension's bootstrap block refuses loudly if it is absent and names its producer, and a finalize block moves the three functions into it with ALTER FUNCTION … SET SCHEMA (view.rs:269-300).

3.2 One body, four callers

argondb_transform::view_door::ViewDoor is what the Flight action, argondb view create|drop|list on the CLI, the create_view tool on the argondb-write MCP mount and (over the action) argon.create_view all run (crates/argondb-transform/src/view_door.rs:1-11). The CLI path is an ADAPTER and decides nothing (crates/argondb-ctl/src/view.rs:353-420).

3.3 What create does, in order, and what each step can refuse

  1. The NAME, by the shared grammar, before anything is parsed — argondb_query::lake_name::validate_lake_name: [a-z][a-z0-9_]*, at most 63 bytes, no _argon_/br_/dv_ prefix, no __ infix. A caller who spelled the name wrong learns it without waiting on a plan, and the refusal names the same rule a transform target meets (view_classify.rs:560-568).
  2. The QUALIFIER REWRITE, and it is STRUCTURAL, never textual: the text is parsed with the same sqlparser dialect DataFusion uses, every table factor's qualifier is inspected, and the statement is re-emitted (view_door.rs:168). lake_main inside a string literal, or as a column alias, is not a qualifier and a regex cannot tell — all three are negative controls in the suite.
  3. THE GRANTED-INPUT CHECK (refuse_granted_inputs), before anything is pinned or enrolled: every relation the definition names is looked up in the read plane's own two answers — which database each lake name comes from, and which names this database is served — and an input that lives in another database yet is served here (an in-force mount grant) is refused as view-input-granted (§2.13). Before the pin, because under the tenant wall the pin does not serve the grantor's warehouse and the caller would otherwise be told plan-failed about a table its own EXPLAIN just read; before enrollment, which would otherwise enroll the GRANTOR's changelog. If either read-plane answer fails, the create FAILS — it is never taken as a pass. The check runs only when the request names its database, which the SQL door always does; the operator doors (the CLI and the MCP tool) send none and skip it.
  4. VALIDATION ON BOTH DIALECTS (the design, §2.3). First in Postgres, before the call leaves the compute: argon.create_view runs EXPLAIN of the definition on the calling backend, which proves the text parses in PostgreSQL, that every relation it names is a lake_<branch> foreign table this database is entitled to, and that the projection's column types are known to Postgres. It dials nothing — a plain EXPLAIN never opens a Flight scan. Then in DataFusion, in the read plane, where the classifier walks the plan. A definition that passes Postgres and fails DataFusion is refused naming the construct (plan-failed, whose remedy names the usual cause: to_char, current_setting, txid_current, nextval — view_classify.rs:719-731). The CLI runs the DataFusion half only, and it says so on its own first line (crates/argondb-ctl/src/view.rs:386-389).
  5. CLASSIFY, with every refusal surfaced verbatim (§3.5 below).
  6. THE DOOR'S OWN TWO REFUSALS, which the classifier deliberately does not raise because they depend on the caller's OPTIONS: the unpredicated self-join (view_classify.rs:705-718) and the keyless incremental view — the latter being ONE predicate the door evaluates BEFORE recording and the landing evaluates again on every landing (land::keyless_incremental_refusal, land.rs:939-955).
  7. RECORD: the dv_<branch>.<name> table is created EMPTY with the declaration on it. That is what makes the chain cycle argument structural — a definition may only name a view that already EXISTS — and what lets drop hand a definition back. A declared view carries NO coordinate: argon.views() prints NEVER REFRESHED, never 0/0, because a view that has never refreshed is not current to any LSN and a zero would read as "the whole head behind" (crates/argondb-query/src/view_catalog.rs:289-307).

3.4 The refusal family

Sixteen kinds, a CLOSED set whose one home is ViewRefusalKind::ALL (view_classify.rs:481-504). The count is proven against the enum by a test whose ordinal is an exhaustive match, so a new variant does not compile until it is given one. Every refusal carries a KIND, the VIEW, the CONSTRUCT that earned it and the REMEDY, and prints as refused: <kind> — view : <construct>. Remedy: <remedy> (view_classify.rs:748-760).

kind what earns it
multi-statement the definition is not ONE statement (argondb_common::sql_lex)
not-a-select DDL, DML, COPY, EXPLAIN, SET
name-grammar the view name breaks the lake grammar
non-deterministic any function DataFusion does not mark Volatility::Immutable — now(), random(). Refused for EVERY class, recompute included
input-not-published an unpublished table, or a table of another database
input-argon-internal an input named _argon_*
input-changelog a <t>__changes companion named DIRECTLY
input-transform-output a dv_* transform output — not a function of L, so a view over one has no coordinate to carry
self-cycle the candidate's own name is in the transitive closure of its inputs; the path v → a → b → v is printed
view-depth-exceeded the longest path to a base table is past ARGONDB_VIEWS_MAX_DEPTH; the path is printed
view-input-postgres-landed a Postgres-landed view as a chain input (two clocks); the lake-sibling remedy is printed
cross-database-join inputs of two databases
top-level-order-or-limit ORDER BY/LIMIT at the top level
unrepresentable-type an unbounded Postgres numeric under sum and family
unpredicated-self-join the largest table joined to itself with no equality predicate; raised by the DOOR, not by classify
plan-failed one SELECT that DataFusion still cannot PLAN

Two properties of the list matter to a caller. It is spec-derived — it comes from what a view must be, not from bugs met. And every one has a positive control beside it in the unit suite, so a refusal that starts firing on a definition it should accept is a test failure, not a support ticket.

A <t>__changes companion is readable in SQL, read-only, and not a view input [D-2026-10-03-ada8e3]. A company's own SQL session — the query tool under its own MCP token — reads its own tables' companions by their flat lake key (argondb lake address --changes --field lake-table <db>.<schema>.<table> prints it), with the changelog's own columns: the source columns plus _argon_lsn, _argon_op, _argon_seq and _argon_ts. Another company's companion is refused by name under the tenant wall, a write to one is refused by name as read-only, and view create over one is still input-changelog (crates/argondb-query/src/tenant_changelog.rs; gate testing/box/changes-names-the-missing-changelog.sh, run C):

-- under acme's own MCP token: acme's orders, every change since a position
SELECT _argon_lsn, _argon_op, id, status
FROM acme__public__orders__changes
WHERE _argon_lsn > 27000000
ORDER BY _argon_lsn, _argon_seq;

One more refusal sits OUTSIDE the closed set, deliberately: view-input-granted (VIEW_INPUT_GRANTED_KIND, view_classify.rs). It is not a fact about what a view MUST be — it waits on an open ruling (OPEN-V3) — so it is raised by the door, not by classify, and a unit test (the_granted_input_refusal_kind_is_not_a_classifier_kind) keeps it out of ViewRefusalKind::ALL. Like every door refusal it is a REFUSAL, not a failure, so it maps to the refusal error code on every door. It prints as <kind>: <detail>:

view-input-granted: view `<v>`: input <db>.<schema>.<table> is a mounted grant
from <grantor>; views over granted tables are not ruled — [ruling pending]. A
lake-landed view is scoped to its inputs' OWNER, so this one would be created
and then unreadable here; define the view in <grantor>, or over tables this
database owns

(view_classify::granted_input_refusal.) Nothing is recorded: argon.views() does not list the view afterwards.

3.5 The classifier

classify(ctx, name, sql, facts) is the door's entry point (view_classify.rs:962-1014); classify_plan(name, sql, plan, facts) is the PURE function underneath (:1024) — no engine, no catalog, no IO. Everything it cannot read off the plan (is this table published? is its changelog enrolled? is this equi-join an FK?) is asked of an injected ViewFacts. That is what keeps the tenant wall the caller's and makes both polarities of every rule a unit test rather than a box gate.

It classifies the UNOPTIMIZED plan, and that is lead rather than lag: DataFusion's SimplifyExpressions pass CONST-FOLDS now() into a timestamp literal, so classifying an optimized plan would erase the very non-determinism §2.5 refuses and the refusal would pass. That is asserted, not assumed (view_classify.rs:23-29).

The answer is Classification { view, class, key, inputs, estimate, notes } (:353-366). Three fields repay attention:

Estimate (:322-350) is a statement about the SHAPE, and no field of it pretends to be a measured number: dominant_term, scans_all_inputs_every_refresh, old_image_lookups, affected_group_recompute, unpruned_fact_scan: Option<String> (the remedy string when a dimension change forces an unpruned fact scan — D10's default clusters by PK, not by FK, so this is the COMMON case), cartesian_product, and terms.

FK metadata is an INPUT in three places and a constraint in none (the design, §2.4). It tells the classifier which side of a join is the FACT; it lets a view's identity be INHERITED through the fact side's PK; and referential integrity itself contributes nothing — the view never refuses a row for FK reasons, inner-join semantics decide, and a row whose parent is missing at L is simply not joined. Every door — the analyst compute's argon.create_view, the CLI and the Flight action — gets the roles from ONE reader: the tail's schema reader (argondb_decoder::schema::foreign_keys_of), which reads pg_constraint beside each published relation's PK and is unioned into the create by view_door::schema_fk_edges. On the CLI an extra edge may be supplied with --fk child.col=parent.col (crates/argondb-ctl/src/view.rs:105-130); the SQL function sends none of its own. Without one, a join classifies C4 with the demotion PRINTED — which is the "if the FK is absent" row of the design's own table, not a silent loss.

3.6 The trigger, the queue, and what backpressure means here

The trigger is the watermark advance, not the clock. Refreshing at W rather than at head H is what makes the changelog the delta source and what makes the refresh reproducible: a re-run at the same L_1 reads the same rows. The tail's cut cadence is ~30 s at rest and byte-driven under load, so a view is at most one cut plus its own refresh time behind head. For a C4 view, min_refresh_interval coalesces cuts whose refresh costs more than a cut's worth of change is worth — since 2026-09-20 this is DERIVED from the cost model rather than left at the every-cut default (§2.9, §5); an incremental class (C1/C1a/C2/C2m/C3) has no such option and refuses one if declared. Interval is a floor, never the mechanism: a view whose inputs have not changed is not refreshed at all, and there is no periodic recompute of an unchanged view. The one clock is max_staleness, which is a reader-facing disclosure bound and not a refresh trigger.

Backpressure is a named state. A view that cannot be scheduled before the next cut targets a later cut. Past the changelog's retention, or across a recorded gap, the window is not a delta: the first cut that meets it reports the view BEHIND once, naming the input, the range and the cause (cause=retention for a pruned changelog, a re-baseline for any other gap), and the next cut RECOMPUTES it past the gap, after which incremental maintenance resumes (RefreshOutcome::BehindGap; §4.12 has the words). The BEHIND report does not charge the per-view breaker. The "already reported" mark is held in memory, so a maintainer restart reports the BEHIND once more before recomputing. Nothing here ever slows the tail. A first refresh never targets a coordinate below its own inputs' enrolment floor — it is PINNED at or after the highest one (§2.2, Block V2a [D-2026-09-23-fd53b9]), so the first cut a newly created view is scheduled at is not necessarily the very next one if an input enrolled its changelog late. A view already refreshing whose input is enrolled LATER is BEHIND, not FAILED (AGENT-DERIVED, design §5.1 and F5 residual (c)): a C4 view's own create enrols nothing, so a later C1–C3 create over the same table can record an enrolment floor above the next cut. That view's next refresh is not a first one and is not raised — the ruling raises first refreshes only — so it pins nothing, logs views: \` BEHIND its input's enrolment floor: `` was enrolled at …, keeps its L_v, and refreshes at the first cut at or past . The per-view breaker is NOT charged. One predicate answers both cases, view_maintainer::enrolment_floor_pin; the box gate is testing/box/view-maintainer-recompute.sh` arm P11. On ms-01 the ordinary sequence did not reach the state (the enrolment floor equalled the cut), so the positive half is proven by unit test; the box proves the breaker is uncharged and the predicate silent.

The maintainer's boot line is composed from the values actually in force rather than from prose beside them, and the class list in it is composed from owning_lane so that a class this build starts or stops maintaining changes the line in the same edit (view_maintainer.rs:193-209):

view maintainer: concurrency=<n> (ARGONDB_VIEWS_CONCURRENCY) breaker=<n> consecutive failure(s) \
(ARGONDB_VIEWS_BREAKER_FAILURES) poll=<n>s (ARGONDB_VIEWS_POLL_SECS) window=<n> key(s) \
(ARGONDB_VIEWS_WINDOW_KEYS) pass deadline=<n>s (ARGONDB_VIEWS_PASS_DEADLINE_SECS) ring step=<n>s \
(ARGONDB_VIEWS_RING_STEP_SECS) recompute bound=<n>s|off (ARGONDB_VIEWS_RECOMPUTE_SECS) idle line \
every <n>s (ARGONDB_VIEWS_IDLE_LOG_SECS) — cut-driven; \
classes <list>; <pool bound>

Every knob is parsed LOUDLY at startup: unset or empty means the default, a plain positive integer means that (one exception: ARGONDB_VIEWS_RECOMPUTE_SECS=0 turns that bound OFF), and anything else is a refusal naming the variable, what was read and what was expected — because "a view maintainer that silently defaulted this would run under a limit nobody chose" (view_maintainer.rs:132-146).

3.7 One refresh, step by step

4b — one refresh: two delta sources, the arithmetic, one snapshot, one coordinate

  1. The window. (L_0, L_1] per input, as an INTEGER predicate over _argon_lsn, read from the changelog (or from the upstream view's snapshots, for a chain member).
  2. The per-key collapse. The window is a sequence of (lsn, seq, op, key, image) per table, and the arithmetic runs per key in (lsn, seq) order — the same total order the merge plan uses. pin()'s own MoR merge plan CALLS latest_per_key rather than restating it, so "the same total order" is structural rather than a comment.
  3. The old-image read, and it is asked of the UNCOLLAPSED window. The keys of all U/D rows are collected and fetched in one keyed read at L_0. A key deleted and re-inserted inside one window COLLAPSES to I yet existed at L_0, so collapsing first would skip its −f(old); that was a defect the composition found, and old_image_keys reads the uncollapsed window.
  4. The delete set is not the tombstones. A row UPDATEd out of the view's WHERE has neither a tombstone nor a new image and would sit in the view for ever. So a refresh equality-deletes EVERY key the window touched and re-adds the survivors in the same row_delta snapshot. That needed a landing able to delete a key with no new image — LakeWriter::upsert_table_with_deletes — and delete-keys-unsupported-mode is refused by name rather than dropped.
  5. The window bound is SOFT, at commit granularity. ARGONDB_VIEWS_WINDOW_KEYS (default 1,000,000 — crates/argondb-query/src/view_delta.rs:603-632) splits a window only at COMMIT boundaries. When the FIRST commit in the window is ALONE over budget, that commit is taken WHOLE and the pass says so: a WARN names the commit, its row count, the budget it exceeded and both remedies (raise the knob, or recreate the view as recompute class), repeated on the refresh's own summary line. This is Reading B of the design §3.6.1, escalated as F4 there, and it is now RULED — the project owner selected Reading B, soft bound, 2026-09-24 [D-2026-09-24-f13a3b] — so what is built is what stands (§6.15). Reading A — a hard budget, split inside the commit — would publish a partially-applied transaction at a coordinate no cut ever had, which the consistency contract does not permit; and the refusal its absence left behind was measured as a poison pill, freezing a view for ever on one large transaction.
  6. Land. One snapshot, one coordinate, one commit (§2.4).

3.8 Chains — a view whose input is a view

4d — a chain at one cut: topological order, one L_1, and what a stalled member does downstream

A view may read another view of the same database, named exactly as a base table is — lake_<branch>.<view>. The graph is a DAG with RECORDED edges: Argon.view-inputs carries view names as well as table names, and the maintainer keeps the edge set per database in memory, rebuilt from the records at start (there is no maintainer-side progress store). That is the difference from the transform runner, which permits reading another transform's output but does not track it: a transform has no dependents to protect, a view has.

Cycles. Structurally a cycle cannot be created — a definition may only name a view that already EXISTS, a definition is immutable, and a drop with dependents is refused or cascades, so every edge points from a newer view to an older one. The closure walk is therefore a BELT, not the mechanism: it exists for create_view('v', 'SELECT … FROM lake_main.v'), which the planner would otherwise refuse as "unknown table v" — a message that misleads — and for the day a v2 in-place alter makes argument one stop holding (view_classify.rs:628-639).

Depth. ARGONDB_VIEWS_MAX_DEPTH (default 8, crates/argondb-query/src/view_chain.rs:29) refuses at create, by name, a view whose longest path to a base table exceeds it, printing the path. Refreshes along one path are SERIAL, so the deepest view is behind head by one cut plus the SUM of its ancestors' refresh times; a bounded depth is what makes the latency promise a bounded statement. There is no fan-in and no fan-out limit — a view joining k views is a join and the join class's own cost model already prices it; n dependents of one upstream cost the upstream nothing beyond retaining its delta.

Order. At each cut the maintainer refreshes in topological order (Kahn's algorithm over the recorded edges, recomputed per cut for the views whose L_v < W), and every member that refreshes at that cut refreshes to the SAME L_1 = W. Independent views run under ARGONDB_VIEWS_CONCURRENCY; a dependent never runs before every one of its inputs has COMMITTED at L_1 (view_maintainer::schedule_chain, :701).

The invariant. For every edge U → D, L_v(D) ≤ L_v(U), always — a view is never ahead of any input. It is asserted after every pass. When it is at risk, the dependent takes a state rather than a coordinate (view_chain.rs:589-620), and both sentences have exactly ONE producer, ChainState::note, so argon.views() and the per-view behind-line agree by construction:

BEHIND (upstream <name> FAILED|INVALID at <L_v of the upstream>)
WAITING (upstream <name> coalescing to <L>)

Where a dependent's delta comes from. An upstream C1–C3 refresh lands ONE row_delta snapshot; read incrementally — its added data files and its equality-delete files — that snapshot IS a window of keyed change rows: a key in the delete set with no new image is a D, a key with a new image is I or U. All rows of one refresh carry _argon_lsn = L_1 and _argon_seq = their ordinal, so the downstream's per-key collapse runs unchanged over a SEQUENCE of upstream snapshots. No <view>__changes table is created. A keyed C4 upstream lands its recompute as a DIFF — the keyed difference against its own state at L_v, landed as an upsert instead of a replace — so every mechanism above applies unchanged and argon.views() prints the class as C4 (diffed). A keyless C4 upstream has no identity to diff on, so its dependents are C4, printed at the dependent's create with the remedy: declare a key on the upstream. That decision has one home, view_chain::upstream_delta_source (:135-160), returning DeltaSource::{Changelog, UpstreamSnapshot, None { cause }} (:109-121).

Retention. Expiry does not remove a view snapshot at coordinate L while any dependent's L_v < L. By design it never keeps one past the pin window: a dependent that has not consumed within the window is past retention and recomputes at its next cut under V4, cause upstream <name> delta expired, the upstream unaffected. The maintenance sweep logs a held upstream as maintenance lake-owned expiry <table>: HELD — dependent is current to ….

The hold reads EVERY dependent's coordinate, wherever it landed. The sweep and the maintainer read the view estate through one merged reader, view_maintainer::merged_views: the lake-landed views off their snapshots, plus the Postgres-landed views off argon.view_state (reached through ARGONDB_TRANSFORM_PG_URL). Before this, the sweep read the lake alone, so a Postgres-landed dependent of a lake view looked NEVER REFRESHED on every pass and held its upstream's snapshots for ever; now it holds them only while it is actually behind them. (The direction is always lake upstream, Postgres-landed dependent — a Postgres-landed view cannot be an input, §6.12.) If argon.view_state cannot be read, every Postgres-landed dependent counts as unknown and its upstream is HELD for that pass — the safe direction — with a warning; if the view catalog itself cannot be read, nothing is held that pass and the sweep says so. Limit, stated plainly: the live sweep passes no pin-window floor to the hold (view_expiry_verdicts(&mv.records, None) in crates/argondb-tail/src/maintenance.rs), so today a dependent that stops consuming holds its upstream's snapshots WITHOUT a time limit, and the "expired past the pin window" arm is exercised only by unit tests (§6.18).

Drop and alter. A drop with dependents is refused by name (view-has-dependents) naming every transitive dependent; cascade => true drops in REVERSE topological order — dependents first, so no moment exists at which a view's input is gone while the view remains — and returns the list it dropped, each with its definition text, so the caller can recreate what they meant to keep. Alter is drop-then-create in v1; the definition is immutable, and that immutability is exactly what makes the structural cycle argument true.

3.9 The rewrite rule, test by test

4c — the rewrite rule's test ladder

The candidate set is the views whose inputs are all in the pin's scope and whose state is not FAILED/INVALID. For each, the rule asks, in order, and stops at the first no:

  1. Table containment. As built this is EQUALITY of the query sub-plan's table set and the view's expanded table set — a narrowing on the design's ⊆. A query joining an extra table is not matched AT THAT NODE; its inner sub-plans are still tried, so orders ⋈ customers ⋈ regions can be served from a view over orders ⋈ customers only when the planner has already grouped that pair under one node. Self-joins are refused.
  2. Equi-join subsumption. Every view equivalence class is a subset of a query class.
  3. Filter containment. The view's range and residual predicates each contain the query's; the difference becomes a compensating filter over the view.
  4. Column availability. The query's output expressions AND the compensating filters must be computable from the view's columns. A predicate on a column the view does not project refuses the match — including a filter on a non-group column over an aggregate view.
  5. Aggregate subsumption, which the contrib crate does not attempt: a roll-up g' ⊆ g is rewritten as sum(sum), sum(count), min(min), max(max). avg is NOT rolled up (refused avg-not-rolled-up) — the view would have to carry both sum and count of that argument, and the classifier keeps avg as that pair only when the definition spells it so. DISTINCT, FILTERed and ORDERed aggregates refuse by name. A query grouping FINER than the view never matches; a query that does not aggregate never matches an aggregate view.
  6. The coordinate, §3.10.

Each step is a function with its own table-driven fixtures — normalize, tables_subsumed, equijoin_subsumed, filters_subsumed, columns_available, aggregate_subsumed — written by hand, nothing linked and nothing vendored.

Across a chain, the normal form is computed over the EXPANDED definition (a view input that is itself SPJ is inlined; an aggregate upstream is an opaque leaf, so a downstream of an aggregate view matches only queries written over that view by name). Candidates are ordered deepest first and the first match wins, because a match against a downstream implies a match against each of its ancestors and the deepest view is by construction the smallest table that still contains the answer.

EXPLAIN prints the node's line (view_rewrite.rs:1291-1305):

ViewRewrite(<view> depth=<d> @ <L_v> [+ compensation (<L_v>, <L>]] [disclosed])

3.10 The coordinate a read gets — exact, compensated, disclosed, or nothing

A pin is at L; a view is at L_v ≤ L. Four answers (view_rewrite.rs:1260-1272):

The reader's bound is argondb_lake.view_staleness, grammar '0' | '<bytes>' | '<secs>s' | 'any', default '0' (crates/argondb-lake-fdw/src/view_opts.rs:28-52). It is validated at READ time, not at SET time, and by design: pgrx's GucRegistry exposes no check-hook variant, so the crate follows argondb_lake.pin's precedent — accept any string at SET, validate at the one place it is read, error loudly by name. SET argondb_lake.view_staleness = 'nonsense' therefore SUCCEEDS, and ViewStaleness::current() is what refuses it (:11-23, :57-89). The seconds form reads Argon.committed-at.

A caller-named bound is a REFUSAL boundary, not a disclosure one. A reader who asked for view_staleness = 0 against a view whose class cannot compensate is served by the BASE PLAN, not by a disclosed view. That is D12's caller-named rule applied to views, and it is why the default being 0 is safe: a reader who set nothing gets an exact answer or no rewrite.

Three opt-outs, each recorded in the rule's DECISION LOG rather than in the plan text — a switched-off rule leaves the plan untouched, because an extension node planted only to say "off" would be a barrier to DataFusion's own passes: per session (SET argondb_lake.view_rewrite = off, view_opts.rs:95-98; the CLI --no-view-rewrite; the MCP tool's view_rewrite: false), per view (Switch::set_view_opt_out, now reachable at create through ViewOptionsV1.use_in_rewrite on all three doors — §6.5), and per deployment (ARGONDB_VIEW_REWRITE=off, view_rewrite.rs:75-77). The log lines are fired <line>, refused <view>: <step>, session-off, view-opted-out, deployment-off, printed by argondb-query --explain-views.

A direct read of a view is NOT a rewrite. SELECT … FROM lake_main.daily_revenue is a scan of a lake table the FDW mounted; the scan ticket carries the session pin L, the read plane resolves delta_source(L, L_v) with L_v in the role of w, and the same answers apply — BaseOnly at L_v = L, compensation at L > L_v for the classes that have it, time travel to an older view snapshot at L < L_v exactly when one is retained, and otherwise the newest below, disclosed.

3.11 What is measured

Every number here is from the cost-measurement lane L11 (2026-09-19), whose conditions are in the design §8.1: one DataFusion 53.1 session at default parallelism on a 20-core i9-13900H with 91 GB, fixtures written through the committer's own writer properties, median of 2–3 runs after a warm-up.

quantity value why it matters to you
LakeWriter upsert 655 k rows/s at 100 k rows; 668 k at 1 M a 190 k-row cut's write is ~0.3 s
LakeWriter replace 776 k rows/s at 100 k; 768 k at 1 M a 1 M-row C4 view's full replace is ~1.5 s
full scan + algebraic aggregate, 3 narrow columns 272–407 M rows/s the cheap half of a C2
full scan + aggregate over a wide text column 55–92 M rows/s the expensive half
one keyed point read, key present 5.5 ms at 10 M rows; 4.5 ms at 100 M the cost does not grow with the table
the keyed/scan bend ~100 keys below ~10 changed keys the keyed lookup is ~1,000× cheaper; from ~100 upward it is a full scan

Three corrections L11 made to the model, stated because they change what you should worry about. (1) For any realistic ERP window the C2 old-image term is ONE FULL SCAN of each input per cut — a cut at c10 carries ~190 k change rows, three to four orders past the bend, so the min() never selects the keyed side. The keyed side matters only for the quiet case. (2) The class letter does NOT bound the memory; the group count and the join build side do. An algebraic C2 over 10 M distinct groups peaked at 2,581.7 MB while a C2 over 997 groups peaked at 2.3 MB and a C1 at 0.0 MB. At a 512 MB pool the high-cardinality shape FAILED with Resources exhausted: Additional allocation failed for GroupedHashAggregateStream — which is the pool doing its job and refusing rather than taking the box down. (3) The recompute class is far cheaper than first assumed: the "4–6 minutes per refresh" line was compaction's bound and is withdrawn; a 1 M-row C4 view is ~2 s per refresh and DOES follow a 30 s cadence.

The run-to-run spread is stated rather than averaged away: the same scan-wide query over the same fixture on the same box measured 180.8 ms in one dispatch and 665.6 ms in another. No conclusion above rests on a factor smaller than four.

N Postgres-landed views over ONE input, on ONE primary (2026-09-27; the XPS, i7-12700H, 20 threads, 61 GB, on the compose-2 stack; product defaults: a 30 s cut cadence and one refresh at a time; median of 10 measured cuts, warm-up excluded). Everything above is one view at a time. This is the per-manufacturer slice shape [D-2026-09-27-3679ea]. It uses one 1.4 M-row invoice_lines-shaped input, N views each filtering one manufacturer's UPCs, declared key (invoice_id, line_no), landed in derived, and 20 changed rows per manufacturer per cut. Method, per-cut data and seven findings: the evidence file.

N views, class landing txns on the primary per cut (sum; longest) maintainer pass per cut (slowest) WAL per cut a write to all N current fits a 30 s cadence?
8, C1 3.2 s; 441 ms 3.8 s (8.4 s) 0.65 MiB 29 s yes
64, C1 6.9 s; 174 ms 13.2 s (17.2 s) 4.0 MiB 34 s yes
200, C1 20.7 s; 537 ms 47.0 s (78.3 s) 13.4 MiB 80 s no
64, C4 (the same filter as SELECT DISTINCT) ~10 s; up to 749 ms ~13–15 min (a partial cut: the 10-cut median was not reachable) 269 MiB ~15 min no

What it means for you. Keyed (C1) views over one input scale to about 64 per maintainer at a 30 s cadence on this box. 200 does not fit. Where between 64 and 200 it stops fitting was NOT measured; a straight line through the medians puts it near 130, and through the slowest passes near 90. Recompute (C4) is not a per-manufacturer shape at this input size. Each C4 view re-reads the whole input at ~12–14 s per view, which is 7–100× the cap's estimate. Two costs in the keyed landing grow with things other than the delta: the key-matched DELETE grows with the VIEW's size (~350 ms per view at 175,000 rows), and at N = 200 creating and dropping two staging tables per refresh is ~9 s of the ~15 s of server time. Both are reported in the evidence file and left for their own charters.


4. How to use it

Grounding of the examples in this section. No argondb serve box was booted in this lane. Commands whose output is pasted below and labelled VERIFIED were run on a development build node under a held fleet slot through scripts/fleet-exec.sh against this branch's tree; everything else is labelled FROM SOURCE and quotes the exact signature, flag, constant or refusal text by path:line. Where a FROM SOURCE example shows expected output, that output is composed from the code that produces it and is marked as such.

4.0 The CLI surface — VERIFIED

argondb view has four subcommands. Built from this branch's tree on a development build node under a held fleet slot through scripts/fleet-exec.sh (cargo build --release -p argondb-ctl --bin argondb, === build rc=0), then run. Verbatim:

$ argondb view --help
Continuous materialized views. `classify` answers what a definition WOULD be — class, key, inputs, FK join roles and the refresh estimate — and creates nothing, so a definition can be iterated on before it is recorded

Usage: argondb view <COMMAND>

Commands:
  classify  Classify a view DEFINITION without creating anything: print its maintenance class (C1/C2/C2m/C3/C4), its key, its inputs with their FK join roles and the per-refresh cost estimate — or the typed refusal, naming the construct and the remedy
  create    **CREATE** a continuous view: validate the definition, classify it, and RECORD it (`argon.create_view` on the CLI — the same body, design §2.2)
  drop      **DROP** a continuous view, printing its definition text so you can recreate what you meant to keep (design §15.6)
  list      List every continuous view with its BEHIND-NOTE: the LSN each view is current to, the tail head at the time of asking, and the difference in bytes (design §3.5). Reads only — the maintainer is a view's one writer
  help      Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help
$ argondb view create --help
**CREATE** a continuous view: validate the definition, classify it, and RECORD it (`argon.create_view` on the CLI — the same body, design §2.2).

The DataFusion half of §2.3's two-dialect validation only, and it says so: a Postgres `EXPLAIN` of this text on the analyst compute is the other half and this command has no backend to run it on. The SQL function runs both.

Exit codes: 0 = created, 2 = REFUSED.

Usage: argondb view create [OPTIONS] --name <NAME> --sql <SQL>

Options:
      --name <NAME>
          The view's NAME, under the same lake grammar a transform target takes

      --sql <SQL>
          The definition: ONE SELECT over published lake tables. Bare lake names or `lake_<branch>.<table>` — both are accepted, because the door rewrites the mount qualifier the analyst compute shows

      --fk <FK>
          A declared foreign key, `<child>.<column>=<parent>.<column>`, repeatable. §2.4 reads these from the analyst compute's own `pg_constraint`; a CLI has no such backend, and WITHOUT one a join classifies C4 with the demotion PRINTED

      --key <KEY>
          The view's key columns, repeatable. Omitted ⇒ inherited from a passed-through PK or `GROUP BY`, or the view is keyless and therefore recompute class — stated either way

      --max-refresh-seconds <MAX_REFRESH_SECONDS>
          Accept a recompute-class refresh at the classifier's estimated cost — §2.5's explicit acceptance for the worst well-typed input (a self-join with no equality predicate)

      --join-role <JOIN_ROLE>
          §15.4: `<table>=fact` or `<table>=dimension`, repeatable — which side of a join is the FACT, for a join on a column an upstream VIEW computed. A view is not in `pg_constraint`, so a declared role is the only way such a join is C3; a base-table join reads the catalog and never needs one. Both sides must be named

      --max-staleness <MAX_STALENESS>
          §4.3's reader-facing disclosure bound, recorded with the view

      --index <INDEX>
          §5.2's declared secondary index, as a comma-separated column list; repeat the flag for a second index. POSTGRES LANDING ONLY — on the lake landing it is REFUSED by name, never ignored, because an Iceberg table is scanned and not indexed

      --tail-addr <TAIL_ADDR>
          [default: http://127.0.0.1:8817]

      --branch <BRANCH>
          [default: main]

      --data-dir <DATA_DIR>
          [env: ARGONDB_DATA_DIR=]
          [default: ~/argon]

      --port <PORT>
          

  -h, --help
          Print help (see a summary with '-h')
$ argondb view drop --help
**DROP** a continuous view, printing its definition text so you can recreate what you meant to keep (design §15.6).

Exit codes: 0 = dropped, 2 = REFUSED.

Usage: argondb view drop [OPTIONS] --name <NAME>

Options:
      --name <NAME>
          

      --cascade
          Drop the view's dependents too (§15.6). LIVE since L13 landed view-over-view (block V8 [D-2026-09-18-da10ac]): without it a view with dependents is REFUSED `view-has-dependents`, naming the full transitive set; with it they are dropped in REVERSE TOPOLOGICAL order (dependents first, this view last) and each is returned with its definition text

      --tail-addr <TAIL_ADDR>
          [default: http://127.0.0.1:8817]

      --branch <BRANCH>
          [default: main]

      --data-dir <DATA_DIR>
          [env: ARGONDB_DATA_DIR=]
          [default: ~/argon]

      --port <PORT>
          

  -h, --help
          Print help (see a summary with '-h')

And what it does with no read plane to talk to — which is the shape you will meet first if you point it at the wrong --tail-addr. classify takes a pin before it plans, so a refused connection surfaces as a chain of causes and not as a view refusal:

$ argondb view classify --name dg_probe \
    --sql 'SELECT id, region FROM orders WHERE region = 1' \
    --tail-addr http://127.0.0.1:1
Error: taking the pin

Caused by:
    0: connecting to tail flight
    1: transport error
    2: tcp connect error
    3: tcp connect error
    4: Connection refused (os error 111)

Two things to read off that. create and drop are on this build — an argondb binary that offers only classify and list predates L3's create door, which is a fast way to tell whether the binary in front of you is current. And --fk, --key, --join-role, --index and --max-refresh-seconds are the CLI's spelling of ViewOptionsV1; the SQL function takes the same six keys as one jsonb document (§6.7).

The remaining subcommands' help (classify, list) is in this lane's run log beside the two above.

4.1 A C1 keyed projection — FROM SOURCE

A select-project-filter over one table with the table's key passed through.

-- on the analyst compute (a read-only Neon compute), as any user who can read
-- the lake_<branch> mount for this database:
SELECT argon.create_view(
  'open_invoice_lines',
  $$ SELECT id, invoice_id, sku, qty, unit_price
       FROM lake_main.invoice_lines
      WHERE status = 'open' $$,
  '{"landing": "lake"}'::jsonb);

What classify answers, and why: one TableScan, a Filter and a Projection; the source table's PK id is passed through, so the key is ViewKey::Inherited { source: "invoice_lines", columns: ["id"] } (view_classify.rs:213-219); ViewClass::C1KeyedProjection (:55-58); landing_verb() is "upsert" (:127-138); needs_old_images() is false (:140-149).

The rendered answer's shape is view_classify::render (view_classify.rs:2085-2122) — the field order below is that function's, line for line:

view:     open_invoice_lines
class:    C1 (maintained from the delta, landing verb `upsert`)
key:      inherited:invoice_lines[id]
inputs:
  - invoice_lines (role sole, changelog enrolled)
estimate: <the dominant term>
  · <term>
note:     <every over-approximation, then the estimate line, then the state line>

The last two notes are always present and are composed by view_door::create_notes (crates/argondb-transform/src/view_door.rs:1384-1462):

note:     estimate: <dominant term> — every refresh reads the window's delta
note:     state: DECLARED — recorded, no refresh has landed. The maintainer (lane L4) lands the first one; until then this view carries no coordinate and `argon.views()` says so

(The V7-cap clause only appears on a C4 view's estimate line — see §4.4 — because an incremental class like this one takes no min_refresh_interval.)

Note what create does NOT do. It does not refresh. The maintainer is the view's one writer (V5), so the table exists and is empty until the next cut.

What refreshes. At the first cut after create, the maintainer schedules it (never-refreshed views sort first) and lands an append — the first refresh of a lake-landed view is always append, because the create door already made the table (land::refresh_mode, land.rs:922-939). Every later refresh is an upsert, unless that window happens to be insert-only, in which case window_specialisation narrows it to C1a and the verb to append (view_classify.rs:198-203).

4.2 A C2 algebraic aggregate — FROM SOURCE

SELECT argon.create_view(
  'revenue_by_region_month',
  $$ SELECT c.region,
            date_trunc('month', o.order_date) AS month,
            sum(l.qty * l.price)             AS revenue,
            count(*)                          AS n
       FROM lake_main.orders       o
       JOIN lake_main.order_lines  l ON l.order_id = o.id
       JOIN lake_main.customers    c ON c.id = o.customer_id
      GROUP BY 1, 2 $$,
  '{"landing": "lake", "max_staleness": "1 cut"}'::jsonb);

Classification: the key is ViewKey::GroupBy(["region", "month"]) — an aggregate's identity is its grouping (view_classify.rs:220-222). The aggregate list is sum and count only, so the class is C2AlgebraicAggregate (:64-67); needs_old_images() is true. Put a min or max in the list and the class becomes C2mMinMaxUnderDeletes (:68-72), whose recomputes_affected_groups() is true (:174-186) — the class is static even though the trigger is per refresh.

avg is accepted and is kept as the sum,count pair — but see §6.5: the rewrite rule will not roll avg up.

What refreshes. Per group, +f(new) / −f(old)+f(new) / −f(old), with the old images fetched at L_0 for every U/D key in the window. A group whose count reaches zero is deleted. At a busy tenant's ~190 k-row cut that old-image term is one full scan of each input, 0.18–0.67 s per 10 M-row input (§3.11).

4.3 A C3 join with a declared FK — FROM SOURCE

On every door the FK roles come from the schema — the tail's schema reader reads pg_constraint — and no flag is needed. On the CLI --fk may restate an edge (or add one the schema does not declare), as here:

argondb view create \
  --name invoice_lines_with_customer \
  --sql "SELECT l.id, l.sku, l.qty, c.region
           FROM lake_main.invoice_lines l
           JOIN lake_main.customers     c ON c.id = l.customer_id" \
  --fk invoice_lines.customer_id=customers.id \
  --tail-addr http://127.0.0.1:8817 --branch main

The --fk grammar is <child>.<column>=<parent>.<column> with no spaces, repeatable, one flag per column pair for a composite key (crates/argondb-ctl/src/view.rs:105-130); the parse refuses each malformed shape by name:

`--fk <f>`: expected `<child>.<col>=<parent>.<col>`, with one `=`
`--fk <f>`: the child side `<s>` must be `<table>.<column>`
`--fk <f>`: the parent side `<s>` must be `<table>.<column>`
`--fk <f>`: every part of `<table>.<column>` must be non-empty

With the edge present, invoice_lines is JoinRole::Fact and customers is JoinRole::Dimension (view_classify.rs:262-272) and the class is C3JoinChangedSideKnown. Drop the --fk and the same definition classifies C4 with the demotion printed — that is the design's "if the FK is absent" row, asserted in both polarities by c3_join_both_polarities_turn_on_the_fk_alone.

The estimate carries C3's second term when it applies: Estimate::unpruned_fact_scan is Some(remedy) when a dimension change forces a scan of the fact because the fact is not clustered by the FK — D10's default clusters by PK, so this is the common case. The remedy line has ONE producer, view_classify::unpruned_fact_scan_note (:1914), and the maintainer prints the classifier's own string on the refresh that performs the scan; the remedy it names is the existing argon:cluster-by=<fk> marker on the fact table, not a new mechanism.

The exit codes are the contract: 0 = created, 2 = REFUSED (crates/argondb-ctl/src/view.rs:368-372, :414-419). A script branches on the code and never parses prose.

4.4 A C4 recompute, and the acceptance it needs — FROM SOURCE

argondb view create \
  --name sku_price_spread \
  --sql "SELECT sku,
                percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price) AS median_price
           FROM lake_main.invoice_lines GROUP BY sku" \
  --key sku \
  --tail-addr http://127.0.0.1:8817 --branch main

percentile_cont is a holistic aggregate; nothing incremental exists for it, so the class is C4Recompute and the landing verb is replace (view_classify.rs:76-78, :127-138). The notes say which construct cost the class. Cost is proportional to |inputs| every cut with any change, and is independent of |Δ|.

A C4 refresh's cadence is now DERIVED, not left uncapped (block V7's follow-up, option A, default since 2026-09-20 [D-2026-09-20-ce8d62]; crates/argondb-query/src/view_cost.rs, view_door::derive_cap, view_maintainer::derive_cap_at_cut). create prints a cost ESTIMATE — the inputs' current row counts from the catalog statistics at the CONSERVATIVE end of L11's measured rates — and derives min_refresh_interval as the smallest whole number of the tail's own cut cadences under which that estimate is at most ARGONDB_VIEWS_C4_COST_SHARE % (default 10) of one cadence. A C4 view whose upstream advanced within that interval is HELD at its previous L_v (Skip::HeldByMinRefreshInterval, printed held by min_refresh_interval (N s); a dependent of a held view is WAITING). The per-view min_refresh_interval_secs option (SQL/CLI/MCP) may raise the derived floor and is refused below it, and refused outright on an incremental class — this is a recompute-class mechanism only. Nothing REFUSES a refresh for being expensive; what changed is that a C4 view no longer refreshes MORE OFTEN than its own estimated cost justifies. The one C4 shape that is REFUSED rather than costed is the design's worst well-typed input — the largest table joined to itself with no equality predicate — and it is admitted only when the caller accepts recompute explicitly (view_classify.rs:705-718):

refused: unpredicated-self-join — view `<v>`: `<t>` is joined to itself with no
equality predicate — a cartesian product, classified C4, estimated <term>.
Remedy: add the equality predicate the join is missing; or, if the cross product
is what you meant, re-issue `create` with an explicit `max_refresh_seconds`
accepting recompute at that cost
# the acceptance, spelled:
argondb view create --name deliberate_cross --sql "..." --max-refresh-seconds 900 ...

4.5 Reading the estate: argon.views() and a view's coordinate — FROM SOURCE

SELECT name, landing, class, current_to_lsn, behind, state
  FROM argon.views()
 WHERE state = 'refreshed'
 ORDER BY name;

argon.views() is a SETOF rather than text — unlike its two siblings — because it IS a listing and a caller filters it (crates/argondb-lake-fdw/src/view.rs:386-425). The column list is (name, landing, class, key_columns, current_to_lsn, behind, inputs, last_refresh, state). current_to_lsn and behind are NULL for a view that has been declared and never refreshed — such a view is not current to any LSN, and a 0/0 would read as "the whole head behind". The door's scope note arrives as a Postgres NOTICE, not as a column, because it is a fact about the ANSWER and not about a row.

state takes its value from the evidence rather than from a stored column: a coordinate on the landing means a refresh landed (crates/argondb-query/src/view_catalog.rs:309-316). Two values have producers on this build — declared and refreshed (:253-270); BEHIND and WAITING come from the chain pass (view_chain.rs:589-620).

The behind-note is three numbers composed once — the view's coordinate, the tail head at the time of asking, and the difference in bytes — and the subtraction has exactly one home, Behind::of (view_catalog.rs:114-127). It is a VARIANT and not a saturating subtraction, because AheadOfHead is real and worth naming rather than rendering as "0 bytes behind": the head was read from a different tail than the one the refresh followed, or the coordinate on the snapshot is not one this writer wrote.

The CLI form prints the same bytes, and prints its own scope:

argondb view list --tail-addr http://127.0.0.1:8817 --branch main
# without --pg-url this covers the LAKE landing only and says so on the first line:
#   LAKE LANDING ONLY — no --pg-url was given, so any view landed in `derived.*` on ...
argondb view list --tail-addr http://127.0.0.1:8817 --pg-url "$ARGON_PG" --branch main

(crates/argondb-ctl/src/view.rs:255-270; crates/argondb-ctl/src/main.rs:437-458.)

A view whose table vanished mid-listing is one marked row. If a view's lake table is dropped between the listing naming it and the listing loading it (a tenant being created or deleted), argondb view list and argon.views() print that view as table not found (dropped since the listing began) and list every other view, exit 0; any other load failure (catalog unreachable, refused, timed out) still fails the whole listing, because a partial answer there would look complete. That includes the probe for the lake namespace itself: a catalog that does not answer fails the listing rather than reading as "no views".

--database <db> filters the listing to the views whose declaration names <db> as an input database (never the view-name prefix), and the scope: line says so: scope: … · database <db> only (N of M views). A database with no views prints an empty listing with that line, exit 0. On a box without per-tenant compute every tenant's views live in the one box warehouse, so the walk is box-wide and the filter is what narrows it; under per-tenant compute the same declaration is read the same way.

Only the CLI listing shows a gap. argondb view list reads the branch's _argon_gaps record and prints, under any view a recorded gap or retention floor is holding back, a gap: line carrying the same BEHIND sentence the maintainer logs (view_catalog::render_list_with_gaps). argon.views() has no gap column on this build — its state stays refreshed for such a view — so a SQL caller learns about a gap from the maintainer log or the CLI, not from the listing.

4.6 Reading a view through the FDW, and through the query plane at a pin — FROM SOURCE

-- on the analyst compute. The mount is scoped to this database already.
SET argondb_lake.pin = '0/3A21B40';         -- read at a named coordinate
SELECT * FROM lake_main.open_invoice_lines WHERE sku = 'X-1';

That is a scan of a lake table, not a rewrite (§3.10). The pin is the session's argondb_lake.pin GUC, or — unpinned — the tail head with a behind-note.

-- the same read, letting the rewrite rule serve it from a view:
SET argondb_lake.view_staleness = '0';      -- the default: exact, or no rewrite
SELECT sku, sum(qty) FROM lake_main.invoice_lines WHERE status = 'open' GROUP BY sku;

SET argondb_lake.view_staleness = 'any';    -- take the rewrite however far behind
SET argondb_lake.view_staleness = '5000000';-- ... or within 5 MB of the pin
SET argondb_lake.view_staleness = '30s';    -- ... or within 30 s (reads Argon.committed-at)
SET argondb_lake.view_rewrite = off;        -- turn the rule off for this session

Grammar and default: view_opts.rs:28-52. The bool GUC is refused at SET by Postgres's own grammar; the string GUC is accepted at SET and refused when read (§3.10).

Through the query plane on the CLI, the same three knobs are flags:

argondb-query --at-lsn 0/3A21B40 --view-staleness 30s --sql "SELECT ..."
argondb-query --no-view-rewrite --sql "SELECT ..."
argondb-query --explain-views  --sql "SELECT ..."   # the rule's decision log

EXPLAIN of a rewritten plan carries the node's line: ViewRewrite(<view> depth=<d> @ <L_v> [+ compensation (<L_v>, <L>]] [disclosed]) (view_rewrite.rs:1291-1305). The differential gate's own fired lines on a booted box read, verbatim from the L9 lane's §12 row:

fired ViewRewrite(v_vrd_c2 depth=0 @ 0/16F3178)
VIEW-REWRITE-DIFFERENTIAL: PASS (22 assertion(s), 0 failed; C1/C2/C3 rewrite-on == rewrite-off row for row)

From DuckDB, outside ArgonDB entirely (the shape testing/box/view-foreign-reader.sh runs, DuckDB v1.5.4 with the iceberg extension; the S3 secret and the catalog endpoint come from your lake's own configuration):

LOAD iceberg;
ATTACH '<warehouse>' AS lake (TYPE iceberg, ENDPOINT '<catalog-uri>',
       AUTHORIZATION_TYPE 'none', ACCESS_DELEGATION_MODE 'none');
SELECT count(*) FROM lake."dv_main"."region_mix";           -- the rows

-- the coordinate: the CURRENT snapshot's summary, read from metadata.json
SET VARIABLE loc = (SELECT metadata_location
                      FROM iceberg_load_table_response('lake."dv_main"."region_mix"'));
CREATE TEMP TABLE md AS SELECT * FROM read_json(getvariable('loc'), compression = 'gzip',
  columns = {'current-snapshot-id': 'UBIGINT', 'snapshots': 'JSON[]'});
SELECT e.key, e.value ->> '$'
  FROM (SELECT unnest(snapshots) AS s, "current-snapshot-id" AS c FROM md) cur,
       json_each(cur.s -> 'summary') e
 WHERE (cur.s ->> 'snapshot-id')::UBIGINT = cur.c;
-- expect Argon.view-current-to-lsn among the keys, and no Argon.watermark-lsn

Two reader-side limits, both measured by that script: DuckDB 1.5.4's iceberg_snapshots() returns no summary column, so the properties are read from the metadata file; and iceberg_load_table_response refuses a multi-level namespace written as an identifier, so the address goes in as a string literal.

4.7 A two-view chain, and the states it takes — FROM SOURCE

-- silver: keyed, C1, over a base table
SELECT argon.create_view(
  'silver_lines',
  $$ SELECT id, order_id, sku, qty, qty * unit_price AS line_total
       FROM lake_main.invoice_lines WHERE status = 'open' $$,
  '{"landing": "lake"}'::jsonb);

-- gold: C2 over the silver view, named exactly as a base table is named
SELECT argon.create_view(
  'gold_sku_totals',
  $$ SELECT sku, sum(line_total) AS total, count(*) AS n
       FROM lake_main.silver_lines GROUP BY sku $$,
  '{"landing": "lake"}'::jsonb);

At the next cut both refresh to the same L_1, silver first (view_maintainer::schedule_chain, :701). gold reads silver's own committed row_delta snapshot as its change stream; no silver_lines__changes table is created (§3.8).

Three things to check when it misbehaves, each with its exact string:

symptom in argon.views() what it means what to do
BEHIND (upstream silver_lines FAILED at <L>) silver's breaker opened; gold did NOT move and is NOT INVALID — its own definition still plans fix silver's cause; at the next cut both advance, gold landing ONE snapshot over the grown window
WAITING (upstream silver_lines coalescing to <L>) silver has min_refresh_interval > 0 and targets a later cut, so gold targets that cut too nothing; the invariant L_v(gold) ≤ L_v(silver) is holding
gold's class is C4 and create said why silver is keyless recompute class, so it has no delta to hand down declare a key on silver (--key, or the key option), drop and recreate

The state sentences have one producer, ChainState::note (view_chain.rs:601-611), so what argon.views() prints and what the per-view behind-line prints are the same bytes by construction.

4.8 drop_view with dependents, and CASCADE — FROM SOURCE

SELECT argon.drop_view('silver_lines');
-- refused: view-has-dependents — names every transitive dependent

SELECT argon.drop_view('silver_lines', cascade => true);
-- drops gold_sku_totals first, then silver_lines, and returns each with its
-- definition text so you can recreate what you meant to keep
argondb view drop --name silver_lines --cascade --tail-addr http://127.0.0.1:8817 --branch main

The signature is drop_view(name text, cascade boolean DEFAULT false) (crates/argondb-lake-fdw/src/view.rs:356-368); the wire shape is ViewDropV1 { name, database, cascade } (crates/argondb-flight-proto/src/view_action.rs:97-105). CASCADE drops in REVERSE topological order so that no moment exists at which a view's input is gone while the view remains; a dependent mid-refresh under a CASCADE is abandoned and its uncommitted snapshot never lands.

--cascade has been LIVE since L13 landed view-over-view (block V8 [D-2026-09-18-da10ac]): a drop with dependents and no --cascade is refused view-has-dependents, naming the full transitive set, exactly as the SQL example above shows.

4.9 A view across the tenant boundary — FROM SOURCE

This is the bottom band of the main diagram, spelled as SQL. Inside ONE distributor's database, after the hub has landed a manufacturer's price list there as derived.mfr_price_list (a landed copy, publish-marked, so it is in this distributor's lake copy):

SELECT argon.create_view(
  'realised_vs_list_by_month',
  $$ SELECT date_trunc('month', l.invoiced_at)          AS month,
            l.sku,
            sum(l.qty * l.unit_price)                    AS realised,
            sum(l.qty * p.list_price)                    AS at_list
       FROM lake_main.invoice_lines   l
       JOIN lake_main.mfr_price_list  p ON p.sku = l.sku
      GROUP BY 1, 2 $$,
  '{"landing": "lake"}'::jsonb);

Nothing here is a cross-tenant feature. Both relations are tables of this one database; the view's database is its inputs' database; the wall is evaluated by the same predicate the pin uses, once more, in the maintainer. What makes it work is that the hub already COPIED the rows in. What would NOT work, and its exact refusal (view_classify.rs:673-681):

refused: cross-database-join — view `<v>`: <detail>. Remedy: joins are within
ONE database in v1; the `all_tenants` composition is a reader roster feature and
a view over it is a different question

A manufacturer's own views work the same way over its hub-<m> database — its slice of orders, invoices, movements and stock joined to its own product master, never a distributor's other rows.

What is refused instead: the same view over a GRANTED price list. If the manufacturer had not landed a copy but granted this database a mount of its own table, the relation would read in this database's EXPLAIN and still be the manufacturer's. argon.create_view refuses it before anything is pinned, enrolled or recorded:

view-input-granted: view `realised_vs_list_by_month`: input <mfr-db>.public.price_list
is a mounted grant from <mfr-db>; views over granted tables are not ruled — [ruling
pending]. A lake-landed view is scoped to its inputs' OWNER, so this one would be
created and then unreadable here; define the view in <mfr-db>, or over tables this
database owns

Both landings meet it. The remedy today is the one the message names — build the view in the grantor's database, or land a copy and build it over that. The behavior is proven by testing/box/view-over-granted-table.sh (arms G1–G4, an owned-input control beside the refusal), with the tenant wall on and off.

One caution specific to this shape. mfr_price_list arrives in the lake through the tail like any other published table, so a view over it is incremental only if its D9 changelog is enrolled. If it is not, this view is C4 and the note says which input cost it the class. Check with argondb view classify before you create.

4.10 The breaker: how a view FAILS and how it recovers — FROM SOURCE

The breaker is a STREAK of CONSECUTIVE failures, not a count (view_maintainer.rs:240-247, BreakerVerdict at :220-235). ARGONDB_VIEWS_BREAKER_FAILURES (default 3) consecutive failures mark the view FAILED (<cause>); one success clears the streak. Two BEHIND states are not failures and are never counted: a window across a recorded gap or below an input's changelog retention floor (V4, §4.12) and a cut below an input's enrollment floor that a later create recorded (§3.6). The second is the v1.5.6 fix: a C4 view used to trip on somebody else's create, because the refused pin was charged to its breaker. Its control-loop profiles are unit-tested: the motivating incident, a healthy steady state, and bursty with a zero-rate window inside it (five failures, never three in a row — the case a count gets wrong), plus an isolation control.

BreakerVerdict has three values (:210-222):

verdict meaning
Retrying { consecutive, of } counted, still below the threshold: this view will be tried again
Tripped { consecutive, cause } FAILED (<cause>); stops being scheduled, keeps serving at its own L_v
AlreadyTripped { cause } the caller should not have scheduled it — REPORTED rather than silently re-counted

What a tripped view does: it keeps serving at L_v, with that state in the disclosure, and the rewrite rule stops using it (Coordinate::Never). It is never a refused read. Recovery is a drop_view + create_view of the fixed definition, or a maintainer restart — there is no maintainer-side progress store, so the next start reads each view's L_v back off its own landing and continues.

Outside the per-view breaker is the row's ordinary RestartPolicy: backoff 2 s → 30 s, breaker at 6 restarts in 120 s; a trip records DEGRADED naming the row and the box keeps serving. The per-view breaker exists exactly so that one pathological definition cannot trip the process breaker for every other view.

4.11 Setting the knobs — FROM SOURCE

Every one of these is read by argondb-maintenance at boot and refuses loudly on a bad value (view_maintainer.rs:108-127, :132-146; crates/argondb-query/src/memory_pool.rs:104-110; crates/argondb-query/src/view_chain.rs:29; crates/argondb-query/src/view_rewrite.rs:75-77):

variable default what it bounds
ARGONDB_VIEWS_CONCURRENCY 1 how many refreshes run at once
ARGONDB_VIEWS_BREAKER_FAILURES 3 CONSECUTIVE failures before a view is FAILED
ARGONDB_VIEWS_POLL_SECS 5 how often the cut follower asks the tail where W is
ARGONDB_VIEWS_WINDOW_KEYS 1000000 the SOFT window bound, at commit granularity
ARGONDB_VIEWS_PASS_DEADLINE_SECS 1800 how long one pass may run before it is abandoned by name (the view and the step it was awaiting) and counted against that view's breaker
ARGONDB_VIEWS_RING_STEP_SECS 94 how long each step of one ring read (the connect, the do_get, the drain of its stream) may run before that read is abandoned — which fails ONE view's refresh for the pass, never the pass; refused at or above ARGONDB_VIEWS_PASS_DEADLINE_SECS
ARGONDB_VIEWS_RECOMPUTE_SECS 600 how long ONE view's whole refresh may run before that view alone is abandoned for the pass — named, counted against its breaker, and the pass goes on; 0 turns it OFF, leaving only the pass deadline; refused at or above ARGONDB_VIEWS_PASS_DEADLINE_SECS — the default too, so a pass deadline of 600 or less needs this knob set below it or to 0
ARGONDB_VIEWS_IDLE_LOG_SECS 600 how often a maintainer with no pass summary says it is idle — and how often a pass still in flight says which step it is in
ARGONDB_PG_CONNECT_TIMEOUT_SECS 30 how long a dial of a landing database may wait for Postgres to finish its startup handshake
ARGONDB_VIEWS_MEMORY_MB a fourth even part of the box reserve the FairSpillPool a refresh computes under
ARGONDB_VIEWS_SPILL_DIR under the data dir where that pool spills
ARGONDB_VIEWS_MAX_DEPTH 8 the chain depth fence, refused at create
ARGONDB_VIEW_REWRITE on the deployment-level rewrite opt-out
ARGONDB_TRANSFORM_PG_URL unset the CONNECTION a Postgres-landed view lands through (host, port, user, credentials); ABSENT is ordinary and such a view is SKIPPED naming this variable

The maintainer is never silent when stuck (2026-10-04). It is a cut follower and prints a pass summary only when a pass refreshed, failed or held a view, so three more lines tell a quiet maintainer from a stuck one:

<step> is one of connect, pin, ring, catalog, object-store, postgres-read, postgres-apply, data-pin, lake-scan <table>, plan, execute, recompute. Which steps the per-step bound covers: ARGONDB_VIEWS_RING_STEP_SECS covers step ring — every await of a ring read (its connect, its do_get, the drain of its stream) — and a view is reported in ring only while such a read is in flight; every other step, recompute included, is ended only by ARGONDB_VIEWS_PASS_DEADLINE_SECS. recompute is the view's own work after its ring read answered (the rest of the pin and the query plan, whose lake reads run on worker tasks that leave no mark). Until v1.5.7.13 a view stalled there kept the ring mark its last read left, so the log said awaiting ring for a stall no ring bound could end — the ERP's demoharbor_v_program_earned, three times in 48 hours, each ended only at 1800 s.

A view's own work now names its wait (2026-10-13). After the ring read, a refresh is in one of four steps, and the mark says which: data-pin (the rest of the pin — loading the inputs' tables and registering them; a ring read or a Flight action inside it hands the mark back to data-pin when it ends), lake-scan <namespace>.<table> (the query plan reading one input table's files, named; the plan reads on worker tasks DataFusion spawns, and those tasks now carry the view's mark, re-entered at every batch so the seconds are the wait for the NEXT batch), plan (planning the definition) and execute (the joins, aggregates and diff between and after the scans). A stall reads, for example, awaiting lake-scan demoharbor.program_earned_src for view <v> (590s in that step; …). recompute is left only for a view that entered no other step after its read.

One view's whole refresh is bounded (2026-10-13; on by default since 2026-10-14). Under ARGONDB_VIEWS_RECOMPUTE_SECS, a view whose refresh — pin, scans, plan and landing together — runs past it is ABANDONED alone: what it had in flight is dropped whole (nothing lands; its worker tasks are stopped, as an abandoned pass's are), it is named in its views: \` refresh FAILED line with the step it was in (the view's refresh was ABANDONED after s awaiting lake-scan (ARGONDB_VIEWS_RECOMPUTE_SECS=)), its breaker counts one failure exactly as a pass-deadline abandon does, and the pass goes on with the other views. While it runs, its "still running" line says its per-view recompute bound is s, ARGONDB_VIEWS_RECOMPUTE_SECS. Unset, the bound is 600 s — ten minutes, four times a normal 146 s pass on the ERP's clone box — so a maintainer nobody configured still marks one stalled view by name and moves on. ARGONDB_VIEWS_RECOMPUTE_SECS=0` turns it OFF, leaving the earlier behaviour, where only the pass deadline ends a stalled view.

A ring read has its own bound, and both ends of it write a line (2026-10-12). Step ring is the maintainer reading a table's recent rows from the tail's ring (a Flight do_get). Until v1.5.7.12 it had no bound of its own: a do_get the tail accepted and never answered held the whole pass until ARGONDB_VIEWS_PASS_DEADLINE_SECS (30 minutes) abandoned it, every other view of that pass with it, and neither log had a line that placed the stall on one side or the other. Now each of the read's three steps — the connect, the do_get, and the drain of the record-batch stream — is bounded by ARGONDB_VIEWS_RING_STEP_SECS (default 94 s; derived from a measurement: a ring read through the real tail moved 218,083,200 bytes in 201 ms at its slowest of three, 1,085 MB/s, so the largest ring a field box has carried (809,291 rows in about 19 GiB) drains in 18.8 s, and the default is five times that, for a loaded box. Every ring read our view suite and our 13-tenant soak made answered in 0 ms, so a bound sized from those alone would abandon the first large legitimate drain). A read past its bound is ABANDONED: that one view's refresh fails for this pass (it stays at its last LSN, is named in the ordinary views: \` refresh FAILEDline with the table, the step and the elapsed milliseconds, counts toward its breaker like any failed refresh, and is retried at the next pass), and the pass's other views go on. The pass deadline stays the outer bound. Both sides log every ring read in one format, joinable ontableandlsnwith onegrep ring-read:`:

ring-read: side=tail table=<t> lsn=(<from>,<to>] rows=<n> answer_ms=<ms> verdict=answered|refused
ring-read: side=maintainer view=<v> table=<t> lsn=(<from>,<to>] rows=<n> answer_ms=<ms> verdict=within-bound bound_s=<s>
ring-read: side=maintainer view=<v> table=<t> lsn=(<from>,<to>] rows=0 answer_ms=<ms> verdict=abandoned step=<connect|do_get|drain> bound_s=<s>

The tail writes its line (INFO) for every ring ticket it answers or refuses — a read-plane pin's included. The maintainer writes its line (INFO; WARN when abandoned) for every ring read inside a pass; a read-plane pin writes none. What a stall looks like now: a maintainer line verdict=abandoned step=do_get with NO tail line for the same table and lsn means the tail never got past its own state lock (its answer time counts that wait, and a do_get stuck on it writes nothing); a tail line verdict=answered beside a maintainer verdict=abandoned step=drain means the rows left the tail and the stream stalled between the two; step=connect means the TCP connection to the tail's Flight port never completed (a port that accepts the connection and then says nothing stalls the do_get instead: the client's connect does not wait for the server's first HTTP/2 frame). The tail's line is new volume: one line per scoped table per pin, which on a box with many views and a busy read plane is the largest INFO source of the tail process (our 13-tenant cross-tenant soak held 101,701 of them in its live tail log at the end of its three phases). Expect the maintainer's line to be RARE. The maintainer pins each refresh at the cut it is following, and a cut's coordinate is normally one the lake already holds for every input, so the pin reads the lake and asks the ring for nothing. It reaches the ring only when the coordinate is ahead of an input's lake watermark, as on a view's first refresh right after it is created, or when a table's lake copy lags the cut. Measured on 2026-10-12: the view suite's four box gates made no maintainer-side ring read at all, and the soak made three, each on a view's first refresh (13 views). Many side=tail lines with no side=maintainer line beside them is therefore the healthy shape, not a missing log. The ERP's stall was a pass that DID reach the ring. If the maintainer task itself ends, a watcher logs views: maintainer task ended: <cause>; respawning at ERROR and starts it again, at most once a minute. When the tail's last cut failed, the tail's freshness answer carries last_cut: {ok, at, target_lsn, reason, error}, argondb view list prints LAST CUT: FAILED <reason> at <time>: <error>, and the maintainer logs views: waiting — tail <addr> last cut FAILED <reason> at <time>; <n> view(s) current to <w> once a minute.

ARGONDB_TRANSFORM_PG_URL is the connection; the view's --database is the target [D-2026-10-03-f2708c]. The maintainer dials the conninfo's host, port, user and credentials with the view's OWN database name — the one its view create --database recorded (Argon.view-database) — and never the conninfo's dbname, which is used only for a view that recorded none (it says so once per such view). A pass that carries views of several databases lands each in its own and prints one views landed: <db>: N view(s) line per database; a database that cannot be dialled withholds ITS views by name and the other databases' views still land. Each target database needs its own argondb transform bootstrap --pg-url <that database> before its first landing (view_maintainer.rs, conninfo_with_dbname).

The refusal grammar is one function for all of them (view_maintainer.rs:132-146):

ARGONDB_VIEWS_CONCURRENCY="two" is not a valid value — expected a plain positive
integer (the default is 1). A view maintainer that silently defaulted this would
run under a limit nobody chose

Size the memory pool from the view's group cardinality and join build side, not from its class letter (§3.11, correction 2).

4.12 Pruning an input's changelog, and what its views do about it — FROM SOURCE

A D9 changelog <t>__changes is append-only and nothing prunes it on its own. The one producer of a retention floor is an operator command, and it takes the BASE table's flat lake key, never the __changes name:

argondb lake changelog-prune postgres__public__orders --through 0/3A21B40 --dry-run
argondb lake changelog-prune postgres__public__orders --through 0/3A21B40
# changelog-prune: `<changelog>` floor=0/3A21B40 pruned=<n> kept=<n> rewritten=true recorded=true cause=retention

(LakeCmd::ChangelogPrune, crates/argondb-ctl/src/main.rs; lake::changelog_prune, crates/argondb-ctl/src/lake.rs; the line is PruneReport::line.) Rows with _argon_lsn <= floor go; a row exactly at the floor goes. --dry-run appends (DRY RUN — nothing recorded, nothing rewritten) and touches nothing. The ORDER is the safety argument: the floor is recorded in _argon_gaps first, and only then is the changelog rewritten, so the refusals read exactly as they happened — recording the retention floor failed, so NOTHING was pruned, or the retention floor is RECORDED but the rewrite did not land — readers already treat the range as pruned. A floor of 0/0 and a table with no changelog are refused by name. If every row sits at or below the floor, the floor is recorded and nothing is rewritten (rewritten=false); the record governs every reader either way.

What each view over that input does. A view whose L_v is at or above the floor is untouched. A view below it cannot take its next window as a delta, so the maintainer logs it BEHIND once:

views: `<view>` BEHIND: input `<t>` cause=retention — its D9 changelog retains
nothing at or below the floor <F>, and the window (<L_v>, <L_1>] needs
(<L_v>, <F>] from it, so the delta no longer exists and is not applied (block
V4: a window that begins below an input's changelog retention is served by
recompute). The view stays at its own L_v; a full recompute (C4) re-baselines
it past <F> and incremental maintenance resumes from there. Record: <reason>

(GapBlock::note, crates/argondb-query/src/view_gap.rs.) At the next cut it logs views: \` is RECOMPUTING to clear a changelog retention floor — …and recomputes; onceL_vis past the floor, incremental maintenance resumes. No operator action is needed, and the breaker is not charged. The same sentence appears as thegap:line ofargondb view list(§4.5). The proof istesting/box/view-gap-behind.sh(legs R1–R4) and, at unit level,a_window_starting_below_the_retained_floor_is_behind_on_retention_and_one_at_it_is_not`.

What does not exist yet. There is no age-based sweep that picks a floor from the pin window on its own, and no configuration knob for one — deleting changelog history by default is not ruled. Until it is, a changelog grows until an operator prunes it.

4.13 What a client waits for — a cut, not a create — FROM SOURCE

In plain words. A view refreshes when the database's watermark moves, not when you create the view. If you create a view on a quiet database, nothing about the data changed, so there is no new cut to wait for.

How to wait for a refresh exactly. Do not poll behind, and do not read the inputs: t@X cells right after a write lands: they are stamped by the cut, so they trail the tail's watermark by the cut's own duration (22 tables took about 10 s). Read the field that names the cut instead:

$ argondb view list --tail-addr http://127.0.0.1:8817 --branch main
orders_by_region  [lake] refreshed
  ...
  inputs:   orders@1/91024F50, regions@1/91024F50
  stamped:  inputs stamped through 1/91024F50 (2026-10-03T16:31:07Z)

When a cut has started and its inputs are not stamped yet, the same cell says so: inputs stamped through 1/91024F50 (...) — STAMPING IN FLIGHT: an input has moved past this cut, .... The line is absent for a view that has never refreshed. "In flight" means an input's tail watermark is past the stamped cut; it is left out, not printed as "no", when the tail does not report per-input watermarks. The wire row (argon.views() / the argondb.view.v1 list answer) carries the same facts as inputs_stamped_through, inputs_stamped_at and stamping_in_flight. A refresh has landed for your write when inputs_stamped_through is at or past the table's row watermark that carries it (below), never a bare LSN you read off the primary. (crates/argondb-query/src/view_catalog.rs, ViewRecord::stamping_in_flight; proof: testing/box/view-maintainer-recompute.sh P7d.)

Landed, and reflected: the two watermarks. The tail keeps two coordinates per table. The LAKE watermark w (argondb lake list's lake @) moves every cut, even for a table nobody wrote. The ROW watermark rows_w moves only when a cut carries that table's rows, and argondb tail freshness prints it (--json is the tail's answer raw, table_watermarks included; the per-table lake @ stays in lake list). A view's inputs: t@X cell is the VIEW's own read coordinate L_v (ViewInputAt.read_at_lsn), not the table's, and argondb view list prints the input's rows_w beside it: t@3/7A59D228 (rows @ 3/A2E2CFD0). When any input's rows_w is past the stamp, the stamped: line says stamping in flight: N input(s) with rows past the stamp. The ERP predicate, with R0 = rows_w(T) read BEFORE the write: the write has landed when rows_w(T) > R0, and a view V has reflected it when L_v(T) >= rows_w(T) (or a content SELECT):

R0=$(argondb tail freshness --json | jq -r '.table_watermarks["<table>"]')
# ... commit the write ...
R1=$(argondb tail freshness --json | jq -r '.table_watermarks["<table>"]')   # landed: R1 > R0
argondb view list | grep -A8 '^<view> '    # reflected: L_v in `<table>@L_v` >= R1

Compare LSNs numerically (X/Y is hex high/low), not as strings. Never probe with pg_current_wal_lsn() after the commit: it is the primary's WAL end, which overshoots the row watermark (a commit, a checkpoint or any other table's write moves it), so a "has the view reached it" test against it refuses a write that is already reflected. (Proof: testing/box/tail-freshness-cli.sh.)


5. Built today vs designed

Status is as of e90da8c0c (2026-09-27, re-derived from main for v1.5.6; the rows this pass touched were checked against the code at that sha; the rest were last re-derived at 995417cce0, 2026-09-22). "Built" means the code is on this tree; where a box gate is the proof and that gate has not gone green, the row says so — a merged lane is not the same thing as a proven one.

Landing next. The C3 join-view declared-landing fix that used to be queued here is FIXED — 72d745e02, merged 9/23 9:31am (bc6fdde66); see §2.11, §9. Three pieces of work remain queued, not yet on this tree, and were blocked on tonight's C4 min-refresh-interval merge (§2.9), which has now landed. Two of the three have since landed: the maintainer now reads the recorded gap record, _argon_gaps, so a view goes BEHIND naming a gap or a retention floor (§3.6, §4.12); and the tail's per-table watermark now holds when no row of that table changed (§9 item 1). The third — support for a view advancing correctly across a column ADD on an input it does not project — is not re-verified in this pass and is not claimed here.

feature status proof
The classifier: 6 classes, 16 refusals, keys, FK roles, estimate BUILT L1 (view-definition-classifier); cargo test -p argondb-query --lib 225 passed, 28 of them view_classify::tests::*
The view catalog + snapshot property family + argon.views() BUILT L2 (view-catalog-and-state); argondb-committer --lib 325 passed; VIEW-FOREIGN-READER: PASS (DuckDB v1.5.4)
The create door: one body, four callers; ViewOptionsV1 closed; the declaration property family BUILT L3 (view-create-door); argondb-flight-proto 39 passed. Its box gate testing/box/view-create-door.sh is WRITTEN and UNRUN — escalated in L3's row
The maintainer, C4 recompute class; the views pool; the per-view breaker BUILT; ITS BOX GATE NOW RUN, ONE ARM RED L4 (view-maintainer-recompute-class); argondb-tail --lib 639 passed. testing/box/view-maintainer-recompute.sh (P1–P6) has since been run: c4-views-gate-six-escalated-arm-defects-fixed reports PASS=37 FAIL=1 on ms-01, with P3 (the idle-negative arm) RED and its cause measured — see §9
C1/C1a incremental, update and delete windows included BUILT; TWO OF THREE MEASURED DEFECTS FIXED, ONE RESIDUAL L5; the delete-batch arity defect and the oversized-single-commit defect are both fixed; latest standing run 5 failed of 102 (a Q8 schema-change-matrix cell, M2) — see §6.1, §9
C2/C2m aggregate refresh BUILT L5a; one escaped defect found and fixed by L9's differential gate (§6.3)
C3 join refresh (refresh_join, the two-term delta) BUILT; REACHABLE THROUGH THE BOX DOOR, ONE RESIDUAL RED L6; box-free differential oracle with dimension churn landed (cargo test -p argondb-query --test view_join_delta_oracle 3 passed); the earlier "C4 through the box door" defect and a pin-scope defect are both FIXED (view-delta-term-pins-only-the-views-inputs-never-the-branchs-served-set), and the churn arm now runs its full 600 s window. Residual: 2 of 13 churn comparisons DIFFERED (a dimension-image mismatch at a split-commit boundary, hypothesis not established) and the schema-change matrix's later cells are unrun in that run. See §6.2, §9
FDW mounts dv_* tables and views; the two reader GUCs BUILT L8; lake-mount-scoped-to-its-own-database.sh, 18 checks green
The rewrite rule: 5 subsumption steps, 4 coordinate answers, 3 opt-outs, chains BUILT, TWO OF THREE ESCALATED SURFACES NOW CLOSED L9; VIEW-REWRITE-DIFFERENTIAL: PASS (22 assertion(s), 0 failed) on a booted box; view-rewrite-rule-opt-out-key-and-reader-surfaces-carry-into-the-pin closed the create-door key and the reader-surface-to-pin gap. See §6.5
Chains (§15): view inputs, cycle belt, depth fence, topological order, BEHIND/WAITING, diffed-C4, CASCADE BUILT L13 (view-chains)
The cost curve, measured DONE L11; §3.11 above
The Postgres landing (derived.<view>, argon.view_state, PK + declared indexes, the row cap on the delta) BUILT (this guide's 9/20 text called it queued; it landed 2026-09-19, before this guide was first written) L7 (view-postgres-landing); proven against a real Postgres by crates/argondb-transform/tests/landing_view_pg.rs (testing/transform/landing.sh, two fresh-initdb passes, 156 tests each, all view-landing cases green both passes); cargo test -p argondb-transform --lib 113 passed, 8 land::view_pg_tests::*. The keyed-lookup / kill-between-COPY-and-COMMIT box gate is WRITTEN AND GREEN: testing/box/view-postgres-landing.sh (runner testing/box/ms01-view-postgres-landing-run.sh), view-postgres-landing: PASS (33 checks) on ms-01, 2026-09-25. Measured there: a C1 view over a keyed table lands in derived.vpl, and at its L_v the whole table equals the definition recomputed with argondb-query --at-lsn (16000 rows compared). 785 single-key lookups equal the definition's rows for those keys, and the planner serves them with Index Scan using vpl_pkey. The maintainer is HELD at the named fault point before-commit in land_view_pg, through the test seam view_land_hold_file. Its backend is idle in transaction with its last statement on the staging relation, so the COPY is done. The maintainer's process group is then SIGKILLed. Afterwards the table is the previous refresh whole: L_v is unchanged and all 16000 rows are byte-identical. No staging relation survives. After the respawn, the next refresh lands at the held coordinate (17800 rows compared). The negative control runs the same kill with the seam disarmed: the refresh commits on its own, no hold appears, and the kill changes nothing. The DuckDB read-back is still UNRUN here and belongs to another task. It needs a live tail+lake, which landing.sh deliberately does not boot, so no verdict is claimed from it. One reachability defect found later, now FIXED (72d745e02, merged 9/23 9:31am, bc6fdde66): a C3 join view declared with the Postgres landing used to land to the LAKE instead — refresh_join called land_view_lake unconditionally, never consulting the declared landing (found by L10). It now runs through land_refresh, and declared_target_spec (crates/argondb-tail/src/view_maintainer.rs:2127-2136) is the one place either landing's target is spelled — see §9
Block V2's create-time changelog enrolment PARTIAL the door SAYS which input lacks a changelog and what it costs; runtime enrolment landed in its own lane. The tail's changelog configuration was an immutable &[String] read at boot
The per-view use_in_rewrite opt-out BUILT ViewOptionsV1.use_in_rewrite, additive with serde(default); surfaced on argon.create_view's options, argondb view create --no-use-in-rewrite, and the MCP create_view tool's use_in_rewrite — closed by view-rewrite-rule-opt-out-key-and-reader-surfaces-carry-into-the-pin, follow-on to L9
The FDW GUCs and the MCP parameter reaching the pin BUILT ScanTicketV1.{view_rewrite, view_staleness} now carries them to Pinned.view_rewrite (argondb-tail/src/scan.rs::apply_view_rewrite_policy); same lane as above. The CLI flags were already wired
The INVALID state (V6, a schema change on an input) BUILT Two producers write it, both through ONE writer (view_maintainer::record_invalid, JSON Argon.view-invalid). A failed refresh records its own error. Re-validation, before every refresh, records a DROPPED or RETYPED column the definition reads, against the columns the create door recorded (Argon.view-declared-columns, argondb_query::view_revalidate); it charges no breaker. argondb view list shows INVALID with an invalid: line, the rewrite rule excludes the view, and a direct read is served at L_v with a note. ONE clearing rule: the next refresh that LANDS. While the input still lacks the column, every refresh is INVALID again. A column the definition does not read changes nothing. Box gate P6's V6 arm and Q8's M4/M6 cells prove it on a box (design §15.8.8, §15.8.9)
A later create enrolling a C4 view's input leaves that view BEHIND, not FAILED BUILT view_maintainer::enrolment_floor_pin, RefreshOutcome::BehindEnrolmentFloor; unit tests both polarities; box arm P11 of testing/box/view-maintainer-recompute.sh proves the breaker uncharged (the below-floor state itself is unit-proven — the box sequence did not reach it)
A view over a table held by mount grant refused by name (view-input-granted) BUILT refuse_granted_inputs in the create door, both landings; testing/box/view-over-granted-table.sh 8 checks, wall on and off. What it waits on — OPEN-V3 — is OPEN (§6.17)
Gap and retention-floor BEHIND (cause=retention), then recompute; argondb lake changelog-prune BUILT; NO AGE-BASED PRUNE view_gap::gap_block, changelog_retention::prune_changelog_through; testing/box/view-gap-behind.sh VIEW-GAP-BEHIND: PASS (36 assertions, 0 failed). The only producer of a floor is the operator command (§4.12)
Expiry holds read Postgres-landed dependents' coordinates BUILT; NO PIN-WINDOW CAP IN THE LIVE SWEEP view_maintainer::merged_views, maintenance::view_expiry_verdicts; four unit tests in crates/argondb-tail/src/maintenance.rs; box leg P10 of testing/box/view-maintainer-recompute.sh. §3.8, §6.18
A foreign reader (DuckDB) sees the view's coordinate, not a watermark PROVEN testing/box/view-foreign-reader.sh, phase T5: view-foreign-reader: PASS (ok=7 fail=0) (§2.10, §4.6)
min_refresh_interval, derived per C4 view from the cost model BUILT L4a (c4-views-refresh-under-a-derived-min-refresh-interval-per-v7-follow-up), merged clean into main by its resolve lane; box gate's own arm for the cap (P7) ran GREEN in full — see §2.9. A Postgres-landed C4 view does not honor it (§6.19)
Restart / hostile-input gates (kill matrix, gap injection, retention ageing) DONE ON THE MATRIX; THE after-lake-commit SEAM NOW KILLED FOR REAL; ONE ALARM-MATCHER LEG RED L10 (view-gates-restart-and-hostile); the seam once declared unexercised is now leg H of testing/t3/view-restart-await-matrix.sh --kill (§2.4), measured PASS (55 assertions) on its own lane. The later resolve run read FAIL (57 passed, 1 failed): leg G, which requires every maintainer alarm site to have an idle matcher, found 5 of 18 without one — red on main before that lane, and escalated, not fixed. The Postgres-landed kill between COPY and COMMIT is still in the merge queue and not claimed
OPEN-V2 — retention for view snapshots OPEN the pin window, or shorter because views are reproducible. Shared with OPEN-2 of the analytics-writes contract. Not decided

6. Gotchas

Each is a real refusal, limit or residual gap, with the remedy.

6.1 C1/C1a on update/delete windows: two defects measured 2026-09-19 are FIXED; the residual is tracked in §9

Measured 2026-09-19, on a booted box with the D9 changelog enrolled: every window carrying an UPDATE or a DELETE refused in the committer (upsert_table_with_deletes projected key-only delete batches to the identifier fields, while write_row_delta_files_by_ids aligned them to the FULL table schema instead, and the arities disagreed — view-upsert-projects-key-only-delete-batches, GREEN since: align_delete_batch_by_identifiers takes the identifier fields BY NAME). A second defect the same gate then reached — a single oversized commit was REFUSED rather than admitted, re-refusing every later cut and freezing the view — is also FIXED (view-window-split-single-oversized-commit; view_delta::split_window, §3.7 step 5 above, Split::Oversized).

As of tonight (2026-09-21), the standing oracle's latest full run reads view-incremental-oracle-eight-failed-assertions: 5 failed assertion(s) of 102, down from the 15-of-56 this section originally measured. What remains red is a Q8 schema-change-matrix cell (M2), a real, unfixed product defect — §9 has it, with the lane that owns it. This section's original 15-of-56 snapshot is kept below only as the record of what the two committer fixes closed; do not read it as the current count.

[2026-09-19, superseded] VIEW-INCREMENTAL-ORACLE: FAIL (15 failed assertion(s) of 56; 0 of 13 oracle comparisons differed)
[2026-09-19, superseded] view-maintainer-incremental-oracle: PASS=41 FAIL=15 comparisons=13 differing=0 fence-discards=0 duckdb-readbacks=7 cuts=1 writers=10
[2026-09-19, superseded] Q1: FAIL (1)  Q2: FAIL (6)  Q3: FAIL (5)  Q4: PASS  Q5: FAIL (3)  Q6: PASS

Remedy for the residual (§9's Q8 M2): until it is fixed, do not rely on a C1/C1a view's correctness across the specific schema-change shape M2 exercises (§9 names it). For any workload where that matters more than the incremental cost saves you, create the view as recompute class (drop the key, or use a shape the classifier makes C4) and pay the input-sized cost, which §3.11 measured at ~2 s per refresh for a 1 M-row view.

6.2 FK roles: one reader, and C3 is reachable through every door

Measured 2026-09-19, and RE-MEASURED 2026-09-20 after the earlier reading turned out to be a test-harness artifact, not the product defect it looked like: a box gate that reaches create through the CLI door was declaring no --fk, and at the time the CLI read no pg_constraint (§4.3) — so a real FK looked unreachable when the gate itself had never supplied one. Since 2026-09-23 the tail's schema reader carries every published relation's FKs and all three doors union them into the create (view_door::schema_fk_edges; 15a0796b9, merged 9/23 3:18pm, box-measured 34/34 by the differential gate at 405ce37aa), so --fk is optional everywhere.

The SQL door's own FK read is gone, measured to its cause first (2026-09-24). argon.create_view used to run a second reader of the same fact, foreign_keys_here, over the compute's own pg_constraint. Instrumented on a box with a real FK join, it ran as cloud_admin on database postgres against mount lake_main, and its query DID find the FK — one row, both lake names right — but decoded ZERO edges: the two column lists are array_agg(attname), type name[], and pgrx 0.14.1 decodes Vec<String> only from text[] (IncompatibleTypes { rust_oid: 1009, datum_type: "name[]", datum_oid: 1003 }), after which the row was dropped by a silent continue. It had never delivered an edge; the C3s it appeared to produce came from the tail's union. It was removed rather than fixed, so the fact has one reader, and the SQL door's C3 with no caller edge is proven on a box: the same join over an FK pair classifies C3, and over an identical twin with no constraint classifies C4 with is not a declared foreign key printed (testing/box/view-create-door.sh leg 5).

Check with argondb view classify — a C4 on a definition you expect to be C3 means the schema declares no FK between the joined columns (or the tail serving the pin is older than its foreign_keys action, which the log says).

6.3 A C2 whose GROUP BY is not the key used to fail every refresh

Found by L9's differential gate and fixed in that lane: on an insert-only window, the empty _argon_view_old_keys_groups relation was registered under the base KEY's schema, so the affected-groups union refused to plan (No field named region) and every C2 refresh whose GROUP BY is not the key failed until the breaker opened. It now carries the group columns' schema. If you are running a build from before that fix, this is what a C2 over GROUP BY region looks like when it dies.

6.4 avg is not rolled up by the rewrite rule

avg is accepted by the classifier and kept as the sum,count pair, but the rewrite rule refuses to roll it up by name (avg-not-rolled-up): the view would have to carry both sum and count of that argument, and the classifier keeps the pair only when the definition spells it so.

Remedy: spell it. Write sum(x) AS x_sum, count(x) AS x_n in the view and divide in the reader's query — that form rolls up through sum(sum) and sum(count).

6.5 One of three rewrite surfaces named at L9 is still not wired

Two of the three surfaces L9 escalated are now BUILT (view-rewrite-rule-opt-out-key-and-reader-surfaces-carry-into-the-pin): (a) the per-view use_in_rewrite opt-out has a create-door key (ViewOptionsV1.use_in_rewrite, on the SQL, CLI and MCP doors), and (b) the FDW GUCs argondb_lake.{view_rewrite,view_staleness} and the MCP query tool parameter now reach Pinned.view_rewrite through the scan ticket, not just the CLI flags (which were already wired). (c) is unchanged: the opt-outs print in the rule's decision log, not as EXPLAIN plan text.

Remedy for (c): read argondb-query --explain-views's decision log for a per-view opt-out's effect; EXPLAIN alone will not show it.

6.6 Table containment is EQUALITY, not containment

A query joining an extra table is not matched at that node. orders ⋈ customers ⋈ regions is served from a view over orders ⋈ customers only when the planner has already grouped that pair under one node. This is a narrowing of the design text and it is stated in §4.6 there.

Remedy: if a rewrite you expect does not fire, read the decision log (argondb-query --explain-views) — it names the step that refused.

6.7 ViewOptionsV1 is CLOSED: an unknown key is REFUSED, not ignored

{"landings": "postgres"} — note the plural — does not create a lake-landed view the caller believes is on Postgres. It is refused, with the known keys listed (crates/argondb-lake-fdw/src/view.rs:322-333):

argon.create_view('<name>', …): the options document was refused: <serde error>.
Known keys: landing ("lake" | "postgres"), key (array of column names),
max_staleness (text), max_refresh_seconds (integer). An unknown key is REFUSED
rather than ignored — an ignored option is a view maintained under terms nobody
asked for

The full wire shape, with two keys that message does not list, is ViewOptionsV1 (crates/argondb-flight-proto/src/view_action.rs:122-161): landing, key, max_staleness, max_refresh_seconds, indexes, join_roles. indexes is POSTGRES LANDING ONLY — Iceberg has no secondary index, so a non-empty list on the lake landing is a landing-option REFUSAL rather than a field quietly ignored.

6.8 options must be jsonb

Ruled [D-2026-09-25-def1c5]: argon.create_view(name, sql, options) keeps the single options jsonb signature — a json overload beside it was measured to break ordinary calls. PostgreSQL 17.5 cannot pick a candidate for an untyped literal or a NULL third argument when both json and jsonb overloads exist: the two share type category U with no preferred type, so resolution fails "is not unique" rather than picking either. The door stays jsonb-only rather than gaining a second signature. Measurement evidence: var/fleet-board/lane-runs/the-sql-door-takes-a-json-options-argument-or-refuses-it-with-the-cast-named/20260925T005324Z-32449043/report.md.

The three call forms, as they measure — not as a form the guide promises:

Signature: crates/argondb-lake-fdw/src/view.rs:48-53 (pgrx::JsonB, not Json — the difference is the SQL type the extension generates for the parameter).

6.9 Non-determinism is refused for EVERY class, recompute included

now(), random(), current_setting(), txid_*, sequences. A view of these is not a function of L, and being recompute class does not rescue it. The remedy the refusal prints: pass the value in as a literal, or compute it in the READER's query over the view.

This is also why the classifier works on the UNOPTIMIZED plan (§3.5) — on the optimized plan now() has already been folded to a literal and the refusal would pass.

6.10 ORDER BY / LIMIT at the top level is refused

A stored row order is not something a view can carry. A materialized top-k is a window-function view (class C4) written WITHOUT the LIMIT, and the reader applies its own.

6.11 A keyless view is recompute-only, and it is refused as an incremental one

ViewKey::None { reason } forces C4; it never refuses. But an incremental class with no key IS refused, by one predicate the door evaluates before recording and the landing evaluates again on every landing (land::keyless_incremental_refusal, land.rs:956-972) — so a caller landing without going through the door gets the same answer.

Remedy: declare the key (--key, or the key option), or pass a PK through unchanged so it is inherited.

6.12 A Postgres-landed view is not a chain input

Its rows reach the lake as a br_* table carrying the refresh TRANSACTION's LSN, not the L_v its content is a function of; a downstream keyed to that coordinate would conflate the two clocks. Refused by name, with the remedy printed: create the LAKE-landed sibling and chain over that. A DOWNSTREAM may be Postgres-landed — nothing depends on where the dependent lives.

6.13 A view under steady upsert accumulates equality-delete files

Compaction of a view's equality-delete files is the ordinary D10 job once dv_* compaction is admitted, and it is excluded in v1 of that program. Until then a view under steady upsert accumulates delete files and pays read amplification for them. Named so it is not a surprise; there is no per-view remedy today.

6.14 argondb-lake-fdw is outside default-members

lint-gate (all) passes GREEN over a tree whose extension source has never been compiled, so a lane that edits that crate and trusts the gate can ship uncompiled code. If you touch crates/argondb-lake-fdw, run cargo clippy -p argondb-lake-fdw --all-targets -- -D warnings by hand. This anomaly was found in L3 and outlives it.

6.15 The window-budget reading — RULED: Reading B, a soft bound

§3.7 step 5 describes Reading B (a SOFT bound at commit granularity, exceeded by at most one oversized commit): when a single commit is larger than the refresh window budget, that one commit is taken WHOLE rather than refused. This was escalated as F4 in the design and built AGENT-DERIVED pending a ruling; the project owner has since ruled it, 2026-09-24: Reading B, soft bound [D-2026-09-24-f13a3b]. Nothing about the mechanism changes — the ruling settles that what is built is what stands, so view_delta::split_window, §3.6.1's language and the reproducibility claim it made are no longer provisional.

6.16 An idle view used to be refused as stale against the moving tail head — fixed

Between 2026-09-21 (when the maintainer's idle clause stopped re-refreshing a view with nothing to do) and 2026-09-23, an idle view — one whose inputs had genuinely not changed — was wrongly refused by the rewrite rule's Exact answer, which still compared the view's L_v against a lake watermark that moves on every cut even when no row changed (a quiet-stamp commit). Ruled by block 2026-09-23, option A, and shipped the same day: "The reader honours the idle rule: an exact read compares the view's coordinate to its own inputs' watermarks; an idle view serves" [D-2026-09-23-c63ef7]. §3.10's Exact and Exact-by-compensation bullets state the fixed rule. If you are running a build from before the fix, this is what an idle view's rewrite looks like when it wrongly refuses: refused v_<name>: coordinate(<n> bytes behind <L> and the reader's staleness bound is Exact) even though nothing the view reads has changed.

6.17 A view over a granted table is refused until OPEN-V3 is ruled

view-input-granted (§2.13, §3.4) is not a bug to route around: a grantee's view over a granted table would be scoped to the grantor and unreadable by the grantee, and who may read such a view is an open ruling (OPEN-V3 in the design: refuse for good, admit it as the grantee's view, or admit it only where the grant says so). Remedy today: define the view in the grantor's database, or have the grantor land a copy into yours and build the view over the copy (§4.9). The check is skipped on the operator doors (CLI, MCP), which name no database; a view created there over a granted table is not refused by this check. The lane that built the refusal also escalated three findings it did not fix, recorded on its row and not re-measured here: a tail panic on a declared view that has never refreshed (entry_of in crates/argondb-tail/src/lake_owned.rs); the maintainer not rewriting lake_main. qualifiers for a view created through argon.create_view; and, with the tenant wall on, the create door being unable to plan any tenant table. Verify a create through the SQL door refreshes before you rely on it.

6.18 The expiry hold has no time limit in the live sweep

§3.8's hold is correct in direction — an upstream view's snapshots are kept while a dependent still needs them, whether that dependent is lake- or Postgres-landed — but the live maintenance sweep passes no pin-window floor to the verdict, so a dependent that stops consuming (a tripped one, say) holds its upstream's snapshots indefinitely, and the "EXPIRED PAST THE PIN WINDOW" warning never fires in production. The cost is storage on the upstream view table, not wrong rows. Remedy today: a dependent you no longer need should be dropped, not left tripped; drop_view removes its hold at the next sweep.

6.19 A Postgres-landed C4 view does not honor its min_refresh_interval

A Postgres-landed view's last-refresh time is read back from argon.view_state.landed_at as Postgres text (crates/argondb-transform/src/view_state.rs, landed_at::text), which is not the RFC 3339 form hold_decision parses, so the hold never engages and the view refreshes at every cut with a change. This was measured on ms-01 (a 900 s floor refreshed again after three minutes) by the expiry-hold lane and left unfixed. The lake-landed cap is the one the box arm P7 proves (§2.9).


7. Sub-diagrams

Four deeper pictures, each drawn with the same generator library and palette as the main diagram. Sources are docs/diagrams/argondb-and-the-erp/generate/d04{a,b,c,d}_*.py; render with cd docs/diagrams/argondb-and-the-erp/generate && python3 d04a_classify.py (and so on). Each writes its SVG beside the main one.

7.1 The class decision tree the classifier walks

4a — the classifier's decision tree

The refusal ladder in the exact order classify and classify_plan fire it (view_classify.rs:962-1120), the two refusals the DOOR owns because they depend on the caller's options, the class walk, and the per-refresh C1a narrowing that is not a class the classifier returns. Use it when a definition comes back a class you did not expect: walk the ladder from the top, and read notes.

7.2 A refresh's two delta sources and the coordinate stamp

4b — one refresh

Embedded in §3.7 above. It carries the ring-versus-changelog rule, what a change row does and does not hold, the four classes' arithmetic side by side, the two things the shipped code does that the formula does not say (the delete set is not the tombstones; the old-image question is asked of the uncollapsed window), the soft window bound, and the full snapshot property family.

7.3 The rewrite rule's test ladder

4c — the rewrite rule's test ladder

Embedded in §3.9 above. Where the rule is installed and why FIRST; the five subsumption tests with their as-built narrowings; the four coordinate answers with the exact condition for each; the three opt-outs and the decision log.

7.4 A chain at one cut, in topological order

4d — a chain at one cut

Embedded in §3.8 above. A diamond DAG, the two fences at create, the healthy pass and the stalled pass side by side with the exact state strings, and what the upstream's class does to the downstream's — which is only ever a question about whether a delta exists.


8. References

The design of record

The code

The gates

The other guides in this set — docs/diagrams/argondb-and-the-erp/, one diagram each: 00 the whole machine, 01 one database per tenant and the wall, 02 the per-tenant unit, 03 the lake and its process model, 04 this one, 07 agents and the MCP endpoint.


9. What is still red

As of this guide's base sha, three things are red or unproven on this program that the sections above already point to, and one that used to be is green. The v1.5.6 limits on granted inputs, the expiry hold and the Postgres-landed interval are gotchas, not reds: §6.17–§6.19. Named plainly, with the lane that owns each, so you know what not to rely on today rather than discovering it live.

1. The C4 recompute box gate's P3 idle negative is GREEN now. The tail's per-table watermark used to advance even when no row of that table changed, so an idle view's L_v advanced for nothing. The fix, tail-per-table-watermark-holds-when-no-row-of-that-table-changed, is on main (merge 2ea7de44f0): testing/box/view-maintainer-recompute.sh read PASS=38 FAIL=0 on ms-01, with P3 holding L_v across a window with no write after two proven advances. Candor: that lane's own verdict was FAIL, because the incremental oracle never reached a verdict on a starved box in its runs — so the fix's effect on the incremental oracle is unmeasured, not green.

2. The incremental oracle has one real product defect left: Q8, matrix cell M2. The two defects §6.1 originally measured (the delete-batch arity refusal, and the oversized-single-commit poison pill) are both fixed. Tonight's fuller run — view-incremental-oracle-eight-failed-assertions — reads 5 failed assertion(s) of 102, and one of them, Q8's M2 schema-change-matrix cell, is attributed as a real, unfixed product defect rather than a gate or fixture problem. A resolve lane landed tonight that closed some of the eight originally reported, but M2 itself is not among what it fixed. What not to rely on today: the exact schema-change shape M2 exercises against a C1/C1a/C2 incremental view; if your workload does that kind of schema change against an incremental view, verify by hand rather than trusting the incremental result, or use a recompute-class view for that table in the meantime (§6.1's remedy).

3. The chain oracle's Q9 is not carried green on a box; the content defect it was chasing is gone. view-oracle-q9-chain-members-assertion-green measured, on the untouched base, that the ORIGINAL product defect (dependents short of their recomputes at the converged coordinate) is fixed — all chain members that were compared AGREED with their recompute, differing=0 of 38 comparisons. The one remaining Q9 red is a COVERAGE assertion tripped by a fence discard (a chain member's read was discarded because the fence moved under it mid-read, not because it failed), which the same lane diagnosed as the oracle's own bug and fixed at unit level (vo_oracle_at_settled, VIEW-ORACLE-SELFTEST: PASS, 22→27 assertions). Three attempts to carry the fix green on ms-01 that night did not succeed — two never reached the fixed code path (a chain member failed to land within its 420 s declaration deadline, attributed to box contention: ms-01 load average up to 15.86 on 20 threads with several other lanes' slots held, and a cold compile eating most of the deadline) and the third was terminated by something outside the run. What not to rely on today: a box-measured GREEN verdict for the chain oracle's Q9 coverage assertion specifically. The chain arithmetic itself (§3.8) is not in question — what is unproven on a box is the oracle's own coverage bookkeeping under a busy box. A rerun is owed and has not landed as of this sha.

4. The incremental oracle's Q3 has a residual red, its cause not yet attributed. After a refresh of a C1 view over one base table, 0 of 500 rows written DURING that refresh are present at the next cut, and a key written during the refresh never appears — while the oracle's own comparison AGREES with a recompute at both cuts, so this is not (yet) shown to be a wrong-rows defect in the view's steady-state content; what is unmeasured is whether a row written mid-refresh becomes visible as promptly as the product's own contract implies it should. The project owner ruled measure first, 2026-09-23 [D-2026-09-23-bd02d0]: one lane instruments the gap and returns a question block only if it turns out to be a real product defect. What not to rely on today: a row written while a C1 view's refresh is in flight showing up at the very next cut — this is neither a confirmed defect nor a confirmed non-issue.

One further, smaller item that used to be red here is now FIXED, named for the record so this section stays a complete history of what this guide called red (the SQL door's FK read, listed here until 2026-09-24, is CLOSED — measured to its cause and removed, §6.2): the C3 join view Postgres-landing defect is fixed at 72d745e02 (merged 9/23 9:31am, bc6fdde66) — refresh_join now runs through land_refresh and declared_target_spec spells either landing's target (§2.11, §5's Postgres-landing row).


Guide 7 — agents and the MCP endpoint

One of the Developer Guide's deep dives, written against ArgonDB v1.5.6. The audience is a senior developer who has to use this system: connect a client, write the calls, read the disclosures, and know what will be refused before it is. Every claim below carries a source by path:line. Every worked example is labelled either VERIFIED (run, with its real output pasted) or FROM SOURCE (the exact signature, flag, table or refusal quoted from the code or the design). Nothing is here that could not be grounded one of those two ways.


1. The diagram

Agents and the MCP endpoint

The upper frame is one process — argondb-mcp, mounted in-process by the tail on :8018 or run standalone (crates/argondb-mcp/src/lib.rs:5). It serves four mounts, and the four boxes across the top are not four servers and not four tool-list presets: each is a trust tier with its own bearer token, its own port, its own bind default and its own backing Postgres role (crates/argondb-mcp/src/lib.rs:2038-2042). That is the structural claim the picture is making, and it is the one to hold on to while reading the rest: an operator composes an agent's capability by choosing which mounts it connects, so the risk analysis happens at connect time rather than by curating a tool list (DESIGN.md:307). The lines of small text under the mounts name the four properties that apply across all of them — a pin on every call so a multi-step session sees one position, the token-budget contract bounding what any tool returns, the G7 isolation rule that keeps the read plane off the primary, and the hybrid Postgres arm, which exists and is off on the demo box.

The lower frame is one agent layer serving many companies, and it was drawn before v1.5.6. Its dashed middle box — an application-level agent layer — is still designed, not built. What v1.5.6 did build under it is the credential: every company on a box now carries its own MCP token on the analyst mount, and a session holding it reads that company's own database and nothing else (§2.6; crates/argondb-mcp/src/company_token.rs:1-35). The red-ink line in that frame, the residual gap, still stands: intra-company role restrictions do not travel to the lake arm, so an analyst tool can read rows the same person could not read through SQL (docs/2026-09-04-erp-spec.md:830-834). That is stated as a real gap with a known failure mode, not a caveat.


2. What it is

The surface is frozen at its v1.5.5 shape (project direction, 2026-09-26). The four mounts, their tool rosters and their served contracts are held as v1.5.5 shipped them. v1.5.6 adds, renames and removes no tool on any mount. The only functional change to crates/argondb-mcp/src since the v1.5.5 cut (38a85f67b) is the per-company token of §2.6, and it changes who may call the analyst mount, not what the tools are. Build against the rosters below as a stable contract. Do not wait on MCP features that are not listed here.

2.1 argondb — the analyst mount

The read-only mount, and "read-only" here means architecturally, not by tool curation. It serves 16 tools: the native contract family (query, schema_overview, describe, profile, sample, search, read, outline, expand, changes, pin, repin, list_branches, publish_status) plus the compat family execute_sql and explain_query, anchored on the crystaldba postgres-mcp names with deviations recorded (DESIGN.md:309; roster at crates/argondb-mcp/src/lib.rs:4573-4625). Its token is ARGONDB_AUTH_TOKEN — the pre-A6 name, kept because the wire contract is frozen, not renamed (crates/argondb-mcp/src/lib.rs:2108-2115). Its port default is 8018 and it follows the global ARGONDB_LISTEN_ADDRESS (lib.rs:2150, lib.rs:6005-6008). Its backing Postgres role on the hybrid path is argon_analyst, a NOSUPERUSER NOCREATEROLE NOCREATEDB NOBYPASSRLS NOREPLICATION role with SELECT and nothing else (docs/2026-08-23-mcp-analyst-isolation-design.md:41-56). The failure mode the picture cannot show is the one that makes the mount's posture true: a SQL parser cannot be the write boundary, because SELECT … INTO, nextval(), a volatile function that writes and COPY … FROM PROGRAM all write while looking like reads. The role is the boundary; the parser's only job is to enforce one statement per call and give a clean error (docs/2026-08-23-mcp-analyst-isolation-design.md:33-37).

2.2 argondb-dba — the diagnostic mount

Privileged in what it can see, and in a deprecation window on the one thing it can change. Seven tools: health, top_queries, index_advice, explain_query (mounted here as a seam whose body is not implemented on this path), publish_status, transform_pin_serves, and run_transform carrying a deprecation line (crates/argondb-mcp/src/dba.rs:457-736). Token ARGONDB_DBA_AUTH_TOKEN, port 8019, following the global listen address (lib.rs:2116, lib.rs:2151). Backing role argon_dba_observer, a pg_monitor member with no write privilege (crates/argondb-mcp/src/dba.rs:115-120). Zero mutation on the observation path is asserted four ways over, and the fourth is the one worth internalising: no observation tool takes free SQL at all — each issues its own fixed, parameterized queries, so a caller has no injection or mutation vector into the observation path (crates/argondb-mcp/src/dba.rs:41-52). The limits the picture could not show: live reads of the same tool are rate-gated to one every 500 ms across all sessions (dba.rs:96-100), a single observation query is capped at 10 s wall clock (dba.rs:104), and the default response budget on this mount is 4000 tokens (dba.rs:111-113). run_transform's presence here is not drift — it was ratified onto this mount by block on 2026-09-03 and ratified off it by block on 2026-09-07; the window keeps it answering for one more release (crates/argondb-mcp/src/dba.rs:7-33).

2.3 argondb-admin — the control mount

Every mutation of the system lives here and nowhere else: lake_publish, lake_unpublish, gap_record, maintenance_run, retention_set, apply_index (crates/argondb-mcp/src/lib.rs:5985-5993). Token ARGONDB_ADMIN_AUTH_TOKEN, port 8020, and — the structural part — it binds loopback unless configured, through its own ARGONDB_MCP_ADMIN_LISTEN_ADDRESS, even when the global listen address is 0.0.0.0, so exposing the analyst surface never accidentally exposes the control surface (lib.rs:6009-6020). Two defaults define the mount's posture. First, dry-run is the default on every tool: the call previews the exact change and mutates nothing until execute=true (lib.rs:5616-5617). Second, the destructive pair — lake_unpublish purge=true and apply_index — additionally require confirm=true, and without it they refuse while naming exactly what would be lost (lib.rs:5629-5630, lib.rs:5681-5682). The design's phrase for the tier is "an agent proposes; the operator confirms", and it is built as a parameter rather than a client elicitation, so it works identically for a model and for a headless program (DESIGN.md:312).

2.4 argondb-write — the data-write mount

The fourth mount, and the youngest: ratified 2026-09-07 by block [D-2026-09-08-69a7b2] because run_transform — a data write by an ordinary application — had been living on argondb-dba, which meant every appending application had to hold the token that also reads the primary's runtime stats (crates/argondb-mcp/src/write.rs:3-17). Four tools: run_transform, append_rows, create_view, and one read-only tool, transform_pin_serves, which is run_transform's own readiness predicate, mounted here so a caller holding only this token can prove a table readable before choosing to transform over it (crates/argondb-mcp/src/write.rs:226-243). Token ARGONDB_WRITE_AUTH_TOKEN, port 8017 — deliberately not 8021, because the per-slot fleet port map gives ARGONDB_MCP_PORT the band 8020+N, so a default-port write mount beside a slot-1 analyst mount would bind 8021 twice (lib.rs:2133-2153). Loopback unless its own ARGONDB_MCP_WRITE_LISTEN_ADDRESS says otherwise — its own variable, not admin's, so exposing the control plane has not thereby exposed the write plane (lib.rs:6009-6020, write.rs:26-30). The property that makes the mount's charter structural rather than disciplinary: WriteBackend has exactly one field, the transform landing conninfo, so "this mount observes nothing" is a property of the type (write.rs:60-75, asserted at write.rs:287-301).

2.5 The pin, the budget and changes() — the three lines under the mounts

A pin is a signed value, not a server-side handle: the tuple (branch, lsn, scope, minted_at, coordinate, coordinate_at), HMAC-signed, base64url-encoded, redeemable by any server holding the deployment key (crates/argondb-mcp/src/token.rs:1-8). It costs nothing to create; the session map is a rebuild-on-miss cache and never correctness. Its validity is arithmetic — the coordinate's age against the retention window, never the token's own mint age — so there is no liveness protocol and no registry of outstanding pins (token.rs:5-8, DESIGN.md:166). The budget is a payload SLA: budget_tokens defaults to 4000 (lib.rs:2245), the response is rendered in full, then measured, then hard-trimmed with an omission marker; the guarantee lives in trim_to, not in the estimator (crates/argondb-mcp/src/budget.rs:18-32). changes() returns a net-effect digest over the __changes changelogs between two positions — per table, created / deleted / updated by first-known-to-last-known state per primary key, plus a revisions count so churn is visible (crates/argondb-mcp/src/lib.rs:4822, row rendering at crates/argondb-mcp/src/delta.rs:721).

2.6 Per-company MCP tokens — one company, one token (new in v1.5.6)

Before v1.5.6 the analyst mount knew one bearer, the box's ARGONDB_AUTH_TOKEN, and a session holding it could reach every company on the box. v1.5.6 gives each company its own token, and the wall behind it is Postgres, not a filter (crates/argondb-mcp/src/company_token.rs:1-35).

What it refuses, and what is not built:


3. How it works

3.1 Startup: which mounts exist, where they bind, and what refuses the process

ARGONDB_MCP_SURFACES is a comma-separated list of mount names; unset means the analyst surface alone, byte-identical to the pre-A6 single mount (lib.rs:2215-2235). Duplicates collapse, order follows first appearance, and a name outside argondb / argondb-dba / argondb-admin / argondb-write (short aliases analyst / dba / admin / write accepted) fails loudly — a typo'd surface must never silently serve the wrong trust tier (lib.rs:2079-2093).

Every surface's listener is resolved and fail-closed-checked before any listener binds (lib.rs:6046-6052). The check is: a non-loopback bind with this surface's token unset is refused, naming the surface, the address and the variable to set (lib.rs:5971-5986). Because the check runs for all surfaces first, a misconfigured mount kills startup rather than dying mid-serve.

Two request-level guards apply to every mount. Bearer auth: with a token configured, Authorization: Bearer <that mount's token> is required, and anything else is a 401 with WWW-Authenticate: Bearer and the body Bearer token required (lib.rs:6468-6477, lib.rs:6520-6526). Because each listener only ever compares against its own token, "an analyst token on the write mount is refused" is true by construction rather than by a tool-level check (lib.rs:6377-6379). The analyst listener alone has a second path. A bearer that is not the box token and starts with amc1. is judged as a company token (§2.6): if it verifies, it is served by that company's own session. If it does not, the 401 body names the company and the reason, for example company token refused: company "alpha"'s token was revoked by a re-issue (this is not its live token) (lib.rs:6484-6519; company_token.rs:102-122). Host allowlist: rmcp ≥ 1.4 rejects a request whose Host header is not on the allowlist — the fix for RUSTSEC-2026-0189, DNS rebinding against a locally bound MCP server — and ARGONDB_MCP_ALLOWED_HOSTS is how a deployment reached by a Service DNS name or a public hostname says so (lib.rs:5772-5796). The hostile-caller detail that matters: rmcp treats an empty allowlist as allow-ALL, so unset, blank, whitespace, "," and ",," all fall back to the loopback set, and there is deliberately no disable sentinel — turning the protection off is not a typo away (lib.rs:5787-5812). One exception to the auth gate, by design: GET on the health route answers before the auth check, so a liveness probe never needs the bearer token (lib.rs:6219-6234).

3.2 The control path of one read: routing, and what is disclosed

query and execute_sql are two tool names on one executor: the native contract name and the crystaldba compat name, both budget-bounded (DESIGN.md:301; both bodies call run_query, lib.rs:4569-4572). Which arm that executor uses is decided in this order.

  1. Lake-only scope. If the statement's table scope is non-empty and every table in it is lake-only by construction — the three branch-system tables _argon_timeline, _argon_gaps, _argon_value_notes, or a __changes changelog — the statement takes the lake arm even on a hybrid backend (docs/2026-08-29-mcp-hybrid-lake-only-routing.md:94-98, and the 2026-09-11 widening of the predicate to changelogs at docs/2026-08-29-mcp-hybrid-lake-only-routing.md:48-59).
  2. Mixed scope. A lake-only table joined to a user table stays on Postgres and fails loudly, by name — that ruling survived the 2026-09-11 revision unchanged (docs/2026-08-29-mcp-hybrid-lake-only-routing.md:59-60).
  3. Backends::route. Hybrid routes to the Postgres arm; lake-only routes to the lake arm. A hybrid pin is a Postgres pin (docs/2026-08-29-mcp-hybrid-lake-only-routing.md:59-60).
  4. Fallback, disclosed. If the routed Postgres arm did not answer — cancelled by its statement timeout, or unreachable — the statement is served from the lake and the response says so ahead of pinned_lsn:, because the fallback changes what every line beneath it means (lib.rs:1499-1509). The disclosure's own budget is charged against the caller's budget_tokens like any other header: a disclosure that pushes a response over budget is not a disclosure, it is a truncation (lib.rs:1500-1502).

The arm is always named in the first header line, and the two spellings are different claims. pinned_lsn: <X/Y> is an exact WAL coordinate and the same value either engine serves for that pin. lsn: <X/Y> (read-only snapshot; served from Postgres) is the analyst compute's current replay position, which trails the writer by replication lag rather than by the lake watermark. Two unpinned reads can therefore disagree, and the read tool's own description says exactly that and says to pin them if it matters (lib.rs:4974).

3.3 What is atomic, what is idempotent, what is refused

Atomic. A pin with no scope is the atomic cut — all tables at one coordinate (lib.rs:5243 and the answer's scope: all tables (atomic cut) line at lib.rs:5289). A lake landing commits as one snapshot swap per table (DESIGN.md:303). On the Postgres landing, run_transform's idempotency token is claimed inside the landing transaction (crates/argondb-mcp/src/write.rs:109).

Idempotent. run_transform is exactly-once on both landings: a token claimed in the landing transaction on pg, stamped into the committed snapshot as Argon.run-token on lake, so re-running with the same token answers already-landed instead of landing twice. A token is generated when you omit one, and it comes back on the token: line of every answer — refusals and state-unknown included (write.rs:109). lake_publish is idempotent: re-publishing a published table is a no-op (lib.rs:5617). apply_index is idempotent through CREATE INDEX IF NOT EXISTS (lib.rs:5682). One documented asymmetry: on landing: lake, drop is not idempotent, because dropping the table destroys the token history — a repeated drop answers target-missing where pg answers already-landed (write.rs:109).

Refused, and the polarity a caller writes retry logic against. Every run_transform refusal kind is raised before the landing transaction is opened, so status: refused means the landing provably did not happen and a re-run cannot double-land. Most kinds are deterministic and re-running is pointless; pin-failed, connect-failed and catalog-unreachable are environment failures and the same call with the same token is expected to land once the dependency answers. state-unknown is the other polarity and means the landing may have committed: never blind-retry, re-run with the same token (write.rs:109, final sentences). append_rows carries the same discipline with a different status vocabulary — staged | committed | duplicate | refused | state-unknown, where staged means the parquet is durable in object storage and the rows are invisible to every reader until the next cadence tick, and duplicate means a batch with this token already staged or committed and this call wrote nothing (write.rs:139).

3.4 What is disclosed at mint time

pin() is always grantable; what varies is what it admits about itself. Five things ride the answer, four of them on one note: line class so that no consumer of the response shape learns a new field (lib.rs:5304-5330):

The token also records how its coordinate was chosen, and the two classes carry different guarantees. ProtectedDefault (freshness=latest_servable, the default) had the ceiling cap, the floor-vs-cap refusal and the durable-servable snap applied, and survives tail restarts and watermark advances for the whole window. CallerNamed (an explicit at_lsn, or freshness=head) is exact — no snapping, no fallback — and survives restarts but not a watermark advance past it; the caller owns its lifetime (token.rs:17-42, DESIGN.md:164-165).

3.5 What is measured, and the numbers

3.6 Statistics before scans

describe() and profile() answer from stored statistics in both backends — lake mode from the D10 statistics files plus Iceberg manifest metadata, pg mode from pg_stats — every value carrying provenance. Live scans leave the default path entirely, and query() is the explicit caller-paid escape (DESIGN.md:305). The provenance is a real rendered line, not a concept: [ndv: D10 stored stats (snapshot <id>, current snapshot)], or (stale: older snapshot), or — when a column has no stored stat — [ndv: live scan — no stored D10 statistics for this table], with a mixed form naming that some columns had no stored stat and were live-scanned (crates/argondb-mcp/src/profile.rs:53-78). Per column, profile() also emits a src field of stored | live scan | mixed (crates/argondb-mcp/src/profile.rs:1020-1025).

schema_overview is the one merged map on a deployment that has both planes, ratified [D-2026-09-11-cbe1c2]: every row carries source = postgres | lake | both, a table held on both planes is one row carrying both spellings, its publish marker, both row counts with their classes (~N planner estimate vs N / ≤N snapshot), its freshness on each plane and whether it has a __changes history (DESIGN.md:301). The ≤N class is not cosmetic: the count comes from total-records, which counts rows present in data files, so a live delete file makes it an upper bound and the card renders ≤N (crates/argondb-mcp/src/cards.rs:7-13).

3.7 Isolation — why agent load cannot slow the transactional path

G7 is a MUST: analytical and agent load exerts no backpressure on the transactional write path, and a slow or failed analytical consumer degrades lake freshness, never OLTP (DESIGN.md:436). Two mechanisms carry it on the agent path. A following analyst replica sets max_standby_streaming_delay = -1, so an analyst query is never cancelled for replay — replay waits for the scan instead, and the freshness surface discloses the induced lag. And hot_standby_feedback stays off, because enabling it would push bloat and vacuum retention back onto the primary, which is precisely the violation (DESIGN.md:299). Static (pinned) computes do not replay past their pin, so the delay policy is replica-only. This is policy, not a knob (DESIGN.md:299).

3.8 The accelerator, and what "accelerated" means on an answer

Inside the one executor, hybrid-mode SQL is shape-matched: whitelisted shapes execute on DataFusion over the LSN-pinned lake session, everything else takes the Postgres path unchanged, and any doubt routes to Postgres — the Postgres path is the semantics anchor (crates/argondb-mcp/src/accel.rs:3-8). Admission is mechanical: a shape is admitted if and only if it is named in SHAPE_NAMES, the differential gate enumerates that list through argondb-mcp accel-shapes, and every enumerated shape must have a green differential cell — PG-executed vs accelerated, canonicalized-equal — or the gate fails. Adding a shape without a gate cell breaks the gate by construction; there is no second, manual list (accel.rs:10-16). The admitted envelope after the 2026-07-27 expansion is group-key columns and whitelisted aggregates over one published table, with optional WHERE, GROUP BY on bare integer columns, ORDER BY, LIMIT and OFFSET (accel.rs:24-37). One correctness note that a developer reading Iceberg metadata will want: the accelerated count(*) scans the pinned merge-on-read view and must never be replaced by a manifest shortcut, because ArgonDB writes an equality delete for every upsert key, so manifest row totals over-approximate retirals and data − deletes is not the live count (accel.rs:18-23).


4. How to use it

Six-plus worked examples. Each is labelled VERIFIED or FROM SOURCE.

Verification environment, stated once

The VERIFIED examples below were run against the release binary at target/release/argondb-mcp in this repository's main checkout, built 2026-09-18 12:16. That binary predates this guide's base commit (c9415ae0a), and the difference is visible and is reported rather than papered over: commits fe93e8de7 … 4a481e7ee added create_view to the argondb-write roster, so the binary's roster shows 3 tools where this base's source asserts 4 (crates/argondb-mcp/src/write.rs:239-243). Every claim about create_view below is therefore labelled FROM SOURCE. No database, compose stack or live deployment was touched: surface-tools and served-schemas enumerate the routers in-process and open no connection (crates/argondb-mcp/src/main.rs, the surface-tools arm; lib.rs:2198-2213).

4.1 Connecting a client to each of the four mounts — VERIFIED (rosters) + FROM SOURCE (the add lines)

The tool list each mount exposes, enumerated from the serving routers themselves rather than from a hand-written list (lib.rs:2185-2196, write.rs:180-189, dba.rs:750):

$ target/release/argondb-mcp surface-tools all
argondb changes
argondb describe
argondb execute_sql
argondb expand
argondb explain_query
argondb list_branches
argondb outline
argondb pin
argondb profile
argondb publish_status
argondb query
argondb read
argondb repin
argondb sample
argondb schema_overview
argondb search
argondb-dba explain_query
argondb-dba health
argondb-dba index_advice
argondb-dba publish_status
argondb-dba run_transform
argondb-dba top_queries
argondb-dba transform_pin_serves
argondb-write   append_rows
argondb-write   create_view
argondb-write   run_transform
argondb-write   transform_pin_serves
argondb-admin   apply_index
argondb-admin   gap_record
argondb-admin   lake_publish
argondb-admin   lake_unpublish
argondb-admin   maintenance_run
argondb-admin   retention_set

VERIFIED — run 2026-09-20 on the binary described above, with ONE line added from source: the argondb-write create_view row, in the roster's alphabetical order, which the roster test asserts at crates/argondb-mcp/src/write.rs:239-243 and which that older binary lacked (it listed three write tools). A binary built from main lists four. The listing was not re-run on a current binary.

Connecting. Two transports. HTTP, one claude mcp add per mount, each with its own URL and its own token:

# The process must be serving the mounts you intend to connect:
export ARGONDB_MCP_SURFACES=argondb,argondb-dba,argondb-write,argondb-admin

# Ports and tokens, one pair per mount (lib.rs:2113-2154):
#   argondb        8018   ARGONDB_AUTH_TOKEN
#   argondb-write  8017   ARGONDB_WRITE_AUTH_TOKEN
#   argondb-dba    8019   ARGONDB_DBA_AUTH_TOKEN
#   argondb-admin  8020   ARGONDB_ADMIN_AUTH_TOKEN

claude mcp add --transport http argondb       http://localhost:8018
claude mcp add --transport http argondb-write http://localhost:8017
claude mcp add --transport http argondb-dba   http://localhost:8019
claude mcp add --transport http argondb-admin http://localhost:8020

FROM SOURCE — the HTTP quickstart form is plans/ARGON-MCP-PLAN.md:207-208; the mount names are what a client types in claude mcp add <name> and are frozen wire surface (docs/reserved-identifiers.md:4211); the port and token pairs are crates/argondb-mcp/src/lib.rs:2113-2154.

The stdio form, for a local box:

claude mcp add argondb \
  --env ARGONDB_MCP_MODE=pg \
  --env ARGONDB_PG_URL=postgresql://argon_analyst@localhost:5433/postgres \
  -- ./target/release/argondb-mcp

FROM SOURCE — docs/macos-build.md:225. Note the DSN names argon_analyst, not cloud_admin: that substitution is belt 1 of the analyst isolation design and is the write boundary (docs/2026-08-23-mcp-analyst-isolation-design.md:67-72).

What the bearer check does to a wrong token:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

Bearer token required

FROM SOURCE — crates/argondb-mcp/src/lib.rs:6240-6253. This is the listener's answer, not a tool's: the analyst token on the write mount never reaches a tool body.

4.2 describe() and profile() answering from stored statistics, and a budget refusal — FROM SOURCE

The served contract for profile:

"Statistics for a table or a single table.column: distinct counts, min/max, null fractions, numeric/temporal quantiles. Answered from STORED statistics (lake: D10 stats file + Iceberg manifest metadata; pg: pg_stats) with per-value provenance; a column lacking a stored stat falls back to a disclosed live scan. Use query() for exact on-demand values (e.g. top-N)."

FROM SOURCE — crates/argondb-mcp/src/lib.rs:4766.

A call and the shape of its answer:

// tools/call  ->  profile
{ "table": "orders", "column": "region", "budget_tokens": 800, "pin_id": "<pin>" }
column  ndv null_frac   src
region  14  0.0000  stored
  [ndv: D10 stored stats (snapshot 7214590284611903223, current snapshot)]

FROM SOURCE — the per-column row format is crates/argondb-mcp/src/profile.rs:1025 ({col}\t{ndv}\t{nf}\t{src} with src one of stored / live scan / mixed, profile.rs:1020-1024); the provenance line is profile.rs:68. The three provenance strings, verbatim:

FROM SOURCE — crates/argondb-mcp/src/profile.rs:57-74.

What a budget refusal looks like — and the precise thing to expect. A budget is not refused; it is enforced. The response is rendered, measured and hard-trimmed, and the trim announces itself:

column  ndv null_frac   src
region  14  0.0000  stored
customer_id 1840221 0.0000  stored
…
[trimmed: full response ≈18442 tokens]

FROM SOURCE — crates/argondb-mcp/src/budget.rs:20-32; the loop that stops emitting columns once budget::estimate(&out) > budget_tokens is crates/argondb-mcp/src/profile.rs:1026-1027. This is the distinction a caller must hold: a budget produces a shorter, marked answer, never a silently shorter one. The calls that genuinely refuse on size are the row caps of §3.5, not budget_tokens.

4.3 query() under a pin across a multi-step session — FROM SOURCE

// step 1 — mint. Scope it, and the pin is never blocked by an unrelated
// table mid-ingest (lib.rs:5243).
{ "tool": "pin", "arguments": { "scope": ["orders", "order_lines"] } }
pin_id: <base64url token>
pinned_lsn: 3F/AB120C8
scope: orders, order_lines
head_lsn: 3F/AB4410A
behind: 3 MiB of WAL (a scoped table is mid-ingest; repin to advance)

FROM SOURCE — the scope: line at lib.rs:5288-5292, the head_lsn: / behind: pair verbatim at lib.rs:5296-5303.

// steps 2..N — every call carries the same pin_id.
{ "tool": "query", "arguments": {
    "sql": "SELECT region, count(*) AS n FROM orders GROUP BY 1 ORDER BY 2 DESC LIMIT 10",
    "pin_id": "<pin_id>", "budget_tokens": 1500 } }

The answer's first header line names the coordinate and, on the accelerated path, the shape: pinned_lsn: 3F/AB120C8 and accelerated: count_star are both real header lines — the pasted bisect in docs/2026-09-08-mcp-run-transform-hybrid-watermark.md:56 shows MCP: pinned_lsn: 0/15C6F08 | accelerated: count_star | n | 1000 | (1 rows) |. FROM SOURCE.

Three refusals to write your session against, all by name rather than by returning a value at another coordinate — the pin expired (call repin()), the pin was not minted here (the HMAC does not verify), and the pin does not cover this table (a scoped pin asked outside its scope). The read tool states the class explicitly: "a pin that cannot be served, has expired, was not minted here, or does not cover this table is an ERROR naming the remedy, never a value at some other coordinate" (FROM SOURCE, lib.rs:4974). And one more that catches people writing session helpers: a transaction-control or session statement — COMMIT, ROLLBACK, BEGIN, SAVEPOINT, SET, RESET — is refused on a pinned path even as a lone, lexer-clean statement, because it breaks the pin's single-snapshot contract; unpinned reads open a fresh snapshot per call and are unaffected (FROM SOURCE, docs/2026-08-23-mcp-analyst-isolation-design.md:112-130).

4.4 changes() between two positions — FROM SOURCE

{ "tool": "changes", "arguments": {
    "since": "3F/AB120C8",      // an LSN, a timestamp, or now-15m
    "until": "now",
    "budget_tokens": 2000 } }
table   created deleted updated revisions
orders  1204    3   887 2094
order_lines 5510    12  1044    6566
  gap (3F/AB0F000 … 3F/AB11800] — re-baseline — orders only
…and 2 more gap range(s)

FROM SOURCE — the digest header row verbatim at crates/argondb-mcp/src/delta.rs:721; the gap-range line and the …and N more gap range(s) continuation at crates/argondb-mcp/src/delta.rs:700-719. Rows are sorted busiest-first because the reader's attention is the budget (delta.rs:735-736), and zero-revision tables are collapsed into a quiet count rather than printed (delta.rs:738-741).

The served contract, which names the semantics: "Net change digest since an LSN or time: per table, rows created/deleted/updated (first-known to last-known state per primary key) plus a revisions count so churn is visible… The response echoes the resolved LSN window so follow-ups are exact" (FROM SOURCE, lib.rs:4822). First-known to last-known state per primary key is the part to internalise: this is a net-effect digest, not a replay. A row created and deleted inside the window nets out; the revisions count is what tells you it churned.

changes() requires the lake tier. Without it the tool answers with the LAKE_REQUIRED coaching: "requires the lake tier: this ArgonDB runs lake-less (OLTP only), so change history is not recorded and there is nothing to time-travel over. Everything else — query, sample, schema_overview, describe, profile, pin/repin — works right now" (FROM SOURCE, lib.rs:2237-2243).

4.5 run_transform landing into derived.* and into the lake — FROM SOURCE

Landing pg (the default). The target is spelled derived.<name>, the table is publish-marked, and the lake copy arrives through the tail:

{ "tool": "run_transform", "arguments": {
    "mode": "create",
    "target": "derived.revenue_by_region",
    "sql": "SELECT region, sum(amount) AS revenue FROM orders GROUP BY 1",
    "key": ["region"],
    "landing": "pg" } }
status: landed
landing: pg
token: 01J8Q2...
key: [region]
key_from: declared

Landing lake. The target is a bare name and the server computes the dv_<branch> namespace — a caller never spells one:

{ "tool": "run_transform", "arguments": {
    "mode": "create",
    "target": "revenue_by_region",
    "sql": "SELECT region, sum(amount) AS revenue FROM orders GROUP BY 1",
    "key": ["region"],
    "landing": "lake" } }
status: landed
landing: lake
snapshot: 7214590284611903223
token: 01J8Q2...

FROM SOURCE — the two landings, their target spellings, the status: / landing: / snapshot: / token: answer lines and the five modes (create | append | replace | drop | upsert) are all the served description at crates/argondb-mcp/src/write.rs:109.

Three rules the description makes explicit and that catch people:

  1. The key is declared or inherited, never inferred. Pass key on a create and the table carries it for real — a PRIMARY KEY on derived.<name> under landing: pg (which the tail turns into Iceberg identifier-field-ids on the lake copy), or the identifier-field-ids directly under landing: lake. Omit it and the output inherits the key only if the SELECT passes one source table's key columns straight through — the answer's key_from: line then says source:<table>. Otherwise it is refused key-missing. Nothing invents a key from the output columns, and nothing lands keyless unless you say no_key: true, which lands it keyless and prints it. FROM SOURCE, write.rs:109.
  2. A dv_* table has no WAL coordinate. It is served at its current snapshot, never at your pin; the run's pin is recorded as provenance only. FROM SOURCE, write.rs:109.
  3. The SELECT computes on the LAKE arm at a pin. So a Postgres-resident table that is unpublished — or published but not yet materialized by the tail — is not in scope, and the refusal is compute-failed even on a hybrid box where query answers over the same name. FROM SOURCE, write.rs:109. This is the single most common surprise on this tool, and §4.8 is the tool that exists to prevent it.

upsert is landing: lake only: one snapshot that equality-deletes every key this run's rows carry, on the table's own identifier fields, then appends their new images. It needs an existing keyed target and refuses target-keyless otherwise, and it is refused landing-option on pg in this release. FROM SOURCE, write.rs:109.

4.6 An admin publish, dry-run and then applied — FROM SOURCE

// Dry run — the DEFAULT. Previews the exact change and mutates nothing.
{ "tool": "lake_publish", "arguments": { "table": "public.orders" } }
// Applied.
{ "tool": "lake_publish", "arguments": { "table": "public.orders", "execute": true } }

FROM SOURCE — the served description: "lake_publish: enrol a Postgres table into the lake (sets the argon:publish marker; the full table baselines at the maintenance cadence, then follows). MUTATION — argondb-admin only. Dry-run by DEFAULT (previews the exact change and mutates nothing); pass execute=true to apply. Idempotent (re-publishing a published table is a no-op)." (crates/argondb-mcp/src/lib.rs:5616-5617).

The destructive sibling, and its second gate:

// Refused: purge is destructive and confirm is absent.
{ "tool": "lake_unpublish", "arguments": { "table": "public.orders",
                                           "purge": true, "execute": true } }

FROM SOURCE — "purge=true ALSO drops the Iceberg table + changelog and deletes their files — DESTRUCTIVE: requires confirm=true, and without it refuses while naming exactly what would be lost" (lib.rs:5629-5630). Non-purge unpublish keeps the lake files and history and is reversible (lib.rs:5629-5630).

Two admin tools are documented V1 cuts and refuse rather than pretend: maintenance_run with execute=true refuses because a maintenance pass is owned by the single maintenance owner and V1 exposes no trigger IPC — starting a second concurrent sweep is the thing being prevented (lib.rs:5655-5656); and retention_set with execute=true validates the value and then discloses the exact operator action (set ARGONDB_PIN_RETENTION_SECS on the maintenance owner and restart) rather than claim a live change it cannot make, because there is no runtime config reload (lib.rs:5668-5669).

And the escalation case that proves the tiering: apply_index is a mutation of the primary. It requires confirm=true; without it, it refuses while naming the exact CREATE INDEX that would run and that it locks and writes the primary. It logs full arguments and results, sits behind a cost circuit breaker, is idempotent via IF NOT EXISTS, and takes structured parameters — table, columns, unique, using, name — with no free SQL, every identifier validated and quoted (FROM SOURCE, lib.rs:5681-5682).

4.7 A DBA publish_status read — VERIFIED (schema) + FROM SOURCE (states)

publish_status answers the question "I published a table and it is not in the lake — why?", and it is mounted on both the analyst and dba surfaces from one body and one description file, so the two cannot drift (lib.rs:4648-4660).

{ "tool": "publish_status", "arguments": { "table": "orders", "include_gaps": true } }

The six states, and the three non-answers, verbatim from the served description: mirrored | awaiting-variant | awaiting-json-text | no-backfill | backfill-pending | not-publishable, "plus catalog-unreachable / state-unknown / unknown, which mean lake presence could NOT be determined and are never a claim that a table is missing" (FROM SOURCE, crates/argondb-mcp/src/publish-status.tool.txt).

The mechanism it exists to expose, verbatim: "Publishing a table marks it, but a column the native decoder cannot handle (jsonb, json, arrays, inet, interval, ranges, user-defined enums, composites, domains) keeps the whole table out of the lake — with no SQL error and no catalog entry, so a lake-routed query simply never finds it at any pin." A marker on something never captured at all — a view, a materialized view, a partitioned parent, a foreign table, an UNLOGGED or TEMPORARY table — shows as not-publishable. FROM SOURCE, same file.

Four further facts from that description that change how you call it:

FROM SOURCE, crates/argondb-mcp/src/publish-status.tool.txt. That the tool is on both rosters is VERIFIED by the surface-tools all output in §4.1 (argondb publish_status and argondb-dba publish_status).

4.8 transform_pin_serves — the readiness poll that removes the race — FROM SOURCE

{ "tool": "transform_pin_serves",
  "arguments": { "tables": ["orders", "derived__revenue_by_region"] } }
status: ready
pin: 0/15C6F08
tables-asked: 2
total-served: 37
serves: orders=yes
serves: derived__revenue_by_region=yes

FROM SOURCE — the answer's one-fact-per-line shape (status:, pin:, tables-asked:, total-served:, one serves: <name>=yes|no per name in the order asked, and a missing: list on not-ready) is crates/argondb-mcp/src/transform-pin-serves.tool.txt.

Read the rest of that description before you write a polling loop, because it names the exact anti-pattern: "THIS IS THE TOOL TO POLL after publishing a table and before calling run_transform. Do NOT use a query count for that: on a hybrid box query routes to the Postgres arm and answers the instant the rows commit, which says nothing about the lake arm a transform computes on — that mismatch is a measured race, not a theory (on v1.4.0 the starter's query probe returned in 0 s and the transform was refused for 61 s)". FROM SOURCE, same file; the bisect behind that sentence is pasted at docs/2026-09-08-mcp-run-transform-hybrid-watermark.md:41-72.

Two spelling rules in the same description: tables are lake spellings — a published orders is orders, and derived.revenue_by_region is derived__revenue_by_region — and a pin-layer spelling (<t>__base, <t>__delta) answers no, because a caller may not reference one.

4.9 What an agent cannot do from each mount — the refusals, verbatim

From argondb (analyst). It cannot write, and the refusal is Postgres's, not ours: SELECT INTO needs CREATE on the schema (revoked); nextval needs sequence USAGE (revoked); COPY FROM PROGRAM needs superuser or pg_execute_server_program (never granted); a volatile write function's inner write needs table write privileges (revoked) — so each fails 42501 insufficient_privilege (FROM SOURCE, docs/2026-08-23-mcp-analyst-isolation-design.md:61-66). It cannot send two statements in one call:

one statement per call — found N; send separate calls

FROM SOURCE — docs/2026-08-23-mcp-analyst-isolation-design.md:93-94, enforced at all three caller-SQL entry points in pg.rs. It cannot issue transaction control under a pin (§4.3). And it cannot reach the primary at all: the analyst server type structurally has no primary-DSN field (crates/argondb-mcp/src/dba.rs:36-39).

From argondb-dba. It cannot mutate: DbaBackend carries an observation DSN and a metrics endpoint and no writer handle, so zero mutation is a property of the type (dba.rs:114-120). It cannot send free SQL on the observation path at all — there is no such tool (dba.rs:48-52). Its explain_query is a mounted seam and says so:

"explain_query: return the query plan (EXPLAIN) for a statement — read-only, deliberately mounted on BOTH the argondb analyst and argondb-dba diagnostic surfaces (D16). On this diagnostic surface: NOT YET IMPLEMENTED — the analyst body shipped in A4; the dba-path plan (over the read-only observation role) ships in workplan A7. Use the argondb analyst surface's explain_query today."

FROM SOURCE — crates/argondb-mcp/src/dba.rs:734.

From argondb-write. It cannot read anything but its own readiness predicate. The roster test asserts the absence of health, top_queries, index_advice, query, sample, explain_query, execute_sql, lake_publish, lake_unpublish, retention_set and apply_index, by name (FROM SOURCE, crates/argondb-mcp/src/write.rs:245-259). It cannot see the primary's runtime internals — the mount's own served instructions say so: "This mount holds no observation handle and no analyst handle: it cannot read the primary's runtime stats, workload or health internals, and it serves no free-SQL read tool" (FROM SOURCE, write.rs:169-172). And it cannot spell a reserved namespace: for append_rows, target is a bare name matching [a-z][a-z0-9_]*, ≤ 63 bytes, with no _argon_ / br_ / dv_ prefix and no __ infix — "the server computes the namespace, so a br_ target is unspellable here, not merely refused" (FROM SOURCE, write.rs:139).

From argondb-admin. It cannot mutate by accident: execute defaults to false everywhere, and the two destructive tools need confirm=true on top (§4.6). It cannot trigger a maintenance pass or reload retention at runtime — both are documented V1 cuts that refuse and name why (§4.6). And it is not reachable from outside the host unless someone set its own listen variable (§2.3).

From a company token (on argondb). It cannot read another company. Its session's only connection is its own database as <key>_mcp, so a table in another company's database is refused by Postgres, never by a filter (§2.6). It cannot write, because the role holds SELECT and nothing else. It cannot call the two box-wide tools:

search is refused under company "alpha"'s MCP token: it reads box-wide state (every company's), not company "alpha"'s own database. Ask the box operator, whose token keeps operator scope.

FROM SOURCE — crates/argondb-mcp/src/lib.rs:4613-4625 (the same text with publish_status for that tool). And it cannot open any other mount, because a company token is accepted on the analyst listener only (lib.rs:6243-6249).

4.10 Minting a company token and connecting with it — FROM SOURCE

tenant create has already left the first token on disk, so there is nothing to mint for a new company:

$ argondb tenant create alpha
…
[argondb] tenant create: MCP token for alpha (reads alpha only, as alpha_mcp) written to <data>/tenants/alpha/mcp-token

FROM SOURCE — crates/argondb-ctl/src/tenant_create.rs:2950-2975. If the mint or the write fails, the tenant is not undone: the line says the MCP token was NOT minted (or minted but NOT written) and names the re-issue command as the remedy (same lines).

Re-issuing prints the token on stdout and the notice on stderr, so the token can be captured cleanly:

$ ALPHA_MCP_TOKEN=$(argondb tenant mcp-token alpha)
[argondb] tenant mcp-token: issued for alpha; every earlier token of alpha is revoked

$ argondb tenant mcp-token alpha --revoke
[argondb] tenant mcp-token: every MCP token of alpha is REVOKED (record removed, alpha_mcp NOLOGIN)

FROM SOURCE — crates/argondb-ctl/src/main.rs:4030-4051. Revoking does two things: it removes the record the MCP checks, and it takes LOGIN away from alpha_mcp as a second barrier (revoke_sql, crates/argondb-ctl/src/mcp_company_token.rs:87-98). Both commands take --data-dir and --port when the box is not at its defaults (main.rs:677-688).

Connecting is the analyst mount's ordinary HTTP add, with the company's token as the bearer instead of the box's:

claude mcp add --transport http argondb-alpha http://localhost:8018 \
  --header "Authorization: Bearer $ALPHA_MCP_TOKEN"

The --header flag is the Claude Code client's. Any MCP client that sends Authorization: Bearer <token> works, because that header is all the listener reads (lib.rs:6478-6483). Every response then carries x-argondb-mcp-scope: company:alpha. A request with the box token carries x-argondb-mcp-scope: operator (lib.rs:6490-6537). Read that header in a client that holds more than one credential: it is the server's own statement of which wall the answer came from. The port is the analyst port the box's MCP child was given (ARGONDB_MCP_PORT, crates/argondb-ctl/src/serve.rs:8712-8722). 8018 is the standalone default from §4.1.


5. Built today versus designed

"Built" means merged to main and gated. Each row names the lane or design section that proves it.

Capability State Proof
The four MCP mounts, one bearer token / port / bind default / role each built crates/argondb-mcp/src/lib.rs:2043-2155; roster enumerated from the routers, lib.rs:2185-2213; surface-tools output in §4.1
argondb-write as a distinct mount (D16's fourth tier) built ratified [D-2026-09-08-69a7b2]; crates/argondb-mcp/src/write.rs:1-46; DESIGN.md:314
Analyst read-only by construction (role boundary + single-statement lexer + fail-closed capability check) built docs/2026-08-23-mcp-analyst-isolation-design.md belts 1, 2, 2b, 3
Pins as stateless signed value tokens; time travel built crates/argondb-mcp/src/token.rs:1-8; DESIGN.md D12
The five pin mint-time disclosures built lib.rs:5288-5336 (scope, behind, transient, served-older, frozen-baseline)
Token-budget contract (render → measure → trim) built crates/argondb-mcp/src/budget.rs:20-82
changes() net-effect digests with gap disclosure built crates/argondb-mcp/src/delta.rs:700-741
describe() / profile() from stored statistics with per-value provenance built DESIGN.md:305; crates/argondb-mcp/src/profile.rs:53-78
One merged schema_overview across both planes built ratified [D-2026-09-11-cbe1c2]; DESIGN.md:301; crates/argondb-mcp/src/merged.rs
run_transform, both landings, exactly-once, five modes built crates/argondb-mcp/src/write.rs:108-121; docs/2026-09-04-erp-spec.md:3351 (shipped in v1.3.0)
append_rows (the direct-append door onto dv_*) built crates/argondb-mcp/src/write.rs:138-146; roster assertion write.rs:222-225
create_view (continuous materialized views) built on this base crates/argondb-mcp/src/write.rs:148-156; roster assertion write.rs:239-243. Absent from the 2026-09-18 binary used for §4.1
transform_pin_serves on both write and dba mounts built write.rs:129-136, dba.rs:704-706; description shared byte-for-byte, gated write.rs:270-283
Compat family (execute_sql, explain_query) on the analyst path built lib.rs:5560-5584; testing/mcp/compat-family.sh
explain_query on the dba path designed (mounted seam) dba.rs:734 — "NOT YET IMPLEMENTED… ships in workplan A7"
maintenance_run execute=true designed, documented V1 cut lib.rs:5655-5656 — no trigger IPC in V1
retention_set execute=true as a live change designed, documented V1 cut lib.rs:5668-5669 — no runtime config reload
The accelerator whitelist (executor-only in V1) built crates/argondb-mcp/src/accel.rs:10-37; DESIGN.md:303 ruling (5)
Hybrid Postgres arm (agent queries over pages at a pinned position) built; ARGONDB_ANALYST_POOL=off answers every read from the lake docs/2026-08-29-mcp-hybrid-lake-only-routing.md (the demo box's current setting is not recorded in READ-THIS-FIRST.md any longer)
D19 HTAP offload on the ordinary Postgres connection shell built; isolation trigger only in V1 DESIGN.md:333 — "the speed trigger is pathless by ruling (logs, never executes) because the accelerator is MCP-tool-only in V1"
G7 isolation on the agent path (max_standby_streaming_delay = -1, hot_standby_feedback off) built, policy not knob DESIGN.md:299, DESIGN.md:436
Per-company MCP token on the analyst mount (minted at tenant create, re-issue, revoke, verified on every request, read as <key>_mcp on the company's own database) built (v1.5.6) crates/argondb-mcp/src/company_token.rs; crates/argondb-ctl/src/mcp_company_token.rs; CompanyScopes, crates/argondb-mcp/src/lib.rs:6472-6561; gated S4+ / S4- of testing/box/cross-company-isolation-eight-surfaces.sh
Company-scoped search / publish_status not built; both refuse by name under a company token lib.rs:4613-4625
Role scoping on the lake arm (intra-company) not built docs/2026-09-04-erp-spec.md:830-834; HISTORY.md#per-company-mcp-token (Block 6's lake half, ratified and unbuilt)
Cross-company isolation test across SQL, MCP and lake built testing/box/cross-company-isolation-eight-surfaces.sh (MCP is surface S4, at token level)

6. Gotchas

A hybrid mount is not a question about the lake. query on a hybrid box routes to Postgres, which answers the instant a row commits; the lake arm a transform computes on may be a minute behind. A watermark probe written as a query count therefore clears at t=0 and proves nothing. Remedy: poll transform_pin_serves, which takes the same pin and reads the same census run_transform's compute-failed refusal prints, so the two cannot disagree (crates/argondb-mcp/src/transform-pin-serves.tool.txt; docs/2026-09-08-mcp-run-transform-hybrid-watermark.md:21-38).

Three tables exist only in the lake. _argon_timeline, _argon_gaps and _argon_value_notes are created only in Iceberg by the committer and are never projected into Postgres, so on a hybrid box a naive query over them used to answer relation "…" does not exist. Remedy: nothing to do — the routing rule now sends a whole-statement lake-only scope to the lake arm. But a mixed scope (one of those tables joined to a user table) stays on Postgres and fails by name, deliberately (docs/2026-08-29-mcp-hybrid-lake-only-routing.md:13-19 and :44-47).

Lake names are not Postgres names. publish_status takes the Postgres spelling (sales.orders); transform_pin_serves and a transform's SELECT take the lake spelling (derived__revenue_by_region). Getting this backwards produces a confident wrong answer in one direction and a no in the other (publish-status.tool.txt; transform-pin-serves.tool.txt). The three-level identity is database + schema + table, and the flat <db>__<schema>__<table> key is not injective on the database coordinate — which is why the merged map joins on the triple (DESIGN.md:301).

A published table can silently never arrive. A column the native decoder cannot handle — jsonb, json, arrays, inet, interval, ranges, user-defined enums, composites, domains — keeps the whole table out of the lake, with no SQL error and no catalog entry. Remedy: publish_status before you rely on a table, and read awaiting-variant / awaiting-json-text / not-publishable as distinct states (publish-status.tool.txt).

Zero is not a measurement. publish_status's gap count is only a fact when gaps-reader reports ok; an unread lake catalog reports UNKNOWN, never zero. Same class as catalog-unreachable / state-unknown, which mean lake presence could not be determined and are never a claim that a table is missing (publish-status.tool.txt).

state-unknown is the only status you must not blind-retry. Every run_transform refusal kind is raised before the landing transaction opens, so refused provably did not land. state-unknown may have committed: re-run with the same token, which is what the token is for (crates/argondb-mcp/src/write.rs:109). Same rule for append_rows (write.rs:139).

staged is durable and invisible. append_rows returning staged means the parquet is in object storage and no reader will see the rows until the next cadence tick. Pass wait_for_commit: true if you need committed with its snapshot id before you return to your caller (write.rs:139).

A dv_* table is not servable at your pin. Lake-owned tables carry provenance, never a WAL watermark; they are read at their current snapshot, and the run's pin is recorded as provenance only (write.rs:109; DESIGN.md:303 §12.20 amendment).

drop on landing: lake is not idempotent. Dropping the table destroys the token history, so a repeated drop answers target-missing where pg answers already-landed (write.rs:109).

A keyless create is refused, not guessed. Nothing invents a key from the output columns. Either declare key, or pass a SELECT that passes one source table's key straight through, or say no_key: true — which lands it keyless and prints it. Without a key an Iceberg table cannot take equality deletes or row-level updates cleanly (write.rs:109).

On landing: lake a key is a declaration, not a constraint. Iceberg has no unique index. On landing: pg Postgres enforces it and a duplicate is a duplicate-key refusal (write.rs:109).

Dry-run is the default, and execute=true is not always enough. The two destructive admin tools need confirm=true as well, and two admin tools refuse execute=true outright as documented V1 cuts (§4.6).

8021 is a trap. The write mount's default port is 8017, not the family-consistent 8021, because the per-slot fleet port map gives ARGONDB_MCP_PORT the band 8020+N — a default-port write mount beside a slot-1 analyst mount would bind 8021 twice. This already bit the dba-serve gate hook (lib.rs:2133-2147).

Exposing the analyst mount does not expose admin or write — and that is load-bearing. ARGONDB_LISTEN_ADDRESS=0.0.0.0 moves analyst and dba; admin and write stay on loopback unless their own variables say otherwise (lib.rs:6005-6020). Conversely, a non-loopback bind without that mount's token refuses the whole process at startup, so the failure is loud (lib.rs:5971-5986).

A blank ARGONDB_MCP_ALLOWED_HOSTS is not "no restriction". rmcp reads an empty allowlist as allow-ALL, so a fumbled value falls back to the loopback set and there is no disable sentinel. A non-empty value replaces the default outright — state the full list, including any loopback entry you still want (lib.rs:5787-5812).

budget_tokens never refuses; it trims and says so. If you need a hard refusal on size, the row caps are what give you one (§3.5). And a budget cannot buy correctness: a per-cell clip at 160 characters has a footer, and read() is the unclipped escape (shaping.rs:35; lib.rs:4974).

Two unpinned reads may disagree, legitimately. The lake arm gives you a fresh snapshot at the latest servable LSN; the Postgres arm gives you its current replay position. Pin them if that matters — the disclosure is there on every answer to let you notice (lib.rs:4974).

Intra-company role restrictions do not reach the lake arm. RLS filters the Postgres arm; the lake arm holds the same company's rows with no policy layer over them, so an analyst tool or a transform can see rows the same person could not read through SQL. The failure mode is over-permissive access within one company, not a breach — and there is no remedy in the product today; the disposition is recorded as a residual gap (docs/2026-09-04-erp-spec.md:830-834).

A company token only opens the analyst mount. The dba, write and admin mounts each compare against their one operator token, so an amc1. bearer there is a plain 401 … Bearer token required (lib.rs:6243-6249).

Re-issuing a company token revokes the old one at the next request. The listener reads the auth store on every call, so a client still holding the previous token gets 401 with …'s token was revoked by a re-issue at once. Re-issue only when you can hand the new token to every client of that company (company_token.rs:265-270).

search and publish_status refuse under a company token. Both read box-wide state. A company session gets no text search at all, and a publication check is the operator's (lib.rs:4613-4625).

The box token does not read a company's lake rows on the shipped shape. With catalog_authz = openfga and the analyst pool off, a box-token lake read of a company table answered pin scope names unknown lake table(s) when the per-company token landed. The box token keeps operator scope as a credential, but its lake session does not list company tables. This was recorded at merge and not fixed (HISTORY.md#per-company-mcp-token, "FOUND ON THE WAY"). Company tokens are unaffected, because their reads never touch the lake.


7. Sub-diagrams

Four deeper pictures of parts the main diagram compresses. Each is generated by a script beside the others in docs/diagrams/argondb-and-the-erp/generate/, in the same palette and with the same legend; every label is a claim sourced above.

7.1 The four mounts, their rosters and their refusals

The four mounts and their refusals

Generator: docs/diagrams/argondb-and-the-erp/generate/d07a_mounts.py. The top band is the coordinate table of §2 — token, port, bind default, backing role and exact roster per mount. The red band beneath it is the part the main diagram had no room for: what each mount refuses, because the refusal is the mount's definition. The bottom frame carries the four checks that apply across all four — startup fail-closed, per-request bearer, the host allowlist, and the fact that every roster is enumerated from its router rather than typed.

7.2 A multi-step agent session under one pin

A session under one pin

Generator: docs/diagrams/argondb-and-the-erp/generate/d07b_session.py. Left to right: the mint and its five disclosures, then the burst — the same pin_id on schema_overview, profile, query, read and finally changes(since=…) — then the five ways a pin is refused by name. The point the picture makes that prose buries: step 5 is only meaningful because steps 1–4 were at a coordinate the agent can name.

7.3 The budget contract

The budget contract

Generator: docs/diagrams/argondb-and-the-erp/generate/d07c_budget.py. The five-stage pipeline (render → shape → estimate → trim → answer), what a budget buys (depth, with the omission always named), and what it does not buy — the row caps, which refuse instead of trimming, and the stats rule, which avoids the scan rather than shortening it.

7.4 The Postgres arm and the lake arm

The Postgres arm and the lake arm

Generator: docs/diagrams/argondb-and-the-erp/generate/d07d_hybrid.py. Side by side: what each arm can see, what each guarantees about a coordinate, the routing rule in the order it is asked, and the two measured incidents that came from forgetting which arm answered — the five widget failures and the watermark that measured the wrong engine.


8. References

Code, on the base commit c9415ae0a. The per-company token citations (§2.6, the company path in §3.1, §4.9, §4.10, and the v1.5.6 rows of §5 and §6) are at e90da8c0c:

Design and record:

Diagrams:


Guide 8 — building an application cloud on ArgonDBCloud: the ERP's configuration system and ERPCloud

One of the Developer Guide's deep dives, written against main on 2026-10-04, while ArgonDBCloud is between its model half (landed) and its apply half (not started). The audience is the senior Splink engineer who has to build the ERP's configuration system on top of ArgonDBCloud, and to give an ERP customer an "ERPCloud" experience: one set of questions, one estimate, one install, in the customer's own AWS or Azure account.

What this document is. Internal technical register [D-2026-08-11-a10032]: exact about what is implemented, what is designed, and which is which. Every factual claim cites its source by path:line. Every worked example is labelled either VERIFIED (run, with its real output pasted) or FROM SOURCE (the exact signature, flag, table or text quoted from the code or the design). Where a thing is not on main it is called DESIGNED (written down, no code) or IN FLIGHT (a lane is building it now, named). The proposals this guide makes for the ERP side are marked PROPOSED and are AGENT-DERIVED; none is a ruling.


1. What ArgonDBCloud is, and the three shapes

ArgonDBCloud is an ArgonDB installation and configuration application for AWS and Azure. It takes a user's needs, prices every way of running ArgonDB for those needs, recommends one, writes a plan, and builds that plan in the user's own cloud account (docs/2026-10-01-argondbcloud-design.md:70-127). It never spends money without showing the bill first: the figures are a model built from list prices and ArgonDB's measured resource profiles, never from running the workload, and it is not a hosted service — everything it creates lives in the user's account under the user's credentials (docs/2026-10-01-argondbcloud-design.md:109-117). It ships both as a stand-alone tool and as a component an ArgonDB application uses to build the same tool for itself, and Splink's ERP is the named case (docs/2026-10-01-argondbcloud-design.md:19-31).

The journey is seven steps, each one screen in the TUI and one call in the library: needs in, alternatives with dollars, one recommended, choose, plan, apply, done (docs/2026-10-01-argondbcloud-design.md:79-107).

1.1 The three shapes

Whatever the shape, a box runs the same process table — broker, pageserver, safekeeper, the PostgreSQL compute(s), the catalog, Lakekeeper, the tail, the maintenance worker, the MCP surfaces and the optional pooler. The shapes differ in WHERE those processes run and WHAT they store to (docs/2026-10-01-argondbcloud-design.md:130-145). The comparison table is docs/2026-10-01-argondbcloud-design.md:147-157:

(a) one VM, local disk (b) one VM + object storage + managed disks (c) per-tenant compute across instances
exists today the one-click VM templates knobs on the box, no cloud wiring designed; the one-box form is built
tenant compute on the VM on the VM one EC2 instance / Azure VM per tenant
data survives the VM dying no (unless snapshots) yes (layers, WAL, lake in the bucket) yes
what idle-stop does nothing for the bill nothing for the bill stops the tenant's INSTANCE

1.2 Why ERPCloud is shape (c)

An ERP customer is a Tenancy of many companies, and each company is a tenant. Shape (c) is the only shape where each company's work runs on its own instance — its compute, and the ERP sidecar beside it, as ruled [D-2026-09-21-7c4769] (docs/2026-09-04-erp-spec.md:2607-2615) — and the only shape where idle-stop saves money: in (c) stand-down is StopInstances / VM deallocate and wake is StartInstances / VM start (docs/2026-10-01-argondbcloud-design.md:335-352). The connect-time waker cannot live on a stopped instance, so it lives on the estate instances and holds the client's first bytes while the tenant's instance starts — the same gate crates/argondb-ctl/src/idle_stop.rs runs on one box today (handle_conn, crates/argondb-ctl/src/idle_stop.rs:822), with a longer deadline than the box's 180 s (crates/argondb-ctl/src/idle_stop.rs:107). On one box the wake is measured at 619 ms p50 (docs/2026-09-20-guide-02-the-per-tenant-unit-gate-wake-and-stand-down.md:776); in the cloud it is instance boot time, measured 13 s for the instance alone by K12's live gate (four wakes of an m7i.large, 13.1 s each; testing/cloud/results/tenant-instance-wake.json), with the tenant's compute start on top once C2 puts the compute there (docs/2026-10-01-argondbcloud-design.md:340-352). Idle-stop in (c) is the user's opt-in, default always-on [D-2026-10-03-8590b8] (docs/2026-10-01-argondbcloud-design.md:1173-1188), and the screen says the measured figure, read from that file, beside the saving (docs/2026-10-01-argondbcloud-design.md:353-354).

(a) and (b) are the small-customer forms. A customer with a handful of companies runs all of them on one VM; (b) adds durability by putting the data in a bucket. Per-tenant instances carry a floor cost per tenant that (a) and (b) do not, which is why the recommendation rarely picks (c) under a few tenants (docs/2026-10-01-argondbcloud-design.md:380-393). The ruled recommendation rule is "cheapest that fits": the cheapest shape satisfying every required switch with a year of growth, ties within 5 % to the more durable shape [D-2026-10-03-45765f] (docs/2026-10-01-argondbcloud-design.md:1105-1110).

1.3 Which shape is offered when

The advisor OFFERS a shape for install only when its test matrix is green on that cloud. Until then the alternative is still shown, with its dollars, marked NOT YET INSTALLABLE with the job that unlocks it (docs/2026-10-01-argondbcloud-design.md:417-426); 1.0 may ship that way [D-2026-10-03-b6089e] (docs/2026-10-01-argondbcloud-design.md:1161-1172). On main today no shape is installable, and each alternative names its job (crates/argondbcloud/src/lib.rs:67-72). (c) is AWS first, with Azure priced but not installable until ArgonDBCloud 1.1 [D-2026-10-03-7a4b8f] (docs/2026-10-01-argondbcloud-design.md:1130-1137).


2. The contract you build against

2.1 Four calls, seven documents

ArgonDBCloud is library first: crates/argondbcloud has no terminal in it, and every front end — the stand-alone CLI/TUI, the later web application, Splink's ERP installer — is a thin caller of the same functions over the same versioned JSON documents (docs/2026-10-01-argondbcloud-design.md:769-786):

needs/v1      ──▶  advise()   ──▶  alternatives/v1   (every shape priced; one recommended)
choice/v1     ──▶  plan()     ──▶  plan/v1           (saved, reviewable; nothing created)
plan/v1       ──▶  apply()    ──▶  apply-result/v1   (idempotent, resumable; state/v1 written)
state/v1      ──▶  teardown() ──▶  teardown-result/v1

So the life of one install is: needs → advise → alternatives → choice → plan → apply → state → teardown. Every document carries "schema": "<name>/v<n>" as its first field; a reader refuses a major version it does not know and accepts unknown fields in a known version (docs/2026-10-01-argondbcloud-design.md:789-793).

The schemas ship as JSON Schema files in the crate, each with an example (crates/argondbcloud/src/schema.rs:11-47):

document schema file what it carries source
needs/v1 crates/argondbcloud/schemas/needs.v1.schema.json usage and switches; only cloud and region required; overlay is an object or null docs/2026-10-01-argondbcloud-design.md:795-807
alternatives/v1 alternatives.v1.schema.json every shape priced as lines, monthly and yearly totals, assumptions, sensitivity, limits, the recommendation docs/2026-10-01-argondbcloud-design.md:809-824
choice/v1 choice.v1.schema.json the chosen shape, overrides, the install name, pinned to the alternatives' hash docs/2026-10-01-argondbcloud-design.md:826
plan/v1 plan.v1.schema.json the ordered resource list with cost lines, client tokens and dependencies; the ArgonDB version and artifact hash docs/2026-10-01-argondbcloud-design.md:828-832
apply-result/v1 apply-result.v1.schema.json per resource created / exists / failed, outputs, resumable docs/2026-10-01-argondbcloud-design.md:834-836
state/v1 state.v1.schema.json the install's record: resources, ids, outputs, version docs/2026-10-01-argondbcloud-design.md:708-719
overlay/v1 overlay.v1.schema.json an application's questions, resources, permissions, install lines (§3 below) docs/2026-10-01-argondbcloud-design.md:840-874

The example documents are in crates/argondbcloud/schemas/examples/, one per schema; read them before writing a document of your own.

2.2 What the crate exposes today — FROM SOURCE

The crate's own header states the split: K1 shipped the documents, the inference rules and the four entry points; K2 the prices; K3 the demand rule, the planners and the recommendation; K9 the overlay; "the SDK apply engine (K4) arrives in its own job" (crates/argondbcloud/src/lib.rs:1-6). The entry points on main:

// crates/argondbcloud/src/lib.rs:40
pub fn price_book(needs: &Needs, svc: &PriceService, opts: &PriceOptions) -> Result<PriceBook>
// crates/argondbcloud/src/lib.rs:72
pub fn advise(needs: &Needs, book: &PriceBook) -> Result<Value>
// crates/argondbcloud/src/lib.rs:161
pub fn advise_document(needs_doc: &Value, book: &PriceBook) -> Result<Value>
// crates/argondbcloud/src/lib.rs:167
pub fn plan(choice: &Value) -> Result<Value>
// crates/argondbcloud/src/lib.rs:177
pub fn apply(plan: &Value) -> Result<Value>
// crates/argondbcloud/src/lib.rs:186
pub fn teardown(state: &Value) -> Result<Value>

advise is BUILT. It prices shapes (a), (b) and (c) for the needs' cloud and region, merges needs.overlay into every alternative when one is given, picks the recommendation, and returns alternatives/v1 with the resolved needs, the price source and date, the demand figures, every alternative, the reason and, with an overlay, an overlay block (crates/argondbcloud/src/lib.rs:72-158).

plan, apply and teardown are signatures only in the library. Each validates its input document against its schema and then returns a typed error. Since K4a (2026-10-21) plan, apply and teardown are the shape (a) engine of crates/argondbcloud/src/apply/: plan orders the resources and mints the tokens, apply and teardown run against the provider ARGONDBCLOUD_PROVIDER names — recorded (a tape under crates/argondbcloud/tests/fixtures/k4/) or live (the AWS client behind the live-aws cargo feature, not yet run against an account: that is K4b). With no provider named they create and remove nothing and the error says how to name one. Shapes (b) and (c) return NotBuildable naming K8 and K13 (§2.3).

2.3 Two ways to integrate

  1. Link the Rust crate crates/argondbcloud: call price_book, advise, plan, and — with your own apply::Provider, or the environment's — apply / teardown (apply::Engine takes the provider directly). This is the path for an installer written in Rust.
  2. Drive the CLI crates/argondbcloud-cli from any language, reading and writing the same documents. The design gives every verb --json: argondbcloud advise writes alternatives, argondbcloud plan reads a choice and writes a plan, argondbcloud apply <plan>, argondbcloud teardown <install-id> (docs/2026-10-01-argondbcloud-design.md:875-887).

On main, the CLI has thirteen verbs plus -V (crates/argondbcloud-cli/src/main.rs, the clap table), K10's TUI included, all with --json. FROM SOURCE (2026-10-24, the ERP's findings 2, 3, 5, 7, 8, 10 and 12) — and CHECKED: crates/argondbcloud-cli/src/main.rs tests::guide_8s_verb_list_is_the_verb_table fails when a verb or a long flag the table ships is missing below, or a line below names a verb the CLI does not ship:

argondbcloud advise needs.json --json [--overlay overlay.json] [--offline] [--price-cache <dir>] [--refresh-prices] [--argondb-version <v>]   # alternatives/v1; --argondb-version (1.0.1): the ArgonDB to install, one of `argondbcloud supported`, written into the document for `plan`; outside the matrix: refused by name, exit 2, no document
argondbcloud tui [--overlay overlay.json] [--name erp-prod] [--json] [--offline] [--price-cache <dir>] [--refresh-prices]   # the nine screens, answers on stdin; --json prints the alternatives/v1 the session ended on
argondbcloud plan needs.json --shape a [--name erp-prod] [--overlay overlay.json] [--offline] [--price-cache <dir>] [--refresh-prices] [--argondb-version <v>] --json   # plan/v1: shape (a) ordered, tokens minted, saved under ~/.argondbcloud/installs/<id>/; --argondb-version (1.0.1): pins that matrix row (version, url, sha256) into the plan and, through the apply, state/v1; outside the matrix: refused by name, exit 2, no document; absent: the default, the plan byte-identical to 1.0.0's
argondbcloud plan --choice choice.json --alternatives alternatives.json --json   # plan/v1 from a choice/v1 (its alternatives_id is the sha256 of the alternatives file)
argondbcloud plan --alternatives alternatives.json --shape a --name k4-happy --token-seed <hex> --json   # plan/v1 whose client tokens are minted FROM THE SEED: same alternatives + same seed = same tokens, so one tape replays against every such plan
argondbcloud plan needs.json --shape b [--override noncurrent_days=1] --json   # plan/v1, shape (b): the bucket, the role + instance profile, the S3 gateway endpoint, the VM (user data boots ArgonDB against the bucket), the cache disk, the address, box-ready
argondbcloud plan needs.json --shape b --restore-from <install-id> --name <new> --json   # plan/v1: a NEW VM from an existing install's bucket, the durable half ADOPTED by id
argondbcloud plan needs.json --shape b [--replay <tape>] [--record <tape>] [--override quota=skip] --json   # 1.0.3 (P7): under ARGONDBCLOUD_PROVIDER=live (or a --replay tape carrying `quota` calls) the account's SERVICE QUOTAS are read before pricing is printed — EC2 L-1216C47A (vCPU), L-0263D0A3 (Elastic IPs), Route 53 L-4EA4796A / L-FF06D527 (zones), EBS L-7A658B76 (gp3 TiB), S3 L-DC2B2D3D (buckets) and the region's in-use counts — one `quota: <service> <name> (<code>) = <applied> in use <n> plan asks <m>` line per read into plan.md and stdout; a plan past the headroom is REFUSED BY NAME (exit 2, no document, `refused: the account's quota <code> … raise it at … aws service-quotas request-service-quota-increase …`); --override quota=skip prints OVERRIDDEN instead; a read the role cannot make is `NOT READ` and the plan continues; --record writes the reads to a tape; no provider named: one `quota: NOT READ (no provider named; …)` line on stderr and the document unchanged
argondbcloud apply plan.json [--answers needs.json] --json   # apply-result/v1 through ARGONDBCLOUD_PROVIDER (recorded | live); state.json after every step; --answers carries the overlay's SECRET answers into the user data only
argondbcloud apply --resume <install-id> --json   # apply-result/v1: the same code path on the saved plan; a TORN-DOWN install is refused by name
argondbcloud apply plan.json --replay <tape.json> [--record <tape.json>] --json   # apply-result/v1 through the recorded provider replaying the tape (a plan with other tokens is refused by name); --record writes the k4-tape/v1 of what ran (under live: the live run)
argondbcloud teardown <install-id|state.json> --confirm <install-id> [--keep-data] [--leave-foreign] [--answers needs.json] [--replay <tape.json>] [--record <tape.json>] --json   # teardown-result/v1: exactly the created records, reverse order; --answers carries the overlay's SECRET answers a teardown needs (a Cloudflare tunnel's API token)
argondbcloud census <install-id> [--cloud aws|azure] [--answers needs.json] [--replay <tape.json>] [--record <tape.json>] --json   # the provider's listing under the install tag; ZERO after a teardown; --answers lets it list a tunnel through Cloudflare's API (without it the tunnel kind reads `unread`)
argondbcloud validate any-document.json --json   # {"document","valid"}, or the refusal (plan, apply-result, state, teardown-result included)
argondbcloud requests <install-id> [--since <RFC 3339 UTC>] [--written-bytes N] --json   # requests/v1: the model's PUTs for the window vs the bucket's own counters, the ratio
argondbcloud destroy <install-id> --confirm <install-id> --json   # {"install_id","destroyed"}: the VM terminated OUTSIDE the engine (the restore proof's zone loss)
argondbcloud upgrade --state <state.json|install-id> --to <ArgonDB version> [--dry-run] [--yes] [--replay <tape.json>] [--record <tape.json>] --json   # O1: upgrade-plan/v1 (the DRY RUN, the default: the install, both versions and shas, the seven steps with the one non-reversible step named, the labeled downtime estimate, the live band's line; no call) or, under --yes, upgrade-result/v1 (the swap on the VM through its command channel, recorded in state/v1's `upgrades`); refuses by name a version outside the matrix, one not newer than the estate's, an install not created
argondbcloud backup --state <install-id|state.json> [--label <text>] [--replay <tape.json>] [--record <tape.json>] --json   # backup/v1 (O2): a NAMED RESTORE POINT of a shape (b) install without copying data — the bucket's current version set under <prefix>/argondbcloud/ plus one on-demand snapshot of the data volume (tagged with the install, the point and the label; outside the daily policy) — written into the install's state/v1 `restore_points`; `census` lists the points and marks one older than the bucket's non-current window EXPIRED
argondbcloud restore --from-state <install-id|state.json> --point <id> --name <new> [--dry-run] [--argondb-version <v>] [--token-seed <hex>] [--replay <tape.json>] [--record <tape.json>] --json   # restore-plan/v1 (O2): a NEW install from the point — its own bucket (the point's view of the source prefix copied server-side at the point's version ids), role, VM, data volume FROM the point's snapshot, address; the source is read and never modified; --dry-run prints the plan, the priced lines, the copy and the labeled downtime estimate and creates nothing; refused by name: an expired point, a cross-version point (unless --argondb-version names the point's version and the matrix carries it), an existing install name
argondbcloud restore --from-state <install-id> --at <RFC 3339 UTC> --name <new>   # the point-in-time-by-timestamp form: in the grammar, REFUSED by name — the WAL backup's path through the box's own restore-to-timestamp, which the served ArgonDB does not carry
argondbcloud supported [--json]   # the ArgonDB versions this ArgonDBCloud installs (design §7's compatibility matrix), one per line NEWEST FIRST, the first the default; --json: supported/v1 with the default and `artifacts`, the matrix as one table (version, url, sha256 per row) (A7: the cut stamps the first line into PROVENANCE.txt and the channel's LATEST as supported_argondb)
argondbcloud -V   # "ArgonDBCloud <version> (<build sha>)" — the sha from the build's own ARGONDB_BUILD_SHA, "(unknown)" when the build set none
argondbcloud -V --json   # {"version","build_sha"}

Which to pick (PROPOSED). If the ERP installer is Rust, link the crate: the documents become typed values and a schema change breaks your build rather than your customer's install. If it is not, drive the CLI with --json and treat the JSON Schema files as the interface; pin the ArgonDBCloud version you test against (§4.4).


3. The overlay — the integration point

3.1 What an overlay is

Splink's ERP needs more than ArgonDB. The design names an application server per tenant (the sidecar), an object-storage prefix for documents, a queue, an email relay and a web front end (docs/2026-10-01-argondbcloud-design.md:842-844). An overlay is an overlay/v1 document the application passes inside needs.overlay that adds to EVERY alternative its own resources, with its own demand rule and price SKUs, and adds its own questions to the TUI screens. The library prices the overlay's lines through the same price clients and merges them into each alternative, and the plan creates them with ArgonDB's resources — in the same install, or a second linked one when the application wants its own lifecycle — so ONE combined estimate and ONE install come out (docs/2026-10-01-argondbcloud-design.md:844-852). What main builds today (the apply arm, 2026-10-24; the ingress kinds, P4, 2026-10-27): the plan creates the resources whose type the engine's ONE list names — s3-bucket, dns-record, tls-certificate and tunnel on AWS, blob-container and tunnel on Azure (crates/argondbcloud/src/apply/resources.rs, OVERLAY_BUILDABLE) — in the SAME install, after ArgonDB's own resources; every other type is a not-buildable row of the plan, which the application provisions itself (a dns-record or tls-certificate on Azure names A8/1.1). The second linked install is not built.

The four parts, from the schema (crates/argondbcloud/schemas/overlay.v1.schema.json:5-50), plus the overlay's optional version (:9):

The overlay's own version travels in the plan, so a later ArgonDBCloud can tell what it installed (docs/2026-10-01-argondbcloud-design.md:870-871). On main that is the optional version string: absent, every older overlay still validates; present, it is carried into the overlay block of alternatives/v1, into plan/v1 beside needs_resolved as overlay_version (crates/argondbcloud/src/apply/mod.rs:352), and into state/v1, and a plan under an applied install whose overlay version changed is refused.

3.2 What advise does with an overlay on main — FROM SOURCE

K9 landed on 2026-10-04 (§6), so an overlay is now validated, priced and merged. Its rules, from crates/argondbcloud/src/overlay.rs:

K9 ships its own first overlay, crates/argondbcloud/examples/splink-erp-overlay.json — the design page's example (the sidecar and a documents bucket). The worked overlay below extends it.

Where the resource list comes from, and where it does not. Only one ERP resource is RULED and BUILT: the ERP sidecar, one OS process per company under its own uid and cgroup on the tenant's instance [D-2026-09-21-7c4769] (docs/2026-09-04-erp-spec.md:3022-3044), started from the command recorded in the estate directory by argondb tenant create --sidecar-command (crates/argondb-ctl/src/main.rs:662-672). A browser reaches a company's application through its sidecar port, one more spliced connection through the same gate (docs/2026-09-04-erp-spec.md:2433-2437). The documents store, the queue and the email relay are named in the ArgonDBCloud design (docs/2026-10-01-argondbcloud-design.md:842-844), but neither the ERP spec nor Guide 5 specifies them: Guide 5's provisioning section says provisioning creates no bucket or prefix of the ERP's own (docs/2026-09-20-guide-05-the-erp-application-on-argondb.md:238-266). So the overlay below carries the sidecar from source, and carries the other four as PLACEHOLDERS for Splink to replace (§7 asks for them). The sidecar's demand (0.5 vCPU, 1 GiB) is the design's own example figure, not a measurement (docs/2026-10-01-argondbcloud-design.md:858-859). It follows K9's grammar (§3.2): documents_gb is per company, so the documents resource is per: tenant with that key as its quantity; the queue, relay and web provider_type names are illustrative, and the advisor has no SKU for them. Since 2026-10-27 (plan row P3) the block carries the ERP's FOUR HAND STEPS as run-after-install resources — the reference data, the ERP's own schema migrations, the Splink estate tenant and the first Administrator — each a program the ERP's installer ships, run on the estate VM after box-ready with the estate's DSN; §3.3.1 below is the kind's grammar. The block IS crates/argondbcloud/examples/splink-erp-post-install-overlay.json, byte for byte (a test plans it on shape (b) and asserts the four steps and the password's path). The queue, relay and web placeholders of the 2026-10-04 block were dropped from it: nothing here prices them, and the ERP's own resource list (§7) replaces placeholders, not this guide.

{
  "schema": "overlay/v1",
  "name": "splink-erp",
  "version": "2026.10.29",
  "questions": [
    {
      "key": "documents_gb",
      "text": "How many GB of scanned documents does each company keep?",
      "unit": "GB",
      "infer": "5",
      "basis": "5 GB of documents per company (PLACEHOLDER: Splink's number)"
    },
    {
      "key": "erp_release",
      "kind": "string",
      "text": "Which ERP release does this install run?",
      "unit": "",
      "infer": "7df00239",
      "basis": "the ERP's current cut (PLACEHOLDER: the installer's own)"
    },
    {
      "key": "estate_tenant",
      "kind": "string",
      "text": "The Splink estate tenant's name?",
      "unit": "",
      "infer": "splink",
      "basis": "the estate tenant the ERP's Users live in"
    },
    {
      "key": "admin_email",
      "kind": "string",
      "text": "The first Administrator's email?",
      "unit": "",
      "infer": "admin@example.com",
      "basis": "a placeholder until you name one"
    },
    {
      "key": "admin_password",
      "kind": "secret",
      "text": "The first Administrator's password?",
      "unit": "",
      "infer": "",
      "basis": "given to the first-Administrator step by environment on the VM"
    },
    {
      "key": "cf_account",
      "kind": "string",
      "text": "The Cloudflare account id the tunnel lives in?",
      "unit": "",
      "infer": "PLACEHOLDER",
      "basis": "the ERP's Cloudflare account (ids only; the token is the secret below)"
    },
    {
      "key": "cf_zone",
      "kind": "string",
      "text": "The Cloudflare zone id of splink.app?",
      "unit": "",
      "infer": "PLACEHOLDER",
      "basis": "the zone the tunnel's hostname is a record of"
    },
    {
      "key": "cf_hostname",
      "kind": "string",
      "text": "The tunnel's public hostname?",
      "unit": "",
      "infer": "erp.splink.app",
      "basis": "the ERP's host name"
    },
    {
      "key": "cf_direct_hostname",
      "kind": "string",
      "text": "The DNS-only name a client that must reach the box directly resolves?",
      "unit": "",
      "infer": "erp-db.splink.app",
      "basis": "PLACEHOLDER: the ERP's name for direct reach (the conformance gate, an operator's psql); an unproxied A record in the same zone at the install's address"
    },
    {
      "key": "cf_token",
      "kind": "secret",
      "text": "The Cloudflare API token (Tunnel: Edit, DNS: Edit on the zone)?",
      "unit": "",
      "infer": "",
      "basis": "handed at apply time (argondbcloud apply --answers); written nowhere; its custody is OPEN, the project owner's"
    }
  ],
  "resources": [
    {
      "id": "erp-sidecar",
      "per": "tenant",
      "provider_type": {
        "aws": "ec2-instance-share",
        "azure": "vm-share"
      },
      "demand": {
        "vcpu": "0.5",
        "ram_gib": "1.0"
      },
      "note": "the ERP application, one process per company on the tenant's own instance"
    },
    {
      "id": "erp-documents",
      "per": "tenant",
      "provider_type": {
        "aws": "s3-bucket",
        "azure": "blob-container"
      },
      "quantity": "documents_gb",
      "unit": "GB-month",
      "note": "PLACEHOLDER: one bucket for the install, one prefix per company (<bucket>/<tenant_key>/)"
    },
    {
      "id": "erp-tunnel",
      "per": "install",
      "provider_type": {
        "aws": "tunnel",
        "azure": "tunnel"
      },
      "provider": "cloudflare",
      "account": "cf_account",
      "zone": "cf_zone",
      "hostname": "cf_hostname",
      "service": "http://localhost:8443",
      "token": "cf_token",
      "direct_record": {
        "name": "cf_direct_hostname",
        "ttl": 300
      },
      "price": {
        "monthly_cents": 0,
        "basis": "PLACEHOLDER: Cloudflare Tunnel is free on the plan the ERP holds; name the plan's price here"
      },
      "note": "the ERP's front door under the zone ruling of 2026-10-28: the Cloudflare tunnel to splink-server's successor — the tunnel, its ingress and the hostname's CNAME in the Cloudflare zone (the hostname route IS the DNS record; TLS terminates at Cloudflare; no Route 53 zone, no ACM certificate) — plus the optional direct_record: a DNS-only A record at the install's address for a client that must reach the box directly (the conformance gate, an operator's psql); the connector on the VM is a run-after-install step the ERP's installer may add (§3.3.1)"
    },
    {
      "id": "erp-reference-data",
      "per": "install",
      "provider_type": {
        "aws": "run-after-install",
        "azure": "run-after-install"
      },
      "script": "#!/bin/bash\n# PLACEHOLDER (the ERP's): load the reference data the ERP's installer put on\n# this VM (user_data_append) into the estate, through the DSN this step is given.\nset -eu\nREL=\"${1:?erp release}\"\nF=/opt/splink-erp/reference-data/$REL.sql\n[ -r \"$F\" ] || { echo \"no reference data for release $REL at $F (the ERP's installer ships it)\" >&2; exit 2; }\n\"$ARGONDB_PSQL\" \"$ARGONDB_DSN\" -v ON_ERROR_STOP=1 -f \"$F\"\necho \"reference data $REL loaded into the estate ($ARGONDBCLOUD_INSTALL_ID)\"\n",
      "args": [
        "${erp_release}"
      ],
      "idempotency": {
        "key": "reference-data-${erp_release}"
      },
      "timeout": 900,
      "order": 10,
      "note": "replaces the hand reference load on splink-server"
    },
    {
      "id": "erp-migrations",
      "per": "install",
      "provider_type": {
        "aws": "run-after-install",
        "azure": "run-after-install"
      },
      "script": "#!/bin/bash\n# PLACEHOLDER (the ERP's): the ERP's OWN schema migrations against the estate's\n# DSN. ArgonDB's own migrations are the component's (`argondb estate migrate`,\n# run by the box at boot and by `upgrade`); these are the application's.\nset -eu\nREL=\"${1:?erp release}\"\n/opt/splink-erp/bin/erp-migrate --dsn \"$ARGONDB_DSN\" --to \"$REL\"\necho \"ERP schema at release $REL\"\n",
      "args": [
        "${erp_release}"
      ],
      "idempotency": {
        "key": "migrations-${erp_release}"
      },
      "timeout": 1800,
      "order": 20,
      "note": "replaces the hand migration on the box"
    },
    {
      "id": "erp-estate-tenant",
      "per": "install",
      "provider_type": {
        "aws": "run-after-install",
        "azure": "run-after-install"
      },
      "script": "#!/bin/bash\n# PLACEHOLDER (the ERP's): the Splink estate tenant, created once and listed in\n# the tenant census; the flags as the ERP's installer spells them.\nset -eu\nT=\"${1:?estate tenant}\"\nif \"$ARGONDB_BIN\" tenant list --data-dir \"$ARGONDB_DATA_DIR\" 2>/dev/null | grep -qx \"$T\"; then\n  echo \"estate tenant $T already stands\"\nelse\n  \"$ARGONDB_BIN\" tenant create \"$T\" --data-dir \"$ARGONDB_DATA_DIR\"\n  echo \"estate tenant $T created\"\nfi\n\"$ARGONDB_BIN\" tenant list --data-dir \"$ARGONDB_DATA_DIR\"\n",
      "args": [
        "${estate_tenant}"
      ],
      "idempotency": {
        "key": "estate-tenant-${estate_tenant}"
      },
      "timeout": 600,
      "order": 30,
      "note": "replaces the hand tenant create; the tenant census lists it"
    },
    {
      "id": "erp-first-administrator",
      "per": "install",
      "provider_type": {
        "aws": "run-after-install",
        "azure": "run-after-install"
      },
      "script": "#!/bin/bash\n# PLACEHOLDER (the ERP's): the Splink estate tenant's Users and the first\n# Administrator. UNTIL the in-engine authorizer's Administrators path lands\n# (lane 3 of docs/2026-10-24-in-engine-authorizer-design.md) this step acts\n# under the BOX TOKEN path: ARGONDB_ADMIN_AUTH_TOKEN comes from\n# /etc/argondb/box.env, sourced into this environment by the engine's wrapper.\n# The password is a SECRET answer: it reaches this step as the argument the\n# wrapper expands from ARGONDBCLOUD_SECRET_ADMIN_PASSWORD on the VM, and is in\n# no plan, state, screen, tape or log.\nset -eu\nT=\"${1:?estate tenant}\"; EMAIL=\"${2:?administrator email}\"; PASSWORD=\"${3:?administrator password}\"\n/opt/splink-erp/bin/erp-admin create --dsn \"$ARGONDB_DSN\" --tenant \"$T\" --email \"$EMAIL\" --password-stdin <<<\"$PASSWORD\" --box-token \"$ARGONDB_ADMIN_AUTH_TOKEN\"\necho \"first Administrator $EMAIL of tenant $T\"\n",
      "args": [
        "${estate_tenant}",
        "${admin_email}",
        "${admin_password}"
      ],
      "idempotency": {
        "key": "first-administrator-${estate_tenant}-${admin_email}"
      },
      "timeout": 300,
      "order": 40,
      "note": "replaces the hand Users insert; written against the box token path until authorizer lane 3 lands"
    }
  ],
  "permissions": {
    "aws": [
      "s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket on erp-documents"
    ],
    "azure": [
      "Storage Blob Data Contributor on erp-documents"
    ]
  },
  "user_data_append": "PLACEHOLDER: the lines that install the ERP's package at a pinned release under /opt/splink-erp (its binaries, its reference data, its migrator) and the sidecar at the same release"
}

The ingress line (P4, 2026-10-27; P10, 2026-10-29). erp-tunnel is the install's front door and the ONLY ingress line this overlay carries: a tunnel whose account, zone and hostname are string questions and whose token is a secret question handed at apply time (argondbcloud apply --answers needs.json; the same file on teardown and census), its service the box port the tunnel fronts, and — new in ArgonDBCloud 1.0.4 (plan row P10) — its optional direct_record: {"name": <a host name, or the key of a string question>, "ttl": <seconds>}, the DNS-only (unproxied) A record in the same Cloudflare zone at the install's public address, for the one client that must reach the box directly (the conformance gate, an operator's psql); the worked overlay names it through the cf_direct_hostname question. The grammar per kind is design §7.3's "as built" paragraph. The overlay above VALIDATES (cargo test -p argondbcloud: the guide-8 fence case holds this block equal to crates/argondbcloud/examples/splink-erp-post-install-overlay.json byte for byte) and plans on both clouds: the tunnel, then its direct-reach record as the step overlay-erp-tunnel-direct, both built when the token is given, and plan prints the overlay's one ingress line in plan.md, on the screen and in --json (pasted from the plan of this overlay on shape (b), 2026-10-29):

ingress: tunnel (cloudflare) — hostname routes are the DNS records; TLS terminates at Cloudflare; no Route 53 zone, no ACM certificate; DNS-only A record erp-db.splink.app for direct reach

What this ERP overlay still does not do: install the connector (cloudflared) on the VM — the ERP's installer may add that as a fifth run-after-install step (§3.3.1); the component does not write it.

Which of the three your overlay carries — RULED 2026-10-28 for the ERP (the project owner's block of 2026-10-28 evening, selected as worded [D-2026-10-29-7d9912]; design §7.3): "The domain stays in Cloudflare. The install's front door is the Cloudflare tunnel: its hostname routes are the DNS records (the ERP keeps creating per-tenant subdomains as today), TLS terminates at Cloudflare, and a DNS-only A record names the box's address where a client must reach it directly. The component's Route 53 zone and ACM certificate kinds are not built for this overlay; P9's ACM measurement is retired." In the overlay author's words: if your application's front door is a Cloudflare tunnel, write the tunnel line and NOTHING ELSE for ingress — no hosted-zone, no dns-record, no tls-certificate. The tunnel's hostname route is your DNS record (Cloudflare serves it), TLS ends at Cloudflare's edge (the box serves the tunnel's service over its local port, and no public certificate is issued for it), and the per-tenant subdomains your onboarding creates stay yours, created the way you create them today through your own Cloudflare token — the component routes only the install hostname(s) you put on its tunnel line. Where a client must reach the box's address directly (a conformance gate, an operator's psql), a DNS-only A record in your Cloudflare zone names it; the component builds that record as the tunnel kind's optional direct_record arm (plan row P10, ArgonDBCloud 1.0.4 — RULED 2026-10-29 ~5:1xam ET, the project owner's block as worded, id at the registry regeneration carrying that block: "The component's tunnel kind creates the DNS-only A record as an optional arm under the same apply-time token, planned, probed, censused and torn down with the tunnel; tape-only until production." — BUILT 2026-10-29, tape-only: the two tapes aws/ingress-tunnel.json and aws/ingress-tunnel-no-direct-record.json), and until 1.0.4 is served it is your own Cloudflare automation's line. The one shape the component REFUSES BY NAME, at argondbcloud validate and at plan alike: a tls-certificate whose domain is your tunnel's hostname (it would never be served — TLS terminates at Cloudflare), or a dns-record for that name (the tunnel's route IS the record); the line names both resources and the ruling, e.g. refused: tls-certificate erp-cert names domain "erp.splink.app", which is the tunnel hostname of erp-tunnel in the same overlay: the certificate would never be served, because TLS terminates at Cloudflare for a tunnel-fronted hostname (the zone ruling of 2026-10-28 [D-2026-10-29-7d9912]); drop erp-cert or give it a name the tunnel does not route. Carrying neither kind is accepted silently. Write a dns-record and a tls-certificate only when your DNS lives on Route 53 and your clients reach the box directly — then the record is the install's address and the certificate is issued by DNS validation in that zone, as P4 built it. Those three kinds — hosted-zone, dns-record and tls-certificate — are KEPT AS BUILT and are NOT the ERP's path: RULED 2026-10-29 ~5:1xam ET (the project owner's block as worded; id at the registry regeneration carrying that block): "The component's hosted-zone, dns-record (Route 53) and tls-certificate (ACM) kinds are kept as built and documented as not for the ERP's overlay; an Azure-DNS or Route 53 customer is the row that exercises them." So, for this guide's reader: the ERP's path uses the tunnel kind and nothing else for ingress; the other three exist, work and are taped for a customer whose DNS is on Route 53 (their Azure halves, plan row A8, for one who names Azure DNS — chartered when that customer exists), and this guide shows them only in this sentence. The worked overlay above carries the tunnel and nothing else for ingress since row P10 (2026-10-29; it showed erp-dns and erp-cert — P4's shape, measured by P5, P6 and P9 — until then), as does crates/argondbcloud/examples/splink-erp-post-install-overlay.json, and the ERP's own document already carried none (P5b, 2026-10-28); plan of any overlay now prints its front door in one ingress: line (tunnel (cloudflare) …, route53 + acm — <zone>, <domains> or none). The ERP's half — the front door spelled tunnel (not cloudflare-tunnel), the install hostname(s), the direct-reach need — is its row r8, sent by note.

VERIFIED 2026-10-04 — advise --overlay on main of that day, with recorded prices, on the block AS IT THEN STOOD (the five placeholder resources; the four run-after-install resources of the block above add no-charge lines only and change no figure below, and the post-install verification of 2026-10-27 is §3.3.1's). The overlay of that day was written to splink-erp-overlay.json (byte-identical to that block, checked with cmp), and argondbcloud advise was run OFFLINE against a price cache filled from the recorded fixtures K3 and K9 test with (crates/argondbcloud/tests/fixtures/k3/, recorded 2026-10-03), on the merged tree at 1101a9ef9, built on ms-01 through scripts/fleet-exec.sh. The needs are the smallest an ERP customer could give: AWS, us-east-1, 20 companies, everything else inferred.

$ cat needs-aws-20.json
{"schema":"needs/v1","cloud":"aws","region":"us-east-1","tenants":20}
$ argondbcloud advise --offline --price-cache cache --overlay splink-erp-overlay.json needs-aws-20.json
prices: AWS us-east-1 as of 2026-10-03 (cache of the Price List bulk file)
…
ALTERNATIVE A — one VM on local disk (AWS us-east-1)
  NOT YET INSTALLABLE (unlocked by K4)
  EXCLUDED: ha=on: one VM does not survive a machine or zone loss
  resource                          unit price          quantity         monthly
  EC2 m7i.4xlarge (16 vCPU, 64 GiB)   $0.8064 /hour     x    730 hours  =     $588.67  ASSUMED price
  EBS gp3 data disk                     $0.08 /GB-mo    x       148 GB  =      $11.84  ASSUMED price
  Elastic IP (attached, in use)        $0.005 /hour     x    730 hours  =       $3.65  ASSUMED price
  data out to the internet              $0.09 /GB       x        20 GB  =       $1.80  ASSUMED price
  EBS snapshots (Data Lifecycle Manager, daily)     $0.05 /GB-mo    x        64 GB  =       $3.20  ASSUMED price
  security group                    no charge
  erp-sidecar: 0.5 vCPU + 1 GiB x 20 tenant(s); the ERP application, one process per company on the tenant's own instance no charge
  erp-documents (s3-bucket, 5 GB x 20 tenants)    $0.023 /GB-mo    x       100 GB  =       $2.30  ASSUMED price
  erp-queue                         price unavailable (the advisor's price list has no SKU for provider_type "sqs-queue")
  erp-email-relay                   price unavailable (the advisor's price list has no SKU for provider_type "ses")
  erp-web                           price unavailable (the advisor's price list has no SKU for provider_type "application-load-balancer")
  -----------------------------------------------------------------------------
  MONTHLY  $611.46       YEARLY  $7,337.52       prices: 2026-09-16..2026-09-28 (Price List bulk file)
  PARTIAL: INCOMPLETE: 3 of 9 lines unpriced
…
ALTERNATIVE C — per-tenant compute across instances (AWS us-east-1)RECOMMENDED
  NOT YET INSTALLABLE (unlocked by K13)
  resource                          unit price          quantity         monthly
  EC2 m7i.xlarge per tenant (4 vCPU, 16 GiB) x 20   $0.2016 /hour     x 14,600 hours  =   $2,943.36  ASSUMED price
  …
  erp-sidecar: 0.5 vCPU + 1 GiB x 20 tenant(s); the ERP application, one process per company on the tenant's own instance no charge
  erp-documents (s3-bucket, 5 GB x 20 tenants)    $0.023 /GB-mo    x       100 GB  =       $2.30  ASSUMED price
  erp-queue                         price unavailable (the advisor's price list has no SKU for provider_type "sqs-queue")
  erp-email-relay                   price unavailable (the advisor's price list has no SKU for provider_type "ses")
  erp-web                           price unavailable (the advisor's price list has no SKU for provider_type "application-load-balancer")
  -----------------------------------------------------------------------------
  MONTHLY  $3,220.08     YEARLY  $38,640.96      prices: 2026-09-11..2026-09-28 (Price List bulk file)
  PARTIAL: INCOMPLETE: 8 of 16 lines unpriced
…
RECOMMENDED: (c), cheapest shape that satisfies backups=on, ha=on,
    private_networking=off, sso=off with one year of growth; (a), (b)
    excluded by ha=on; PARTIAL total (8 line(s) unpriced): recommended only
    because no complete alternative satisfies the switches

(… marks lines cut for length; nothing inside a kept block was changed.) The same run with --json (exit 0) carries the same lines and the overlay block; its screen-3 lines are How many GB of scanned documents does each company keep? [__] and How many emails does each company send on a busy day? [__], and its screen-5 lines mark both values ~ with their basis. The same needs WITHOUT the overlay price (a) at $167.66, (b) at $159.21 and (c) at $1,746.10 a month.

The same overlay for ONE company — the production shape (P2, 2026-10-27). Twenty companies infer ha=on, which excludes (a) and (b); the first NCD migration is one tenant, and the design's §7.2 rules shape (b) its production shape. The same overlay against {"schema":"needs/v1","cloud":"aws", "region":"us-east-1","tenants":1,"backups":true}, run on 2026-10-27 with the P2 build (argondbcloud advise --overlay splink-erp-overlay.json needs-aws-1.json, live Price List prices), reads (b) — the lines kept verbatim, … for the cut:

ALTERNATIVE B — one VM + object storage (AWS us-east-1)       RECOMMENDED
  INSTALLABLE (argondbcloud plan --shape b then apply; K8 landed)
  capability: lake-in-bucket (needs ArgonDB 1.5.7.19; not in the default plan: plan --argondb-version names the release once the matrix carries it)
  …
  EBS snapshots of the data volume (Data Lifecycle Manager, daily)     $0.05 /GB-mo    x         4 GB  =       $0.20
    basis: (a)'s snapshot rule — one full copy of the data at D + 12 x G, 4
    GB — capped at the (b) data volume's 22 GB (the cache disk, pgdata and
    the catalog's PostgreSQL; the durable copy is the bucket's)
  …
RECOMMENDED: (b), cheapest shape that satisfies backups=on, ha=off,
    private_networking=off, sso=off with one year of growth; within 5 % of
    the cheapest ($155.10), so the more durable shape; (b) is the production
    shape for one tenant and (c) the growth path (design §7.2, ruled
    2026-10-27)

(a) at $155.10, (b) at $158.96 and (c) at $776.86 a month: (b) is within the 5 % tie band of (a) and wins as the more durable shape, and every recommendation now carries the production-shape sentence. What the ERP reads from the (b) plan: capabilities (lake-in-bucket once the install's ArgonDB is 1.5.7.19 or later — the box's [lake] points at the install's bucket through the instance role, so the lake comes back with the database on a replacement VM) or capability_notes naming why not, argondb.source (matrix or override), and the snapshot-policy resource beside the bucket's versioning (§4.3). (c) stays the growth path: the same overlay at twenty companies is the run above.

What this run tells the ERP team. (1) The sidecar's share moves the estimate most: in (a) and (b) it puts 10 more vCPU on the one VM (20 companies × 0.5), which takes (a) from $167.66 to $611.46 a month; in (c) it is absorbed into each company's instance. The 0.5 vCPU / 1 GiB figure is therefore the number Splink most needs to replace with a measurement. (2) The documents store is priced; the queue, the relay and the web front end are not, and every alternative says PARTIAL until the advisor's price list carries them or Splink names resources it already prices. (3) Twenty companies infer ha=on ("on from 10 tenants"), which excludes (a) and (b); a customer who does not need to survive a machine loss says so on screen 4. (4) The document's top-level incomplete reads false in both runs although every alternative carries "partial": true; a caller should read each alternative's partial, not the top-level flag.

What the schema check below adds. It proves the document's SHAPE against the shipped JSON Schema with an independent validator, and that the schema refuses a per it does not know:

VERIFIED — the overlay validates against overlay/v1, alone and inside a needs/v1. The JSON block above was cut out of this file by the command below (so the bytes checked are the bytes printed) and validated with Python jsonschema against overlay.v1.schema.json; then the same overlay was put into the shipped needs/v1 example and that was validated against needs.v1.schema.json. First run on 2026-10-04 on that day's block; re-run on 2026-10-27 on the block above (its output, with the four steps listed, is pasted in the P3 lane report; argondbcloud validate on the same bytes answers overlay/v1: valid). From the repository root:

$ python3 - <<'EOF'
import json, re, jsonschema
src = open("docs/2026-10-04-guide-08-building-an-application-cloud-on-argondbcloud.md").read()
overlay = json.loads(re.search(r'```json\n(\{ "schema": "overlay/v1".*?)\n```', src, re.S).group(1))
S = "crates/argondbcloud/schemas/"
jsonschema.validate(overlay, json.load(open(S + "overlay.v1.schema.json")))
print("overlay/v1:", overlay["name"], len(overlay["questions"]), "questions,",
      len(overlay["resources"]), "resources:", [r["id"] + "/" + r["per"] for r in overlay["resources"]])
needs = json.load(open(S + "examples/needs.v1.example.json")); needs["overlay"] = overlay
jsonschema.validate(needs, json.load(open(S + "needs.v1.schema.json")))
print("needs/v1 with the overlay: valid")
bad = dict(overlay, resources=[dict(overlay["resources"][0], per="company")])
try: jsonschema.validate(bad, json.load(open(S + "overlay.v1.schema.json")))
except jsonschema.ValidationError as e: print("per=company refused:", e.message)
EOF
overlay/v1: splink-erp 2 questions, 5 resources: ['erp-sidecar/tenant', 'erp-documents/tenant', 'erp-queue/install', 'erp-email-relay/tenant', 'erp-web/install']
needs/v1 with the overlay: valid
per=company refused: 'company' is not one of ['tenant', 'install']

3.3.1 Post-install steps: run-after-install (plan row P3, 2026-10-27)

What it is. A resource whose provider_type is run-after-install on every cloud it names is a POST-INSTALL STEP: a program the application ships, run ON the estate VM, as root, AFTER box-ready (the estate answers on its port), through the instance's command channel — SSM SendCommand (AWS-RunShellScript) on AWS under the installer's own session; Run Command on Azure, NOT BUILT on this tier (the Azure client refuses the step by name). Nothing is created in the cloud: the step is a run, recorded in state/v1. It is what replaces the ERP's hand steps on splink-server: the reference load, the migrations, the estate tenant, the first Administrator. Source: crates/argondbcloud/src/overlay.rs (PostInstall, the grammar), crates/argondbcloud/src/apply/resources.rs (overlay_steps, the wrapper render_post_install_wrapper, POST_INSTALL_ENV), crates/argondbcloud/src/apply/mod.rs (the run, the record, the resume), crates/argondbcloud/src/apply/live_aws.rs (run_after_install).

The grammar (overlay.v1.schema.json; every rule below is refused BY NAME by Overlay::from_value):

field what rule
per install a step runs once, on the estate VM; per: tenant is refused
script the program's text, INLINE (ONE shape: the overlay is a document passed inside needs.overlay or by --overlay, with no directory of its own to resolve a path against, so the plan carries the whole program and apply executes what the plan says) required; at most 16 KiB; no NUL. It reaches the VM base64-encoded inside the wrapper, is written to /opt/argondbcloud/post-install/<id>.sh (mode 0700) and run with args
args strings; ${key} names a question of this overlay a number or a string answer is resolved into the text AT PLAN TIME (visible in plan/v1); a secret answer's ${key} stays a reference and is expanded ON THE VM from the environment — see the secret rule below
idempotency.key text, ${key} of a number or string question allowed required; the record carries it; a resume re-runs a created step ONLY when its recorded key differs; a secret in the key is refused (the key is written to the state)
timeout seconds 1 to 3600; 600 when absent; a timeout is a FAILED step by name
order integer required; lowest first; two steps with one order are refused by name
after ids of INGRESS resources of this overlay (dns-record, tls-certificate, tunnel), e.g. ["erp-cert"] — ArgonDBCloud 1.0.3 (plan row P6) optional; a step that names one waits on that resource's LAST step (a certificate's issuance wait, a record, a tunnel — or the tunnel's direct-reach record when it carries one, P10) and is placed after the ingress block — and so is every step ordered behind it, because each step waits on the one before. Refused by name: an id no resource carries, a resource that is not an ingress resource (a bucket, another step), the step itself, an id twice, a value that is not an array, or after on a resource that is not a step. Without it, NO step waits on any ingress step
demand, quantity, price — refused: a step is priced at ZERO

The order rule (ArgonDBCloud 1.0.3, plan row P6; P5's row 4). A run-after-install step depends on box-ready, the instance, the overlay's DATA steps (the buckets and containers its resources create) and the step before it — and on NO ingress step: not the hosted zone, the record, the certificate, its validation records, the certificate-issued wait or the tunnel. The plan places the steps immediately after the data steps and BEFORE the ingress block, so the apply (which runs the plan's order and stops at the first failed row) reaches every step before it waits on ACM: a refused or slow issuance never leaves the reference load, the migrations, the estate tenant and the first Administrator planned. On the migration night the ERP's data load and its ingress are independent work. The served 1.0.2 did the opposite — the steps were placed after the ingress chain, and P5's rehearsal (2026-10-28) measured all four planned behind a 240 s refusal on an undelegated zone. A step that NEEDS the certificate names it with after:; nothing else moves.

The environment a step sees on the VM (POST_INSTALL_ENV, the one list):

variable value
ARGONDB_DSN the estate's DSN, composed ON the VM from the box's own files: host=127.0.0.1 port=5432 user=cloud_admin dbname=postgres password=<the box's pg-password> — the keyword form argondb serve prints; PGPASSWORD is set too, so "$ARGONDB_PSQL" "$ARGONDB_DSN" -c … just works
ARGONDB_PSQL, ARGONDB_BIN, ARGONDB_DATA_DIR the box's own psql (/opt/argondb/pg_install/v17/bin/psql), CLI (/opt/argondb/bin/argondb) and data directory (/var/lib/argondb); /etc/argondb/box.env (the box's tokens: ARGONDB_ADMIN_AUTH_TOKEN and the rest) is sourced into the environment
ARGONDBCLOUD_INSTALL_ID, ARGONDBCLOUD_STEP, ARGONDBCLOUD_IDEMPOTENCY_KEY the install id, the step's plan id (post-install-<resource id>), the key as resolved
ARGONDBCLOUD_ANSWER_<KEY> every number and string answer of the overlay's questions, KEY upper-cased
ARGONDBCLOUD_SECRET_<KEY> a secret answer the step's args reference, read on the VM from /etc/argondbcloud/secrets/<key> (root-only, written by the first boot from apply --answers)

The secret rule. A secret answer reaches a step ONLY by environment on the VM: the plan carries ${key} and secret_args: ["key"], the wrapper the channel carries holds the expansion "$ARGONDBCLOUD_SECRET_<KEY>", and the value is read from the VM's root-only file where it lands — so no byte of it crosses the command channel, is logged by SSM, or reaches a plan, a state, a screen, a tape or a log. What the script itself PRINTS is the script's own: a step that echoes its secret puts it in its log. The negative grep over every document, screen, install file and tape is crates/argondbcloud/tests/k4.rs a_secret_arg_reaches_the_steps_environment_on_the_vm_and_no_document_screen_or_log.

The record, the log, the resume. The step's state/v1 record carries idempotency_key, exit, started_at, finished_at and log (the path ~/.argondbcloud/installs/<install-id>/post-install/<step id>.log, the captured stdout and stderr — SSM caps each at 24,000 characters; the whole log stays on the VM at /var/log/argondbcloud/post-install/<id>.log) and NEVER the arguments; provider_id is exit=<n>@<finished>. Exit 0 is created. A non-zero exit or a timeout is a FAILED step: the apply stops there by name with the log's path and the resume command, everything before it stands, and apply --resume <install-id> re-runs ONLY that step (every created step whose recorded key is the plan's is exists without a call). Teardown marks every post-install record deleted without a call; the census never counts one. The hook's own rule: the ENGINE guarantees a step is not run twice under one key; it cannot guarantee a step that was killed half-way (a lost channel, a kill between the run and the record) is not run AGAIN — so a script is written to be re-runnable (create table if not exists, on conflict do nothing, "already stands" checks), exactly as the four steps above are.

What the ERP's installer must provide, and what it may drop. PROVIDE: the four scripts (the block above is the component's worked example, not the ERP's code — the ERP's repository is the ERP coordinator's, routed through the ArgonDB coordinator), each re-runnable, each reading the DSN from ARGONDB_DSN and never a box path; its package on the VM under /opt/splink-erp/ by user_data_append (the reference data, the migrator, erp-admin), at the release erp_release names; the secret question for the first Administrator's password, answered through apply --answers. The first-Administrator step is written against the BOX TOKEN path (ARGONDB_ADMIN_AUTH_TOKEN from /etc/argondb/box.env) until the in-engine authorizer's Administrators path lands (lane 3 of docs/2026-10-24-in-engine-authorizer-design.md); when it does, the script changes and the overlay's version moves. DROP: the hand reference load, the hand migration, the hand tenant create and the hand Users insert on splink-server — every one is a resource of the plan now, run by apply, recorded in state/v1 and readable from it (§4.3: both configuration systems derive from plan/v1 and state/v1).

Where the step shows. Screen 5 (what I assumed) carries one line per step — post-install <id> … no charge … run-after-install, order N, after box-ready; script <bytes> bytes sha256 <12 hex> — and every alternative's lines carry it as a no-charge line naming the script by size and sha, never its text; plan.md likewise names it by size and sha (the text is in plan.json). A step with after: reads after box-ready and <ids> on the same line; its plan step carries the resolved step ids in parameters.after.

VERIFIED 2026-10-27 — the block above under argondbcloud validate and planned on shape (b). See the P3 lane report (docs/lane-reports/argondbcloud-p3-rerun-2-post-install-resource-kinds-for-an-overlay-run-after-install-with-the-estates-dsn-idempotent-and-recorded-in-state-v1-no-hand-on-the-box.md) for the pasted lines; the test is crates/argondbcloud/tests/k4.rs the_erps_worked_overlay_plans_its_four_steps_after_the_documents_bucket_on_shape_b. The live two-step proof (a reference load, then a read of it, on AWS (b) in the scratch account) is the same report's.

VERIFIED 2026-10-28 — the order rule and after:, taped and live. crates/argondbcloud/src/apply/resources.rs post_install_steps_wait_on_box_ready_not_on_the_ingress_chain (the planner's order and dependencies, both polarities of after:), crates/argondbcloud/tests/k4.rs b_post_install_steps_are_recorded_created_behind_a_refused_issuance_and_the_resume_runs_none_again (the tape aws/b-post-install-ingress-refused.json: the four steps created before the issuance wait refuses; the resume runs none again), b_post_install_steps_keep_their_place_before_the_ingress_block_when_the_issuance_succeeds and after_names_an_ingress_resource_of_the_same_overlay_or_is_refused_by_name. The live (b) run on the scratch account from the lane's build — the four steps recorded while the issuance still refused on the undelegated zone — is in the P6 lane report (docs/lane-reports/argondbcloud-p6-post-install-steps-depend-on-box-ready-and-the-data-steps-not-on-the-ingress-wait-taped-and-one-live-b-run-behind-a-refused-issuance.md).

3.3.2 The user-data append: user_data_append (plan row P5b-2, ArgonDBCloud 1.0.4, 2026-10-29)

What it is. The overlay's user_data_append is shell text the VM's FIRST BOOT runs, as root, AFTER the engine's own block: ArgonDB installed from the pinned tarball, argondb serve up and answering select 1, the restore key uploaded (shape (b)), the overlay's secret answers written to /etc/argondbcloud/secrets/<key>. It is where the application installs its own package on the VM (the ERP's six lines: install -d /srv/erp, the tarball at a pinned release, its render step, its systemd unit), which the run-after-install steps of §3.3.1 then call through the DSN. The ERP's whole install path is this field when its document declares no step (P5b measured that shape on 2026-10-28).

What the engine substitutes — EXACTLY TWO placeholders, nothing else.

placeholder value
${install.id} the install id, <name>-<6 hex> — what every resource is tagged with, the state file's directory
${install.name} the install name the operator chose (--name)

A ${...} naming anything else is REFUSED BY NAME at validate (user_data_append: placeholder ${argondb.version} is not one the engine substitutes (${install.id}, ${install.name}); a shell variable is written $NAME, without braces). So: ArgonDB's version is NOT substituted — it is argondb_version in state/v1 and the application pins ITS OWN release in its own text (the ERP's splink-erp-<version>-…tar.zst carries the ERP's version, which its cut writes literally); the estate's DSN is NOT substituted — its password is minted ON the box, and the append reads /etc/argondb/pg-password there exactly as a step's wrapper does (host=127.0.0.1 port=5432 user=cloud_admin dbname=postgres); the overlay's answers are NOT substituted — a step's args carry those. A shell variable is written $NAME; a braced shell form (${HOME}, ${1:-x}) is read as a placeholder and refused — write it without braces or in a script the append fetches.

How it runs on the VM. The text is written through a quoted here-document (no byte of it expanded by the shell; a line equal to the delimiter ARGONDBCLOUD_OVERLAY_USER_DATA_EOF is refused by name) to /opt/argondbcloud/overlay-user-data.sh, run with bash -e (the first failing line ends it, non-zero), its output appended to /var/log/argondbcloud/overlay-user-data.log, its exit and the moment written to /var/lib/argondbcloud/overlay-user-data.exit, and the log's last 20 lines plus one marker line (ARGONDBCLOUD-OVERLAY-USER-DATA exit=<n> finished_at=<RFC 3339> log=<path>) printed to cloud-init's output, which reaches /var/log/cloud-init-output.log and the serial console. box-ready is unchanged: it probes ArgonDB's port, which opens before the append runs, so an append that fails never leaves box-ready false for the engine's own reasons and never changes its meaning.

How it is read back and recorded. The plan carries one step, user-data-append (kind overlay-user-data), right after box-ready, waiting on box-ready and the instance; every post-install step waits on it too, so the ERP's steps run only once its package is on the VM. The step is a RUN, not a resource (no probe, no adoption, no rollback; the census never counts it; a teardown marks it deleted without a call): on AWS the engine polls the instance's console output (GetConsoleOutput, latest) for the marker — no agent, no channel, no IAM action on the VM's role needed, so a document with no step (the ERP's) is read the same as one with four; on Azure the reader script runs through Run Command. Bounded at 900 s, refused by name past it (resumable: apply --resume reads again). The outcome is recorded twice: the step's own record (exit=<n>@<finished>, like a post-install step) and, UNDER THE INSTALL, state/v1's overlay_user_data — status, exit, log (the engine's copy of what the read printed, ~/.argondbcloud/installs/<install-id>/post-install/user-data-append.log), vm_log, started_at, finished_at, finished_on_vm, channel, channel_id, lines, sha256, last_lines (the VM's own last 20 log lines), reason. The apply-result carries it as outputs.overlay_user_data. A non-zero exit is a failed step that stops the apply by name (the ERP's install did not land; the reason names the exit and the log) with the resume command; plan.md says in one line how many lines the overlay appends.

The size bound. EC2 takes 16 KiB of user data and the engine's own (b) block is ~12.7 KB, so the append is bounded at 16384 bytes by the schema and the WHOLE user data (the block, the secrets, the append) is checked against the cloud's bound at plan (a near-exact figure) and again at RunInstances / the VM PUT (exact), refused by name past it — "shorten the overlay's user_data_append (fetch a script from a place the VM reaches instead of inlining it)". Azure's customData takes 64 KiB.

What an overlay without the field gets. Byte-identical user data, no user-data-append step, no overlay_user_data in the plan or the state — the shipped two-step tape (aws-b-post-install.json) is unchanged.

What the ERP's six lines may now rely on, and what they may not. The lines reach the VM and run after ArgonDB is up, as root, with ${install.id} and ${install.name} filled in. They may NOT rely on ~/.argondbcloud/installs/<install-id>/state.json being ON the VM: the state file lives on the operator's machine (the installer's ~/.argondbcloud), and nothing copies it to the VM — the ERP's render step as its 2026-10-28 cut wrote it reads a path that does not exist there (escalated in the lane report; P5b's row 3 records the answers in the state, and where the render reads them FROM on the VM is the ERP coordinator's and the program agent's question).

The gate: cargo test -p argondbcloud, tests/k4.rs b_user_data_append_is_planned_after_the_engines_block_and_the_no_append_overlay_is_byte_identical (the rendered user data: the engine's ARGONDBCLOUD-B-OK line before the here-document; the no-append polarity byte for byte), aws_b_user_data_append_record_is_read_back_exit_0_and_written_under_the_install (the tape aws/b-user-data-append.json), aws_b_user_data_append_that_exited_non_zero_fails_the_apply_by_name_and_runs_no_step, a_user_data_append_past_the_clouds_bound_is_refused_by_name_at_plan, and tests/overlay.rs user_data_append_placeholders_outside_the_contract_are_refused_by_name. The live (b) run (a marker overlay on the scratch account, the marker read back, the record pasted) is in the lane report (docs/lane-reports/argondbcloud-apply-appends-the-overlays-user-data-append-to-the-vm.md).


4. Two configuration systems, one source

An ERPCloud install configures two things: ArgonDB (what runs on every instance, written into argon.toml) and the ERP (what the sidecar, the document store, the queue and the relay need to find each other). This section says what exists on each side and proposes one rule for both.

4.1 ArgonDB's own configuration surface — what an ArgonDBCloud install writes

ArgonDB reads one file per box, <data-dir>/argon.toml; unknown keys are refused, not ignored (#[serde(deny_unknown_fields)], crates/argondb-ctl/src/serve.rs:77-101). argondb config check --data-dir <dir> parses it and prints what the box would do, booting nothing and touching no running box; exit 0 means the file boots, non-zero means argondb serve would refuse it with the same message (crates/argondb-ctl/src/main.rs:1065-1074). It echoes port, lake.enabled, storage.remote, storage.sole_owner and resources.memory_budget_mb, and refuses a sole_owner without an offsite remote (crates/argondb-ctl/src/serve.rs:3800-3841). An installer should run it on every argon.toml it writes, before the first boot.

The keys an ArgonDBCloud install touches, where each is documented, and its default:

key what it does default where documented
[storage] remote (+ endpoint, region, account) the pageserver's offsite store: s3://bucket/prefix or azure://container/prefix; a garbage value refuses the box unset (LocalFs) crates/argondb-ctl/src/serve.rs:149-191; docs/deployment-spec.md:1513-1524
WAL backup (K5) when remote is offsite, the safekeeper backs WAL up to <prefix>/safekeeper; the box lists that prefix once at boot (15 s bound) and refuses to start if it cannot. Not a key of its own: it follows remote off (follows remote) crates/argondb-ctl/src/serve.rs:4632-4665, :4722-4737; docs/lane-reports/argondbcloud-k5-the-boxs-safekeeper-backs-wal-up-to-the-storage-remote-when-one-is-set-with-the-refusal-when-the-remote-is-unreachable-at-boot-and-the-minio-gate-both-polarities.md
[storage] sole_owner (K6) the installer's declaration that this box alone writes its prefix, which opens layer deletion there; backed by the owner marker <prefix>/owner and refused with no offsite remote; argondb storage claim-owner --yes takes a prefix over for a replacement box [D-2026-10-03-3d3b79] false crates/argondb-ctl/src/serve.rs:199-208, :3963-3968; docs/ops-runbook.md:257-266
[lake].scheme = "azure" + adls_account, adls_filesystem, adls_account_key or credentials = "instance", adls_endpoint (K7) the lake warehouse in an ADLS Gen2 filesystem instead of S3; every s3_* key must then be unset unset (S3 lake) crates/argondb-ctl/src/serve.rs:259-285; docs/deployment-spec.md:1335-1352
[compute] per_tenant one compute per tenant database, each with its own port pair, spec and idle clock; ARGONDB_COMPUTE_PER_TENANT wins off crates/argondb-ctl/src/serve.rs:120-127
[compute] idle_stop the primary stands down after N idle seconds and wakes on the next connection; a STRING ("300"), ARGONDB_PRIMARY_IDLE_SECS wins [D-2026-09-08-c60bce] "off" crates/argondb-ctl/src/serve.rs:107-119; docs/deployment-spec.md:1641-1645

Which ship in the Linux artifact, and where ArgonDBCloud itself ships. The K5, K6 and K7 landings (1d5999ada, 125c3b782, f96cdfb9a) ship in every Linux release from v1.5.7.4 on (the released tarball is v1.5.7.16, tag v1.5.7.16, 2026-10-23; until 2026-10-24 this paragraph read "they ship with v1.5.8", a cut no plan carries). All three are default-off: WAL backup only follows a configured offsite remote, sole_owner defaults to false, and scheme defaults to the S3 lake. [compute] idle_stop and [compute] per_tenant are in v1.5.7.x and are also off by default. ArgonDBCloud has its own release line since 1.0.0 (2026-10-24): the argondbcloud channel of releases.argondb.ai (https://releases.argondb.ai/argondbcloud/<version>/, Linux x86_64; its LATEST carries supported_argondb, the ArgonDB version the installer installs by default — the Linux channel's LATEST at the cut, pinned by sha, D8), beside the copy that still rides inside every ArgonDB Linux tarball. The served check is scripts/release/release-verify-argondbcloud.sh; the channel is outside channel-drift.py's database drift set (D11: its own version line, design §7).

What is not a key. Per-tenant compute on separate INSTANCES (shape (c)), the connect-time waker for a stopped instance, and the per-tenant instance arm are not configuration at all yet: they are jobs C1–C10 (K11, K12) that have not started (docs/2026-10-01-argondbcloud-design.md:394-404). On one box the tenant directory already has the columns they will fill — instance_id, availability_zone, elastic_address, safekeeper_member_set — each commented "NULL on the box tier: no producer" (crates/argondb-ctl/src/estate_directory.rs:89-117).

4.2 What the ERP's configuration system must carry

From the sources above, the ERP's side needs at least:

  1. The tenant → instance map. Which company runs where. ArgonDB already keeps this, and the ERP should READ it rather than keep a copy: the estate directory argon_estate.tenant has one row per (system_key, tenant_key) with the database name, timeline, compute port, pooler entry, schema version, sidecar command and uid, status, and (in the cloud) instance id, zone and address (crates/argondb-ctl/src/estate_directory.rs:89-107). The ERP spec's rule is that no service reconstructs a company's coordinates from a naming convention (docs/2026-09-04-erp-spec.md:2397-2400).
  2. The sidecar's own config. Its command and uid live in the directory row (crates/argondb-ctl/src/main.rs:662-682); its connection string is the tenant's pooled string, which tenant create prints by default (crates/argondb-ctl/src/main.rs:689-695).
  3. Document prefixes. One bucket and one prefix per company (the overlay's erp-documents, §3.3) — PLACEHOLDER until Splink names the store.
  4. The queue and relay endpoints. The overlay resources' outputs — PLACEHOLDER until Splink names them.

4.3 The rule this guide proposes: derive both from plan/v1 and state/v1

PROPOSED (AGENT-DERIVED). Neither side has a configuration of its own that a customer fills in. The customer answers ArgonDBCloud's questions and the overlay's questions ONCE (the screens of §5); the answers become one needs/v1, one plan/v1 and, after apply, one state/v1. Then:

So a customer never configures a thing twice, and a re-install from the same plan produces byte-identical configuration on both sides.

What this depends on, and how far it is built. state/v1 is written by the K4a engine (crates/argondbcloud/src/apply/state.rs): the apply side — plan, apply, teardown for shape (a), direct SDK calls ruled [D-2026-10-03-69f57a] — is proven against a RECORDED provider on main (2026-10-21), and the first deploy into a real account is K4b (docs/2026-10-01-argondbcloud-design.md:1072). The state file's home is ~/.argondbcloud/installs/<install-id>/state.json, with a copy in the install's bucket for (b) and (c) still to come, and every resource tagged with the install id so a lost state file can be rebuilt (docs/2026-10-01-argondbcloud-design.md:708-719).

4.4 How each side versions and upgrades


5. The ERPCloud customer experience

This is the flow a customer walks, screen by screen, as the design writes the TUI (docs/2026-10-01-argondbcloud-design.md:888-1019), with the places the ERP's overlay appears. The screens are BUILT, up to the plan: argondbcloud tui (K10, landed 2026-10-04) walks them, one answer per line on stdin (crates/argondbcloud-cli/src/tui.rs:1-3); with --overlay it asks the overlay's questions on screen 3 (crates/argondbcloud-cli/src/tui.rs:90-101) and lists them on screen 5 (crates/argondbcloud-cli/src/screens.rs:180-212), as the design asks (docs/2026-10-01-argondbcloud-design.md:846-848). Screens 8 and 9 call the library's apply; with no provider configured (ARGONDBCLOUD_PROVIDER unset — every plain build until K4b's live run) a session ends at the plan, saved, with nothing created and screen 8 saying so (crates/argondbcloud-cli/src/tui.rs).

  1. Welcome — "install ArgonDB in your own AWS or Azure account … Nothing is created in your account until you type "apply"", and the cloud choice (docs/2026-10-01-argondbcloud-design.md:895-905). An ERPCloud front end would say "the ERP" where this says ArgonDB.
  2. Where — the region, searchable (docs/2026-10-01-argondbcloud-design.md:907-913).
  3. How big — tenants (companies), data today, busy people at once, hours a day, growth, requests per second; Enter skips any of them (docs/2026-10-01-argondbcloud-design.md:915-929). The overlay's number questions go here: from §3.3, "How many GB of scanned documents does each company keep?" and "How many emails does each company send on a busy day?". advise already renders these lines, in the overlay block's screens.how_big (§3.3).
  4. What you need — backups, keep running on a failure, private network only, single sign-on, a trial (docs/2026-10-01-argondbcloud-design.md:931-939). Backups default to yes [D-2026-10-03-e36ece].
  5. What I assumed — every number with its basis, estimates marked ~, each line editable (docs/2026-10-01-argondbcloud-design.md:941-957). The overlay's skipped answers appear here with their basis line, as screens.assumed in the same block.
  6. The alternatives — one column per shape, monthly and yearly, what survives a failure, "first query after idle: 13 s (measured)" for (c), RECOMMENDED and NOT YET INSTALLABLE marked, the price date in the footer (docs/2026-10-01-argondbcloud-design.md:959-977). With an overlay, each column's total includes the ERP's lines — one combined estimate.
  7. The plan — every resource in order with its monthly cost, the total, the credentials it will use; "Type "apply" to build it" (docs/2026-10-01-argondbcloud-design.md:979-994). The ERP's resources are lines in the same list.
  8. Apply — one line per resource as it is created; on failure, the provider's error and the resume and teardown commands (docs/2026-10-01-argondbcloud-design.md:996-1006).
  9. Done — the connection string, ssh, the state file, the cost and the teardown command (docs/2026-10-01-argondbcloud-design.md:1008-1016). An ERPCloud "done" screen would add the ERP's own URL.

After the install: the waker on first connect. In shape (c) with idle-stop on, a company nobody has used for a while has its instance stopped. The first connection — a SQL client, or a browser through the sidecar port — reaches the waker on an estate instance, which starts the instance and splices the connection; a failed wake answers SQLSTATE 57P03 for SQL and HTTP 503 with Retry-After for a sidecar port (docs/2026-10-01-argondbcloud-design.md:340-352; crates/argondb-ctl/src/idle_stop.rs:679). The HTTP 503 form is proposed, not built (docs/2026-09-04-erp-spec.md:3334). The cloud waker is built (K12, argondb tenant waker, crates/argondb-ctl/src/instance_waker.rs): it stops the instance by id after the idle period and starts it by id on the next connection, splicing it once the instance answers; it runs as a foreground command today, and its place on the estate instances is K13's. The customer waits about 13 s measured for the instance, plus the compute's own start once C2 puts it there — not a second (docs/2026-10-01-argondbcloud-design.md:358-362).

Credentials. The customer signs in with their own cloud tooling; the app assumes a dedicated installer role (AWS) or creates a per-install service principal scoped to the install's resource group (Azure), and never stores a long-lived key [D-2026-10-03-f11331] (docs/2026-10-01-argondbcloud-design.md:636-677).


6. Built vs designed, with dates

Each row's state is read from the task ledger at write time (2026-10-04 9:3xam ET, from the committed ledger mirror task-ledger.jsonl under ops/state/ at origin/main, last mirrored in db9dbc518): a consumed event with verdict pass is landed (its merge_commit named), spawned with no exit is in flight, queued is queued, and no row is not chartered. Where main is ahead of the mirror, the row says so and names the LAND: line. The jobs and their planned dates are design §8 (docs/2026-10-01-argondbcloud-design.md:1056-1095); the dates there are UNRATIFIED, and this guide promises none of its own.

# job ledger state landing commit
K1 the crate and the contract landed 2026-10-03 81fc8973b
K2 the price clients and the cache landed 2026-10-03 1c74d799c
K3 the demand rule and the shape (a) planner landed 2026-10-03 d5a7006b2
K4 plan and apply for shape (a), direct SDK calls not chartered —
K5 ArgonDB: WAL backup when [storage] remote is set landed 2026-10-03 1d5999ada
K6 ArgonDB: layer deletion under an offsite bucket (sole_owner) landed 2026-10-03 125c3b782
K7 ArgonDB: the Azure lake keys landed 2026-10-03 f96cdfb9a
K8 shape (b) planner and SDK apply not chartered —
K9 the overlay spawned 2026-10-04 06:26 ET in the mirror; its code is on main, landed by scripts/land.sh 5a1f93a3e
K10 the TUI queued in the mirror; its code is on main, landed by scripts/land.sh 2026-10-04 ~12:25pm ET 4c8038e32
K11 ArgonDB: the cloud arms C1–C7 not chartered —
K12 ArgonDB: the cloud waker (C9) and the per-tenant instance arm (C10) not chartered —
K13 shape (c) planner and SDK apply not chartered —
K14 the release channel and the ArgonDBCloud 1.0 cut not chartered —
K15 the health monitor design page landed 2026-10-04 577128e6e

K9's mirror row ends at spawned because the mirror is a timer's copy and had not caught up with the landing; its landing is the LAND: line of its lane report (docs/lane-reports/argondbcloud-k9-the-overlay-merged-into-every-alternative-with-splink-erp-overlay-json-as-the-first-and-its-questions-in-the-tui-screens.md).

Stated plainly [D-2026-08-05-fa0d63]: the MODEL half — the documents, the inference rules, the prices, the advisor, the overlay and the TUI up to the plan — is landed, and so are the three ArgonDB-side changes shape (b) needs. The APPLY half — K4, then K8, then K13 — has not started, so ArgonDBCloud cannot yet create anything in an account. The 1.0 cut (K14) has not started. The cloud arms of shape (c) (K11, K12) are the long pole (docs/2026-10-01-argondbcloud-design.md:1085-1088), and they are what ERPCloud's shape depends on. K4 also needs a scratch account per cloud, which is on the project owner's list [D-2026-10-03-d5d2de] (docs/2026-10-01-argondbcloud-design.md:1205-1213).

What Splink can build against today: the needs/v1 and overlay/v1 schemas, advise with an overlay (linked, or argondbcloud advise --overlay … --json), and the overlay document itself — with object storage priced and every other overlay resource shown as unpriced. What it should design against but not depend on yet: a real plan/v1 (the CLI's is a preview), apply-result/v1, state/v1, screens 8 and 9, and everything in shape (c).


One note answering these, numbered so the answers can be numbered too:

  1. The overlay's contents. The real list of the ERP's resources beyond the sidecar — the documents store, the queue, the email relay, the web front end, and anything else — each with per: tenant or per: install, its demand or quantity, and its unit (§3.3 marks every placeholder).
  2. The overlay's questions. The exact text a customer is asked, the unit, and what to assume when they skip it (infer and basis).
  3. The overlay's permissions. What the ERP's processes need from the cloud, per cloud, at least privilege (§3.3's list is a placeholder).
  4. The sidecar's install lines. What user_data_append runs on a tenant instance: where the ERP binary comes from, how it is pinned, how it is configured (§4.3 proposes rendering from state/v1).
  5. The cloud(s) and regions. AWS only, Azure only, or both; which regions. Shape (c) is AWS first [D-2026-10-03-7a4b8f].
  6. The credential model for a customer's account. Does the customer run the installer themselves under their own sign-in (the design's path, docs/2026-10-01-argondbcloud-design.md:636-677), or does Splink operate in the customer's account, and under which role?
  7. Same stack or linked lifecycle. Should the ERP's resources be created and torn down with ArgonDB's, or as a second linked install with its own lifecycle (docs/2026-10-01-argondbcloud-design.md:848-851)?
  8. Which front end. Will the ERP installer link the Rust crate, drive the CLI with --json, or wait for the later web application?
  9. What ERPCloud shows that ArgonDBCloud's screens do not. Any screen, question, total or "done" line the ERP's customer needs that §5 does not have.