Skip to content

Commit 38b05a9

Browse files
feat(decisions): add queue and triage APIs
Add company-scoped decision queue membership, decide-by triage, lazy data-backed starter queues, source-mediated authorization, and immutable provenance. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent d4f57ea commit 38b05a9

20 files changed

Lines changed: 39554 additions & 17 deletions

doc/DATABASE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `s
177177

178178
This policy makes training exports self-describing while keeping the decision record usable after a comment deletion without retaining content the author removed.
179179

180+
## Decision queues and triage provenance
181+
182+
The decisions desk stores queue membership and decide-by/snooze state in `decision_queues`, `decision_queue_items`, and `decision_triage`. These sidecars use the stable attention identity `(source_kind, source_id)` so all attention source kinds can participate without copying source titles, bodies, projects, or other visibility-sensitive data.
183+
184+
`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.
185+
180186
## Plugin database namespaces
181187

182188
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.

doc/SPEC-implementation.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,14 @@ The current implementation includes additional V1-control-plane tables beyond th
455455
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`.
456456
- Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos.
457457

458+
Decision-desk triage uses company-scoped sidecars rather than adding queue fields to every attention source:
459+
460+
- `decision_queues` stores durable named queues, optional retention overrides, server-derived creator/run provenance, and data-backed seed rules.
461+
- `decision_queue_items` keys membership by `(queue_id, source_kind, source_id)` and repeats `company_id` for company-consistent joins.
462+
- `decision_triage` keys current decide-by/snooze state by `(company_id, source_kind, source_id)` and preserves the latest setter attribution.
463+
- `decision_triage_events` is the immutable mutation history for queue membership and triage overrides, including actor, run, API-key, and responsible-user provenance.
464+
- Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts.
465+
458466
## 8. State Machines
459467

460468
## 8.1 Agent Status
@@ -974,6 +982,15 @@ The current app also exposes V1-supporting surfaces for:
974982
- plugin installation, configuration, state, jobs, logs, webhooks, and plugin database namespace migration
975983
- company import/export preview/apply, feedback export/vote routes, instance backup/config routes, invites, join requests, memberships, and permission grants
976984
- company skill policy read/replace/reset/simulation, enforced by the same core evaluator used by skill mutation routes
985+
- decision queues and per-attention-item triage:
986+
- `GET|POST /companies/:companyId/decision-queues`
987+
- `PATCH /companies/:companyId/decision-queues/:key`
988+
- `GET|POST /companies/:companyId/decision-queues/:key/items`
989+
- `DELETE /companies/:companyId/decision-queues/:key/items/:sourceKind/:sourceId`
990+
- `GET /companies/:companyId/decision-queue-seed-rules`
991+
- `GET|PUT /companies/:companyId/decision-triage/:sourceKind/:sourceId`
992+
993+
Queue and triage mutations accept board non-viewers and active standard-scope agents, apply responsible-user intersection for run JWTs, and reject low-trust, `task_bridge`, and `skill_test` contexts. Missing, cross-company, and unauthorized attention sources share the same not-found response.
977994

978995
## 11. Heartbeat and Adapter Contract
979996

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
CREATE TABLE "decision_queue_items" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"company_id" uuid NOT NULL,
4+
"queue_id" uuid NOT NULL,
5+
"source_kind" text NOT NULL,
6+
"source_id" text NOT NULL,
7+
"added_by_type" text NOT NULL,
8+
"added_by_agent_id" uuid,
9+
"added_by_user_id" text,
10+
"added_by_run_id" uuid,
11+
"added_by_agent_api_key_id" uuid,
12+
"responsible_user_id" text,
13+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
14+
CONSTRAINT "decision_queue_items_actor_check" CHECK ((
15+
("decision_queue_items"."added_by_type" = 'agent' AND "decision_queue_items"."added_by_agent_id" IS NOT NULL AND "decision_queue_items"."added_by_user_id" IS NULL)
16+
OR ("decision_queue_items"."added_by_type" = 'user' AND "decision_queue_items"."added_by_agent_id" IS NULL AND "decision_queue_items"."added_by_user_id" IS NOT NULL)
17+
OR ("decision_queue_items"."added_by_type" = 'system' AND "decision_queue_items"."added_by_agent_id" IS NULL AND "decision_queue_items"."added_by_user_id" IS NULL)
18+
))
19+
);
20+
--> statement-breakpoint
21+
CREATE TABLE "decision_queues" (
22+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
23+
"company_id" uuid NOT NULL,
24+
"key" text NOT NULL,
25+
"title" text NOT NULL,
26+
"description" text,
27+
"created_by_type" text NOT NULL,
28+
"created_by_agent_id" uuid,
29+
"created_by_user_id" text,
30+
"created_by_run_id" uuid,
31+
"created_by_agent_api_key_id" uuid,
32+
"retention_days" integer,
33+
"seed_rules" jsonb DEFAULT '[]'::jsonb NOT NULL,
34+
"seed_rules_enabled" boolean DEFAULT false NOT NULL,
35+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
36+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
37+
CONSTRAINT "decision_queues_creator_check" CHECK ((
38+
("decision_queues"."created_by_type" = 'agent' AND "decision_queues"."created_by_agent_id" IS NOT NULL AND "decision_queues"."created_by_user_id" IS NULL)
39+
OR ("decision_queues"."created_by_type" = 'user' AND "decision_queues"."created_by_agent_id" IS NULL AND "decision_queues"."created_by_user_id" IS NOT NULL)
40+
OR ("decision_queues"."created_by_type" = 'system' AND "decision_queues"."created_by_agent_id" IS NULL AND "decision_queues"."created_by_user_id" IS NULL)
41+
)),
42+
CONSTRAINT "decision_queues_retention_days_check" CHECK ("decision_queues"."retention_days" IS NULL OR ("decision_queues"."retention_days" >= 1 AND "decision_queues"."retention_days" <= 3650))
43+
);
44+
--> statement-breakpoint
45+
CREATE TABLE "decision_triage" (
46+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
47+
"company_id" uuid NOT NULL,
48+
"source_kind" text NOT NULL,
49+
"source_id" text NOT NULL,
50+
"decide_by" text,
51+
"decide_by_date" date,
52+
"snoozed_until" timestamp with time zone,
53+
"set_by_type" text NOT NULL,
54+
"set_by_agent_id" uuid,
55+
"set_by_user_id" text,
56+
"set_by_run_id" uuid,
57+
"set_by_agent_api_key_id" uuid,
58+
"responsible_user_id" text,
59+
"version" integer DEFAULT 1 NOT NULL,
60+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
61+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
62+
CONSTRAINT "decision_triage_actor_check" CHECK ((
63+
("decision_triage"."set_by_type" = 'agent' AND "decision_triage"."set_by_agent_id" IS NOT NULL AND "decision_triage"."set_by_user_id" IS NULL)
64+
OR ("decision_triage"."set_by_type" = 'user' AND "decision_triage"."set_by_agent_id" IS NULL AND "decision_triage"."set_by_user_id" IS NOT NULL)
65+
)),
66+
CONSTRAINT "decision_triage_decide_by_check" CHECK ((
67+
("decision_triage"."decide_by" IS NULL AND "decision_triage"."decide_by_date" IS NULL)
68+
OR ("decision_triage"."decide_by" IN ('today', 'this_week', 'whenever') AND "decision_triage"."decide_by_date" IS NULL)
69+
OR ("decision_triage"."decide_by" = 'date' AND "decision_triage"."decide_by_date" IS NOT NULL)
70+
))
71+
);
72+
--> statement-breakpoint
73+
CREATE TABLE "decision_triage_events" (
74+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
75+
"company_id" uuid NOT NULL,
76+
"queue_id" uuid,
77+
"source_kind" text,
78+
"source_id" text,
79+
"action" text NOT NULL,
80+
"actor_type" text NOT NULL,
81+
"actor_agent_id" uuid,
82+
"actor_user_id" text,
83+
"actor_run_id" uuid,
84+
"agent_api_key_id" uuid,
85+
"responsible_user_id" text,
86+
"details" jsonb DEFAULT '{}'::jsonb NOT NULL,
87+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
88+
CONSTRAINT "decision_triage_events_actor_check" CHECK ((
89+
("decision_triage_events"."actor_type" = 'agent' AND "decision_triage_events"."actor_agent_id" IS NOT NULL AND "decision_triage_events"."actor_user_id" IS NULL)
90+
OR ("decision_triage_events"."actor_type" = 'user' AND "decision_triage_events"."actor_agent_id" IS NULL AND "decision_triage_events"."actor_user_id" IS NOT NULL)
91+
OR ("decision_triage_events"."actor_type" = 'system' AND "decision_triage_events"."actor_agent_id" IS NULL AND "decision_triage_events"."actor_user_id" IS NULL)
92+
))
93+
);
94+
--> statement-breakpoint
95+
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
96+
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_agent_id_agents_id_fk" FOREIGN KEY ("added_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
97+
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("added_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
98+
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("added_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
99+
CREATE UNIQUE INDEX "decision_queues_id_company_uq" ON "decision_queues" USING btree ("id","company_id");--> statement-breakpoint
100+
ALTER TABLE "decision_queue_items" ADD CONSTRAINT "decision_queue_items_queue_company_fk" FOREIGN KEY ("queue_id","company_id") REFERENCES "public"."decision_queues"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
101+
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
102+
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
103+
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
104+
ALTER TABLE "decision_queues" ADD CONSTRAINT "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("created_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
105+
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
106+
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_agent_id_agents_id_fk" FOREIGN KEY ("set_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
107+
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("set_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
108+
ALTER TABLE "decision_triage" ADD CONSTRAINT "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("set_by_agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
109+
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
110+
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_queue_id_decision_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."decision_queues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
111+
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
112+
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_actor_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("actor_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
113+
ALTER TABLE "decision_triage_events" ADD CONSTRAINT "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk" FOREIGN KEY ("agent_api_key_id") REFERENCES "public"."agent_api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
114+
CREATE UNIQUE INDEX "decision_queue_items_queue_source_uq" ON "decision_queue_items" USING btree ("queue_id","source_kind","source_id");--> statement-breakpoint
115+
CREATE INDEX "decision_queue_items_company_source_idx" ON "decision_queue_items" USING btree ("company_id","source_kind","source_id");--> statement-breakpoint
116+
CREATE UNIQUE INDEX "decision_queues_company_key_uq" ON "decision_queues" USING btree ("company_id","key");--> statement-breakpoint
117+
CREATE INDEX "decision_queues_company_updated_idx" ON "decision_queues" USING btree ("company_id","updated_at");--> statement-breakpoint
118+
CREATE UNIQUE INDEX "decision_triage_company_source_uq" ON "decision_triage" USING btree ("company_id","source_kind","source_id");--> statement-breakpoint
119+
CREATE INDEX "decision_triage_company_decide_by_idx" ON "decision_triage" USING btree ("company_id","decide_by");--> statement-breakpoint
120+
CREATE INDEX "decision_triage_events_company_source_created_idx" ON "decision_triage_events" USING btree ("company_id","source_kind","source_id","created_at");--> statement-breakpoint
121+
CREATE INDEX "decision_triage_events_queue_created_idx" ON "decision_triage_events" USING btree ("queue_id","created_at");

0 commit comments

Comments
 (0)