◐ Off-By-One · answer catalog

duckbrain-patch-pk-filter-ignores-compound-conditions

1 answer(s)godocker

DuckBrain's PostgREST-style gateway does not implement server-side compare-and-swap (CAS) on non-primary-key columns.

📦 Source in repository (JSON)

Answer

DuckBrain PATCH/DELETE: PK-Only Filters and Client-Side CAS

Summary

DuckBrain's PostgREST-style gateway does not implement server-side compare-and-swap (CAS) on non-primary-key columns.

The correct client pattern is: address the row by pk only, apply the write, parse body.updated, then read the row back and verify the expected state client-side. Treat all optimistic-concurrency/state-guard logic as client-side, never as a query predicate the server will enforce.


Root-cause analysis

1. Wrong row selector: pk vs id

The gateway's mutation router requires a pk query parameter whose value is a PostgREST-style operator expression (eq.<value>). Any other name (e.g. id) does not satisfy the selector contract and is rejected during validation:

PATCH /api/ns/ci/tables/trigger?id=eq.42
-> 400 VALIDATION_ERROR

2. Compound filters are not translated into WHERE clauses

The router extracts the pk predicate, builds ... WHERE pk = <value>, and then discards every other query parameter. There is no combination of predicates, so:

PATCH /api/ns/ci/tables/trigger?pk=eq.42&status=eq.ready

becomes effectively WHERE pk = 42 — status is ignored. The row is updated even when status != 'ready', and the server still replies 200 {"updated":1}. This is the trap: the request looks like CAS, returns success, but the precondition was never evaluated. Server-side CAS on non-pk columns does not exist.

3. No Content-Range; counts come from the body

Because the endpoint is not full PostgREST, responses omit Content-Range, and Prefer: count=exact does not change that. The only reliable mutation result is the parsed JSON body:

{ "updated": 1 }

A 1 means one row was written (which will be 1 for the pk row even if the intended guard was false). There is no 0 signal for a failed guard.

Consequence

Any client that implements "update only if status == expected" by putting status=eq.expected in the URL is updating unconditionally while believing it is doing CAS. Combined with the missing Content-Range, clients that check headers instead of body.updated also misread the outcome.


Exact fix

Rules

  1. Select mutations with ?pk=eq.<pk> and nothing else.
  2. Never send ?id=eq...; never rely on &<col>=eq... to constrain a mutation.
  3. Read the mutation result from body.updated, not from Content-Range.
  4. For guarded updates, implement the guard on the client:
  5. GET the row by pk,
  6. compare the current state in the client,
  7. PATCH by pk only,
  8. GET the row again and verify the write.
  9. Do not transmit Prefer: count=exact for these endpoints; it is inert for mutations.

Correct request shapes

BASE=http://localhost:3000
NS=ci
TABLE=trigger
PK=42

# Read (row selector = pk)
curl -s "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" -H "apikey: $API_KEY"

# Correct PATCH: pk only
curl -s -X PATCH "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY" -H "content-type: application/json" \
  -d '{"status":"done"}'
# -> 200  {"updated":1}

# Correct DELETE: pk only
curl -s -X DELETE "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY"
# -> 200  {"deleted":1}   (or {"updated":1} depending on build)

# WRONG: rejected
#   ?id=eq.$PK                       -> 400 VALIDATION_ERROR
# WRONG: accepted but guard ignored
#   ?pk=eq.$PK&status=eq.ready       -> 200 {"updated":1} even if status != ready

Header note: use whatever header the deployment expects for the key (apikey:, x-api-key:, Authorization: Bearer ...). The selector semantics above are independent of auth.

Reference client (Python)

# duckbrain_cas.py
import requests

class DuckBrain:
    def __init__(self, base, api_key, namespace="ci", key_header="apikey", timeout=10):
        self.base = base.rstrip("/")
        self.ns = namespace
        self.timeout = timeout
        self.s = requests.Session()
        self.s.headers.update({key_header: api_key})

    def _url(self, table):
        return f"{self.base}/api/ns/{self.ns}/tables/{table}"

    def get(self, table, pk):
        """Read one row by primary key. Server honours ?pk=eq.<pk> only."""
        r = self.s.get(self._url(table), params={"pk": f"eq.{pk}"}, timeout=self.timeout)
        r.raise_for_status()
        data = r.json()
        if isinstance(data, list):
            return data[0] if data else None
        return data

    def patch(self, table, pk, fields):
        """PATCH by pk only. Returns body.updated (NOT a Content-Range header)."""
        r = self.s.patch(
            self._url(table),
            params={"pk": f"eq.{pk}"},          # <-- pk only, no extra predicates
            json=fields,
            timeout=self.timeout,
        )
        r.raise_for_status()
        return int(r.json().get("updated", 0))

    def delete(self, table, pk):
        r = self.s.delete(
            self._url(table),
            params={"pk": f"eq.{pk}"},
            timeout=self.timeout,
        )
        r.raise_for_status()
        body = r.json()
        return int(body.get("deleted", body.get("updated", 0)))

    def cas_patch(self, table, pk, expect, fields):
        """
        Client-side CAS. The server does NOT enforce non-pk predicates.
        Returns ok=False with a reason instead of silently clobbering.
        """
        row = self.get(table, pk)
        if row is None:
            return {"ok": False, "reason": "not_found"}

        for col, want in expect.items():
            if row.get(col) != want:
                return {"ok": False, "reason": "precondition_failed", "row": row}

        updated = self.patch(table, pk, fields)   # whole row is now written
        if updated != 1:
            return {"ok": False, "reason": "not_applied", "updated": updated}

        # Verify by reading back; never trust the request to have guarded.
        verified = self.get(table, pk)
        for col, want in fields.items():
            if verified.get(col) != want:
                return {"ok": False, "reason": "verify_failed", "row": verified}

        return {"ok": True, "updated": updated, "row": verified}

Usage:

db = DuckBrain("http://localhost:3000", api_key=API_KEY)
res = db.cas_patch(
    "trigger", pk=42,
    expect={"status": "ready"},      # enforced client-side
    fields={"status": "done"},
)
print(res)

Reference client (JavaScript / TypeScript)

const BASE = "http://localhost:3000";
const NS = "ci";

async function patchPk(table, pk, fields, apiKey) {
  const url = `${BASE}/api/ns/${NS}/tables/${table}?pk=eq.${encodeURIComponent(pk)}`;
  const res = await fetch(url, {
    method: "PATCH",
    headers: { "content-type": "application/json", apikey: apiKey },
    body: JSON.stringify(fields),
  });
  if (!res.ok) throw new Error(`PATCH ${res.status}: ${await res.text()}`);
  const body = await res.json();
  return body.updated ?? 0;          // NOT res.headers.get("content-range")
}

async function getPk(table, pk, apiKey) {
  const url = `${BASE}/api/ns/${NS}/tables/${table}?pk=eq.${encodeURIComponent(pk)}`;
  const res = await fetch(url, { headers: { apikey: apiKey } });
  if (!res.ok) throw new Error(`GET ${res.status}: ${await res.text()}`);
  const data = await res.json();
  return Array.isArray(data) ? (data[0] ?? null) : data;
}

async function casPatch(table, pk, expect, fields, apiKey) {
  const row = await getPk(table, pk, apiKey);
  if (!row) return { ok: false, reason: "not_found" };
  for (const [k, v] of Object.entries(expect)) {
    if (row[k] !== v) return { ok: false, reason: "precondition_failed", row };
  }
  const updated = await patchPk(table, pk, fields, apiKey);
  if (updated !== 1) return { ok: false, reason: "not_applied", updated };
  const verified = await getPk(table, pk, apiKey);
  for (const [k, v] of Object.entries(fields)) {
    if (verified[k] !== v) return { ok: false, reason: "verify_failed", row: verified };
  }
  return { ok: true, updated, row: verified };
}

Concurrency caveat

read → guard → patch by pk → read-back is not atomic. Two writers can both pass the client-side guard before either writes (lost update). Because there is no server-side CAS on non-pk columns, you cannot fix this purely at the row-update call. If correctness under concurrency matters, do one of:

The read-back is a correctness check for "did my write land", not a substitute for a transaction.


Verification

0. Environment sanity

curl -s http://localhost:3000/health
# -> 200 {"status":"healthy", ...}

All /api/... routes require the API key; without it:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/api/ns/ci/tables/trigger
# -> 401

1. Reproduce the two traps (before the fix)

BASE=http://localhost:3000; NS=ci; TABLE=trigger; PK=42

# Trap A: id selector rejected
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
  "$BASE/api/ns/$NS/tables/$TABLE?id=eq.$PK" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' \
  -d '{"status":"done"}'
# Expected: 400  (VALIDATION_ERROR)

# Put the row into a known non-matching state, then try a "guarded" write.
curl -s -X PATCH "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' \
  -d '{"status":"pending"}'

# Trap B: compound filter accepted, non-pk condition silently ignored
curl -s -D - -X PATCH \
  "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK&status=eq.ready" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' \
  -d '{"status":"done"}'
# Expected: 200 {"updated":1}  even though status was "pending"

Record the two specific observations from the live run on 2026-10-04 against /api/ns/ci/tables/trigger:

Request Result
matching "CAS" (status=eq.ready when row was ready) 200 {"updated":1}
non-matching "CAS" (status=eq.ready when row was not ready) 200 {"updated":1}

Both return the same body, proving the predicate was ignored. Neither response contains a Content-Range header.

2. Verify the fixed pattern

# Set the row to a non-matching state.
curl -s -X PATCH "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' \
  -d '{"status":"pending"}'

# Client-side guard must now refuse:
python3 - <<'PY'
import os
from duckbrain_cas import DuckBrain
db = DuckBrain("http://localhost:3000", api_key=os.environ["API_KEY"])
print(db.cas_patch("trigger", 42, {"status": "ready"}, {"status": "done"}))
# Expected: {'ok': False, 'reason': 'precondition_failed', 'row': {... 'status': 'pending'}}
PY

# Set the row to the matching state; the same call must succeed and verify.
curl -s -X PATCH "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' \
  -d '{"status":"ready"}'

python3 - <<'PY'
import os
from duckbrain_cas import DuckBrain
db = DuckBrain("http://localhost:3000", api_key=os.environ["API_KEY"])
r = db.cas_patch("trigger", 42, {"status": "ready"}, {"status": "done"})
assert r["ok"] and r["updated"] == 1 and r["row"]["status"] == "done", r
print("CAS + read-back verification passed:", r)
PY

3. Assert the mutation result comes from the body

resp=$(curl -s -D /tmp/h -X PATCH "$BASE/api/ns/$NS/tables/$TABLE?pk=eq.$PK" \
  -H "apikey: $API_KEY" -H 'content-type: application/json' -d '{"status":"done"}')
echo "body=$resp"
grep -i '^content-range:' /tmp/h && echo "unexpected Content-Range" || echo "no Content-Range (expected)"
# Parse the count only from the JSON body:
echo "$resp" | python3 -c 'import json,sys; print("updated =", json.load(sys.stdin)["updated"])'

4. Acceptance criteria


Migration checklist

Evidence & signatures

# Evidence
- Problem class: duckbrain-patch-pk-filter-ignores-compound-conditions
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-04T17:24:55.864Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-patch-pk-filter-ignores-compound-conditions", "provider": "openrouter", "solved_at": "2026-10-04T17:24:55.864Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog