Skip to content

fix(db): cancel attribution lands in a write-only column while canceled_by stays NULL #119

Description

@cristim

Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.

Two defects compound on a single UPDATE: the cancel actor is stamped into a column nothing reads, and the readable column is never written.

Where

  • internal/config/store_postgres.go:997-1011 - TransitionExecutionStatus (actor written to transitioned_by at :1001; RETURNING list at :1006)
  • Caller: internal/api/handler_purchases.go:445 (cancelOrRecoverExecution), reached from handleCancel at handler_purchases.go:420
  • Column contract: internal/database/postgres/migrations/000089_rename_cancelled_to_canceled.up.sql and 000077_audit_actor_stamps.up.sql
  • The setter that is not on this path: SetCancelledBy (store_postgres.go:1048), called only from the revoke flow at handler_purchases.go:1468
  • Sibling readers that do it right: store_postgres.go:1194, :1235, :1260, :1302, :1323, :1347, :1377, :1404, :1608

What

  1. The audit stamp is write-only. TransitionExecutionStatus stamps the actor into transitioned_by, the column migration 000077 added as the audit actor stamp. git grep transitioned_by over all non-test Go on origin/main shows it appears only in three UPDATE statements (store_postgres.go:1001, store_postgres.go:2505, store_postgres_registrations.go:184) and in comments. No SELECT list in the repository projects it, and PurchaseExecution has no field for it. The value is written and then unreachable.

  2. The RETURNING clause breaks the expand-contract invariant. The same UPDATE's RETURNING list projects the raw legacy cancelled_by, while all nine sibling readers project COALESCE(canceled_by, cancelled_by). Migration 000089 states the invariant explicitly:

    every read projects COALESCE(canceled_by, cancelled_by), so a row written by EITHER old or new code at ANY point in the deploy window reads correctly.

    This RETURNING clause is the one place in the repository that breaks it.

Failure scenario

A user cancels a pending purchase from the UI. handleCancel resolves the session user and calls cancelOrRecoverExecution -> TransitionExecutionStatus(..., "canceled", actor).

The row ends with status = 'canceled', transitioned_by = <user uuid>, and both canceled_by and cancelled_by still NULL. The ordinary cancel path never calls SetCancelledBy; that setter is invoked only from the revoke flow.

Every subsequent read projects COALESCE(canceled_by, cancelled_by) and gets NULL, so the History view shows a canceled execution with no accountable party, while the actor that was recorded sits in transitioned_by where no query can reach it. For a money-mutation action that is a complete loss of attribution.

Separately, when canceled_by is already set (for example by CancelExecutionAtomic, store_postgres.go:1081, which writes canceled_by only), a later SavePurchaseExecutionTx (store_postgres.go:924, cancelled_by = $17) writes only the legacy column. The COALESCE prefers the already-set canceled_by, so that update is silently invisible.

Fix direction

  • Have TransitionExecutionStatus write canceled_by (and cancelled_by for the deploy window) when toStatus is a cancel state.
  • Change its RETURNING to the same COALESCE(canceled_by, cancelled_by) the other readers use.
  • Either project transitioned_by into PurchaseExecution so the audit stamp is reachable, or drop the write. A column written by three statements and read by none is worse than no column, because it looks like the audit trail exists.

Related

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions