Summary
The guard scheduler fails on every batch with SQLSTATE 42P08 (could not determine data type of parameter $2). The cause is untyped bind parameters passed into jsonb_build_object(...) inside applyDecision(). Because pgx uses the extended protocol (prepared statements), PostgreSQL must infer parameter types at PREPARE time, and jsonb_build_object(VARIADIC "any") gives it nothing to infer from → deterministic failure on every call.
Net effect: the guard subsystem has never completed a single batch on this deploy. Near-duplicate detection and auto-archive are non-functional. Core store/query/retrieval are on a separate code path and are unaffected — ctx otherwise works normally.
Verified by live reproduction against the running DB (PREPARE-only, no data mutated).
Environment
|
|
| Repo checkout |
GottZ/ctx @ branch root, commit 7091825 |
| PostgreSQL |
18.4 (Debian) |
| Extensions |
pgvector 0.8.2, timescaledb 2.27.1 |
| Image |
pgvector-timescaledb:pg18 |
| Driver |
pgx (extended protocol / prepared statements) |
| Host |
MacBook Pro (Mac14,9), Apple M2 Pro (10-core: 6P/4E), 16 GB RAM |
| OS |
macOS 26.5 (build 25F71), arm64 |
| Container runtime |
OrbStack (Docker API v29.4.0, client+server), aarch64 |
First failure observed ~3 min after container creation. Persists across container restarts and host reboot.
Symptom (daemon logs, verified)
Every guard batch (60s interval) produces, in order:
guard: apply decision failed ... error":"mark checked: ERROR: could not determine data type of parameter $2 (SQLSTATE 42P08)"
(block_id = whichever block is processed first in the batch)
current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02)
+ guard: update state failed (for every subsequent block in the batch)
scheduler: guard batch error ... commit unexpectedly resulted in rollback
The error string mark checked: identifies the failing site as the else branch of applyDecision() (the needs_review/clean path, which wraps the error with mark checked: %w). The first block in a batch is typically clean, so the else branch fails first; the transaction aborts (25P02), which then takes down every subsequent block in the same batch, and the final COMMIT rolls back.
Root cause (verified)
File: go/internal/guard/guard.go — func applyDecision(...) (L185-237 @ 7091825).
Both UPDATE branches build a JSONB metadata patch with bare parameters:
else branch (the one that surfaces in the error), L215-230:
_, err := tx.Exec(ctx,
`UPDATE context_blocks SET
guard_status = $2,
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
'guard_status', $2,
'guard_checked_at', $3,
'guard_similarity', $4,
'guard_matched_id', $5,
'guard_is_cross_scope', $6,
'guard_threshold_duplicate', 0.98,
'guard_threshold_review', 0.92
),
updated_at = now()
WHERE id = $1`,
blockID, result.Decision, checkedAt, result.Similarity, matchedIDVal, result.IsCrossScope,
)
near_duplicate branch, L195-209 — same pattern (note its column assignment is the literal guard_status = 'archived_dup', so only the jsonb_build_object carries the untyped params):
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
'guard_status', $2,
'guard_checked_at', $3,
'guard_similarity', $4,
'guard_matched_id', $5,
'guard_is_cross_scope', $6
),
Parameter bindings (identical in both branches):
| Param |
Go value |
Go type |
Intended PG type |
$1 |
blockID |
string |
uuid/text |
$2 |
result.Decision |
string |
text |
$3 |
checkedAt |
string (RFC3339) |
text |
$4 |
result.Similarity |
float64 |
float8 |
$5 |
matchedIDVal |
string |
text |
$6 |
result.IsCrossScope |
bool |
bool |
jsonb_build_object has signature jsonb_build_object(VARIADIC "any"). Every value argument is "any", so PostgreSQL cannot derive a concrete type for $2–$6 at PREPARE time and aborts with 42P08. The error caret points at the jsonb_build_object line.
Note the guard_status = $2 column assignment in the else branch does not rescue the inference: although guard_status is character varying, the same $2 is also used in the "any" context, and PG reports the undetermined parameter against the jsonb_build_object usage.
Reproduction (verified — live DB, PREPARE only, no rows mutated)
Failing form:
PREPARE guard_fail AS
UPDATE context_blocks SET
guard_status = $2,
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
'guard_status', $2, 'guard_checked_at', $3, 'guard_similarity', $4,
'guard_matched_id', $5, 'guard_is_cross_scope', $6
),
updated_at = now()
WHERE id = $1;
-- ERROR: could not determine data type of parameter $2 (SQLSTATE 42P08)
Working form (explicit casts):
PREPARE guard_ok AS
UPDATE context_blocks SET
guard_status = $2::text,
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
'guard_status', $2::text, 'guard_checked_at', $3::text, 'guard_similarity', $4::float8,
'guard_matched_id', $5::text, 'guard_is_cross_scope', $6::bool
),
updated_at = now()
WHERE id = $1;
-- PREPARE
Fix (verified)
Add explicit casts inside both jsonb_build_object(...) calls in applyDecision(). ~2 changed lines per branch.
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
- 'guard_status', $2,
- 'guard_checked_at', $3,
- 'guard_similarity', $4,
- 'guard_matched_id', $5,
- 'guard_is_cross_scope', $6
+ 'guard_status', $2::text,
+ 'guard_checked_at', $3::text,
+ 'guard_similarity', $4::float8,
+ 'guard_matched_id', $5::text,
+ 'guard_is_cross_scope', $6::bool
),
Apply the same casts to the else branch (which additionally has the two literal 0.98 / 0.92 keys — those need no change). Casting the guard_status = $2::text column assignment is optional and harmless.
In-repo precedent: writeAuditLog() in the same file (L256-266) already casts its bind params explicitly ($1::uuid, $2::uuid, ...), which is exactly why that statement prepares cleanly. The fix just brings applyDecision() in line with that existing pattern.
Impact
- Guard scheduler has never completed a batch since DB creation.
- All blocks remain at the insert-default
guard_status = 'active'; 0 have ever been guard-processed.
- Near-duplicate detection + auto-archive are effectively disabled.
- Each failed batch is one aborted transaction, rolling back any guard state updates batched within it.
- Unaffected: core store / query / retrieval (separate code path).
Open questions (unverified — for maintainer)
- This is likely surfaced or worsened by PG18's stricter parameter-type inference. It may have been latent — silently working — on older PostgreSQL versions or under a different pgx prepare mode. Does it reproduce on your targeted PG version?
- Would running this statement under
QueryExecModeSimpleProtocol mask the failure? If so, the explicit-cast fix is still the correct one (it's protocol-independent), but it would explain why the bug wasn't caught earlier.
Summary
The guard scheduler fails on every batch with
SQLSTATE 42P08(could not determine data type of parameter $2). The cause is untyped bind parameters passed intojsonb_build_object(...)insideapplyDecision(). Because pgx uses the extended protocol (prepared statements), PostgreSQL must infer parameter types at PREPARE time, andjsonb_build_object(VARIADIC "any")gives it nothing to infer from → deterministic failure on every call.Net effect: the guard subsystem has never completed a single batch on this deploy. Near-duplicate detection and auto-archive are non-functional. Core store/query/retrieval are on a separate code path and are unaffected — ctx otherwise works normally.
Verified by live reproduction against the running DB (PREPARE-only, no data mutated).
Environment
GottZ/ctx@ branchroot, commit7091825pgvector-timescaledb:pg18First failure observed ~3 min after container creation. Persists across container restarts and host reboot.
Symptom (daemon logs, verified)
Every guard batch (60s interval) produces, in order:
The error string
mark checked:identifies the failing site as theelsebranch ofapplyDecision()(theneeds_review/cleanpath, which wraps the error withmark checked: %w). The first block in a batch is typicallyclean, so theelsebranch fails first; the transaction aborts (25P02), which then takes down every subsequent block in the same batch, and the finalCOMMITrolls back.Root cause (verified)
File:
go/internal/guard/guard.go—func applyDecision(...)(L185-237 @7091825).Both UPDATE branches build a JSONB metadata patch with bare parameters:
elsebranch (the one that surfaces in the error), L215-230:near_duplicatebranch, L195-209 — same pattern (note its column assignment is the literalguard_status = 'archived_dup', so only thejsonb_build_objectcarries the untyped params):Parameter bindings (identical in both branches):
$1blockID$2result.Decision$3checkedAt$4result.Similarity$5matchedIDVal$6result.IsCrossScopejsonb_build_objecthas signaturejsonb_build_object(VARIADIC "any"). Every value argument is"any", so PostgreSQL cannot derive a concrete type for$2–$6at PREPARE time and aborts with42P08. The error caret points at thejsonb_build_objectline.Note the
guard_status = $2column assignment in theelsebranch does not rescue the inference: althoughguard_statusischaracter varying, the same$2is also used in the"any"context, and PG reports the undetermined parameter against thejsonb_build_objectusage.Reproduction (verified — live DB, PREPARE only, no rows mutated)
Failing form:
Working form (explicit casts):
Fix (verified)
Add explicit casts inside both
jsonb_build_object(...)calls inapplyDecision(). ~2 changed lines per branch.metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( - 'guard_status', $2, - 'guard_checked_at', $3, - 'guard_similarity', $4, - 'guard_matched_id', $5, - 'guard_is_cross_scope', $6 + 'guard_status', $2::text, + 'guard_checked_at', $3::text, + 'guard_similarity', $4::float8, + 'guard_matched_id', $5::text, + 'guard_is_cross_scope', $6::bool ),Apply the same casts to the
elsebranch (which additionally has the two literal0.98/0.92keys — those need no change). Casting theguard_status = $2::textcolumn assignment is optional and harmless.In-repo precedent:
writeAuditLog()in the same file (L256-266) already casts its bind params explicitly ($1::uuid, $2::uuid, ...), which is exactly why that statement prepares cleanly. The fix just bringsapplyDecision()in line with that existing pattern.Impact
guard_status = 'active'; 0 have ever been guard-processed.Open questions (unverified — for maintainer)
QueryExecModeSimpleProtocolmask the failure? If so, the explicit-cast fix is still the correct one (it's protocol-independent), but it would explain why the bug wasn't caught earlier.