Skip to content

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.

  • 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 CONCURRENTLY isn’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 marker alone can be forged. pg_logical_emit_message is 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 holding deedglass_agent’s credentials can write ledger rows through begin_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_limit caps what comes back, not what Postgres reads; statement_timeout bounds the work.
  • Triggers can write unwatched tables. Those writes are invisible to L2: the note triggers_present names the triggers, and witness.strict_triggers: true makes it the gap triggers_unwatched (UNVERIFIABLE, never MISMATCH); check lists 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_halted is listed at once.
  • New tables aren’t granted automatically: run init again (no ALTER DEFAULT PRIVILEGES in v1).
  • Roles are cluster-wide. Two watched databases on one cluster share deedglass_agent and deedglass_witness, and init --rotate-passwords on 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 by statement_timeout and the role’s privileges.
  • Through a transaction pooler, parameter types are inferred by the server from text, and DISCARD ALL is skipped (the pooler resets sessions).
  • --probe runs 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).

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 a MISMATCH.
  • 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.

What the decoder doesn’t do (DECODER.md, OBSERVATION_BOUNDARY.md):

  • deedglass_witness could 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. Treat witness_dsn as 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, with witness.sensitive_columns stored 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.
  • Expectations only for simple statements. A single-table UPDATE, DELETE or INSERT … VALUES gets a footprint; a JOIN, UPDATE … FROM, a WITH query, ON CONFLICT, MERGE or a subquery in SET gets the gap footprint_unknown, plus witness.max_rows when the connector sets it.
  • Column checks need REPLICA IDENTITY FULL. Without it, counts and keys are still compared and column checks are the gap old_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 unknown by 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.

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 VALUE for 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 numeric that reads 1.00 where the action wrote 1.0 counts 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 is MISMATCH if 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: allow in its approval settings.
  • 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_pub to the version tables.
  • What the decoder holds back: a transaction no action names waits up to witness.bind_grace while a call is running; one whose action is recorded later than that is stored unattributed, and that action ends Can’t check (l2_timeout).

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), method debezium.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 way begin_action’s row is on Postgres.
  • Whole transactions only. A transaction without BEGIN, END, matching counts or an unbroken event order is feed_incomplete and never compared, so a feed without provide.transaction.metadata never 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 CASCADE or ON DELETE CASCADE foreign 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-it records them); other databases and Debezium versions are parsed by the same rules but haven’t been recorded.