fix(decisions): serialize triage updates

Lock each company/source triage identity before reading current state so concurrent partial updates preserve both fields and emit monotonic history versions. Document the enforced serialization invariant.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 17:38:14 +00:00
1 parent c92b8851a6
commit 0e40228016
3 files changed
+36

No files matched your search

+2
View File
@@ -183,6 +183,8 @@ The decisions desk stores queue membership and decide-by/snooze state in `decisi
`decision_triage_events` is append-only history for queue and triage changes. Current rows and history both carry server-derived user/agent, heartbeat run, API-key, and responsible-user attribution where applicable. Queue reads must resolve and authorize their source rows at read time; a sidecar row is never a visibility grant.
Triage writes serialize on the company and attention-source identity so concurrent partial updates preserve both fields and produce monotonic history versions.
## Plugin database namespaces
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
@@ -396,6 +396,38 @@ describeEmbeddedPostgres("decision queue routes", () => {
expect(current[0]?.setByUserId).toBe("override-user");
});
it("serializes concurrent partial triage updates without losing state or reusing a version", async () => {
const { companyId, issueId } = await seed();
const board = boardActor(companyId);
const [decideByResult, snoozeResult] = await Promise.all([
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "today" }),
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ snoozedUntil: "2026-08-03T12:00:00.000Z" }),
]);
expect(decideByResult.status).toBe(200);
expect(snoozeResult.status).toBe(200);
expect([decideByResult.body.version, snoozeResult.body.version].sort()).toEqual([1, 2]);
const current = await request(app(board))
.get(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.expect(200);
expect(current.body).toMatchObject({
decideBy: "today",
snoozedUntil: "2026-08-03T12:00:00.000Z",
version: 2,
});
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
));
expect(events.map((event) => event.details.version).sort()).toEqual([1, 2]);
});
it("rejects task-bridge JWTs and returns the same 404 for missing and unauthorized sources", async () => {
const { companyId, agentId, approvalId } = await seed();
const bridge = {
+2
View File
@@ -597,6 +597,8 @@ export function decisionQueueService(db: Db) {
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const lockKey = `decision-triage:${input.companyId}:${input.sourceKind}:${input.sourceId}`;
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
const current = await txDb.select().from(decisionTriage).where(and(
eq(decisionTriage.companyId, input.companyId),
eq(decisionTriage.sourceKind, input.sourceKind),