EDR 0007 Status: accepted Implementation: none
Prove object scope, fence row scope, and escalate anything unprovable
A delegation is checked in three ways: object scope by static proof over a restricted statement grammar, row scope by a transactional fence that aborts loudly rather than narrowing silently, and magnitude by an affected-row assertion.
TL;DR
An operator wants to say: "Sam may update settings on accounts, for rows where tier = 'sandbox', up to 100 rows at a time." Marque enforces that sentence in three separate ways, because
its three clauses have three different truth conditions:
| Clause | Mechanism | Where |
|---|---|---|
UPDATE, table accounts, column settings |
static proof over a restricted statement grammar | Harbourmaster, at submission |
rows where tier = 'sandbox' |
transactional fence — a pre-check that aborts, plus an assertion on affected rows | Pilot, at execution |
| at most 100 rows | affected-row assertion inside the transaction | Pilot, at execution |
Anything the grammar cannot parse into a provable shape is not "probably fine" — no delegation applies to it, and it goes to a full human approval. Refusing to decide is the design, not a gap in it.
The fence never silently narrows a statement. If your statement would touch a row outside your scope, the transaction aborts and tells you how many rows it was. Silently applying to the subset is worse than refusing, because it produces a partially-applied change nobody reviewed.
Context
"Delegate the ability to change this column on these rows" is the feature that makes an approval system usable at more than one team's scale. It is also where such systems quietly become unsound.
The tempting implementation is to parse the SQL and check the WHERE clause implies the delegated
predicate. General predicate entailment over SQL is undecidable, and the decidable fragments are
small enough that any real statement falls outside them. Every implementation that tries ends up
approximating — and an approximation in this direction is a silent authorisation bug: the checker
believes WHERE id = 42 is contained by tier = 'sandbox' because it cannot see that row 42 is a
production account.
The second tempting implementation is to rewrite the statement, conjoining the delegated predicate
into the WHERE. That is sound — but it silently changes what the operator asked for. They wrote a
statement they believe updates 40 rows, 12 of them out of scope; it updates 28; nobody is told. A
half-applied change is often worse than no change, and it is always worse than a refusal.
So the decision is to stop trying to prove the part that cannot be proved, and instead check it at the only place where the answer is knowable — inside the transaction, against the actual data, with an abort.
ZFN-2 governs the residual: when the analyser is unsure, correctness beats availability, and the answer is a human.
Decision
The delegation
{
"id": "dlg_01JB…",
"to": "sam@acme.example",
"grants": ["submit", "self_approve"], // or ["approve"] to delegate reviewing
"target": "prod-primary",
"role": "settings_writer",
"operations": ["update"],
"objects": [
{ "schema": "public", "relation": "accounts", "columns": ["settings", "settings_updated_at"] }
],
"fence": ["tier = 'sandbox'"],
"max_rows": 100,
"not_after": "2026-11-30T00:00:00Z",
"granted_by": "theo@acme.example"
}
Two structural rules:
- Attenuation only. A delegation can never grant more than the delegator holds — narrower
operations, a subset of objects, a
max_rowsno larger, anot_afterno later, and a fence that is tighter by syntactic conjunct-set inclusion, never by entailment. Thefencearray is conjunctive, and a narrower fence must literally carry every conjunct of the wider one and may add more. Entailment is the undecidable check EDR-0029 check 7 was rewritten to avoid, and it arrives here once per hop in a chain, with the permissive approximation as the failure direction. Chains attenuate at every hop, and depth is bounded by deployment configuration. - Delegations expire.
not_afteris required. There is no perpetual delegation (ZFN-37).
1. Object scope: static proof
Checked at submission against a deliberately small grammar. A statement is in the checkable subset only if all of the following hold:
- It is a single top-level statement, one of
SELECT,INSERT,UPDATE,DELETE. - Its target relation is a base table named directly. Not a view, not a table-returning function, not resolved through a search path that could bind elsewhere.
- No data-modifying common table expressions.
- No DDL, no
DO, noCALL, noCOPY … PROGRAM, noSET ROLE, no transaction control. - Every function it calls is on an allowlist of known-pure built-ins. This is not pedantry: a
function can execute arbitrary SQL, so
SELECT tidy_up()is a write statement wearing aSELECT. A statement calling anything unrecognised leaves the subset. - Assigned columns (
SET,INSERTcolumn list) are a subset of the delegation'scolumns. - Read-only subqueries are permitted; their tables must be readable by the role but need not be in the delegation's object list.
A statement outside the subset is not rejected — it is simply unmatched by any delegation, so it
follows the normal path to a human approver. The analyser says why in one sentence
("tidy_up() is not a recognised pure function, so scope could not be established"), because an
operator who does not know why they are queueing will assume the tool is broken.
2. Row scope: the fence
The fence never rewrites the operator's statement. The Pilot settles the session, then executes in one transaction:
-- Connection setup, its own round trip. standard_conforming_strings and
-- backslash_quote are read by the LEXER, and PostgreSQL raw-parses a whole
-- simple-query message before running any of it — so a SET sent beside the
-- fence is inert while the GUC still reads back correct. The Pilot VERIFIES
-- both with current_setting() and refuses on a mismatch — at connection
-- setup, and again before every step that follows code the Pilot did not
-- compose: the fence's own evaluation, the operator's statement, and
-- SET CONSTRAINTS. See rule 3.
SET standard_conforming_strings = on; -- pinned: see rule 3
SET backslash_quote = off; -- E'…' escapes regardless of the above
BEGIN ISOLATION LEVEL REPEATABLE READ;
SET LOCAL search_path = pg_catalog; -- pinned: see rule 3
SET LOCAL statement_timeout = …;
SET LOCAL lock_timeout = …;
-- (a) pre-check: would this touch anything outside the fence?
-- NOTE: `IS NOT TRUE`, never `NOT (…)`. A row whose fence expression is
-- UNKNOWN is OUTSIDE the fence.
-- The fence is a list of conjuncts (EDR-0041). `<fence>` is the bare
-- conjunction, each conjunct parenthesised: `(c1) AND (c2)`. The
-- template's own parentheses are the outer wrap, so this composes to
-- `((c1) AND (c2)) IS NOT TRUE` — and never `(c1) AND (c2) IS NOT TRUE`,
-- which tests c2 alone, because `IS` binds tighter than `AND`.
SELECT count(*) FROM public.accounts
WHERE (<the statement's own predicate>) AND (<fence>) IS NOT TRUE;
-- > 0 → ROLLBACK, and report the count
-- re-verify the three pins (rule 3): evaluating the fence in (a) may have
-- called a function, and a function can call set_config
-- (b) the operator's statement, unmodified, with RETURNING added
UPDATE public.accounts SET settings = … WHERE … RETURNING id, tier;
-- re-verify the three pins (rule 3): the statement above may have fired a
-- BEFORE trigger that called set_config and moved them
-- (c) post-assert: did any affected row end up outside the fence?
-- same TRUE-only rule; catches an update that moves a row out of scope
-- (d) affected rows <= max_rows (of the NAMED RELATION only)
SET CONSTRAINTS ALL IMMEDIATE; -- deferred triggers must fire BEFORE (e)
-- re-verify the three pins again (rule 3): (c) evaluated the fence again,
-- and SET CONSTRAINTS just ran user-defined trigger code
-- (e) write-set assert (EDR-0033)
COMMIT;
- (a) is possible precisely because the grammar in step 1 guarantees the predicate is extractable. The statement's own parameter values are bound as parameters, never spliced into this text.
- (c) exists because an
UPDATEcan satisfy the fence before and violate it after — settingtier = 'production'on a sandbox row is exactly the escape a naive check misses. - Any of (a) through (e) failing rolls the transaction back. Nothing is partially applied, and the operator is told which check failed and by how much.
Six rules govern how those checks are written. The first five each close a way the fence would otherwise fail open — rule 5's is a concurrent change to a row the fence depends on and this transaction never writes — and the sixth bounds what a row count covers:
-
A row is inside the fence only when the fence predicate evaluates TRUE. UNKNOWN is outside. Written as
NOT (tier = 'sandbox'), a row withtier IS NULLyieldsNOT NULL=NULL,WHEREadmits only TRUE, and the row is silently not counted — so a NULL-fenced row passes the pre-check, the post-assert and the row count with no concurrency involved at all. Every fence comparison is therefore written(<fence>) IS NOT TRUE, and every future engine binding inherits this rule (EDR-0026).<fence>is the bare conjunction of the fence's conjuncts, each conjunct parenthesised —(c1) AND (c2)(EDR-0041). It is always written inside the parentheses this rule requires, so the TRUE-only test applies to the whole fence and never to one conjunct of it. The parentheses belong to the template, not to<fence>— they are idempotent, so reading them into both is harmless. Reading the whole comparison into<fence>is not: that yieldsX IS NOT TRUE IS NOT TRUE, which isX IS TRUE, and inverts the check into one that counts the rows inside the fence and passes every row outside it. -
The execution transaction runs at REPEATABLE READ or stricter. At READ COMMITTED, (a) and (b) take different snapshots, and because the fence is deliberately not conjoined into the
WHERE, PostgreSQL'sEvalPlanQualre-check never re-evaluates it — so a concurrent update that moves a row into the statement's predicate escapes the pre-check. A40001serialization failure is provably not applied and is therefore retryable under the same nonce, rather thanindeterminate(EDR-0011). On MySQL, InnoDB's repeatable read uses current reads for writes, so the locking pre-select in EDR-0026 is what applies instead. -
search_pathis pinned, and every identifier is schema-qualified. PostgreSQL resolves unqualified names — relations, functions and operators — throughsearch_path, so an unqualified fence liketier = 'sandbox'can be made to mean something else by anyone who can create an object in an earlier schema. The session setssearch_path = pg_catalog, the grammar already requires relations to be named directly (public.accounts). The pin does not reach an explicitly-qualified operator —tier OPERATOR(public.===) 'x'resolves past it — and which references a conjunct may make at all is undefined: issue #25. An earlier version of this rule said such a reference was "refused at compile time (EDR-0016)"; EDR-0016 states no such rule, and a compile-time rule would not reach a hand-authored delegation or an agent's declared scope in any case, neither of which meets the compiler.standard_conforming_stringsandbackslash_quoteare pinned for a related reason — the fence's conjuncts are composed as text — and they are verified withcurrent_setting()rather than set beside the fence: both are read by the lexer, and PostgreSQL raw-parses a whole simple-query message before executing any of it, so aSETin the same message is inert while the GUC reads back correct. All three pins are re-verified immediately before every step that follows code the Pilot did not compose itself, not once atBEGIN. Three things run such code: the fence itself, because a conjunct may call a function (EDR-0041 bounds a conjunct's shape and not its behaviour); the operator's own statement, whoseBEFOREtrigger can callset_config; andSET CONSTRAINTS ALL IMMEDIATE, whose whole purpose is to fire deferred constraint triggers. So the pins are re-verified before (b), before (c) and before (e). Check (a) needs none, because betweenBEGINand it only the Pilot's ownSET LOCALs have run.Re-verification bounds the damage; it does not prevent it. Not for the obvious reason: a
set_configcalled during (a) cannot rebind (a)'s own identifiers, because PostgreSQL resolves those at parse analysis, before execution begins. Two mechanisms survive that, and both apply to (c) as well as (a), since both evaluate the fence. A function carrying its ownSET search_pathclause restores it on exit, so every latercurrent_setting()check passes while resolution inside that function has already happened under the attacker's path. And more simply, with no setting involved at all: a volatile conjunct function can read or write whatever it likes and return whatever it likes, so it can spoil the count (a) produces without touching a pin.So the pins are worth re-verifying, and re-verification is not the control. Bounding what a conjunct may do is, and nothing does yet — issue #25. Revalidating a conjunct's shape before composing it is a different control, and not a substitute.
-
Deferred constraint triggers are forced to fire before the write-set assertion. A
DEFERRABLE INITIALLY DEFERREDconstraint fires atCOMMIT— after check (e) has read a clean write set — so its writes would land inside the committed transaction unchecked, by a mechanism designed to defer until commit.SET CONSTRAINTS ALL IMMEDIATEimmediately before (e) pulls them forward into the checked window (EDR-0033). -
Every conjunct may reference only columns of the target relation, tested when the delegation is authored rather than by the Pilot at execution — §1's enumeration is about the statement, and does not carry this. The rule is per conjunct, and it holds for each separately — one conjunct naming a column of some other relation in the grant's
objectsputs the fence outside the subset just as a single-predicate fence would. REPEATABLE READ makes the pre-check and the statement agree about rows this transaction writes; it does not protect a fence that depends on some other row — a tenant row, a parent — which a concurrent transaction may change between (a) and (b). A fence needing another relation is outside the checkable subset unless the engine can lock the referenced rows for the transaction's duration. -
max_rowsbounds the named relation only. Everything the engine writes on the statement's behalf — cascades, triggers, rewritten targets — is bounded by the write-set assertion in EDR-0033, not by this count.
Additional exclusions from the checkable subset, for shapes whose predicate is not extractable as (a)
assumes: multi-relation DML (UPDATE … FROM, DELETE … USING), subqueries carrying LIMIT,
FOR UPDATE or SKIP LOCKED, and INSERT … ON CONFLICT DO UPDATE — whose DO UPDATE arm touches
rows no pre-check can see. A target relation carrying a non-default rewrite rule is excluded as well
(EDR-0033).
3. Magnitude
max_rows is asserted inside the transaction, not estimated beforehand. A rehearsal
(EDR-0010) gives the operator the number in advance, but the
rehearsal is advice; the assertion is the control.
Multi-statement requests
Every statement in a request is checked independently, and all must be in scope for a delegation to apply. They execute in one transaction, so the whole request commits or none of it does.
Consequences
Easier.
- The sentence an operator wants to say is expressible, and the enforcement of each clause is auditable in isolation. "Why was this allowed?" has three separate, checkable answers.
- Delegation can be granted safely to people who are not trusted with the role itself, which is the entire reason to have it.
- The unprovable cases fail towards a human rather than towards permission.
Harder.
- The checkable subset will feel arbitrary and restrictive, especially the function allowlist. People will hit it with statements that are obviously fine to a human. That friction is the cost of not having a soundness bug, and the mitigation is a good error message, not a wider grammar.
- Every supported engine needs its own parser and its own notion of purity. PostgreSQL first; MySQL is a second implementation, not a configuration flag.
- The pre-check costs a scan. On a large table with an unindexed fence predicate this is slow, and it runs inside the transaction holding the write. Fences should be over indexed columns, and the rehearsal warns when one is not.
- The fence is evaluated at execution time, so a row that moves into scope between approval and execution becomes eligible. Short marque lifetimes bound this; EDR-0010 reports when the rehearsed row set differs from the executed one.
New obligations.
- The checkable-subset rules are versioned, and a delegation records which version it was evaluated under. Widening the grammar must not retroactively change what an old delegation permits.
- The function allowlist is reviewed when the target's schema changes; a new extension can add volatile functions with innocent names.
References
- ZFN-2 — when unsure, refuse.
- ZFN-37 — delegations expire.
- ZFN-38 — delegation is explicit, scoped, time-bounded, revocable, and recorded on both sides.
- EDR-0006 — the role is the outer bound this narrows.
- EDR-0010 — how the numbers reach the approver in advance.
Changelog
- 2026-08-15: Accepted.
- 2026-08-15: Amended after an expert-panel review found the worked fence SQL unsound in two independent ways. The decision is unchanged — three checks, abort loudly, never narrow — but the encoding was wrong:
NOT (fence)let a row with a NULL fence column pass every check, so the rule is now TRUE-only (IS NOT TRUE); and no isolation level was named, soBEGINgot READ COMMITTED and the pre-check and the statement took different snapshots. Also added the parameter-binding rule, further subset exclusions (multi-relation DML, locking subqueries,ON CONFLICT DO UPDATE), and a pointer to EDR-0033 for writes outside the named relation. - 2026-08-16: Amended after the expert panel's should-fix pass: stated that
max_rowsbounds the named relation only, and added the write-set assertion as check (e) — see EDR-0033. - 2026-08-16: Amended after a second expert panel: pinned
search_path(PostgreSQL resolves unqualified relations, functions and operators through it, so an unqualified fence can be redefined by anyone who can create an object in an earlier schema), forced deferred constraint triggers to fire before the write-set assertion, and restricted a fence to columns of the target relation — REPEATABLE READ protects only rows this transaction writes. - 2026-08-16: Amended in the second panel's should-fix pass: attenuation compares fences by syntactic conjunct-set inclusion, not entailment — the undecidable check EDR-0029 was rewritten to avoid, which otherwise arrives once per hop in a chain.
- 2026-08-19: Amended so the worked delegation matches this record's own prose, and so the fence SQL survives the fence becoming a list. The decision is unchanged — attenuation by syntactic conjunct-set inclusion, never by entailment — but the encoding contradicted it:
fencewas a string eleven lines above the sentence calling it an array, the relation was one dotted string, and the operation was uppercase. All three now follow EDR-0041, which also settles when two conjuncts are equal and therefore what the inclusion test compares. The worked SQL says what<fence>denotes — the bare conjunction(c1) AND (c2), wrapped by the template so the comparison reads((c1) AND (c2)) IS NOT TRUE;ISbinds tighter thanAND, so the unwrapped form tests c2 alone and lets a row failing c1 through, which is the fail-open the 2026-08-15NOT (fence)correction closed and the list reopened. Rule 3 drops a claim that was never true — it attributed the refusal of a non-builtin reference in a fence to EDR-0016, which states no such rule — pinsstandard_conforming_stringsandbackslash_quoteat connection setup and verifies them withcurrent_setting(), since the lexer reads them and PostgreSQL raw-parses a whole simple-query message before running any of it; and re-verifies all three pins before (b), (c) and (e), because three kinds of code the Pilot does not compose can move them: a fence conjunct calling a function, aBEFOREtrigger, and a deferred constraint trigger fired bySET CONSTRAINTS ALL IMMEDIATE. It also says plainly that re-verification bounds the damage without preventing it, since a function that restoressearch_pathon exit or simply returns a spoiled answer leaves every later check passing — which is why bounding a conjunct's behaviour (issue #25) is load-bearing rather than tidying. Rule 5 is restated per conjunct and says when it is tested; the preamble said "Three rules" above six; and a stale "see rule 4" against thesearch_pathpin now says rule 3. The positive rule that the Pilot revalidates each conjunct's shape, and the correction that rule 5 is not a refusal the Pilot makes, both belong to EDR-0041 and EDR-0016, which is where they were made.