From da549123cc05c82de452220b2b1d861441b84ada Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:48:39 -0500 Subject: [PATCH] feat(db): add inbox archive agent policies (#9654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The inbox tracks issue visibility separately for each responsible user > - Existing inbox archive records only identify the user whose inbox changed, not the actor that made the change > - Agent-managed inbox cleanup needs durable agent and heartbeat-run attribution for auditability > - Users also need a minimal core policy that can remain open, restrict access to an allowlist, or disable agent inbox management > - This pull request adds those database contracts without changing routes or services > - The benefit is a company-scoped, auditable foundation for later agent inbox-management APIs ## Linked Issues or Issue Description ### Subsystem affected `packages/db` — Drizzle schema and migrations. ### Problem or motivation Agent workflows such as PR gardening can reduce inbox noise after work is complete, but existing inbox archive records only identify the user whose inbox changed. They cannot retain which agent acted or which heartbeat run performed the action, and there is no user-specific policy controlling whether agents may manage that inbox. ### Proposed solution Add database-only contracts for agent-managed inbox archiving: actor type, agent ID, and heartbeat-run attribution on archive rows, plus one company-scoped policy per user with `open`, `allowlist`, or `disabled` mode. Existing archive rows retain `user` attribution by default. Route, service, and UI behavior are intentionally deferred. ### Alternatives considered - Store attribution only in activity logs: rejected because archive state needs durable, directly queryable attribution. - Add richer per-agent rules immediately: rejected because advanced policy rules belong in a later or enterprise layer; the core table stays deliberately minimal. - Implement APIs in the same change: rejected to keep this migration-focused PR independently reviewable and safe to deploy. ### Roadmap alignment `ROADMAP.md` does not currently list this capability. This PR establishes only the database foundation and does not overlap a listed roadmap item. ### Additional context Expected behavior is that legacy archive rows upgrade without backfill, new archive rows can reference an agent and heartbeat run, and each company/user pair has at most one agent policy. ## What Changed - Added actor type, agent ID, and heartbeat run ID attribution columns to `issue_inbox_archives` with enum checks and `SET NULL` foreign keys. - Added the company-scoped `user_inbox_agent_policies` table with mode validation, JSONB agent allowlists, timestamps, and unique company/user ownership. - Added migration `0172_inbox_archive_agent_policies.sql` using idempotent DDL for existing installations. - Added an embedded PostgreSQL migration test covering legacy rows, attribution round-trips, policy JSON, and policy uniqueness. ## Verification - `pnpm --filter @paperclipai/db exec vitest run src/inbox-archive-agent-policies-migration.test.ts` - `pnpm --filter @paperclipai/db typecheck` ## Risks - Low migration risk: adding the non-null actor type uses a constant `user` default so legacy rows upgrade without a data backfill. - Agent and run deletions clear attribution foreign keys by design; the actor type remains available for audit interpretation. - No API behavior changes are included, so the new contracts remain unused until follow-up service work lands. > 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 using GPT-5.4 with reasoning, repository tools, shell execution, and test execution. The runtime did not expose a context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ...x-archive-agent-policies-migration.test.ts | 175 ++++++++++++++++++ .../0172_inbox_archive_agent_policies.sql | 42 +++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../db/src/schema/issue_inbox_archives.ts | 13 +- .../src/schema/user_inbox_agent_policies.ts | 26 +++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/inbox-archive-agent-policies-migration.test.ts create mode 100644 packages/db/src/migrations/0172_inbox_archive_agent_policies.sql create mode 100644 packages/db/src/schema/user_inbox_agent_policies.ts diff --git a/packages/db/src/inbox-archive-agent-policies-migration.test.ts b/packages/db/src/inbox-archive-agent-policies-migration.test.ts new file mode 100644 index 0000000000..ab59a16358 --- /dev/null +++ b/packages/db/src/inbox-archive-agent-policies-migration.test.ts @@ -0,0 +1,175 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { applyPendingMigrations, inspectMigrations } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION = "0172_inbox_archive_agent_policies.sql"; + +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createTempDatabase(): Promise { + const db = await startEmbeddedPostgresTestDatabase("paperclip-inbox-archive-policies-"); + cleanups.push(db.cleanup); + return db.connectionString; +} + +async function migrationHash(): Promise { + const content = await fs.promises.readFile( + new URL(`./migrations/${INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION}`, import.meta.url), + "utf8", + ); + return createHash("sha256").update(content).digest("hex"); +} + +afterEach(async () => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); + } +}); + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres inbox archive policy migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("inbox archive agent policy migration", () => { + it( + "upgrades legacy archives and round-trips attribution and unique policies", + async () => { + const connectionString = await createTempDatabase(); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const legacyArchiveId = randomUUID(); + const agentArchiveId = randomUUID(); + + try { + const hash = await migrationHash(); + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${hash}`; + await sql`DROP TABLE IF EXISTS "user_inbox_agent_policies"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_agent_id_agents_id_fk"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP CONSTRAINT IF EXISTS "issue_inbox_archives_archived_by_actor_type_check"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_agent_id"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_run_id"`; + await sql`ALTER TABLE "issue_inbox_archives" DROP COLUMN IF EXISTS "archived_by_actor_type"`; + + await sql` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES (${companyId}, 'Inbox migration company', 'IAM') + `; + await sql` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config") + VALUES (${agentId}, ${companyId}, 'Inbox agent', 'engineer', 'process', '{}'::jsonb) + `; + await sql` + INSERT INTO "issues" ("id", "company_id", "title", "identifier") + VALUES (${issueId}, ${companyId}, 'Legacy inbox issue', 'IAM-1') + `; + await sql` + INSERT INTO "heartbeat_runs" ("id", "company_id", "agent_id", "status") + VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded') + `; + await sql` + INSERT INTO "issue_inbox_archives" ("id", "company_id", "issue_id", "user_id") + VALUES (${legacyArchiveId}, ${companyId}, ${issueId}, 'legacy-user') + `; + } finally { + await sql.end(); + } + + expect(await inspectMigrations(connectionString)).toMatchObject({ + status: "needsMigrations", + pendingMigrations: [INBOX_ARCHIVE_AGENT_POLICIES_MIGRATION], + }); + + await applyPendingMigrations(connectionString); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const legacyRows = await verifySql<{ + archived_by_actor_type: string; + archived_by_agent_id: string | null; + archived_by_run_id: string | null; + }[]>` + SELECT "archived_by_actor_type", "archived_by_agent_id", "archived_by_run_id" + FROM "issue_inbox_archives" + WHERE "id" = ${legacyArchiveId} + `; + expect(legacyRows).toEqual([{ + archived_by_actor_type: "user", + archived_by_agent_id: null, + archived_by_run_id: null, + }]); + + await verifySql` + INSERT INTO "issue_inbox_archives" ( + "id", + "company_id", + "issue_id", + "user_id", + "archived_by_actor_type", + "archived_by_agent_id", + "archived_by_run_id" + ) + VALUES (${agentArchiveId}, ${companyId}, ${issueId}, 'agent-managed-user', 'agent', ${agentId}, ${runId}) + `; + const agentRows = await verifySql<{ + archived_by_actor_type: string; + archived_by_agent_id: string; + archived_by_run_id: string; + }[]>` + SELECT "archived_by_actor_type", "archived_by_agent_id", "archived_by_run_id" + FROM "issue_inbox_archives" + WHERE "id" = ${agentArchiveId} + `; + expect(agentRows).toEqual([{ + archived_by_actor_type: "agent", + archived_by_agent_id: agentId, + archived_by_run_id: runId, + }]); + + await verifySql` + INSERT INTO "user_inbox_agent_policies" ( + "company_id", + "user_id", + "mode", + "allowed_agent_ids" + ) + VALUES (${companyId}, 'agent-managed-user', 'allowlist', ${verifySql.json([agentId])}) + `; + const policies = await verifySql<{ + mode: string; + allowed_agent_ids: string[]; + }[]>` + SELECT "mode", "allowed_agent_ids" + FROM "user_inbox_agent_policies" + WHERE "company_id" = ${companyId} + AND "user_id" = 'agent-managed-user' + `; + expect(policies).toEqual([{ mode: "allowlist", allowed_agent_ids: [agentId] }]); + + await expect(verifySql` + INSERT INTO "user_inbox_agent_policies" ("company_id", "user_id", "mode") + VALUES (${companyId}, 'agent-managed-user', 'disabled') + `).rejects.toMatchObject({ code: "23505" }); + } finally { + await verifySql.end(); + } + + expect((await inspectMigrations(connectionString)).status).toBe("upToDate"); + }, + 30_000, + ); +}); diff --git a/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql b/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql new file mode 100644 index 0000000000..87f78a3d4f --- /dev/null +++ b/packages/db/src/migrations/0172_inbox_archive_agent_policies.sql @@ -0,0 +1,42 @@ +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_actor_type" text DEFAULT 'user' NOT NULL;--> statement-breakpoint +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "issue_inbox_archives" ADD COLUMN IF NOT EXISTS "archived_by_run_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_inbox_archives_archived_by_agent_id_agents_id_fk' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_agent_id_agents_id_fk" FOREIGN KEY ("archived_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("archived_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_inbox_archives_archived_by_actor_type_check' + ) THEN + ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_archived_by_actor_type_check" CHECK ("archived_by_actor_type" IN ('user', 'agent')); + END IF; +END $$;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_inbox_agent_policies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "mode" text DEFAULT 'open' NOT NULL, + "allowed_agent_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_inbox_agent_policies_mode_check" CHECK ("mode" IN ('open', 'allowlist', 'disabled')) +);--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'user_inbox_agent_policies_company_id_companies_id_fk' + ) THEN + ALTER TABLE "user_inbox_agent_policies" ADD CONSTRAINT "user_inbox_agent_policies_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_inbox_agent_policies_company_user_uq" ON "user_inbox_agent_policies" USING btree ("company_id", "user_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 08362a9f16..5e4450b883 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1191,6 +1191,13 @@ "when": 1784160000000, "tag": "0171_issue_create_idempotency_keys", "breakpoints": true + }, + { + "idx": 172, + "version": "7", + "when": 1784169959628, + "tag": "0172_inbox_archive_agent_policies", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 636f3154d2..ab9b93281f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -73,6 +73,7 @@ export { issueTreeHolds } from "./issue_tree_holds.js"; export { issueTreeHoldMembers } from "./issue_tree_hold_members.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; +export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js"; export { inboxDismissals } from "./inbox_dismissals.js"; export { feedbackVotes } from "./feedback_votes.js"; export { feedbackExports } from "./feedback_exports.js"; diff --git a/packages/db/src/schema/issue_inbox_archives.ts b/packages/db/src/schema/issue_inbox_archives.ts index 73152f13d6..c1ac3f5ebe 100644 --- a/packages/db/src/schema/issue_inbox_archives.ts +++ b/packages/db/src/schema/issue_inbox_archives.ts @@ -1,5 +1,8 @@ -import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { check, pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; import { issues } from "./issues.js"; export const issueInboxArchives = pgTable( @@ -9,6 +12,10 @@ export const issueInboxArchives = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), issueId: uuid("issue_id").notNull().references(() => issues.id), userId: text("user_id").notNull(), + archivedByActorType: text("archived_by_actor_type").$type<"user" | "agent">().notNull().default("user"), + // Agent-attributed writes must set both IDs; SET NULL preserves rows if referenced records are deleted. + archivedByAgentId: uuid("archived_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + archivedByRunId: uuid("archived_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -21,5 +28,9 @@ export const issueInboxArchives = pgTable( table.issueId, table.userId, ), + archivedByActorTypeCheck: check( + "issue_inbox_archives_archived_by_actor_type_check", + sql`${table.archivedByActorType} in ('user', 'agent')`, + ), }), ); diff --git a/packages/db/src/schema/user_inbox_agent_policies.ts b/packages/db/src/schema/user_inbox_agent_policies.ts new file mode 100644 index 0000000000..4709cee69c --- /dev/null +++ b/packages/db/src/schema/user_inbox_agent_policies.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const userInboxAgentPolicies = pgTable( + "user_inbox_agent_policies", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + mode: text("mode").$type<"open" | "allowlist" | "disabled">().notNull().default("open"), + allowedAgentIds: jsonb("allowed_agent_ids").$type().notNull().default([]), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUserUq: uniqueIndex("user_inbox_agent_policies_company_user_uq").on( + table.companyId, + table.userId, + ), + modeCheck: check( + "user_inbox_agent_policies_mode_check", + sql`${table.mode} in ('open', 'allowlist', 'disabled')`, + ), + }), +);