Problem class: duckbrain-http-delete-pk-param
I found the actual substrate (wojons/duckbrain feat/native-s3, consumed by coding-hermes/auger), traced the delete handler, reproduced the bug against the real route stack, and wrote the solution to ~/duckbrain-http-delete-pk-param.md. Verification was live, not assumed: existing tables-supa3.test.ts (29/29 pass) plus a focused repro (delete-pk-repro.test.ts, 2/2 pass) showing ?id=eq.3 → 400 + row survives, ?pk=eq.3 → {"deleted":1} + row gone.
?pk=eq.<value>, not ?<column>=eq.<value>Problem class: duckbrain-http-delete-pk-param
Substrate: DuckBrain declared-tables HTTP API (/api/ns/<ns>/tables/<table>), branch feat/native-s3 (wojons/duckbrain), consumed by auger.
Evidence: auger AUG-017 closeout probe, 2026-09-22 — column-name form returned HTTP 400 and left the row behind; pk form returned {"deleted":1}.
Row-level DELETE (and PATCH) accept exactly one filter parameter, pk, whose value must be an equality expression eq.<value>:
# CORRECT
curl -X DELETE "$DUCKBRAIN_URL/api/ns/<ns>/tables/<table>?pk=eq.<primary-key-value>"
# -> 200 {"deleted":1}
# WRONG (400; row survives)
curl -X DELETE "$DUCKBRAIN_URL/api/ns/<ns>/tables/<table>?id=eq.<primary-key-value>"
# -> 400 {"error":"PATCH/DELETE require a primary key filter: ?pk=eq.<value>",
# "code":"VALIDATION_ERROR"}
id=eq.… is a valid GET filter; it is not a valid DELETE/PATCH filter. Write every delete probe with the pk= form from the start.
The declared-table router parses query strings in two different places with two different rules.
filterParams() (src/http/routes/tables.ts), which accepts any key matching a declared column and rejects unknown filter-shaped keys:// src/http/routes/tables.ts (filterParams)
if (key === "order" || key === "limit" || key === "offset" ||
key === "count" || key === "pk") { continue; }
if (!declaration.columns.some((c) => c.name === key)) {
if (/^[a-z]+\./.test(first)) { throw new ApiError(..., 400, "VALIDATION_ERROR"); }
continue;
}
out[key] = value;
So GET …?id=eq.3 works, because id is a declared column.
filterParams(). They call requirePkFilter() (src/http/routes/tables.ts:531), which reads only req.query.pk:function requirePkFilter(declaration, req) {
if (!declaration.primary) { throw new ApiError(
`Table '${declaration.name}' declares no primary key; PATCH/DELETE are not available`,
400, "VALIDATION_ERROR"); }
const col = declaration.columns.find((c) => c.name === declaration.primary)!;
const pk = req.query.pk; // <-- ONLY query.pk is read
if (pk === undefined) { throw new ApiError(
"PATCH/DELETE require a primary key filter: ?pk=eq.<value>",
400, "VALIDATION_ERROR"); }
const m = String(pk).match(/^eq\.(.+)$/s);
if (!m) { throw new ApiError(
"PATCH/DELETE require an equality filter on the primary key: ?pk=eq.<value>",
400, "VALIDATION_ERROR"); }
return { col, value: m[1] };
}
The DELETE handler (src/http/routes/tables.ts:618) is a thin wrapper:
router.delete("/:table", requireNamespaceGrant(...), asyncHandler(async (req, res) => {
const declaration = getTable(ns, tableName);
const { col, value } = requirePkFilter(declaration, req); // 400 if no ?pk=
const removed = deleteRowsByPk(namespaceDir(ns), declaration, col, value);
res.json({ deleted: removed });
}));
Failure chain for DELETE …?id=eq.3:
req.query.pk is undefined (the client sent id, not pk).requirePkFilter throws ApiError(..., 400, "VALIDATION_ERROR").deleteRowsByPk is never reached → the JSONL rewrite never runs.400, and the row remains on disk and in every subsequent GET — exactly the AUG-017 observation.The naming is the trap: the registered primary key is the column id, but the filter parameter is always the literal pk. PATCH shares the identical requirement (e.g. ?pk=eq.O-003 in docs/SUBSTRATE-VERIFICATION.md).
pk=eq.<encoded-value>Never derive the query key from the column name for DELETE/PATCH. Always use the literal pk.
curl
NS=my-namespace
TABLE=sdm_option
PK_VALUE='O-003'
curl -sS -X DELETE \
-H "x-api-key: $DUCKBRAIN_API_KEY" \
"$DUCKBRAIN_URL/api/ns/$NS/tables/$TABLE?pk=eq.$PK_VALUE"
# -> {"deleted":1}
Python (stdlib only, matches auger's transport)
import json, urllib.parse, urllib.request
def delete_by_pk(base_url, api_key, ns, table, pk_value):
"""DELETE a declared-table row using the primary-key PARAM (`pk`), never a column name."""
qs = urllib.parse.urlencode({"pk": f"eq.{pk_value}"})
url = f"{base_url}/api/ns/{urllib.parse.quote(ns)}/tables/{urllib.parse.quote(table)}?{qs}"
req = urllib.request.Request(url, method="DELETE",
headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=45) as resp:
body = json.loads(resp.read() or b"{}")
assert body.get("deleted") == 1, f"expected one row deleted, got {body}"
return body
Discovering the primary key name (for assertions/cleanup, not for the query key)
GET /api/ns/<ns>/tables returns each table's primary field (confirmed at src/http/routes/tables.ts:346). The wire param is still pk:
def primary_of(base_url, api_key, ns, table):
url = f"{base_url}/api/ns/{urllib.parse.quote(ns)}/tables"
req = urllib.request.Request(url, headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=45) as resp:
tables = json.loads(resp.read())
decl = next(t for t in tables["tables"] if t["name"] == table)
if not decl.get("primary"):
raise RuntimeError(f"{table} declares no primary key; DELETE is not available")
return decl["primary"] # e.g. "id" — but the query param is still "pk"
def test_delete_requires_pk_param_and_removes_the_row(base_url, api_key, ns, table):
pk_col, pk_value = "id", 3
bad = http_status("DELETE", f"{base_url}/api/ns/{ns}/tables/{table}?{pk_col}=eq.{pk_value}", api_key)
assert bad == 400, f"expected 400 for ?{pk_col}=, got {bad}"
assert row_exists(base_url, api_key, ns, table, pk_col, pk_value), "row must still exist after the 400"
out = delete_by_pk(base_url, api_key, ns, table, pk_value)
assert out == {"deleted": 1}
assert not row_exists(base_url, api_key, ns, table, pk_col, pk_value), "row must be gone"
Keep strictness; at minimum make the 400 name the declared primary key. If you must accept the column-name form, add it inside requirePkFilter only (never GET):
const pkRaw = req.query.pk ?? req.query[col.name]; // accept ?id=eq.v as an alias
The client-side contract is the fix that unblocks probes now.
Run against the real route stack at wojons/duckbrain@77b5b8b (feat/native-s3).
A. Existing contract tests
cd wojons-duckbrain && pnpm install
npx vitest run src/http/routes/tables-supa3.test.ts
# Test Files 1 passed (1) | Tests 29 passed (29)
B. Minimal reproduction / proof (both directions)
npx vitest run src/http/routes/delete-pk-repro.test.ts
# Test Files 1 passed (1) | Tests 2 passed (2)
DELETE …/widgets?id=eq.3 → 400 VALIDATION_ERROR, then GET …?id=eq.3 still returns 1 row (gamma). Row survived.DELETE …/widgets?pk=eq.3 → 200 {"deleted":1}, then GET …?id=eq.3 returns 0 rows. Row removed.C. Live curl check
curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE -H "x-api-key: $KEY" \
"$URL/api/ns/$NS/tables/widgets?id=eq.3" # -> 400
curl -sS -H "x-api-key: $KEY" "$URL/api/ns/$NS/tables/widgets?id=eq.3"
# -> [{"id":3,...}] (still there)
curl -sS -X DELETE -H "x-api-key: $KEY" \
"$URL/api/ns/$NS/tables/widgets?pk=eq.3" # -> {"deleted":1}
curl -sS -H "x-api-key: $KEY" "$URL/api/ns/$NS/tables/widgets?id=eq.3"
# -> []
DELETE/PATCH on a declared table, the query key is the literal pk. Never use the primary-key column's name as the query key.pk=eq.<value>. pk=gte.…, pk=in.…, and bare pk=<value> are rejected with 400.primary: null cannot be row-deleted ("declares no primary key; PATCH/DELETE are not available").eq. prefix stays unencoded.400 on a mutation probe as nothing happened — re-read the row, because a rejected DELETE leaves it in place.id=eq.… remains correct for GET; the read/mutate filter difference is deliberate.# Evidence - Problem class: duckbrain-http-delete-pk-param - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-22T21:11:19.608Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "DuckBrain declared-table API DELETE uses ?pk=eq.<value> (the registered primary key param), NOT ?<column>=eq.<value> \u2014 the column-name form returns 400 and leaves the row behind (hit in auger AUG-017 closeout probe 2026-09-22; pk form returned {deleted:1}). Write probes with the pk= form from the start.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-http-delete-pk-param", "provider": "openrouter", "solved_at": "2026-09-22T21:11:19.608Z", "version": ""}I found the actual substrate (wojons/duckbrain feat/native-s3, consumed by coding-hermes/auger), traced the delete handler, reproduced the bug against the real route stack, and wrote the solution to ~/duckbrain-http-delete-pk-param.md. Verification was live, not assumed: existing tables-supa3.test.ts (29/29 pass) plus a focused repro (delete-pk-repro.test.ts, 2/2 pass) showing ?id=eq.3 → 400 + row survives, ?pk=eq.3 → {"deleted":1} + row gone.
?pk=eq.<value>, not ?<column>=eq.<value>Problem class: duckbrain-http-delete-pk-param
Substrate: DuckBrain declared-tables HTTP API (/api/ns/<ns>/tables/<table>), branch feat/native-s3 (wojons/duckbrain), consumed by auger.
Evidence: auger AUG-017 closeout probe, 2026-09-22 — column-name form returned HTTP 400 and left the row behind; pk form returned {"deleted":1}.
Row-level DELETE (and PATCH) accept exactly one filter parameter, pk, whose value must be an equality expression eq.<value>:
# CORRECT
curl -X DELETE "$DUCKBRAIN_URL/api/ns/<ns>/tables/<table>?pk=eq.<primary-key-value>"
# -> 200 {"deleted":1}
# WRONG (400; row survives)
curl -X DELETE "$DUCKBRAIN_URL/api/ns/<ns>/tables/<table>?id=eq.<primary-key-value>"
# -> 400 {"error":"PATCH/DELETE require a primary key filter: ?pk=eq.<value>",
# "code":"VALIDATION_ERROR"}
id=eq.… is a valid GET filter; it is not a valid DELETE/PATCH filter. Write every delete probe with the pk= form from the start.
The declared-table router parses query strings in two different places with two different rules.
filterParams() (src/http/routes/tables.ts), which accepts any key matching a declared column and rejects unknown filter-shaped keys:// src/http/routes/tables.ts (filterParams)
if (key === "order" || key === "limit" || key === "offset" ||
key === "count" || key === "pk") { continue; }
if (!declaration.columns.some((c) => c.name === key)) {
if (/^[a-z]+\./.test(first)) { throw new ApiError(..., 400, "VALIDATION_ERROR"); }
continue;
}
out[key] = value;
So GET …?id=eq.3 works, because id is a declared column.
filterParams(). They call requirePkFilter() (src/http/routes/tables.ts:531), which reads only req.query.pk:function requirePkFilter(declaration, req) {
if (!declaration.primary) { throw new ApiError(
`Table '${declaration.name}' declares no primary key; PATCH/DELETE are not available`,
400, "VALIDATION_ERROR"); }
const col = declaration.columns.find((c) => c.name === declaration.primary)!;
const pk = req.query.pk; // <-- ONLY query.pk is read
if (pk === undefined) { throw new ApiError(
"PATCH/DELETE require a primary key filter: ?pk=eq.<value>",
400, "VALIDATION_ERROR"); }
const m = String(pk).match(/^eq\.(.+)$/s);
if (!m) { throw new ApiError(
"PATCH/DELETE require an equality filter on the primary key: ?pk=eq.<value>",
400, "VALIDATION_ERROR"); }
return { col, value: m[1] };
}
The DELETE handler (src/http/routes/tables.ts:618) is a thin wrapper:
router.delete("/:table", requireNamespaceGrant(...), asyncHandler(async (req, res) => {
const declaration = getTable(ns, tableName);
const { col, value } = requirePkFilter(declaration, req); // 400 if no ?pk=
const removed = deleteRowsByPk(namespaceDir(ns), declaration, col, value);
res.json({ deleted: removed });
}));
Failure chain for DELETE …?id=eq.3:
req.query.pk is undefined (the client sent id, not pk).requirePkFilter throws ApiError(..., 400, "VALIDATION_ERROR").deleteRowsByPk is never reached → the JSONL rewrite never runs.400, and the row remains on disk and in every subsequent GET — exactly the AUG-017 observation.The naming is the trap: the registered primary key is the column id, but the filter parameter is always the literal pk. PATCH shares the identical requirement (e.g. ?pk=eq.O-003 in docs/SUBSTRATE-VERIFICATION.md).
pk=eq.<encoded-value>Never derive the query key from the column name for DELETE/PATCH. Always use the literal pk.
curl
NS=my-namespace
TABLE=sdm_option
PK_VALUE='O-003'
curl -sS -X DELETE \
-H "x-api-key: $DUCKBRAIN_API_KEY" \
"$DUCKBRAIN_URL/api/ns/$NS/tables/$TABLE?pk=eq.$PK_VALUE"
# -> {"deleted":1}
Python (stdlib only, matches auger's transport)
import json, urllib.parse, urllib.request
def delete_by_pk(base_url, api_key, ns, table, pk_value):
"""DELETE a declared-table row using the primary-key PARAM (`pk`), never a column name."""
qs = urllib.parse.urlencode({"pk": f"eq.{pk_value}"})
url = f"{base_url}/api/ns/{urllib.parse.quote(ns)}/tables/{urllib.parse.quote(table)}?{qs}"
req = urllib.request.Request(url, method="DELETE",
headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=45) as resp:
body = json.loads(resp.read() or b"{}")
assert body.get("deleted") == 1, f"expected one row deleted, got {body}"
return body
Discovering the primary key name (for assertions/cleanup, not for the query key)
GET /api/ns/<ns>/tables returns each table's primary field (confirmed at src/http/routes/tables.ts:346). The wire param is still pk:
def primary_of(base_url, api_key, ns, table):
url = f"{base_url}/api/ns/{urllib.parse.quote(ns)}/tables"
req = urllib.request.Request(url, headers={"x-api-key": api_key})
with urllib.request.urlopen(req, timeout=45) as resp:
tables = json.loads(resp.read())
decl = next(t for t in tables["tables"] if t["name"] == table)
if not decl.get("primary"):
raise RuntimeError(f"{table} declares no primary key; DELETE is not available")
return decl["primary"] # e.g. "id" — but the query param is still "pk"
def test_delete_requires_pk_param_and_removes_the_row(base_url, api_key, ns, table):
pk_col, pk_value = "id", 3
bad = http_status("DELETE", f"{base_url}/api/ns/{ns}/tables/{table}?{pk_col}=eq.{pk_value}", api_key)
assert bad == 400, f"expected 400 for ?{pk_col}=, got {bad}"
assert row_exists(base_url, api_key, ns, table, pk_col, pk_value), "row must still exist after the 400"
out = delete_by_pk(base_url, api_key, ns, table, pk_value)
assert out == {"deleted": 1}
assert not row_exists(base_url, api_key, ns, table, pk_col, pk_value), "row must be gone"
Keep strictness; at minimum make the 400 name the declared primary key. If you must accept the column-name form, add it inside requirePkFilter only (never GET):
const pkRaw = req.query.pk ?? req.query[col.name]; // accept ?id=eq.v as an alias
The client-side contract is the fix that unblocks probes now.
Run against the real route stack at wojons/duckbrain@77b5b8b (feat/native-s3).
A. Existing contract tests
cd wojons-duckbrain && pnpm install
npx vitest run src/http/routes/tables-supa3.test.ts
# Test Files 1 passed (1) | Tests 29 passed (29)
B. Minimal reproduction / proof (both directions)
npx vitest run src/http/routes/delete-pk-repro.test.ts
# Test Files 1 passed (1) | Tests 2 passed (2)
DELETE …/widgets?id=eq.3 → 400 VALIDATION_ERROR, then GET …?id=eq.3 still returns 1 row (gamma). Row survived.DELETE …/widgets?pk=eq.3 → 200 {"deleted":1}, then GET …?id=eq.3 returns 0 rows. Row removed.C. Live curl check
curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE -H "x-api-key: $KEY" \
"$URL/api/ns/$NS/tables/widgets?id=eq.3" # -> 400
curl -sS -H "x-api-key: $KEY" "$URL/api/ns/$NS/tables/widgets?id=eq.3"
# -> [{"id":3,...}] (still there)
curl -sS -X DELETE -H "x-api-key: $KEY" \
"$URL/api/ns/$NS/tables/widgets?pk=eq.3" # -> {"deleted":1}
curl -sS -H "x-api-key: $KEY" "$URL/api/ns/$NS/tables/widgets?id=eq.3"
# -> []
DELETE/PATCH on a declared table, the query key is the literal pk. Never use the primary-key column's name as the query key.pk=eq.<value>. pk=gte.…, pk=in.…, and bare pk=<value> are rejected with 400.primary: null cannot be row-deleted ("declares no primary key; PATCH/DELETE are not available").eq. prefix stays unencoded.400 on a mutation probe as nothing happened — re-read the row, because a rejected DELETE leaves it in place.id=eq.… remains correct for GET; the read/mutate filter difference is deliberate.# Evidence - Problem class: duckbrain-http-delete-pk-param - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-22T21:11:19.608Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "DuckBrain declared-table API DELETE uses ?pk=eq.<value> (the registered primary key param), NOT ?<column>=eq.<value> \u2014 the column-name form returns 400 and leaves the row behind (hit in auger AUG-017 closeout probe 2026-09-22; pk form returned {deleted:1}). Write probes with the pk= form from the start.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "duckbrain-http-delete-pk-param", "provider": "openrouter", "solved_at": "2026-09-22T21:11:19.608Z", "version": ""}