feat(db): add inbox archive agent policies (#9654)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-16 08:48:39 -05:00 committed by GitHub
parent 054b076b58
commit da549123cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 263 additions and 1 deletions

View File

@ -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<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createTempDatabase(): Promise<string> {
const db = await startEmbeddedPostgresTestDatabase("paperclip-inbox-archive-policies-");
cleanups.push(db.cleanup);
return db.connectionString;
}
async function migrationHash(): Promise<string> {
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,
);
});

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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";

View File

@ -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')`,
),
}),
);

View File

@ -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<string[]>().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')`,
),
}),
);