fix(execution-admission): keep admitted project sets after settlement (#18871)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - #18746 added execution-admission storage: one admission row per
attempt and one charge row per admitted project.
> - Settlement deletes every charge row. The admission row keeps only
the primary project and an opaque context revision.
> - Thus, after settlement, the complete admitted project set is lost.
> - A retained provider session still carries the references of its
previous admission. Example: a task mentions A and B, the admission
settles, and then a user removes B from the task text. The next
admission must still count B, or start a fresh context. The primary
project or the context token cannot supply B.
> - This pull request keeps the complete admitted set on the admission
row after settlement. Charge rows stay the only active capacity.
> - The benefit is that later retained-session resolution can read the
previous set. Settled history does not hold capacity or block project
deletion.

## Linked Issues or Issue Description

Refs #18746 (corrects its storage). Related: #2831, #14333.

**Subsystem affected**

Server execution admission (`server/src/modules/execution-admission/`)
and the `packages/db` schema.

**Problem or motivation**

`settleAdmission` deletes all charges of an admission. No other storage
records the complete project set. After settlement, the set cannot be
recovered. Current task text is not a replacement, because it can change
after admission.

**Proposed solution**

Add `execution_admissions.admitted_project_ids uuid[]`. It holds the
sorted, distinct set. It is written once at admission and a trigger
blocks changes. It has no project foreign key. `{}` is a known empty
set. Null means unknown. Only settled rows can have null. Migration
`0428` fills the set of unsettled `0426` rows from their charges,
because those charges are still complete. Settled `0426` rows stay
unknown.

**Alternatives considered**

- Keep charge rows after settlement with a `released_at` column and a
partial active index. The project foreign key would then block project
deletion permanently, or the key must be removed from active charges.
One table would also hold two different facts.
- A separate history table with one row per project. This is more rows,
joins and constraints for data that is only read as a whole set by
admission ID.
- Encode the set in `context_revision`. That token is opaque and owned
by the context owner. The database cannot validate it.
- Use `heartbeat_runs.context_snapshot`. It is mutable and is per run,
not per admission attempt.

## What Changed

- Schema and migration `0428_retained_admitted_project_sets`: add
`admitted_project_ids`, a check (null only when settled, no null
elements, at most 100 entries) and an immutability trigger. The backfill
fills unsettled rows from their charges and leaves settled rows null.
The migration is safe to run again. Migration `0426` is not changed.
- `recordAdmission` writes the set. A replay compares against the stored
set. `settleAdmission` still deletes the whole charge set in one
transaction and does not change the stored set.
- Add `readAdmittedProjects` (company-scoped, by admission ID). It
returns `known` with the set, or `unknown`.
- Add a pure `retainedSessionProjects` rule: a retained session uses
previous ∪ current. An unknown previous set returns
`fresh_context_required`. No consumer uses it yet; C1b integrates
retained sessions.
- Update `doc/DATABASE.md` and `server/src/modules/README.md`.

Not changed: no public cap writes, no claim or dispatch integration, no
session integration, no provider activation, no `serialize` → cap 1
migration. Active counts still use only the indexed charge rows.

## Verification

Red, on unchanged `master` code, with the new tests:

- `server`: `npx vitest run
src/modules/execution-admission/adapters/postgres.test.ts`. The new
tests fail. With a temporary reader that uses the only storage `master`
has (charges), the settled A+B admission reads `[]` instead of `[A, B]`.
- `packages/db`: `npx vitest run
src/execution-admission-migration.test.ts` fails, because no `0428`
migration exists.

Green, with this change:

- `server`: `npx vitest run src/modules/execution-admission`: 27 passed.
The new PostgreSQL test covers: A+B admission → settlement → set still
readable, charges and counts zero, settlement replay keeps the set, a
second attempt with a different set, primaryless and empty sets, company
isolation, rollback, immutability, the check constraint, and deletion of
a project that has only settled history (its ID stays in the set). It
also checks that a project with an active charge still cannot be
deleted.
- `packages/db`: `npx vitest run
src/execution-admission-migration.test.ts`: 2 passed. The upgrade test
starts from the shipped `0426` schema with an unsettled A+B row, an
unsettled empty row and a settled row. After the upgrade the sets are
`[A, B]`, `{}` and null. A second run of `0428` changes nothing.
- `pnpm --filter @paperclipai/db typecheck` and `check:migrations`
passed. `node scripts/check-module-boundaries.mjs` passed.
- Not run locally: the server layered typecheck. The local process was
killed for memory (a 5.8 GiB limit shared with other jobs). CI runs it.

This PR does not prove C2 claim races or C3 lifecycle authority.

## Risks

- Migration: one `ADD COLUMN`, an `UPDATE` that only touches unsettled
rows (none in production, because nothing writes admissions yet), a
check and a trigger. It does not delete or rewrite data.
- Settled rows created before `0428` stay unknown. A future consumer
must start a fresh context for them. It must not treat them as empty or
primary-only.
- The stored set has no project foreign key. Company scope is checked at
admission time, under the same project locks and the charge foreign
keys.

Size: about 70 production lines (schema 8, migration SQL 22, adapter 28,
domain 15), 105 test lines and 24 doc lines. Generated metadata
(`0428_snapshot.json`, `_journal.json`, removal of `0422_snapshot.json`
by the standard pruning) is separate.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file edits, embedded PostgreSQL tests). Default context
window and reasoning mode.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: nickyleach <331803+nickyleach@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-03 15:31:10 -08:00
1 parent 11ac90e31f
commit ce332ecec0
10 files changed
+552 -24

No files matched your search

+4 -2
View File
@@ -60,5 +60,7 @@ pause, resume, and termination. Use its commands for these changes.
The execution admission module owns project task-execution policy and the
durable admission records that charge project capacity. Today it exposes only
internal policy commands and the deletion guards that project, agent, and
company deletion call. Execution claims still belong to `services/heartbeat/queue.ts`
and native review dispatch. See [execution admission accounting](../../../doc/DATABASE.md#execution-admission-accounting).
company deletion call. Settlement releases active charges but keeps each
admission's complete project set for later retained-session resolution.
Execution claims still belong to `services/heartbeat/queue.ts` and native
review dispatch. See [execution admission accounting](../../../doc/DATABASE.md#execution-admission-accounting).
@@ -16,9 +16,9 @@ import { withAccountingTransaction } from "../../../services/accounting-transact
import { deleteAgentDependencies } from "../../../services/agent-deletion.js";
import { deleteCompany } from "../../../services/company-deletion.js";
import { projectService } from "../../../services/projects.js";
import { prepareAdmissionRecord, type AdmissionRecord } from "../domain/admission.js";
import { prepareAdmissionRecord, retainedSessionProjects, type AdmissionRecord } from "../domain/admission.js";
import { createExecutionAdmission } from "../index.js";
import { countActiveProjectCharges, recordAdmission, settleAdmission } from "./postgres.js";
import { countActiveProjectCharges, readAdmittedProjects, recordAdmission, settleAdmission } from "./postgres.js";
const support = await getEmbeddedPostgresTestSupport();
type TestDb = ReturnType<typeof createDb>;
@@ -124,7 +124,7 @@ describe.skipIf(!support.supported)("execution admission storage", () => {
expect(await admit({ companyId, runId, admissionId, record: record(a, [b], "issue:2") })).toEqual({ kind: "admission_conflict" });
expect(await admit({ companyId, runId, record: admitted })).toEqual({ kind: "run_already_admitted", admissionId });
expect(await db.select({ id: admissions.id }).from(admissions).where(eq(admissions.runId, runId))).toHaveLength(1);
await expect(db.insert(admissions).values({ id: randomUUID(), companyId, runId, attempt: 2, contextRevision: "x" }))
await expect(db.insert(admissions).values({ id: randomUUID(), companyId, runId, attempt: 2, contextRevision: "x", admittedProjectIds: [] }))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admissions_live_run_uq" } });
});
@@ -149,6 +149,51 @@ describe.skipIf(!support.supported)("execution admission storage", () => {
.rejects.toMatchObject({ cause: { constraint_name: "execution_admissions_settlement_check" } });
});
it("keeps each admitted set readable after settlement releases its capacity", async () => {
const { companyId, agentId, runId, projectIds: [a, b, c] } = await fixture();
const first = randomUUID();
await admit({ companyId, runId, admissionId: first, record: record(a, [b]) });
const settlementId = randomUUID();
await settle(companyId, first, settlementId);
const settledRead = { kind: "found", runId, attempt: 1, primaryProjectId: a, contextRevision: "issue:1", settled: true,
projects: { kind: "known", projectIds: [a, b] } };
expect(await chargedProjects(first)).toEqual([]);
expect(Object.fromEntries(await countActiveProjectCharges(db, companyId, [a, b]))).toEqual({ [a]: 0, [b]: 0 });
expect(await readAdmittedProjects(db, { companyId, admissionId: first })).toEqual(settledRead);
expect(await settle(companyId, first, settlementId)).toEqual({ kind: "already_settled" });
expect(await readAdmittedProjects(db, { companyId, admissionId: first })).toEqual(settledRead);
// B was removed from the task text; the retained session still carries it.
const second = randomUUID();
await admit({ companyId, runId, admissionId: second, record: record(a, [c], "issue:2") });
expect(await readAdmittedProjects(db, { companyId, admissionId: second }))
.toMatchObject({ attempt: 2, settled: false, projects: { kind: "known", projectIds: [a, c] } });
expect(retainedSessionProjects(settledRead.projects as { kind: "known"; projectIds: string[] }, [a]))
.toEqual({ kind: "retain", projectIds: [a, b] });
expect(retainedSessionProjects({ kind: "unknown" }, [a])).toEqual({ kind: "fresh_context_required" });
const [otherRun, emptyRun] = await db.insert(heartbeatRuns).values([{ companyId, agentId }, { companyId, agentId }]).returning();
const primaryless = randomUUID(), empty = randomUUID();
await admit({ companyId, runId: otherRun.id, admissionId: primaryless, record: record(null, [b]) });
await admit({ companyId, runId: emptyRun.id, admissionId: empty, record: record(null, []) });
await settle(companyId, primaryless);
await settle(companyId, empty);
expect(await readAdmittedProjects(db, { companyId, admissionId: primaryless }))
.toMatchObject({ primaryProjectId: null, projects: { kind: "known", projectIds: [b] } });
expect(await readAdmittedProjects(db, { companyId, admissionId: empty })).toMatchObject({ projects: { kind: "known", projectIds: [] } });
expect(await readAdmittedProjects(db, { companyId: (await fixture()).companyId, admissionId: first })).toEqual({ kind: "not_found" });
await expect(db.update(admissions).set({ admittedProjectIds: [a] }).where(eq(admissions.id, first)))
.rejects.toMatchObject({ cause: { message: "execution admission project set is immutable" } });
await expect(db.insert(admissions).values({ id: randomUUID(), companyId, runId: emptyRun.id, attempt: 9, contextRevision: "x" }))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admissions_project_set_check" } });
// Settled history neither blocks deleting B nor loses its identity.
expect(await projectService(db).remove(b)).toMatchObject({ id: b });
expect(await readAdmittedProjects(db, { companyId, admissionId: first })).toEqual(settledRead);
await expect(projectService(db).remove(c)).rejects.toMatchObject({ details: { code: "project_execution_admission_pending" } });
});
it("keeps company isolation and rolls back the whole record", async () => {
const { companyId, runId, projectIds: [a] } = await fixture();
const other = await fixture();
@@ -169,7 +214,7 @@ describe.skipIf(!support.supported)("execution admission storage", () => {
await recordAdmission(tx, { companyId: other.companyId, runId: other.runId, admissionId: rolledBack, record: record(null, other.projectIds) });
throw new Error("claim lost");
})).rejects.toThrow("claim lost");
expect(await db.select().from(admissions).where(eq(admissions.id, rolledBack))).toEqual([]);
expect(await readAdmittedProjects(db, { companyId: other.companyId, admissionId: rolledBack })).toEqual({ kind: "not_found" });
expect(await chargedProjects(rolledBack)).toEqual([]);
await expect(recordAdmission(db, { companyId, runId, admissionId: randomUUID(), record: record(a, []) }))
.rejects.toThrow("requires a database transaction");
@@ -8,7 +8,7 @@ import {
} from "@paperclipai/db";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { TaskExecutionPolicyStore } from "../application/policy.js";
import { decideSettlement, sameAdmissionRecord, type AdmissionRecord } from "../domain/admission.js";
import { decideSettlement, sameAdmissionRecord, type AdmissionRecord, type AdmittedProjectSet } from "../domain/admission.js";
import { ABSENT_TASK_EXECUTION_POLICY, type TaskExecutionPolicy } from "../domain/policy.js";
function assertTransaction(tx: Db) {
@@ -88,10 +88,8 @@ export async function recordAdmission(tx: Db, input: RecordAdmissionInput): Prom
if (existing) {
if (existing.companyId !== companyId || existing.runId !== runId) return { kind: "admission_conflict" };
if (existing.settledAt) return { kind: "admission_settled" };
const charged = await tx.select({ projectId: charges.projectId }).from(charges)
.where(and(eq(charges.companyId, companyId), eq(charges.admissionId, admissionId))).orderBy(asc(charges.projectId));
const stored = { primaryProjectId: existing.primaryProjectId, contextRevision: existing.contextRevision,
projectIds: charged.map(row => row.projectId) };
projectIds: existing.admittedProjectIds ?? [] };
return sameAdmissionRecord(stored, record) ? { kind: "replayed", attempt: existing.attempt } : { kind: "admission_conflict" };
}
const [live] = await tx.select({ id: admissions.id }).from(admissions)
@@ -101,7 +99,7 @@ export async function recordAdmission(tx: Db, input: RecordAdmissionInput): Prom
const [{ attempt }] = await tx.select({ attempt: sql<number>`coalesce(max(${admissions.attempt}), 0)::int + 1` })
.from(admissions).where(eq(admissions.runId, runId));
await tx.insert(admissions).values({ id: admissionId, companyId, runId, attempt,
primaryProjectId: record.primaryProjectId, contextRevision: record.contextRevision });
primaryProjectId: record.primaryProjectId, contextRevision: record.contextRevision, admittedProjectIds: record.projectIds });
if (record.projectIds.length > 0) {
await tx.insert(charges).values(record.projectIds.map(projectId => ({ admissionId, companyId, projectId })));
}
@@ -113,8 +111,9 @@ export type SettleAdmissionOutcome =
| { kind: "already_settled" | "settled_by_other" | "not_found" };
/**
* Storage transition only: releases the admission's whole charge set once.
* Callers must first verify authoritative lifecycle facts. Takes no project lock.
* Storage transition only: releases the admission's whole charge set once and
* keeps its admitted project set. Callers must first verify authoritative
* lifecycle facts. Takes no project lock.
*/
export async function settleAdmission(tx: Db, input: { companyId: string; admissionId: string; settlementId: string }):
Promise<SettleAdmissionOutcome> {
@@ -132,6 +131,21 @@ Promise<SettleAdmissionOutcome> {
return { kind: "settled", releasedProjectIds: released.map(charge => charge.projectId).sort() };
}
export type AdmittedProjectsRead =
| { kind: "found"; runId: string; attempt: number; primaryProjectId: string | null; contextRevision: string;
settled: boolean; projects: AdmittedProjectSet }
| { kind: "not_found" };
/** The admission's complete admitted set, settled or not, for retained-session resolution. */
export async function readAdmittedProjects(db: Db, scope: { companyId: string; admissionId: string }): Promise<AdmittedProjectsRead> {
const [row] = await db.select().from(admissions)
.where(and(eq(admissions.companyId, scope.companyId), eq(admissions.id, scope.admissionId)));
if (!row) return { kind: "not_found" };
return { kind: "found", runId: row.runId, attempt: row.attempt, primaryProjectId: row.primaryProjectId,
contextRevision: row.contextRevision, settled: row.settledAt !== null,
projects: row.admittedProjectIds ? { kind: "known", projectIds: row.admittedProjectIds } : { kind: "unknown" } };
}
/** Active charge counts per project. Every stored charge is unsettled. */
export async function countActiveProjectCharges(db: Db, companyId: string, projectIds: readonly string[]) {
const counts = new Map(projectIds.map(id => [id, 0]));
@@ -51,3 +51,18 @@ export function decideSettlement(existingSettlementId: string | null, settlement
if (existingSettlementId === null) return "settle";
return existingSettlementId === settlementId ? "already_settled" : "settled_by_other";
}
/** A recorded complete set, or unknown: settled before admitted sets were kept. Unknown is never empty. */
export type AdmittedProjectSet = { kind: "known"; projectIds: string[] } | { kind: "unknown" };
export type RetainedSessionProjects = { kind: "retain"; projectIds: string[] } | { kind: "fresh_context_required" };
/**
* A resumed provider session still carries the previous admission's references,
* so its next set is current ∪ previous. An unknown previous set cannot be
* bounded: the consumer must start a fresh context instead of resuming.
*/
export function retainedSessionProjects(previous: AdmittedProjectSet, currentProjectIds: readonly string[]): RetainedSessionProjects {
if (previous.kind === "unknown") return { kind: "fresh_context_required" };
return { kind: "retain", projectIds: [...new Set([...previous.projectIds, ...currentProjectIds])].sort() };
}