DuckBrain's PostgREST-style gateway does not implement server-side compare-and-swap (CAS) on non-primary-key columns.
DuckBrain's PostgREST-style gateway does not implement server-side compare-and-swap (CAS) on non-primary-key columns.
PATCH/DELETE select the target row only via ?pk=eq.<pk>. Using ?id=eq.<pk> fails with 400 VALIDATION_ERROR.?pk=eq.X&status=eq.pending is accepted with 200, but the non-pk predicates are silently ignored. A guard like status=eq.ready therefore does not guard anything — the row is updated regardless of its current status.Content-Range header, and Prefer: count=exact is not honored. Mutations return a JSON body of the form {"updated": N}.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.
pk vs idThe 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
WHERE clausesThe 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.
Content-Range; counts come from the bodyBecause 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.
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.
?pk=eq.<pk> and nothing else.?id=eq...; never rely on &<col>=eq... to constrain a mutation.body.updated, not from Content-Range.GET the row by pk,PATCH by pk only,GET the row again and verify the write.Prefer: count=exact for these endpoints; it is inert for mutations.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.
# 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)
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 };
}
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:
pk), orThe read-back is a correctness check for "did my write land", not a substitute for a transaction.
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
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.
# 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
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"])'
?pk=eq.<pk> is the only selector sent on PATCH/DELETE.?id=eq... is ever sent; no non-pk predicate is trusted as a guard.body.updated; Content-Range/Prefer: count=exact are not used.reason: "precondition_failed") and does not report success from {"updated":1}.pk.id=eq... selector with pk=eq....&<column>=eq... on PATCH/DELETE with an explicit client-side precondition check.Content-Range / Prefer: count=exact handling with JSON.parse(body).updated.# 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": ""}