Skip to content

feat(store): migrate messages + topics + delivery to sqlc (T4) - #795

Merged
mattwilkinsonn merged 1 commit into
mainfrom
compass-repo/rig-3034-sqlc-t4-messages
Aug 31, 2026
Merged

feat(store): migrate messages + topics + delivery to sqlc (T4)#795
mattwilkinsonn merged 1 commit into
mainfrom
compass-repo/rig-3034-sqlc-t4-messages

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 4 PRs:

  1. main
  2. "feat(store): migrate messages + topics + delivery to sqlc (T4)" (this PR)
  3. feat(store): migrate agent sessions/transcripts/activity/config/placements to sqlc (T5) #796
  4. feat(store): migrate authz/tokens/secrets/issues/forge/tenant/linear to sqlc (T6) #806
  5. feat(store): sqlc vet in CI + promote inline-sql-gate to gate identifier-passed SQL (T7) #809

Migrate the message, topic, and delivery-consumer store domains from inline
pgx SQL to sqlc-generated queries (RIG-3034 T4, design record §T4). The
hand-written Store methods keep their exact exported signatures and wrap the
generated calls; error wrapping, domain-type mapping, tx orchestration, the
D9 not-found/forbidden merge, and the D2 seed self-guard all stay
hand-written.

Files migrated (queries/*.sql compiled into internal/store/db/):

  • messages.go (16 sites): post-policy read, insert (:one, ON CONFLICT dedup
    signalled via errMessageInsertConflict), topic get-or-create, list/search
    (:many), ask-containment find (FOR UPDATE), authored update (:one RETURNING),
    block update (:execrows), dedup read.
  • topics.go (9 sites): list, resolve-for-update, rename/merge resolution loop,
    archive, get.
  • delivery_cursors.go (15 sites): seed (per-agent + per-channel), owed-mention
    record/read/clear (:execrows)/count, mention-routed mark + recovery scan,
    AckDelivery resolve/load(FOR UPDATE)/self-authored/advance, undelivered
    sweep, in-sweep-set.
  • delivery_reads.go (7 sites): subscribed agents, channel agent members,
    is-agent, message-by-id, message-channel, topic/channel names, sweep
    channels.
  • presence_reads.go (2 sites): open-ask JSONB path probe, shared-channel
    EXISTS. Const-hoisted, never in the gate allowlist — no entry to remove.

Each migrated statement is semantically identical to the inline SQL it
replaces; RowsAffected-branching sites use :execrows. scanMessages/scanTopics/
scanMessagesByChannel and the now-dead execer interface are removed in favor of
generated-row mappers (messageFromParts, topicFromRow); the fail-loud decode
error on a malformed block set is preserved.

inline-sql-gate allowlist: 21 -> 17 (drop messages.go, topics.go,
delivery_cursors.go, delivery_reads.go).

Verified: go build ./... clean; go test ./internal/store/ ok; full pgtest lane
(podman postgres:16) ok; golangci-lint 0 issues; sqlc generate drift-clean;
GATE_ROOT=. bun tools/inline-sql-gate/index.ts exits 0 (allowlist 17).

Co-authored-by: Matt Wilkinson matt@rigel.build

@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

RIG-3034

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-repo-rig-3034-sqlc-t-qu7x.compass-eng-docs.pages.dev

Deployed from compass-repo/rig-3034-sqlc-t4-messages at c84f42a.

@rigel-mintaka
rigel-mintaka force-pushed the compass-repo/rig-3034-sqlc-t4-messages branch from e49ae3f to 8c39f8b Compare August 31, 2026 16:52
Base automatically changed from compass-repo/rig-3034-sqlc-t3-channels to main August 31, 2026 17:25
Migrate the message, topic, and delivery-consumer store domains from inline
pgx SQL to sqlc-generated queries (RIG-3034 T4, design record §T4). The
hand-written Store methods keep their exact exported signatures and wrap the
generated calls; error wrapping, domain-type mapping, tx orchestration, the
D9 not-found/forbidden merge, and the D2 seed self-guard all stay
hand-written.

Files migrated (queries/*.sql compiled into internal/store/db/):
- messages.go (16 sites): post-policy read, insert (:one, ON CONFLICT dedup
  signalled via errMessageInsertConflict), topic get-or-create, list/search
  (:many), ask-containment find (FOR UPDATE), authored update (:one RETURNING),
  block update (:execrows), dedup read.
- topics.go (9 sites): list, resolve-for-update, rename/merge resolution loop,
  archive, get.
- delivery_cursors.go (15 sites): seed (per-agent + per-channel), owed-mention
  record/read/clear (:execrows)/count, mention-routed mark + recovery scan,
  AckDelivery resolve/load(FOR UPDATE)/self-authored/advance, undelivered
  sweep, in-sweep-set.
- delivery_reads.go (7 sites): subscribed agents, channel agent members,
  is-agent, message-by-id, message-channel, topic/channel names, sweep
  channels.
- presence_reads.go (2 sites): open-ask JSONB path probe, shared-channel
  EXISTS. Const-hoisted, never in the gate allowlist — no entry to remove.

Each migrated statement is semantically identical to the inline SQL it
replaces; RowsAffected-branching sites use :execrows. scanMessages/scanTopics/
scanMessagesByChannel and the now-dead execer interface are removed in favor of
generated-row mappers (messageFromParts, topicFromRow); the fail-loud decode
error on a malformed block set is preserved.

applyAskAnswer takes a nil-guard on its *Message parameter: messageFromParts
returns a value Message flowing in via &msg, and the module's nilaway gate (now
gating, not advisory) cannot prove the pointer non-nil across the call, so it
reports a potential nil-panic at the msg.Blocks deref. The guard is the clean
suppression (nilaway ignores //nolint); the branch is a defensive invariant —
the sole caller passes the address of a freshly-built local — so the gating
nilaway job is its regression guard.

inline-sql-gate allowlist: 21 -> 17 (drop messages.go, topics.go,
delivery_cursors.go, delivery_reads.go).

Verified: go build ./... clean; go test ./internal/store/ ok; full pgtest lane
(podman postgres:16) ok; golangci-lint 0 issues; sqlc generate drift-clean;
GATE_ROOT=. bun tools/inline-sql-gate/index.ts exits 0 (allowlist 17).

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@mattwilkinsonn
mattwilkinsonn merged commit ff5bfab into main Aug 31, 2026
28 of 30 checks passed
@mattwilkinsonn
mattwilkinsonn deleted the compass-repo/rig-3034-sqlc-t4-messages branch August 31, 2026 21:15
rigel-mintaka added a commit that referenced this pull request Aug 31, 2026
…te identifier-passed SQL (RIG-3034)

Collapses the remaining sqlc-adoption slices (T5, T6, T7) into one change atop
the merged T4 (#795). Every store domain now uses sqlc-generated typed queries,
the inline-sql-gate allowlist is down to its two permanent escape hatches, `sqlc
vet` (db-prepare) runs against a live schema in CI, and the gate now catches
identifier-passed SQL, not just string literals. Method signatures are unchanged
throughout; only bodies now call generated queries via s.q (pool) or
s.q.WithTx(tx) / db.New(tx). Error wrapping, domain-type mapping, RowsAffected
branching, uint64<->int64 narrowing, flush orchestration, and tx isolation stay
hand-written.

## Agent domains (was T5)

Five agent-domain files migrated (queries/<domain>.sql):
- agent_sessions.go    → 3 queries
- agent_transcripts.go → 13 distinct queries backing 16 call sites
  (SessionTranscript + SafetyValveSegments each serve a pool method and the
  resume-snapshot tx; InsertTranscriptEntry is :execrows for ON CONFLICT dedup).
- agent_activity.go    → 2 queries
- agent_config.go      → 3 queries
- agent_placements.go  → 5 queries

COALESCE(MAX/SUM(...),0) reads carry an explicit ::BIGINT cast so sqlc types them
int64 (mirrors messages.sql MessagesHeadSeq). Bare-column references visible in
both outer and MAX-subquery scope are table-aliased (te/cp/e) — pure
disambiguation, semantically identical. Removed dead scanTranscriptRows/
scanSegmentRows helpers and the remarkSafetyValveSQL const.

## Remaining domains (was T6)

Every remaining domain file migrated, leaving the allowlist at only the two
permanent entries (store.go migration runner, pgshare.go test harness):
- authz.go: 3 new queries; dead querier interface removed from store.go; helpers
  take db.DBTX (pool and pgx.Tx both satisfy it).
- tokens.go: 4 (RevokeToken :execrows; revoked_at IS NOT NULL cast ::boolean).
- secrets.go: 3 (DeleteSecret :execrows).
- issues.go: 4 (UpsertIssueForgeFields CTE, SetIssueState :execrows); scanIssue/
  scanRow → issueFromColumns.
- forge_authored.go: 4.
- forge_cursors.go: 7 (StoreForgeRepoWatermark, SetForgeRepoSubscriptionEnabled
  :execrows).
- forge_subscriptions.go: 9 (AdvanceForgeDeliveredRevision :execrows). derefString
  removed.
- tenant.go: 2.
- linear_sessions.go: 2 (UpsertLinearAgentSession :execrows). scanLinearAgentSession
  removed.
- dm.go: residue file (added post-record); 8 queries covering get-or-create, the
  channel resolution/insert loop, the advisory lock, the R3 verify-reconcile belt,
  and the reserved-group discriminator.

Non-obvious mappings: interface{} columns forced concrete via casts (tokens
revoked ::boolean; forge_subscriptions coord_number ::BIGINT, has_cursor
::boolean; SubscribersForArtifact openedEvent ::boolean); GC subquery columns
table-qualified to resolve an ambiguous-column analyzer failure; constant
enum→int16 conversions carry no nolint (compile-time checked), only variable
narrowings do.

## Vet in CI + gate promotion (was T7)

sqlc.yaml: add `database.uri: ${SQLC_DATABASE_URL}` + `rules: [sqlc/db-prepare]`
so `sqlc vet` PREPAREs every generated query against a live Postgres. Set
`analyzer.database: false` so `sqlc generate` and the DB-free sqlc-drift gate keep
working without a database — only db-prepare (vet) uses database.uri. Without
this, the database stanza would force generate to require a live DB and break the
'sqlc-drift is fully local, no DB' invariant.

moon.yml: add a `sqlc-vet` task (modeled on sqlc-drift) — DSN-gated skip when
neither COMPASS_TEST_DATABASE_DSN nor SQLC_DATABASE_URL is set; otherwise it
provisions a uniquely-named throwaway DB (sqlcvet_$$) on the service, applies
internal/store/migrations/*.sql via psql, points SQLC_DATABASE_URL at it, runs
`sqlc vet`, and drops the DB on a cleanup trap. NOT added to the moon `ci` deps:
that battery realizes no service.

ci.yml: run `moon run compass-go:sqlc-vet --force` in the existing `pgtest` job,
which already carries the postgres:16-alpine service + COMPASS_TEST_DATABASE_DSN.
This is where db-prepare runs live — the same peel-into-a-service-job pattern as
pgtest/microvm/forge/gtk4 (Matt's Option-B ruling on the CI fork: the spec
assumed the ci-aggregate job had the service, but the service lives only on the
pgtest job). A phase-two nixpkgs-tools step puts psql + sqlc on PATH. `--force`
because the pgtest job checks out shallow (no fetch-depth: 0); a bare `moon run`
resolves the PR base branch for affected/cache-hash and dies git-128 in a shallow
clone, and sqlc-vet must always run here regardless of what the diff touched.

inline-sql-gate: promote identifier-passed SQL from advisory to GATED. Beyond
string-literal SQL at any receiver, a bare-identifier SQL slot (`q`, `ddl`,
`m.sql`) is now flagged when the call's receiver is a pgx pool/tx/conn handle
(last segment in {pool, tx, conn, c}). The receiver scope is the load-bearing
guard against runtime/compute false positives (r.runtime.Exec(ctx,id,spec) /
g.client.Exec(ctx,req) are not queries). Only the SQL slot (arg after ctx) is
tested; a call/composite/concat expression is left alone. Stays GREEN on the
migrated tree: the only remaining identifier-passed sites are conn.Exec(ctx,ddl)
/ tx.Exec(ctx,m.sql) in the permanently-allowlisted store.go (allowlist=2).
Fail-closed stale-entry ratchet intact; +7 detection tests.

RIG-3034

Co-authored-by: Matt Wilkinson <matt@rigel.build>
mattwilkinsonn added a commit that referenced this pull request Aug 31, 2026
…te identifier-passed SQL (RIG-3034) (#812)

Collapses the remaining sqlc-adoption slices (T5, T6, T7) into one change atop
the merged T4 (#795). Every store domain now uses sqlc-generated typed queries,
the inline-sql-gate allowlist is down to its two permanent escape hatches, `sqlc
vet` (db-prepare) runs against a live schema in CI, and the gate now catches
identifier-passed SQL, not just string literals. Method signatures are unchanged
throughout; only bodies now call generated queries via s.q (pool) or
s.q.WithTx(tx) / db.New(tx). Error wrapping, domain-type mapping, RowsAffected
branching, uint64<->int64 narrowing, flush orchestration, and tx isolation stay
hand-written.

## Agent domains (was T5)

Five agent-domain files migrated (queries/<domain>.sql):
- agent_sessions.go    → 3 queries
- agent_transcripts.go → 13 distinct queries backing 16 call sites
  (SessionTranscript + SafetyValveSegments each serve a pool method and the
  resume-snapshot tx; InsertTranscriptEntry is :execrows for ON CONFLICT dedup).
- agent_activity.go    → 2 queries
- agent_config.go      → 3 queries
- agent_placements.go  → 5 queries

COALESCE(MAX/SUM(...),0) reads carry an explicit ::BIGINT cast so sqlc types them
int64 (mirrors messages.sql MessagesHeadSeq). Bare-column references visible in
both outer and MAX-subquery scope are table-aliased (te/cp/e) — pure
disambiguation, semantically identical. Removed dead scanTranscriptRows/
scanSegmentRows helpers and the remarkSafetyValveSQL const.

## Remaining domains (was T6)

Every remaining domain file migrated, leaving the allowlist at only the two
permanent entries (store.go migration runner, pgshare.go test harness):
- authz.go: 3 new queries; dead querier interface removed from store.go; helpers
  take db.DBTX (pool and pgx.Tx both satisfy it).
- tokens.go: 4 (RevokeToken :execrows; revoked_at IS NOT NULL cast ::boolean).
- secrets.go: 3 (DeleteSecret :execrows).
- issues.go: 4 (UpsertIssueForgeFields CTE, SetIssueState :execrows); scanIssue/
  scanRow → issueFromColumns.
- forge_authored.go: 4.
- forge_cursors.go: 7 (StoreForgeRepoWatermark, SetForgeRepoSubscriptionEnabled
  :execrows).
- forge_subscriptions.go: 9 (AdvanceForgeDeliveredRevision :execrows). derefString
  removed.
- tenant.go: 2.
- linear_sessions.go: 2 (UpsertLinearAgentSession :execrows). scanLinearAgentSession
  removed.
- dm.go: residue file (added post-record); 8 queries covering get-or-create, the
  channel resolution/insert loop, the advisory lock, the R3 verify-reconcile belt,
  and the reserved-group discriminator.

Non-obvious mappings: interface{} columns forced concrete via casts (tokens
revoked ::boolean; forge_subscriptions coord_number ::BIGINT, has_cursor
::boolean; SubscribersForArtifact openedEvent ::boolean); GC subquery columns
table-qualified to resolve an ambiguous-column analyzer failure; constant
enum→int16 conversions carry no nolint (compile-time checked), only variable
narrowings do.

## Vet in CI + gate promotion (was T7)

sqlc.yaml: add `database.uri: ${SQLC_DATABASE_URL}` + `rules: [sqlc/db-prepare]`
so `sqlc vet` PREPAREs every generated query against a live Postgres. Set
`analyzer.database: false` so `sqlc generate` and the DB-free sqlc-drift gate keep
working without a database — only db-prepare (vet) uses database.uri. Without
this, the database stanza would force generate to require a live DB and break the
'sqlc-drift is fully local, no DB' invariant.

moon.yml: add a `sqlc-vet` task (modeled on sqlc-drift) — DSN-gated skip when
neither COMPASS_TEST_DATABASE_DSN nor SQLC_DATABASE_URL is set; otherwise it
provisions a uniquely-named throwaway DB (sqlcvet_$$) on the service, applies
internal/store/migrations/*.sql via psql, points SQLC_DATABASE_URL at it, runs
`sqlc vet`, and drops the DB on a cleanup trap. NOT added to the moon `ci` deps:
that battery realizes no service.

ci.yml: run `moon run compass-go:sqlc-vet --force` in the existing `pgtest` job,
which already carries the postgres:16-alpine service + COMPASS_TEST_DATABASE_DSN.
This is where db-prepare runs live — the same peel-into-a-service-job pattern as
pgtest/microvm/forge/gtk4 (Matt's Option-B ruling on the CI fork: the spec
assumed the ci-aggregate job had the service, but the service lives only on the
pgtest job). A phase-two nixpkgs-tools step puts psql + sqlc on PATH. `--force`
because the pgtest job checks out shallow (no fetch-depth: 0); a bare `moon run`
resolves the PR base branch for affected/cache-hash and dies git-128 in a shallow
clone, and sqlc-vet must always run here regardless of what the diff touched.

inline-sql-gate: promote identifier-passed SQL from advisory to GATED. Beyond
string-literal SQL at any receiver, a bare-identifier SQL slot (`q`, `ddl`,
`m.sql`) is now flagged when the call's receiver is a pgx pool/tx/conn handle
(last segment in {pool, tx, conn, c}). The receiver scope is the load-bearing
guard against runtime/compute false positives (r.runtime.Exec(ctx,id,spec) /
g.client.Exec(ctx,req) are not queries). Only the SQL slot (arg after ctx) is
tested; a call/composite/concat expression is left alone. Stays GREEN on the
migrated tree: the only remaining identifier-passed sites are conn.Exec(ctx,ddl)
/ tx.Exec(ctx,m.sql) in the permanently-allowlisted store.go (allowlist=2).
Fail-closed stale-entry ratchet intact; +7 detection tests.

RIG-3034

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants