Files
PaperClipAI/packages/db/src/migrations/0134_run_responsible_user_invariant.sql
T
DottaandPaperclip 0f08c2b526 fix(db): repair responsible user migration timestamps (#9146)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The database migration layer keeps local and deployed instances
moving forward safely as schema/data contracts evolve.
> - The responsible-user backfill migration could make historical issues
look newly updated by bumping user-visible `updated_at` columns during a
backfill.
> - That timestamp churn can invalidate inbox/archive state and make old
work appear fresh even though no user-facing activity happened.
> - This pull request moves the responsible-user invariant migration
later in the current migration sequence, keeps it from touching
user-visible timestamps, and adds a repair sweep for databases that
already saw the timestamp bump.
> - The benefit is safer migration replay and regression coverage for
future backfills that might otherwise mutate visible timestamps.

## Linked Issues or Issue Description

No public GitHub issue exists. Inline bug report:

**Pre-submission checklist**

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip (or can reproduce
on `master`).
- [x] I have confirmed the error originates in Paperclip itself — not in
my agent adapter, API provider, or local configuration.

**What happened?**

A responsible-user migration backfill could update user-visible
`updated_at` columns while filling missing responsible-user data. That
makes historical issues/runs/routines appear newer even when no
user-facing activity happened.

**Expected behavior**

Responsible-user backfills should populate ownership metadata without
mutating user-visible recency fields, and databases already affected by
a timestamp sweep should be repairable.

**Steps to reproduce**

1. Start from current `master` with the responsible-user migration
sequence.
2. Apply migrations to a database containing historical issues, runs,
routines, and companies with older activity timestamps.
3. Replay the responsible-user invariant migration and inspect
user-visible `updated_at` values.

**Paperclip version or commit**

`master` at the PR base.

**Deployment mode**

Local dev (`pnpm dev`) and deployed instances using the same migrations.

**Installation method**

Built from source (`pnpm dev` / `pnpm build`).

**Agent adapter(s) involved**

- [x] Not adapter-specific (core bug)

**Database mode**

External Postgres and embedded development Postgres migration paths.

**Access context**

Board and agent-visible issue recency can both be affected.

**Relevant logs or output**

Covered by the added embedded-Postgres regression tests.

**Relevant config (if applicable)**

Not applicable.

**Additional context**

This PR adapts an extracted local migration fix onto the current master
migration sequence, where `0133` is already occupied.

**Privacy checklist**

- [x] I have reviewed all pasted output for PII (usernames, file paths,
API keys, tokens, company names) and redacted where necessary.

## What Changed

- Renumbered the responsible-user invariant migration onto the current
master migration sequence.
- Added a repair migration that detects broad timestamp sweeps and
restores safer `updated_at` values for issues, heartbeat runs, routines,
routine runs, and companies.
- Added focused embedded-Postgres regression coverage for the relocated
migration, repair migration, and updated-at backfill allowlist.

## Verification

- `pnpm --filter @paperclipai/db run check:migrations`
- `/srv/paperclip/home/paperclipai/paperclip/node_modules/.bin/vitest
run packages/db/src/client.test.ts -t "migration 0134|migration
0135|unallowlisted migration backfills"`

## Risks

Migration behavior is the main risk. The PR intentionally changes the
active migration sequence by removing the old responsible-user invariant
slot and replaying that work later with a repair migration. Reviewers
should confirm this matches the intended release/migration policy for
installations that may already have applied the earlier migration.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5.5 coding agent with repository tool use and local
shell execution. Context window was not surfaced by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-07 05:54:17 -05:00

212 lines
7.0 KiB
SQL

ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "default_responsible_user_id" text;
--> statement-breakpoint
WITH owner_defaults AS (
SELECT DISTINCT ON ("company_id")
"company_id",
"principal_id" AS "user_id"
FROM "company_memberships"
WHERE "principal_type" = 'user'
AND "status" = 'active'
AND "membership_role" = 'owner'
ORDER BY "company_id", "created_at" ASC, "id" ASC
)
UPDATE "companies" AS c
SET "default_responsible_user_id" = owner_defaults."user_id"
FROM owner_defaults
WHERE c."id" = owner_defaults."company_id"
AND c."default_responsible_user_id" IS NULL;
--> statement-breakpoint
WITH RECURSIVE issue_chain AS (
SELECT
child."id" AS "issue_id",
child."company_id",
child."parent_id",
child."responsible_user_id",
child."created_by_user_id",
0 AS "depth"
FROM "issues" AS child
WHERE child."responsible_user_id" IS NULL
UNION ALL
SELECT
issue_chain."issue_id",
parent."company_id",
parent."parent_id",
parent."responsible_user_id",
parent."created_by_user_id",
issue_chain."depth" + 1
FROM issue_chain
JOIN "issues" AS parent
ON parent."id" = issue_chain."parent_id"
AND parent."company_id" = issue_chain."company_id"
WHERE issue_chain."depth" < 50
),
resolved_issue_users AS (
SELECT DISTINCT ON ("issue_id")
"issue_id",
COALESCE("responsible_user_id", "created_by_user_id") AS "user_id"
FROM issue_chain
WHERE COALESCE("responsible_user_id", "created_by_user_id") IS NOT NULL
ORDER BY "issue_id", "depth" ASC
)
UPDATE "issues" AS i
SET "responsible_user_id" = resolved_issue_users."user_id"
FROM resolved_issue_users
WHERE i."id" = resolved_issue_users."issue_id"
AND i."responsible_user_id" IS NULL;
--> statement-breakpoint
UPDATE "issues" AS i
SET "responsible_user_id" = c."default_responsible_user_id"
FROM "companies" AS c
WHERE i."company_id" = c."id"
AND i."responsible_user_id" IS NULL
AND c."default_responsible_user_id" IS NOT NULL;
--> statement-breakpoint
WITH routine_responsible_users AS (
SELECT
r."id",
COALESCE(r."created_by_user_id", parent_issue."responsible_user_id", c."default_responsible_user_id") AS "user_id"
FROM "routines" AS r
JOIN "companies" AS c ON c."id" = r."company_id"
LEFT JOIN "issues" AS parent_issue
ON parent_issue."id" = r."parent_issue_id"
AND parent_issue."company_id" = r."company_id"
WHERE r."responsible_user_id" IS NULL
)
UPDATE "routines" AS r
SET "responsible_user_id" = routine_responsible_users."user_id"
FROM routine_responsible_users
WHERE r."id" = routine_responsible_users."id"
AND routine_responsible_users."user_id" IS NOT NULL;
--> statement-breakpoint
WITH routine_revision_responsible_users AS (
SELECT
rr."id",
COALESCE(rr."created_by_user_id", r."responsible_user_id", c."default_responsible_user_id") AS "user_id"
FROM "routine_revisions" AS rr
JOIN "routines" AS r
ON rr."routine_id" = r."id"
AND rr."company_id" = r."company_id"
JOIN "companies" AS c ON c."id" = rr."company_id"
WHERE rr."responsible_user_id" IS NULL
)
UPDATE "routine_revisions" AS rr
SET "responsible_user_id" = routine_revision_responsible_users."user_id"
FROM routine_revision_responsible_users
WHERE rr."id" = routine_revision_responsible_users."id"
AND routine_revision_responsible_users."user_id" IS NOT NULL;
--> statement-breakpoint
WITH routine_run_responsible_users AS (
SELECT
rr."id",
COALESCE(linked_issue."responsible_user_id", r."responsible_user_id", c."default_responsible_user_id") AS "user_id"
FROM "routine_runs" AS rr
JOIN "routines" AS r
ON rr."routine_id" = r."id"
AND rr."company_id" = r."company_id"
JOIN "companies" AS c ON c."id" = rr."company_id"
LEFT JOIN "issues" AS linked_issue
ON linked_issue."id" = rr."linked_issue_id"
AND linked_issue."company_id" = rr."company_id"
WHERE rr."responsible_user_id" IS NULL
)
UPDATE "routine_runs" AS rr
SET "responsible_user_id" = routine_run_responsible_users."user_id"
FROM routine_run_responsible_users
WHERE rr."id" = routine_run_responsible_users."id"
AND routine_run_responsible_users."user_id" IS NOT NULL;
--> statement-breakpoint
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = original."responsible_user_id"
FROM "heartbeat_runs" AS original
WHERE h."retry_of_run_id" = original."id"
AND h."company_id" = original."company_id"
AND h."responsible_user_id" IS NULL
AND original."responsible_user_id" IS NOT NULL;
--> statement-breakpoint
WITH extracted_run_refs AS (
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'issueId', '') AS "issue_ref",
1 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'issueId', '') IS NOT NULL
UNION ALL
SELECT
h."id" AS "run_id",
h."company_id",
NULLIF(h."context_snapshot" ->> 'taskId', '') AS "issue_ref",
2 AS "ref_priority"
FROM "heartbeat_runs" AS h
WHERE h."responsible_user_id" IS NULL
AND NULLIF(h."context_snapshot" ->> 'taskId', '') IS NOT NULL
),
uuid_run_refs AS (
SELECT
"run_id",
"company_id",
"issue_ref"::uuid AS "issue_id",
"ref_priority"
FROM extracted_run_refs
WHERE "issue_ref" ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
),
resolved_run_users AS (
SELECT DISTINCT ON ("run_id")
"run_id",
"responsible_user_id"
FROM (
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
1 AS "match_priority"
FROM uuid_run_refs AS refs
JOIN "issues" AS i
ON i."id" = refs."issue_id"
AND i."company_id" = refs."company_id"
WHERE i."responsible_user_id" IS NOT NULL
UNION ALL
SELECT
refs."run_id",
i."responsible_user_id",
refs."ref_priority",
2 AS "match_priority"
FROM extracted_run_refs AS refs
JOIN "issues" AS i
ON i."identifier" = refs."issue_ref"
AND i."company_id" = refs."company_id"
WHERE refs."issue_ref" IS NOT NULL
AND i."responsible_user_id" IS NOT NULL
) AS candidates
ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC
)
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = resolved_run_users."responsible_user_id"
FROM resolved_run_users
WHERE h."id" = resolved_run_users."run_id"
AND h."responsible_user_id" IS NULL;
--> statement-breakpoint
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = awr."requested_by_actor_id"
FROM "agent_wakeup_requests" AS awr
WHERE h."wakeup_request_id" = awr."id"
AND h."company_id" = awr."company_id"
AND h."responsible_user_id" IS NULL
AND awr."requested_by_actor_type" = 'user'
AND awr."requested_by_actor_id" IS NOT NULL;
--> statement-breakpoint
UPDATE "heartbeat_runs" AS h
SET "responsible_user_id" = c."default_responsible_user_id"
FROM "companies" AS c
WHERE h."company_id" = c."id"
AND h."responsible_user_id" IS NULL
AND c."default_responsible_user_id" IS NOT NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "companies_default_responsible_user_idx"
ON "companies" ("default_responsible_user_id");