Summary
dedupeSignalSnapshots keeps "the latest row per key" by selecting MAX(rowid). On Postgres — the primary production database — the dialect shim rewrites rowid to ctid, which is a physical heap location, not insertion order. The dedupe therefore keeps a stale snapshot and permanently deletes the fresh one for a large fraction of keys.
This is empirically confirmed on the production database, not inferred. Measured on edge-nl-01 on 2026-07-27, ~36% of multi-row keys for contributor-evidence-graph have ctid ordering that disagrees with actual recency.
Mechanism (verified at HEAD, 776c414)
src/db/retention.ts:256:
const staleCondition = `signal_type = ?1 AND rowid NOT IN (SELECT MAX(rowid) FROM signal_snapshots WHERE signal_type = ?1 GROUP BY target_key)`;
src/selfhost/pg-dialect.ts:192-194:
export function translateRowid(sql: string): string {
return sql.replace(/\browid\b/gi, "ctid");
}
ctid is (page, tuple). After the age-prune frees pages, a newer row inserted into a reclaimed early page receives a lower ctid than an older row sitting on a later page. MAX(ctid) then selects a stale row, and the NOT IN delete removes the genuinely newest snapshot. The loss is permanent.
The shim's own documentation forbids this usage
src/selfhost/pg-dialect.ts:185-191 states ctid is safe only for
internal bookkeeping resolved within one statement … never for durable application-facing row identity
But MAX(rowid) here decides which row survives a DELETE — durable row identity by definition. Meanwhile retention.ts:236-239 asserts the invariant:
"Latest" is the highest rowid: signal_snapshots is populated by a single sequential batch job, so insertion order and generated_at agree, and rowid (unlike generated_at) can never tie.
That is a SQLite-only truth which became false the moment the pg dialect shim was introduced. Neither comment references the other.
Production evidence (edge-nl-01, loopover-postgres-1, 2026-07-27)
Comparing, per (signal_type, target_key) with more than one row, the row chosen by ctid against the row chosen by recency:
SELECT signal_type, count(*) AS keys_where_ctid_picks_wrong_row FROM (
SELECT signal_type, target_key,
(array_agg(id ORDER BY ctid DESC))[1] AS by_ctid,
(array_agg(id ORDER BY generated_at DESC, id DESC))[1] AS by_time
FROM signal_snapshots GROUP BY 1,2 HAVING count(*) > 1
) t WHERE by_ctid IS DISTINCT FROM by_time GROUP BY 1 ORDER BY 2 DESC;
| signal_type |
keys where ctid picks the wrong row |
dedupe-eligible? |
contributor-evidence-graph |
114 of 319 |
yes |
contributor-strategy |
112 of 310 |
yes |
contributor-outcome-history |
112 of 310 |
yes |
repo-doc-refresh-attempt |
3 |
yes |
config-quality |
3 |
yes |
repo-public-focus-manifest |
3 |
yes |
contributor-decision-pack |
157 |
no — excluded by design |
queue-health |
16 |
no — excluded by design |
Dedupe eligibility per LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES (src/db/retention.ts:198-218). The two largest divergence counts are excluded from dedupe and are therefore not part of the impact — roughly 344 keys across eligible types are.
The dedupe demonstrably runs, and deletes at scale
audit_events where event_type = 'retention.prune':
2026-07-27T03:03:13.729Z | pruned 0 row(s), deduped 8570 signal_snapshots row(s)
2026-07-26T03:03:13.536Z | pruned 0 row(s), deduped 1977 signal_snapshots row(s)
2026-07-25T03:03:13.651Z | pruned 0 row(s), deduped 2031 signal_snapshots row(s)
2026-07-24T03:03:13.872Z | pruned 0 row(s), deduped 16928 signal_snapshots row(s)
Note pruned 0 row(s) — the age-prune deletes nothing (everything is inside its window), so the dedupe is doing all of the deleting. The measurement above was taken after the 03:03 run, on rows accumulated since; it is the population the next run will process. The mechanism has been active on every prior daily run.
Impact
signal_snapshots is the largest table in the database (818 MB / 13,155 rows). The affected types back contributor-intelligence and repo-policy state. The observable symptom is older intelligence state silently reappearing after a retention run, with no error and no log — readers order by generated_at DESC and faithfully serve whichever row survived.
Additional note: on PostgreSQL < 14 the same SQL hard-errors (function max(tid) does not exist), which would make runRetentionPrune throw after the prune step (src/queue/retention.ts:20). The compose file pins pgvector/pg16, so it silently mis-selects instead of failing loudly.
Requirements
- "Latest per key" must be decided by a real, monotonic, application-owned column — never by physical row location.
- The fix must be correct on both backends.
- The regression must be caught by a test that runs against real Postgres, not SQLite.
Deliverables
Tests (must fail against current main)
Expected outcome
The dedupe keeps the newest snapshot per key on every backend, and the class of bug — SQLite-shaped SQL silently changing meaning under the pg shim — is closed by documentation plus a real-Postgres test.
Summary
dedupeSignalSnapshotskeeps "the latest row per key" by selectingMAX(rowid). On Postgres — the primary production database — the dialect shim rewritesrowidtoctid, which is a physical heap location, not insertion order. The dedupe therefore keeps a stale snapshot and permanently deletes the fresh one for a large fraction of keys.This is empirically confirmed on the production database, not inferred. Measured on
edge-nl-01on 2026-07-27, ~36% of multi-row keys forcontributor-evidence-graphhavectidordering that disagrees with actual recency.Mechanism (verified at HEAD, 776c414)
src/db/retention.ts:256:src/selfhost/pg-dialect.ts:192-194:ctidis(page, tuple). After the age-prune frees pages, a newer row inserted into a reclaimed early page receives a lowerctidthan an older row sitting on a later page.MAX(ctid)then selects a stale row, and theNOT INdelete removes the genuinely newest snapshot. The loss is permanent.The shim's own documentation forbids this usage
src/selfhost/pg-dialect.ts:185-191statesctidis safe only forBut
MAX(rowid)here decides which row survives a DELETE — durable row identity by definition. Meanwhileretention.ts:236-239asserts the invariant:That is a SQLite-only truth which became false the moment the pg dialect shim was introduced. Neither comment references the other.
Production evidence (edge-nl-01,
loopover-postgres-1, 2026-07-27)Comparing, per
(signal_type, target_key)with more than one row, the row chosen byctidagainst the row chosen by recency:contributor-evidence-graphcontributor-strategycontributor-outcome-historyrepo-doc-refresh-attemptconfig-qualityrepo-public-focus-manifestcontributor-decision-packqueue-healthDedupe eligibility per
LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES(src/db/retention.ts:198-218). The two largest divergence counts are excluded from dedupe and are therefore not part of the impact — roughly 344 keys across eligible types are.The dedupe demonstrably runs, and deletes at scale
audit_eventswhereevent_type = 'retention.prune':Note
pruned 0 row(s)— the age-prune deletes nothing (everything is inside its window), so the dedupe is doing all of the deleting. The measurement above was taken after the 03:03 run, on rows accumulated since; it is the population the next run will process. The mechanism has been active on every prior daily run.Impact
signal_snapshotsis the largest table in the database (818 MB / 13,155 rows). The affected types back contributor-intelligence and repo-policy state. The observable symptom is older intelligence state silently reappearing after a retention run, with no error and no log — readers order bygenerated_at DESCand faithfully serve whichever row survived.Additional note: on PostgreSQL < 14 the same SQL hard-errors (
function max(tid) does not exist), which would makerunRetentionPrunethrow after the prune step (src/queue/retention.ts:20). The compose file pinspgvector/pg16, so it silently mis-selects instead of failing loudly.Requirements
Deliverables
MAX(rowid)selection with a deterministic application-level key:MAX(generated_at)withidas the tie-break, orMAX(id)if id ordering is guaranteed monotonic. State which and why.rowidusage that reaches Postgres for the same class of error.translateRowid's doc namesretention.tsbatched pruning andorb/relay.tsordering as the sanctioned uses — confirm each is genuinely "resolved within one statement" and not a durable selection. (src/orb/relay.ts:348-351's coalescerowid <predicate is a known suspect and is tracked separately.)translateRowid's doc with an explicit "never use for MAX/MIN row selection" clause, since that is the exact misuse that occurred.retention.ts:236-239.contributor_evidence/contributor_scoring_profilesupsert tables perretention.ts:203-208) and, if so, whether a one-off re-derivation is warranted.Tests (must fail against current main)
generated_at. This is the test whose absence allowed the bug — a SQLite-only test cannot catch it.contributor-decision-pack,queue-health) are still not deduped.Expected outcome
The dedupe keeps the newest snapshot per key on every backend, and the class of bug — SQLite-shaped SQL silently changing meaning under the pg shim — is closed by documentation plus a real-Postgres test.