Skip to content

refactor(store): maintain updated_at with a trigger, not by hand (RIG-3495) - #989

Merged
trunk-io[bot] merged 1 commit into
compass-managed/rig-3108-routing-fabricfrom
compass-managed/rig-3495-updated-at-trigger
Sep 8, 2026
Merged

refactor(store): maintain updated_at with a trigger, not by hand (RIG-3495)#989
trunk-io[bot] merged 1 commit into
compass-managed/rig-3108-routing-fabricfrom
compass-managed/rig-3495-updated-at-trigger

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

This PR is part of a stack containing 6 PRs:

  1. main
  2. chore(go): raise module floor to 1.26 + absorb modernize sweep (RIG-3107) #876
  3. feat(fabric): add NATS EventFabric + RunnerFabric seams (RIG-3107) #877
  4. feat(fabric): add tenant-wildcard comms subscribe (RIG-3107) #903
  5. feat(fabric): add the RoutingFabric seam for binding-cache invalidation (RIG-3108) #955
  6. "refactor(store): maintain updated_at with a trigger, not by hand (RIG-3495)" (this PR)
  7. feat(store): add the durable session_bindings table and its store methods (RIG-3108) #990

updated_at was hand-maintained in every write statement, with nothing
enforcing it — and it had already rotted. secrets.updated_at is selected and
surfaced to callers as a time.Time (store/secrets.go:71, :191), but the
table's only write omits the column and there is no UPDATE at all, so the value
could never differ from created_at: a field that reads like freshness data and
never was.

Coverage before this change, over the five tables carrying the column: 5 of 9
write statements bumped it. Nothing distinguished the four that didn't.

Add a set_updated_at() trigger function and a BEFORE UPDATE trigger per
table, applied through a DO loop over an updated_at_tables array — the same
shape, and for the same reason, as the RLS policy loop above it. Then delete
every hand-written updated_at = now() so exactly one mechanism owns the
column. Adding a table is now one array entry.

BEFORE UPDATE and not BEFORE INSERT OR UPDATE: the column's DEFAULT now()
already stamps an inserted row, and an INSERT trigger would destroy the ability
to insert a deliberate value. An upsert's ON CONFLICT DO UPDATE fires on the
conflict path, which is what keeps every upsert's column live for free.

search_path is pinned to pg_catalog alone. The function is SECURITY INVOKER,
so it would otherwise resolve names against the caller's search_path; pinning
makes the body independent of it. public is deliberately NOT named — the
migration is applied into a per-test isolation schema as often as into public,
so public would pin to a schema that is not the one holding these tables.

Left untouched: issues.forge_updated_at and
forge_repo_subscriptions.swept_updated_at. Those are forge-supplied
watermarks, not this row's local mutation time, and their writers set them
deliberately (the issues upsert guards on the incoming value going forward).
They are differently named precisely so this trigger cannot reach them.

Matt's ruling (2026-09-07): created_at + updated_at is the general schema
convention going forward, maintained by this trigger rather than by hand.

Verified: 3 pgtest proofs — the value advances on UPDATE while created_at
does not move (the assertion that catches a BEFORE INSERT mistake), the upsert
conflict path fires through the real RecordAgentPlacement, and the secrets
table is armed. Red control: disabling only trigger creation fails all three,
each with before and after identical, i.e. still the insert-time default.
The pre-existing forge_cursors test asserting updated_at advances on an
enable-flip passes unchanged — it previously passed via the hand-written
assignment and now passes via the trigger, corroborating the cutover.

Gates: sqlc-drift, sql-migration-gate:check (squawk + sqruff, 0 issues),
go build ./..., golangci-lint run ./internal/store/... (0 issues), and the
full -tags pgtest store suite all pass.

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

@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

RIG-3495

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-managed-rig-3495-upd.compass-eng-docs.pages.dev

Deployed from compass-managed/rig-3495-updated-at-trigger at 7e61da9.

…-3495)

`updated_at` was hand-maintained in every write statement, with nothing
enforcing it — and it had already rotted. `secrets.updated_at` is selected and
surfaced to callers as a `time.Time` (`store/secrets.go:71`, `:191`), but the
table's only write omits the column and there is no UPDATE at all, so the value
could never differ from `created_at`: a field that reads like freshness data and
never was.

Coverage before this change, over the five tables carrying the column: 5 of 9
write statements bumped it. Nothing distinguished the four that didn't.

Add a `set_updated_at()` trigger function and a `BEFORE UPDATE` trigger per
table, applied through a `DO` loop over an `updated_at_tables` array — the same
shape, and for the same reason, as the RLS policy loop above it. Then delete
every hand-written `updated_at = now()` so exactly one mechanism owns the
column. Adding a table is now one array entry.

`BEFORE UPDATE` and not `BEFORE INSERT OR UPDATE`: the column's `DEFAULT now()`
already stamps an inserted row, and an INSERT trigger would destroy the ability
to insert a deliberate value. An upsert's `ON CONFLICT DO UPDATE` fires on the
conflict path, which is what keeps every upsert's column live for free.

`search_path` is pinned to `pg_catalog` alone. The function is SECURITY INVOKER,
so it would otherwise resolve names against the caller's `search_path`; pinning
makes the body independent of it. `public` is deliberately NOT named — the
migration is applied into a per-test isolation schema as often as into public,
so `public` would pin to a schema that is not the one holding these tables.

Left untouched: `issues.forge_updated_at` and
`forge_repo_subscriptions.swept_updated_at`. Those are forge-supplied
watermarks, not this row's local mutation time, and their writers set them
deliberately (the `issues` upsert guards on the incoming value going forward).
They are differently named precisely so this trigger cannot reach them.

Matt's ruling (2026-09-07): `created_at` + `updated_at` is the general schema
convention going forward, maintained by this trigger rather than by hand.

Verified: 3 pgtest proofs — the value advances on UPDATE while `created_at`
does not move (the assertion that catches a BEFORE INSERT mistake), the upsert
conflict path fires through the real `RecordAgentPlacement`, and the `secrets`
table is armed. Red control: disabling only trigger creation fails all three,
each with `before` and `after` identical, i.e. still the insert-time default.
The pre-existing `forge_cursors` test asserting `updated_at` advances on an
enable-flip passes unchanged — it previously passed via the hand-written
assignment and now passes via the trigger, corroborating the cutover.

Gates: `sqlc-drift`, `sql-migration-gate:check` (squawk + sqruff, 0 issues),
`go build ./...`, `golangci-lint run ./internal/store/...` (0 issues), and the
full `-tags pgtest` store suite all pass.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@rigel-mintaka
rigel-mintaka force-pushed the compass-managed/rig-3495-updated-at-trigger branch from 303a133 to 7e61da9 Compare September 7, 2026 22:29
@rigel-mintaka
rigel-mintaka marked this pull request as ready for review September 7, 2026 22:29
@trunk-io
trunk-io Bot merged commit 90a0af6 into main Sep 8, 2026
13 checks passed
@trunk-io
trunk-io Bot deleted the compass-managed/rig-3495-updated-at-trigger branch September 8, 2026 03:14
@trunk-io

trunk-io Bot commented Sep 8, 2026

Copy link
Copy Markdown

This pull request was merged into main as part of stacked PR 990.

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