Limits: Postgres, the check and undo
What Deedglass can’t see or do in Postgres: the database, the Postgres connector, the change-log check, the decoder, FHIR servers, foreign feeds and undo.
Database limits (S02)
Section titled “Database limits (S02)”- A Postgres superuser or the schema owner can change anything. They can disable triggers, turn off row-level security and rewrite deeds. The database can’t stop its own superuser; offline deed verification (S05) detects a rewritten or broken chain, it doesn’t prevent one.
- Row-level security stops a forgotten filter, not an attacker on the server. Anyone who can run SQL as the runtime role can set the org setting too. Injection and a compromised server are handled by parameterized SQL, no user SQL on the control plane, the two-role split and offline verification.
- The database checks a seal’s shape, not its signature. Ed25519 seals are checked by
deedglass verify, not by Postgres. - Forward-only migrations. There is no down migration; a mistake is fixed by the next one.
CREATE INDEX CONCURRENTLYisn’t available through the runner in v1. - Direct Postgres only for session settings. Session settings travel in the connection’s startup packet; a transaction-mode pooler may drop them.
- The control plane and a watched database are separate databases. Deedglass refuses to put its own schema into a database it watches.
The Postgres connector (S17)
Section titled “The Postgres connector (S17)”- The marker alone can be forged.
pg_logical_emit_messageis open to PUBLIC by default, so any role can write a message that looks like Deedglass’s; the ledger row in the same transaction is what authenticates it. Anyone holdingdeedglass_agent’s credentials can write ledger rows throughbegin_action, so Deedglass witnesses what Postgres committed, not whether a compromised Deedglass told the truth (POSTGRES.md). - A REPLICATION role can decode every row change in the database, not only the watched tables.
row_limitcaps what comes back, not what Postgres reads;statement_timeoutbounds the work.- Triggers can write unwatched tables. Those writes are invisible to L2: the note
triggers_presentnames the triggers, andwitness.strict_triggers: truemakes it the gaptriggers_unwatched(UNVERIFIABLE, never MISMATCH);checklists triggers on watched tables by name. - DDL is blocked, not witnessed.
- L2 needs the decoder. A write on a witnessed connector waits for its frame (S21); when the decoder is dropped or disabled there is no L2 and the claim stays at L0 or L1, and when it is halted the gap
decoder_haltedis listed at once. - New tables aren’t granted automatically: run
initagain (noALTER DEFAULT PRIVILEGESin v1). - Roles are cluster-wide. Two watched databases on one cluster share
deedglass_agentanddeedglass_witness, andinit --rotate-passwordson one changes the password for both. - No overloads in
functions, and arguments are scalars (strings, integers up to 2^53−1, booleans, null); send decimals as strings. - The denylist is defence in depth, not the boundary. A function it doesn’t list (
pg_sleep, for example) runs, bounded bystatement_timeoutand the role’s privileges. - Through a transaction pooler, parameter types are inferred by the server from text, and
DISCARD ALLis skipped (the pooler resets sessions). --proberuns in local mode only (check --dsn-env); the server-side check is read-only.- Managed providers are guidance only until a smoke run exists (CLOUD_COMPATIBILITY.md).
The Witness (S19)
Section titled “The Witness (S19)”What the Witness doesn’t do (WITNESS.md):
- L1 confirms what the target reports now, not what it reported at commit time. A concurrent
change can make a REST read-back disagree, and the diff says so. For a Postgres row, a later
writer makes the read-back a gap (
row_superseded), never aMISMATCH. - L1 checks only the fields you name. A tool that also changed something else is invisible to L1; L2 sees every row change of the watched tables for Postgres.
- A read-back target that lies consistently can’t be caught by reading it back. L1 trusts the read tool as much as the system it reads.
The decoder (S20)
Section titled “The decoder (S20)”What the decoder doesn’t do (DECODER.md, OBSERVATION_BOUNDARY.md):
deedglass_witnesscould decode every table in its database. REPLICATION isn’t scoped to tables (spike S1(h)). Deedglass asks only for the publication’s tables and stores only those; the credential lives in the vault. Treatwitness_dsnas a read-everything credential.- Only the publication is watched. Changes to unpublished, unlogged or temporary tables, sequences and large objects are invisible, and so are effects outside the WAL.
- DDL leaves only a shape change (
relation_changed:<table>) on the next change to that table. - Failover slots and point-in-time recovery are not tested. A new timeline degrades the decoder; a different cluster halts it.
- Each read re-reads WAL from the slot’s restart point. A long transaction elsewhere makes reads
slower (
peek_slow,restart_retained); the streaming transport is the fix. - Stored row images are customer data. They stay for
decoder.retention(7 days; frames with no Deedglass action 30 days), under row-level security, withwitness.sensitive_columnsstored only as a digest. A frame keeps at most 1,000 images; its counts and digest cover every change. - A deleted connector whose database can’t be reached leaves its slot behind (
disabled · slot_orphaned, with the SQL to drop it by hand). - A logical message’s content is held in memory whole. A role that writes a huge message makes the decoder hold it once.
The L2 witness (S21)
Section titled “The L2 witness (S21)”- Expectations only for simple statements. A single-table
UPDATE,DELETEorINSERT … VALUESgets a footprint; a JOIN,UPDATE … FROM, a WITH query,ON CONFLICT,MERGEor a subquery in SET gets the gapfootprint_unknown, pluswitness.max_rowswhen the connector sets it. - Column checks need
REPLICA IDENTITY FULL. Without it, counts and keys are still compared and column checks are the gapold_image_missing. - The value check is narrow. Only plain string, integer and boolean SET literals, only for text, varchar, char, integer and boolean columns. Numerics, timestamps, JSON and expressions aren’t compared (that isn’t a gap).
- Keys of other types aren’t matched. A primary key whose type isn’t text, an integer, a boolean or a uuid is checked by count only.
- The diff keeps 20 rows (
witness.diff_rows, at most 100) and the decoder keeps at most 1,000 row images per transaction; counts and the digest cover every row. - No L2 in proxy mode (a wrapped third-party Postgres MCP server): there is no marker to bind.
- No L2 for actions recovered after a crash (executing past its deadline, sealed
unknownby the sweeper): the sweeper has no connector at hand, so it seals at L0. The ledger row and the frame stay, and reconcile reports them. - At most three amendment deeds per action, while the decoder keeps the frames (7 days).
- Trigger bodies aren’t read. Deedglass sees what a trigger did to watched tables, not what it might do elsewhere.
- The 100 writes/s benchmark’s tail varies. Commit to verdict p50 met the contract (< 1 s) in every
run on an Apple M1, and the aim (< 300 ms) in most; p99 ranged from about 1 s to several seconds,
in both the decoder’s SQL peek and the witness step under sustained load (
make bench-l2,bench/results/2026-10-02-darwin-arm64-l2.json). S35 re-measures on an idle machine and decides on the streaming transport.
Undo (S23)
Section titled “Undo (S23)”The whole story is in UNDO.md. Undo puts rows back from Postgres’s own row images, and it refuses rather than guess:
- Postgres rows only, witnessed at L2. Actions witnessed at L0 or L1 (REST, GitHub, Stripe and the other connectors without a change log) can’t be undone, and neither can proxy-mode actions, which carry no Deedglass marker to bind the evidence.
- No DDL, no TRUNCATE. A transaction that changed a table’s definition or truncated a table is refused; undo never runs DDL.
- Sequences are not rewound. Rows get their original ids back (
OVERRIDING SYSTEM VALUEfor identity columns); the sequence stays where it is. - Rows changed since are refused. Every row must still hold exactly what the action wrote, byte
for byte as Postgres prints it, so a
numericthat reads1.00where the action wrote1.0counts as changed. That is on purpose. - A primary key on every table, at most 1,000 rows (
undo.max_rows), and actions sealed in the last 7 days whose row images weren’t pruned (undo.max_age). - Sensitive values can’t be written back. The decoder kept only their digest. A sensitive value
that is only compared is compared by its digest and shown as
•••. - Rows that swapped keys (a cycle) are refused: no order of single-row steps puts them back.
- Triggers, rules and cascading foreign keys on the tables are refused by default.
allow_triggers(admins, with a reason) lets triggers fire again, and the L2 witness then shows what they did; the undo isMISMATCHif they changed anything outside the plan. - Nothing outside Postgres. Emails sent, payments made, webhooks fired, caches and downstream copies of the data stay as they are; those need new actions a person chooses.
- One live undo per action, and a person always starts it: agent keys are refused, and the
starter policy asks another person to countersign, so a one-admin org can’t run an undo until it
has a second admin or sets
self_countersign: allowin its approval settings.
Proven for FHIR servers
Section titled “Proven for FHIR servers”- The binding rests on the FHIR server’s own answer. A transaction is bound by the version the
server reported (ADR-108), not by a marker Deedglass writes, so a server that lies in both its
answer and its tables isn’t caught. The method (
l2.fhir.hapi.8,l2.fhir.medplum.5) says which kind of Proven a record has. - Two layouts are mapped: HAPI FHIR JPA 8.x and Medplum 5.x. Another layout is Checked with
fhir_schema_unknown, never guessed. - A REPLICATION role can decode every published row of the FHIR server’s database: keep
deedglass_pubto the version tables. - What the decoder holds back: a transaction no action names waits up to
witness.bind_gracewhile a call is running; one whose action is recorded later than that is stored unattributed, and that action ends Can’t check (l2_timeout).
Foreign change feeds
Section titled “Foreign change feeds”TY-05, WITNESS_DEBEZIUM.md. deedglass witness debezium reads a Debezium feed you give it; nothing reads a feed on its own.
- Proven by the feed, not by the database. The comparison reads what Debezium carried. A connector that drops, filters or rewrites events changes what Deedglass sees, and the label (
Proven · via Debezium (MySQL), methoddebezium.mysql) says which kind of Proven a result is. - Any writer, unless the feed carries the action id. Without a ledger row in the action’s own transaction, every transaction in the feed that touches the statement’s tables is compared as the action’s, and the record carries the note
any_writer. The ledger row is written by your code, so it shows which transaction the writer called the action’s; it isn’t authenticated the waybegin_action’s row is on Postgres. - Whole transactions only. A transaction without BEGIN, END, matching counts or an unbroken event order is
feed_incompleteand never compared, so a feed withoutprovide.transaction.metadatanever gets past Can’t check. On MySQL a feed that resumed inside a transaction is caught by its first row’s position; other databases have no such check yet. - What MySQL doesn’t log, the feed doesn’t show. In MySQL 8.4, rows changed by
ON UPDATE CASCADEorON DELETE CASCADEforeign keys aren’t written to the binlog as row events, so they aren’t counted. - Types and statements are read loosely. Without a schema the feed says only string, number, boolean or null; dates, decimals and JSON are shown, not compared. Statements go through sqlguard’s Postgres reading, so MySQL-only syntax is
footprint_unknown. - Offline, one feed at a time, no Kafka. The Gate doesn’t wait for a feed and the result isn’t sealed into the Ledger. A Kafka topic has to be dumped to JSON lines first.
- One recorded pair in CI. The goldens are MySQL 8.4.6 through Debezium Server 3.3.1 (
make debezium-itrecords them); other databases and Debezium versions are parsed by the same rules but haven’t been recorded.
More limits
Section titled “More limits”- Limitations: the known limits in v1, and every part
- Limits of records, verify and the Ledger