diff --git a/docs/architecture/ADR-001-meta-orchestrator-extension.md b/docs/architecture/ADR-001-meta-orchestrator-extension.md index 101e20e..c451a36 100644 --- a/docs/architecture/ADR-001-meta-orchestrator-extension.md +++ b/docs/architecture/ADR-001-meta-orchestrator-extension.md @@ -118,3 +118,48 @@ CHITTYOS/chittycommand surfaces the meta-orchestrator can route to. No code change required this PR. - The cluster-daemon runtime depends on Neon reachability for leader election; the "park the node" fallback is acceptable for MVP and will be revisited. + +--- + +## Delta: Roux/Triage Carry-Through (2026-06-03) + +> @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + +Ratified by chittycanon-code-cardinal. + +### Q1 — Where do `privilege` / `space` live? +**(c) ratified.** Add to `cc_intents` AND `cc_disputes` directly as first-class +columns (text, NOT NULL, defaults `public` / `business`), backed by indexes on +`(privilege, status)` and `(space, status)`. CHECK constraints deferred because +the Roux spec URI is `STATUS:PENDING` certification. App layer enforces the +enum in `meta/intent.ts` and `src/routes/triage.ts`. + +### Q2 — Migration semantics on existing rows? +**(a) pass-through-with-warn.** The migration applies `DEFAULT 'public'` / +`'business'` so existing rows are valid. `pushUnlinkedDisputesToNotion` emits +a one-time per-row log when it encounters a row sitting on those defaults +recommending an explicit tag. No backfill writes. + +### Q3 — How does Triage claim work? +**Both modes.** Specific-by-ID claim (`POST /api/triage/:id/claim`, atomic, +409 if not pending) for human triagers; bucket-ordered claim +(`POST /api/triage/claim-next`) for autonomous agents, parameterised on +`privilege`, `space`, `priority_lte`. Routes are MCP-exposed as +`triage_list_intents`, `triage_claim_intent`, `triage_claim_next`, +`triage_complete_intent`. + +### Q4 — Vocabulary alignment with sovereignty.ts? +**Orthogonal axes — DO NOT TOUCH `decide()`.** The pre-existing +`sensitivity ∈ {low, normal, sensitive, critical}` on +`IntentForSovereignty` is the trust-tier axis the sovereignty matrix consumes. +`privilege ∈ {privileged, pii, hoa_evidentiary, public}` is the Roux +classification axis — informational on `IntentForSovereignty`, persisted on +the intent row, but never an input to the autonomous/human/blocked decision. + +### Notion mirror gate +`linkDisputeToNotion` refuses to mirror any dispute where the effective +`privilege ∈ {privileged, pii}` OR `space === 'legalink'`. Effective values +resolve as `explicit > deriveRouxFromType(dispute_type)`. `legal` ⇒ +`(privileged, legalink)`; `insurance` ⇒ `(pii, business)`; everything else +defaults to `(public, business)`. This prevents privileged work-product and +PII from being mirrored into the operations Notion workspace. diff --git a/meta/intent.ts b/meta/intent.ts index dde0145..ad440cc 100644 --- a/meta/intent.ts +++ b/meta/intent.ts @@ -20,6 +20,14 @@ export type IntentStatus = | 'failed' | 'blocked_human'; +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// ChittyRoux privilege class — orthogonal to sovereignty trust-tier sensitivity. +export type IntentPrivilege = 'privileged' | 'pii' | 'hoa_evidentiary' | 'public'; + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// ChittyRoux two-Space partition. +export type IntentSpace = 'business' | 'legalink'; + export interface SovereigntyAssessmentSnapshot { decision: 'autonomous' | 'requires_human' | 'blocked'; trustScore: number; @@ -75,6 +83,10 @@ export interface Intent { scheduledFor: Date | null; completedAt: Date | null; errorMessage: string | null; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilege: IntentPrivilege; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + space: IntentSpace; metadata: Record; createdAt: Date; updatedAt: Date; @@ -192,6 +204,10 @@ export interface CreateIntentInput { sovereigntyAssessment?: SovereigntyAssessmentSnapshot; humanGateReason?: string; scheduledFor?: Date; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilege?: IntentPrivilege; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + space?: IntentSpace; metadata?: Record; } @@ -206,16 +222,20 @@ export async function createIntent(env: IntentEnv, input: CreateIntentInput): Pr ? 'failed' : 'pending'; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // privilege/space default to public/business at the column level; pass through + // explicit caller values so high-privilege intents are tagged at creation. const rows = await sql` INSERT INTO cc_intents (plan_id, goal_id, intent_type, target_channel, payload, status, priority, - sovereignty_assessment, human_gate_reason, scheduled_for, metadata) + sovereignty_assessment, human_gate_reason, scheduled_for, privilege, space, metadata) VALUES (${input.planId}, ${input.goalId}, ${input.intentType}, ${input.targetChannel ?? null}, ${JSON.stringify(input.payload)}::jsonb, ${initialStatus}, ${input.priority ?? 5}, ${input.sovereigntyAssessment ? JSON.stringify(input.sovereigntyAssessment) : null}::jsonb, ${input.humanGateReason ?? null}, ${input.scheduledFor ?? null}, + ${input.privilege ?? 'public'}, ${input.space ?? 'business'}, ${JSON.stringify(input.metadata ?? {})}::jsonb) RETURNING *`; return rowToIntent(rows[0]); @@ -231,37 +251,39 @@ export async function getIntent(env: IntentEnv, id: string): Promise { const sql = getSql(env); - const rows = options.channel - ? await sql` - UPDATE cc_intents - SET status = 'claimed', updated_at = NOW() - WHERE id = ( - SELECT id FROM cc_intents - WHERE status = 'pending' - AND target_channel = ${options.channel} - AND (scheduled_for IS NULL OR scheduled_for <= NOW()) - ORDER BY priority ASC, created_at ASC - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - RETURNING *` - : await sql` - UPDATE cc_intents - SET status = 'claimed', updated_at = NOW() - WHERE id = ( - SELECT id FROM cc_intents - WHERE status = 'pending' - AND (scheduled_for IS NULL OR scheduled_for <= NOW()) - ORDER BY priority ASC, created_at ASC - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - RETURNING *`; + const channel = options.channel ?? null; + const privilege = options.privilege ?? null; + const space = options.space ?? null; + const priorityLte = options.priorityLte ?? null; + const rows = await sql` + UPDATE cc_intents + SET status = 'claimed', updated_at = NOW() + WHERE id = ( + SELECT id FROM cc_intents + WHERE status = 'pending' + AND (${channel}::text IS NULL OR target_channel = ${channel}) + AND (${privilege}::text IS NULL OR privilege = ${privilege}) + AND (${space}::text IS NULL OR space = ${space}) + AND (${priorityLte}::int IS NULL OR priority <= ${priorityLte}) + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + ORDER BY priority ASC, created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING *`; return rows[0] ? rowToIntent(rows[0]) : null; } @@ -288,6 +310,11 @@ export async function markIntentDispatched( // returns after a fresher leader has reclaimed + redispatched the intent, the // stale dispatched_task_id will no longer match and the UPDATE will affect 0 // rows. Pass `undefined` to skip the token check (legacy / non-leader paths). +// fixes codex-p2 PR#104 finding-4 — accept 'claimed' as well as 'running'. +// The triage routes expose claim (→'claimed') but no explicit transition to +// 'running', so an autonomous agent that does work and then calls complete +// always hit 409. The token gate from P1-B still prevents stale completions +// when a token is supplied. Failing from terminal states is still rejected. export async function completeIntent( env: IntentEnv, intentId: string, @@ -299,13 +326,13 @@ export async function completeIntent( ? await sql` UPDATE cc_intents SET status = 'done', completed_at = NOW(), updated_at = NOW() - WHERE id = ${intentId} AND status = 'running' + WHERE id = ${intentId} AND status IN ('claimed', 'running') RETURNING *` : await sql` UPDATE cc_intents SET status = 'done', completed_at = NOW(), updated_at = NOW() WHERE id = ${intentId} - AND status = 'running' + AND status IN ('claimed', 'running') AND dispatched_task_id = ${expectedDispatchedTaskId} RETURNING *`; return rows[0] ? rowToIntent(rows[0]) : null; @@ -434,6 +461,10 @@ function rowToIntent(row: Record): Intent { scheduledFor: row.scheduled_for ? new Date(row.scheduled_for as string) : null, completedAt: row.completed_at ? new Date(row.completed_at as string) : null, errorMessage: (row.error_message as string) ?? null, + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilege: ((row.privilege as IntentPrivilege | undefined) ?? 'public'), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + space: ((row.space as IntentSpace | undefined) ?? 'business'), metadata: (row.metadata as Record) ?? {}, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), diff --git a/meta/sovereignty.ts b/meta/sovereignty.ts index 256f7e7..4bb0727 100644 --- a/meta/sovereignty.ts +++ b/meta/sovereignty.ts @@ -28,6 +28,19 @@ export interface IntentForSovereignty { * decision. Optional — defaults to 'normal'. */ sensitivity?: 'low' | 'normal' | 'sensitive' | 'critical'; + /** + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + * + * ChittyRoux privilege class — orthogonal to `sensitivity` (which is the + * trust-tier axis the decide() matrix consumes). `privilege` is informational + * here so callers can persist it on the intent; it does NOT feed decide(). + * + * - privileged — attorney-client / work-product + * - pii — personally identifiable info + * - hoa_evidentiary — HOA-relevant evidentiary material + * - public — no privilege class applies + */ + privilege?: 'privileged' | 'pii' | 'hoa_evidentiary' | 'public'; /** Optional human-readable summary for audit trail. */ summary?: string; } diff --git a/migrations/0004_premium_toad_men.sql b/migrations/0004_premium_toad_men.sql new file mode 100644 index 0000000..415ebcc --- /dev/null +++ b/migrations/0004_premium_toad_men.sql @@ -0,0 +1,8 @@ +ALTER TABLE "cc_disputes" ADD COLUMN "privilege" text DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE "cc_disputes" ADD COLUMN "space" text DEFAULT 'business' NOT NULL;--> statement-breakpoint +ALTER TABLE "cc_intents" ADD COLUMN "privilege" text DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE "cc_intents" ADD COLUMN "space" text DEFAULT 'business' NOT NULL;--> statement-breakpoint +CREATE INDEX "idx_cc_disputes_privilege" ON "cc_disputes" USING btree ("privilege","status");--> statement-breakpoint +CREATE INDEX "idx_cc_disputes_space" ON "cc_disputes" USING btree ("space","status");--> statement-breakpoint +CREATE INDEX "idx_cc_intents_privilege" ON "cc_intents" USING btree ("privilege","status");--> statement-breakpoint +CREATE INDEX "idx_cc_intents_space" ON "cc_intents" USING btree ("space","status"); \ No newline at end of file diff --git a/migrations/meta/0004_snapshot.json b/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..b33e270 --- /dev/null +++ b/migrations/meta/0004_snapshot.json @@ -0,0 +1,3450 @@ +{ + "id": "6e6fff66-21a5-4a75-b92d-be92b3b7035b", + "prevId": "9d20e2fa-8abc-4f30-94ef-c171acc93fd3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cc_accounts": { + "name": "cc_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_balance": { + "name": "current_balance", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "credit_limit": { + "name": "credit_limit", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "interest_rate": { + "name": "interest_rate", + "type": "numeric(5, 3)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_actions_log": { + "name": "cc_actions_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_actions_log_date": { + "name": "idx_cc_actions_log_date", + "columns": [ + { + "expression": "executed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_cashflow_projections": { + "name": "cc_cashflow_projections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "projection_date": { + "name": "projection_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "projected_inflow": { + "name": "projected_inflow", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "projected_outflow": { + "name": "projected_outflow", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "projected_balance": { + "name": "projected_balance", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "obligations": { + "name": "obligations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_cashflow_date": { + "name": "idx_cc_cashflow_date", + "columns": [ + { + "expression": "projection_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_decision_feedback": { + "name": "cc_decision_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recommendation_id": { + "name": "recommendation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "obligation_id": { + "name": "obligation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_action": { + "name": "original_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_action": { + "name": "modified_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence_at_decision": { + "name": "confidence_at_decision", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "outcome_status": { + "name": "outcome_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome_recorded_at": { + "name": "outcome_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_decision_feedback_rec": { + "name": "idx_cc_decision_feedback_rec", + "columns": [ + { + "expression": "recommendation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_decision_feedback_ob": { + "name": "idx_cc_decision_feedback_ob", + "columns": [ + { + "expression": "obligation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_decision_feedback_created": { + "name": "idx_cc_decision_feedback_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_decision_feedback_recommendation_id_cc_recommendations_id_fk": { + "name": "cc_decision_feedback_recommendation_id_cc_recommendations_id_fk", + "tableFrom": "cc_decision_feedback", + "tableTo": "cc_recommendations", + "columnsFrom": [ + "recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_decision_feedback_obligation_id_cc_obligations_id_fk": { + "name": "cc_decision_feedback_obligation_id_cc_obligations_id_fk", + "tableFrom": "cc_decision_feedback", + "tableTo": "cc_obligations", + "columnsFrom": [ + "obligation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_dispute_correspondence": { + "name": "cc_dispute_correspondence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dispute_id": { + "name": "dispute_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_cc_dispute_corr_dispute": { + "name": "idx_cc_dispute_corr_dispute", + "columns": [ + { + "expression": "dispute_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_dispute_correspondence_dispute_id_cc_disputes_id_fk": { + "name": "cc_dispute_correspondence_dispute_id_cc_disputes_id_fk", + "tableFrom": "cc_dispute_correspondence", + "tableTo": "cc_disputes", + "columnsFrom": [ + "dispute_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_disputes": { + "name": "cc_disputes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counterparty": { + "name": "counterparty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dispute_type": { + "name": "dispute_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_claimed": { + "name": "amount_claimed", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "amount_at_stake": { + "name": "amount_at_stake", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'filed'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_date": { + "name": "next_action_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "resolution_target": { + "name": "resolution_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privilege": { + "name": "privilege", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "space": { + "name": "space", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'business'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_disputes_privilege": { + "name": "idx_cc_disputes_privilege", + "columns": [ + { + "expression": "privilege", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_disputes_space": { + "name": "idx_cc_disputes_space", + "columns": [ + { + "expression": "space", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_documents": { + "name": "cc_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "doc_type": { + "name": "doc_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_text": { + "name": "content_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_data": { + "name": "parsed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linked_obligation_id": { + "name": "linked_obligation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_dispute_id": { + "name": "linked_dispute_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cc_documents_linked_obligation_id_cc_obligations_id_fk": { + "name": "cc_documents_linked_obligation_id_cc_obligations_id_fk", + "tableFrom": "cc_documents", + "tableTo": "cc_obligations", + "columnsFrom": [ + "linked_obligation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_documents_linked_account_id_cc_accounts_id_fk": { + "name": "cc_documents_linked_account_id_cc_accounts_id_fk", + "tableFrom": "cc_documents", + "tableTo": "cc_accounts", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_documents_linked_dispute_id_cc_disputes_id_fk": { + "name": "cc_documents_linked_dispute_id_cc_disputes_id_fk", + "tableFrom": "cc_documents", + "tableTo": "cc_disputes", + "columnsFrom": [ + "linked_dispute_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_email_connections": { + "name": "cc_email_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connect_ref": { + "name": "connect_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_email_conn_email_user": { + "name": "idx_cc_email_conn_email_user", + "columns": [ + { + "expression": "email_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_email_conn_user": { + "name": "idx_cc_email_conn_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_email_conn_namespace": { + "name": "idx_cc_email_conn_namespace", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_goals": { + "name": "cc_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_chitty_id": { + "name": "owner_chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "target_date": { + "name": "target_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "achieved_at": { + "name": "achieved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_goals_owner": { + "name": "idx_cc_goals_owner", + "columns": [ + { + "expression": "owner_chitty_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_goals_status": { + "name": "idx_cc_goals_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_goals_priority": { + "name": "idx_cc_goals_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_intents": { + "name": "cc_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_id": { + "name": "plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "intent_type": { + "name": "intent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_channel": { + "name": "target_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "sovereignty_assessment": { + "name": "sovereignty_assessment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "human_gate_reason": { + "name": "human_gate_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_task_id": { + "name": "dispatched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reclaim_count": { + "name": "reclaim_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "privilege": { + "name": "privilege", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "space": { + "name": "space", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'business'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_intents_plan": { + "name": "idx_cc_intents_plan", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_goal": { + "name": "idx_cc_intents_goal", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_status": { + "name": "idx_cc_intents_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_priority": { + "name": "idx_cc_intents_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_scheduled": { + "name": "idx_cc_intents_scheduled", + "columns": [ + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_privilege": { + "name": "idx_cc_intents_privilege", + "columns": [ + { + "expression": "privilege", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_intents_space": { + "name": "idx_cc_intents_space", + "columns": [ + { + "expression": "space", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_intents_goal_id_cc_goals_id_fk": { + "name": "cc_intents_goal_id_cc_goals_id_fk", + "tableFrom": "cc_intents", + "tableTo": "cc_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cc_intents_plan_goal_cc_plans_fk": { + "name": "cc_intents_plan_goal_cc_plans_fk", + "tableFrom": "cc_intents", + "tableTo": "cc_plans", + "columnsFrom": [ + "plan_id", + "goal_id" + ], + "columnsTo": [ + "id", + "goal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_legal_deadlines": { + "name": "cc_legal_deadlines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "case_ref": { + "name": "case_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "case_system": { + "name": "case_system", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deadline_type": { + "name": "deadline_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deadline_date": { + "name": "deadline_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reminder_days": { + "name": "reminder_days", + "type": "integer[]", + "primaryKey": false, + "notNull": false, + "default": "'{7,3,1}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'upcoming'" + }, + "urgency_score": { + "name": "urgency_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "evidence_db_ref": { + "name": "evidence_db_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_legal_deadlines_date": { + "name": "idx_cc_legal_deadlines_date", + "columns": [ + { + "expression": "deadline_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_node_leases": { + "name": "cc_node_leases", + "schema": "", + "columns": { + "role": { + "name": "role", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "node_descriptor": { + "name": "node_descriptor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_node_leases_node": { + "name": "idx_cc_node_leases_node", + "columns": [ + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_node_leases_expires": { + "name": "idx_cc_node_leases_expires", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_obligations": { + "name": "cc_obligations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payee": { + "name": "payee", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_due": { + "name": "amount_due", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "amount_minimum": { + "name": "amount_minimum", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recurrence_day": { + "name": "recurrence_day", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "auto_pay": { + "name": "auto_pay", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "negotiable": { + "name": "negotiable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "late_fee": { + "name": "late_fee", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "grace_period_days": { + "name": "grace_period_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "urgency_score": { + "name": "urgency_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_payload": { + "name": "action_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_doc_id": { + "name": "source_doc_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "escalation_type": { + "name": "escalation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "escalation_threshold_days": { + "name": "escalation_threshold_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "escalation_amount": { + "name": "escalation_amount", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "credit_impact_score": { + "name": "credit_impact_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "preferred_account_id": { + "name": "preferred_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_obligations_due": { + "name": "idx_cc_obligations_due", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_obligations_status": { + "name": "idx_cc_obligations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_obligations_urgency": { + "name": "idx_cc_obligations_urgency", + "columns": [ + { + "expression": "urgency_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_obligations_account_id_cc_accounts_id_fk": { + "name": "cc_obligations_account_id_cc_accounts_id_fk", + "tableFrom": "cc_obligations", + "tableTo": "cc_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_obligations_preferred_account_id_cc_accounts_id_fk": { + "name": "cc_obligations_preferred_account_id_cc_accounts_id_fk", + "tableFrom": "cc_obligations", + "tableTo": "cc_accounts", + "columnsFrom": [ + "preferred_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_payment_plans": { + "name": "cc_payment_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_type": { + "name": "plan_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "horizon_days": { + "name": "horizon_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 90 + }, + "starting_balance": { + "name": "starting_balance", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "ending_balance": { + "name": "ending_balance", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "lowest_balance": { + "name": "lowest_balance", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "lowest_balance_date": { + "name": "lowest_balance_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "total_inflows": { + "name": "total_inflows", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "total_outflows": { + "name": "total_outflows", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "total_late_fees_avoided": { + "name": "total_late_fees_avoided", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_late_fees_risked": { + "name": "total_late_fees_risked", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "warnings": { + "name": "warnings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_payment_plans_status": { + "name": "idx_cc_payment_plans_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_plans": { + "name": "cc_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "supersedes_plan_id": { + "name": "supersedes_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "sovereignty_assessment": { + "name": "sovereignty_assessment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_plans_goal": { + "name": "idx_cc_plans_goal", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_plans_status": { + "name": "idx_cc_plans_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_plans_goal_id_cc_goals_id_fk": { + "name": "cc_plans_goal_id_cc_goals_id_fk", + "tableFrom": "cc_plans", + "tableTo": "cc_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cc_plans_id_goal_id_unique": { + "name": "cc_plans_id_goal_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "goal_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_properties": { + "name": "cc_properties", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "property_name": { + "name": "property_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "doorloop_id": { + "name": "doorloop_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_hoa": { + "name": "monthly_hoa", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "hoa_payee": { + "name": "hoa_payee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "annual_tax": { + "name": "annual_tax", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "tax_pin": { + "name": "tax_pin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mortgage_account_id": { + "name": "mortgage_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mortgage_servicer": { + "name": "mortgage_servicer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mortgage_account": { + "name": "mortgage_account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cc_properties_mortgage_account_id_cc_accounts_id_fk": { + "name": "cc_properties_mortgage_account_id_cc_accounts_id_fk", + "tableFrom": "cc_properties", + "tableTo": "cc_accounts", + "columnsFrom": [ + "mortgage_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cc_properties_tax_pin_unique": { + "name": "cc_properties_tax_pin_unique", + "nullsNotDistinct": false, + "columns": [ + "tax_pin" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_recommendations": { + "name": "cc_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "obligation_id": { + "name": "obligation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dispute_id": { + "name": "dispute_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rec_type": { + "name": "rec_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_savings": { + "name": "estimated_savings", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_payload": { + "name": "action_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "action_url": { + "name": "action_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "suggested_account_id": { + "name": "suggested_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "suggested_amount": { + "name": "suggested_amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "payment_sequence": { + "name": "payment_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "escalation_risk": { + "name": "escalation_risk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scenario_impact": { + "name": "scenario_impact", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "acted_on_at": { + "name": "acted_on_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_cc_recommendations_priority": { + "name": "idx_cc_recommendations_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_recommendations_status": { + "name": "idx_cc_recommendations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_recommendations_obligation_id_cc_obligations_id_fk": { + "name": "cc_recommendations_obligation_id_cc_obligations_id_fk", + "tableFrom": "cc_recommendations", + "tableTo": "cc_obligations", + "columnsFrom": [ + "obligation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_recommendations_dispute_id_cc_disputes_id_fk": { + "name": "cc_recommendations_dispute_id_cc_disputes_id_fk", + "tableFrom": "cc_recommendations", + "tableTo": "cc_disputes", + "columnsFrom": [ + "dispute_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_recommendations_suggested_account_id_cc_accounts_id_fk": { + "name": "cc_recommendations_suggested_account_id_cc_accounts_id_fk", + "tableFrom": "cc_recommendations", + "tableTo": "cc_accounts", + "columnsFrom": [ + "suggested_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_revenue_sources": { + "name": "cc_revenue_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recurrence_day": { + "name": "recurrence_day", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_expected_date": { + "name": "next_expected_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.50'" + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_revenue_sources_next": { + "name": "idx_cc_revenue_sources_next", + "columns": [ + { + "expression": "next_expected_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_revenue_sources_status": { + "name": "idx_cc_revenue_sources_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_revenue_sources_account_id_cc_accounts_id_fk": { + "name": "cc_revenue_sources_account_id_cc_accounts_id_fk", + "tableFrom": "cc_revenue_sources", + "tableTo": "cc_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_scrape_jobs": { + "name": "cc_scrape_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "job_type": { + "name": "job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_job_id": { + "name": "parent_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cron_source": { + "name": "cron_source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_scrape_jobs_status": { + "name": "idx_cc_scrape_jobs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_scrape_jobs_type": { + "name": "idx_cc_scrape_jobs_type", + "columns": [ + { + "expression": "job_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_scrape_jobs_chitty": { + "name": "idx_cc_scrape_jobs_chitty", + "columns": [ + { + "expression": "chitty_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_sync_log": { + "name": "cc_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chitty_id": { + "name": "chitty_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_type": { + "name": "sync_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "records_synced": { + "name": "records_synced", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_tasks": { + "name": "cc_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notion_page_id": { + "name": "notion_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'notion'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "backend_status": { + "name": "backend_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "verification_type": { + "name": "verification_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'soft'" + }, + "verification_artifact": { + "name": "verification_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verification_notes": { + "name": "verification_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "spawned_recommendation_id": { + "name": "spawned_recommendation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ledger_record_id": { + "name": "ledger_record_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_tasks_status": { + "name": "idx_cc_tasks_status", + "columns": [ + { + "expression": "backend_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_tasks_external_id": { + "name": "idx_cc_tasks_external_id", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_tasks_notion_page_id": { + "name": "idx_cc_tasks_notion_page_id", + "columns": [ + { + "expression": "notion_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_tasks_due_date": { + "name": "idx_cc_tasks_due_date", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_tasks_priority": { + "name": "idx_cc_tasks_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_tasks_type": { + "name": "idx_cc_tasks_type", + "columns": [ + { + "expression": "task_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_tasks_spawned_recommendation_id_cc_recommendations_id_fk": { + "name": "cc_tasks_spawned_recommendation_id_cc_recommendations_id_fk", + "tableFrom": "cc_tasks", + "tableTo": "cc_recommendations", + "columnsFrom": [ + "spawned_recommendation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cc_tasks_external_id_unique": { + "name": "cc_tasks_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + }, + "cc_tasks_notion_page_id_unique": { + "name": "cc_tasks_notion_page_id_unique", + "nullsNotDistinct": false, + "columns": [ + "notion_page_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_transactions": { + "name": "cc_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "obligation_id": { + "name": "obligation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counterparty": { + "name": "counterparty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_date": { + "name": "tx_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cc_transactions_date": { + "name": "idx_cc_transactions_date", + "columns": [ + { + "expression": "tx_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_transactions_account": { + "name": "idx_cc_transactions_account", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_transactions_source": { + "name": "idx_cc_transactions_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_transactions_account_id_cc_accounts_id_fk": { + "name": "cc_transactions_account_id_cc_accounts_id_fk", + "tableFrom": "cc_transactions", + "tableTo": "cc_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cc_transactions_obligation_id_cc_obligations_id_fk": { + "name": "cc_transactions_obligation_id_cc_obligations_id_fk", + "tableFrom": "cc_transactions", + "tableTo": "cc_obligations", + "columnsFrom": [ + "obligation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cc_user_namespaces": { + "name": "cc_user_namespaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cc_user_namespaces_user_id_unique": { + "name": "cc_user_namespaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "cc_user_namespaces_namespace_unique": { + "name": "cc_user_namespaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 27102f8..1324ef9 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1780538575987, "tag": "0003_foamy_king_cobra", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1780539161737, + "tag": "0004_premium_toad_men", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 8add0dc..8f8fdb5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -134,10 +134,20 @@ export const ccDisputes = pgTable('cc_disputes', { nextAction: text('next_action'), nextActionDate: date('next_action_date'), resolutionTarget: text('resolution_target'), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // privilege ∈ {privileged, pii, hoa_evidentiary, public} + privilege: text('privilege').notNull().default('public'), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // space ∈ {business, legalink} + space: text('space').notNull().default('business'), metadata: jsonb('metadata').default({}), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), -}); +}, (table) => ({ + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilegeIdx: index('idx_cc_disputes_privilege').on(table.privilege, table.status), + spaceIdx: index('idx_cc_disputes_space').on(table.space, table.status), +})); // ── Dispute Correspondence ──────────────────────────────────── export const ccDisputeCorrespondence = pgTable('cc_dispute_correspondence', { @@ -482,6 +492,12 @@ export const ccIntents = pgTable('cc_intents', { errorMessage: text('error_message'), // fixes codex-p2 PR#101 finding-1 — bookkeeping for reclaimStuckIntents() reclaimCount: integer('reclaim_count').notNull().default(0), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // privilege ∈ {privileged, pii, hoa_evidentiary, public} + privilege: text('privilege').notNull().default('public'), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // space ∈ {business, legalink} + space: text('space').notNull().default('business'), metadata: jsonb('metadata').default({}), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), @@ -491,6 +507,9 @@ export const ccIntents = pgTable('cc_intents', { statusIdx: index('idx_cc_intents_status').on(table.status), priorityIdx: index('idx_cc_intents_priority').on(table.priority), scheduledIdx: index('idx_cc_intents_scheduled').on(table.scheduledFor), + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilegeIdx: index('idx_cc_intents_privilege').on(table.privilege, table.status), + spaceIdx: index('idx_cc_intents_space').on(table.space, table.status), // fixes codex-p2 PR#101 finding-4 — composite FK so intent.goal_id MUST // match its plan's goal_id. Backed by UNIQUE(id, goal_id) on cc_plans. planGoalFk: foreignKey({ diff --git a/src/index.ts b/src/index.ts index d557397..8e992b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { agentsMiddleware } from 'hono-agents'; -import { authMiddleware, bridgeAuthMiddleware, mcpAuthMiddleware } from './middleware/auth'; +import { authMiddleware, bridgeAuthMiddleware, mcpAuthMiddleware, requireTriageScope } from './middleware/auth'; import type { AuthVariables } from './middleware/auth'; import { getDb } from './lib/db'; import { runCronSync } from './lib/cron'; @@ -33,6 +33,7 @@ import { tokenManagementRoutes } from './routes/token-management'; import { jobRoutes } from './routes/jobs'; import { transactionRoutes } from './routes/transactions'; import { timelineRoutes } from './routes/timeline'; +import { triageRoutes } from './routes/triage'; // Re-export ActionAgent DO class so the runtime can find it export { ActionAgent } from './agents/action-agent'; @@ -140,6 +141,13 @@ app.route('/api/email-connections', emailConnectionRoutes); app.route('/api/chat', chatRoutes); app.route('/api/litigation', litigationRoutes); app.route('/api/tasks', taskRoutes); +// ChittyTriage — pending-intent queue partitioned by Roux (privilege, space) +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// fixes codex-p2 PR#104 P1 — triage routes require elevated scope +// (chittytriage:write/admin, local-KV admin, or wildcard) on top of the +// generic /api/* authMiddleware. +app.use('/api/triage/*', requireTriageScope); +app.route('/api/triage', triageRoutes); // Identity (authenticated) app.route('/api/v1', metaRoutes); // Context (authenticated) diff --git a/src/lib/dispute-sync.ts b/src/lib/dispute-sync.ts index 4f22258..bf8976b 100644 --- a/src/lib/dispute-sync.ts +++ b/src/lib/dispute-sync.ts @@ -27,9 +27,46 @@ interface DisputeCore { amount_at_stake?: number | null; description?: string | null; priority: number; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + privilege?: 'privileged' | 'pii' | 'hoa_evidentiary' | 'public' | null; + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + space?: 'business' | 'legalink' | null; metadata?: Record | null; } +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// Map a cc_disputes.dispute_type to default Roux (privilege, space). +// Explicit caller-supplied values always override (Q1=(c) pass-through derive). +export function deriveRouxFromType(disputeType: string): { + privilege: 'privileged' | 'pii' | 'hoa_evidentiary' | 'public'; + space: 'business' | 'legalink'; +} { + // Fail-safe routing: dispute_type is free-text from the API/UI. Any string + // containing "legal" routes to privileged/legalink (prevents leakage of + // "Legal", "legal dispute", etc. to the public/business Notion bucket). + // Any string containing "insurance" routes to pii/business. This is + // intentionally over-broad on the privileged side — better to over-suppress + // a Notion mirror than to leak privileged content. + const normalized = (disputeType ?? '').toLowerCase().trim(); + if (normalized.includes('legal')) { + return { privilege: 'privileged', space: 'legalink' }; + } + if (normalized.includes('insurance')) { + return { privilege: 'pii', space: 'business' }; + } + switch (normalized) { + case 'property': + case 'vendor': + case 'tenant': + case 'financial': + return { privilege: 'public', space: 'business' }; + default: + // Unknown dispute_type — fall back to the safest defaults that still + // route through the public/business bucket so the dispute is visible. + return { privilege: 'public', space: 'business' }; + } +} + // ── Public API ──────────────────────────────────────────────── /** @@ -180,7 +217,8 @@ export async function pushUnlinkedDisputesToNotion( sql: NeonQueryFunction, ): Promise { const unlinked = await sql` - SELECT id, title, counterparty, dispute_type, priority, description, metadata + SELECT id, title, counterparty, dispute_type, priority, description, metadata, + privilege, space FROM cc_disputes WHERE (metadata->>'notion_task_id') IS NULL AND status NOT IN ('resolved', 'dismissed') @@ -192,7 +230,32 @@ export async function pushUnlinkedDisputesToNotion( for (const dispute of unlinked) { try { - const linked = await linkDisputeToNotion(dispute.id as string, dispute as unknown as DisputeCore, env, sql); + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // Q2=(a) pass-through-with-warn: rows that landed on the column defaults + // (public/business) may be untagged legacy rows (the DB stores defaults + // even when the caller didn't supply explicit values). For the Notion-gate + // decision, we cannot distinguish "caller explicitly chose public/business" + // from "caller omitted both fields and PG defaulted them". When both axes + // sit on the defaults, re-derive from dispute_type so a row with + // dispute_type='legal' is still suppressed even though privilege/space + // were stored as public/business. The DB row itself is left untouched — + // an explicit retag is a separate concern. + const storedPrivilege = (dispute.privilege as string | null) ?? 'public'; + const storedSpace = (dispute.space as string | null) ?? 'business'; + const onDefaults = storedPrivilege === 'public' && storedSpace === 'business'; + let gateDispute = dispute as unknown as DisputeCore; + if (onDefaults) { + console.log( + `[dispute-sync:roux-backfill] dispute_id=${dispute.id} on default privilege=public space=business — re-deriving from dispute_type=${dispute.dispute_type as string} for gate decision; explicit tag recommended`, + ); + const derived = deriveRouxFromType(dispute.dispute_type as string); + gateDispute = { + ...(dispute as unknown as DisputeCore), + privilege: derived.privilege, + space: derived.space, + }; + } + const linked = await linkDisputeToNotion(dispute.id as string, gateDispute, env, sql); if (linked) pushed++; } catch (err) { console.error(`[dispute-sync:push] Failed for dispute ${dispute.id}:`, err); @@ -204,12 +267,29 @@ export async function pushUnlinkedDisputesToNotion( // ── Internal helpers ────────────────────────────────────────── -async function linkDisputeToNotion( +export async function linkDisputeToNotion( disputeId: string, - dispute: Pick, + dispute: Pick, env: Env, sql: NeonQueryFunction, ): Promise { + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + // Roux gate: privileged/pii content and anything in the legalink space must + // NOT be mirrored into Notion. Resolve effective values: explicit > derived. + const derived = deriveRouxFromType(dispute.dispute_type); + const effectivePrivilege = dispute.privilege ?? derived.privilege; + const effectiveSpace = dispute.space ?? derived.space; + if ( + effectivePrivilege === 'privileged' || + effectivePrivilege === 'pii' || + effectiveSpace === 'legalink' + ) { + console.log( + `[dispute-sync:notion] suppressed (privilege=${effectivePrivilege} space=${effectiveSpace}) dispute=${disputeId}`, + ); + return false; + } + try { const notion = notionClient(env); if (!notion) { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 57f5a41..1bb6098 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -174,3 +174,49 @@ export async function mcpAuthMiddleware(c: Context<{ Bindings: Env; Variables: A c.set('scopes', ['mcp']); return next(); } + +/** + * Elevated-scope gate for /api/triage/* and the MCP triage_* tools. + * + * The triage queue lists, claims, and completes orchestration intents that may + * be `privileged` or `legalink` — exposing them to any ordinary ChittyAuth + * user token (which authMiddleware grants `['admin']` for KV tokens or the + * raw `scopes` claim for ChittyAuth tokens) is too broad. This middleware + * runs AFTER authMiddleware/mcpAuthMiddleware has populated `c.var.scopes` + * and enforces that the caller carries one of the recognized elevated + * scopes: + * - `chittytriage:write` — canonical scope name for triage mutation + * - `chittytriage:admin` — admin-level + * - `admin` — local KV-token superuser path (authMiddleware + * sets ['admin'] for KV-issued tokens) + * - `*` — wildcard (operator/service principal) + * + * fixes codex-p2 PR#104 P1 — elevated scope on triage routes/tools. + */ +export async function requireTriageScope( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, + next: Next, +) { + const scopes = c.get('scopes') || []; + const ok = scopes.some( + (s) => s === 'chittytriage:write' || s === 'chittytriage:admin' || s === 'admin' || s === '*', + ); + if (!ok) { + return c.json({ error: 'Insufficient scope: chittytriage:write required' }, 403); + } + return next(); +} + +/** + * Returns true if the caller has the elevated triage scope. Used by MCP + * tool handlers and `tools/list` filtering, which run inside a JSON-RPC + * dispatcher rather than as Hono middleware. + * + * fixes codex-p2 PR#104 P1 — scope-aware MCP triage tool advertisement. + */ +export function hasTriageScope(scopes: string[] | undefined | null): boolean { + if (!scopes) return false; + return scopes.some( + (s) => s === 'chittytriage:write' || s === 'chittytriage:admin' || s === 'admin' || s === '*', + ); +} diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index c377e03..6a003a4 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -1,11 +1,38 @@ import { Hono } from 'hono'; import type { Env } from '../index'; import type { AuthVariables } from '../middleware/auth'; +import { hasTriageScope } from '../middleware/auth'; + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// fixes codex-p2 PR#104 P1 — triage MCP tools require elevated scope; we +// filter them from tools/list and gate tools/call for callers without it. +const TRIAGE_TOOL_NAMES = new Set([ + 'triage_list_intents', + 'triage_claim_intent', + 'triage_claim_next', + 'triage_complete_intent', +]); import { getDb, typedRows } from '../lib/db'; import type { NeonQueryFunction } from '@neondatabase/serverless'; import { listJobs, getJobStatus, retryJob, getDeadLetters, enqueueJob } from '../lib/job-dispatcher'; import type { ScrapeJobType, ScrapeJobStatus } from '../lib/job-dispatcher'; import { evidenceClient, ledgerClient, govClient } from '../lib/integrations'; +import { + claimNextIntent, + completeIntent, + failIntent, + type IntentPrivilege, + type IntentSpace, +} from '../../meta/intent'; + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +const TRIAGE_VALID_PRIVILEGE: ReadonlySet = new Set([ + 'privileged', + 'pii', + 'hoa_evidentiary', + 'public', +]); +const TRIAGE_VALID_SPACE: ReadonlySet = new Set(['business', 'legalink']); /** * MCP (Model Context Protocol) server for ChittyCommand. @@ -441,6 +468,55 @@ const TOOLS = [ required: [] as string[], }, }, + // ChittyTriage / Roux — @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + { + name: 'triage_list_intents', + description: 'List pending intents in the ChittyTriage queue, optionally filtered by Roux privilege/space.', + inputSchema: { + type: 'object' as const, + properties: { + privilege: { type: 'string', description: "Roux privilege class: 'privileged'|'pii'|'hoa_evidentiary'|'public'" }, + space: { type: 'string', description: "Roux space: 'business'|'legalink'" }, + limit: { type: 'number', description: 'Max results (default 25, cap 200)' }, + }, + required: [] as string[], + }, + }, + { + name: 'triage_claim_intent', + description: 'Atomically claim a specific pending intent by ID. Returns 409 if already claimed.', + inputSchema: { + type: 'object' as const, + properties: { id: { type: 'string', description: 'Intent UUID' } }, + required: ['id'], + }, + }, + { + name: 'triage_claim_next', + description: 'Bucket-ordered claim of the next pending intent for an autonomous agent. Filters: privilege, space, priority_lte.', + inputSchema: { + type: 'object' as const, + properties: { + privilege: { type: 'string' }, + space: { type: 'string' }, + priority_lte: { type: 'number', description: 'Only claim intents with priority <= this value' }, + }, + required: [] as string[], + }, + }, + { + name: 'triage_complete_intent', + description: "Terminal transition for an intent. outcome must be 'done' (must be running) or 'failed' (must be claimed/running).", + inputSchema: { + type: 'object' as const, + properties: { + id: { type: 'string' }, + outcome: { type: 'string', description: "'done' | 'failed'" }, + error: { type: 'string', description: 'Optional failure detail when outcome=failed' }, + }, + required: ['id', 'outcome'], + }, + }, ]; // MCP endpoint — handles JSON-RPC 2.0 requests @@ -483,8 +559,16 @@ mcpRoutes.post('/', async (c) => { // Per JSON-RPC 2.0: notifications have no id and MUST NOT receive a response return c.body(null, 204); - case 'tools/list': - return c.json({ jsonrpc: '2.0', id, result: { tools: TOOLS } }); + case 'tools/list': { + // fixes codex-p2 PR#104 P1 — filter triage_* tools from the catalog + // when the caller lacks chittytriage:write (don't advertise what they + // can't call). + const listScopes = c.get('scopes'); + const visibleTools = hasTriageScope(listScopes) + ? TOOLS + : TOOLS.filter((t) => !TRIAGE_TOOL_NAMES.has(t.name)); + return c.json({ jsonrpc: '2.0', id, result: { tools: visibleTools } }); + } case 'tools/call': { const toolName = params?.name as string; @@ -493,6 +577,19 @@ mcpRoutes.post('/', async (c) => { const sql = getDb(c.env); const userId = c.get('userId'); const scopes = c.get('scopes'); + // fixes codex-p2 PR#104 P1 — defense-in-depth scope gate for the + // triage tools so a caller that knows the name can't bypass the + // tools/list filter. + if (TRIAGE_TOOL_NAMES.has(toolName) && !hasTriageScope(scopes)) { + return c.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: 'Error: Insufficient scope: chittytriage:write required' }], + isError: true, + }, + }); + } const result = await executeTool(c.env, sql, toolName, args, { userId, scopes }); const content = [{ type: 'text' as const, text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }]; @@ -1359,6 +1456,107 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN return { caseId: caseId || 'all', pending: pending || [] }; } + // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + case 'triage_list_intents': { + const privilegeRaw = typeof args.privilege === 'string' ? args.privilege : null; + const spaceRaw = typeof args.space === 'string' ? args.space : null; + if (privilegeRaw && !TRIAGE_VALID_PRIVILEGE.has(privilegeRaw as IntentPrivilege)) { + return { error: `Invalid privilege: ${privilegeRaw}` }; + } + if (spaceRaw && !TRIAGE_VALID_SPACE.has(spaceRaw as IntentSpace)) { + return { error: `Invalid space: ${spaceRaw}` }; + } + const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 200); + const rows = await sql` + SELECT id, plan_id, goal_id, intent_type, target_channel, status, priority, + privilege, space, scheduled_for, created_at, updated_at, + human_gate_reason, reclaim_count + FROM cc_intents + WHERE status = 'pending' + AND (${privilegeRaw}::text IS NULL OR privilege = ${privilegeRaw}) + AND (${spaceRaw}::text IS NULL OR space = ${spaceRaw}) + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + ORDER BY priority ASC, created_at ASC + LIMIT ${limit} + `; + return { intents: rows, count: rows.length, filter: { privilege: privilegeRaw, space: spaceRaw, limit } }; + } + + case 'triage_claim_intent': { + const id = String(args.id || ''); + if (!id) return { error: 'id required' }; + const existing = await sql`SELECT id, status, scheduled_for FROM cc_intents WHERE id = ${id} LIMIT 1`; + if (existing.length === 0) return { error: 'Intent not found', code: 404 }; + // Finding 5: mirror the HTTP route — direct claim must not bypass + // scheduled_for the way list + claim-next don't. + const claimed = await sql` + UPDATE cc_intents SET status = 'claimed', updated_at = NOW() + WHERE id = ${id} + AND status = 'pending' + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + RETURNING * + `; + if (claimed.length === 0) { + const scheduledFor = existing[0].scheduled_for as string | null; + if ( + existing[0].status === 'pending' && + scheduledFor && + new Date(scheduledFor) > new Date() + ) { + return { + error: 'Intent scheduled for future; refusing to claim early', + code: 409, + scheduled_for: scheduledFor, + }; + } + return { error: 'Intent already claimed or not pending', code: 409, current_status: existing[0].status }; + } + return { intent: claimed[0] }; + } + + case 'triage_claim_next': { + const privilegeRaw = typeof args.privilege === 'string' ? args.privilege : null; + const spaceRaw = typeof args.space === 'string' ? args.space : null; + if (privilegeRaw && !TRIAGE_VALID_PRIVILEGE.has(privilegeRaw as IntentPrivilege)) { + return { error: `Invalid privilege: ${privilegeRaw}` }; + } + if (spaceRaw && !TRIAGE_VALID_SPACE.has(spaceRaw as IntentSpace)) { + return { error: `Invalid space: ${spaceRaw}` }; + } + const priorityLteRaw = args.priority_lte; + let priorityLte: number | undefined; + if (priorityLteRaw !== undefined && priorityLteRaw !== null) { + const n = Number(priorityLteRaw); + if (!Number.isFinite(n)) return { error: 'priority_lte must be a number' }; + priorityLte = Math.floor(n); + } + const intent = await claimNextIntent(env, { + privilege: (privilegeRaw as IntentPrivilege) ?? undefined, + space: (spaceRaw as IntentSpace) ?? undefined, + priorityLte, + }); + if (!intent) return { intent: null, message: 'Queue empty for the requested bucket' }; + return { intent }; + } + + case 'triage_complete_intent': { + const id = String(args.id || ''); + if (!id) return { error: 'id required' }; + const outcome = args.outcome; + if (outcome !== 'done' && outcome !== 'failed') { + return { error: "outcome must be 'done' or 'failed'" }; + } + if (outcome === 'done') { + const updated = await completeIntent(env, id); + if (!updated) return { error: "Intent not in 'claimed' or 'running' state; refusing to mark done", code: 409 }; + return { intent: updated }; + } + const errMsg = String(args.error ?? 'failed via mcp triage_complete_intent'); + const updated = await failIntent(env, id, errMsg); + if (!updated) return { error: "Intent not in 'claimed' or 'running' state; refusing to mark failed", code: 409 }; + return { intent: updated }; + } + default: throw new Error(`Unknown tool: ${toolName}`); } diff --git a/src/routes/triage.ts b/src/routes/triage.ts new file mode 100644 index 0000000..716fd38 --- /dev/null +++ b/src/routes/triage.ts @@ -0,0 +1,221 @@ +/** + * ChittyTriage routes — autonomous + human intent triage queue. + * + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + * + * Exposes the cc_intents pending bucket as a triage queue partitioned by the + * ChittyRoux (privilege, space) axes. Two claim modes: + * + * POST /api/triage/:id/claim — claim a specific intent by ID + * POST /api/triage/claim-next — bucket-ordered claim (for autonomous + * agents pulling work) + * + * Auth is provided by the global /api/* authMiddleware mount in src/index.ts. + */ + +import { Hono } from 'hono'; +import type { Env } from '../index'; +import type { AuthVariables } from '../middleware/auth'; +import { getDb } from '../lib/db'; +import { + claimNextIntent, + completeIntent, + failIntent, + type IntentPrivilege, + type IntentSpace, +} from '../../meta/intent'; + +export const triageRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); + +const VALID_PRIVILEGE: ReadonlySet = new Set([ + 'privileged', + 'pii', + 'hoa_evidentiary', + 'public', +]); +const VALID_SPACE: ReadonlySet = new Set(['business', 'legalink']); + +function parsePrivilege(raw: unknown): IntentPrivilege | null { + if (typeof raw !== 'string' || raw.length === 0) return null; + return VALID_PRIVILEGE.has(raw as IntentPrivilege) ? (raw as IntentPrivilege) : null; +} + +function parseSpace(raw: unknown): IntentSpace | null { + if (typeof raw !== 'string' || raw.length === 0) return null; + return VALID_SPACE.has(raw as IntentSpace) ? (raw as IntentSpace) : null; +} + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// GET /api/triage — list pending intents in the queue, optionally filtered +// by privilege/space. Used by the dashboard and human triagers. +triageRoutes.get('/', async (c) => { + const sql = getDb(c.env); + const privilegeQ = c.req.query('privilege'); + const spaceQ = c.req.query('space'); + const limitQ = c.req.query('limit'); + + if (privilegeQ && !parsePrivilege(privilegeQ)) { + return c.json({ error: `Invalid privilege; must be one of ${[...VALID_PRIVILEGE].join(',')}` }, 400); + } + if (spaceQ && !parseSpace(spaceQ)) { + return c.json({ error: `Invalid space; must be one of ${[...VALID_SPACE].join(',')}` }, 400); + } + let limit = 25; + if (limitQ) { + const n = Number(limitQ); + if (!Number.isFinite(n) || n <= 0 || n > 200) { + return c.json({ error: 'limit must be 1..200' }, 400); + } + limit = Math.floor(n); + } + + const privilege: string | null = privilegeQ ?? null; + const space: string | null = spaceQ ?? null; + + const rows = await sql` + SELECT id, plan_id, goal_id, intent_type, target_channel, status, priority, + privilege, space, scheduled_for, created_at, updated_at, + human_gate_reason, reclaim_count + FROM cc_intents + WHERE status = 'pending' + AND (${privilege}::text IS NULL OR privilege = ${privilege}) + AND (${space}::text IS NULL OR space = ${space}) + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + ORDER BY priority ASC, created_at ASC + LIMIT ${limit} + `; + + return c.json({ + intents: rows, + filter: { privilege, space, limit }, + count: rows.length, + }); +}); + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// POST /api/triage/:id/claim — claim a specific pending intent by ID. +// Atomic via UPDATE...WHERE status='pending' RETURNING. Returns 409 if the +// intent is no longer pending (already claimed, running, etc). +triageRoutes.post('/:id/claim', async (c) => { + const sql = getDb(c.env); + const id = c.req.param('id'); + if (!id) return c.json({ error: 'id required' }, 400); + + // Confirm existence vs. status separately so 404 and 409 are distinguishable. + const existing = await sql`SELECT id, status, scheduled_for FROM cc_intents WHERE id = ${id} LIMIT 1`; + if (existing.length === 0) return c.json({ error: 'Intent not found' }, 404); + + // Finding 5: list + claim-next exclude future scheduled_for, so the direct + // claim path must too — otherwise a client with the ID can short-circuit the + // schedule and pull tomorrow's work today. + const claimed = await sql` + UPDATE cc_intents + SET status = 'claimed', updated_at = NOW() + WHERE id = ${id} + AND status = 'pending' + AND (scheduled_for IS NULL OR scheduled_for <= NOW()) + RETURNING * + `; + + if (claimed.length === 0) { + const scheduledFor = existing[0].scheduled_for as string | null; + if ( + existing[0].status === 'pending' && + scheduledFor && + new Date(scheduledFor) > new Date() + ) { + return c.json( + { + error: 'Intent scheduled for future; refusing to claim early', + scheduled_for: scheduledFor, + }, + 409, + ); + } + return c.json( + { error: 'Intent already claimed or not pending', current_status: existing[0].status }, + 409, + ); + } + + return c.json({ intent: claimed[0] }); +}); + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// POST /api/triage/claim-next — bucket-ordered claim used by autonomous +// agents. Filters delegated to meta/intent.ts claimNextIntent(). +triageRoutes.post('/claim-next', async (c) => { + const body = await c.req.json().catch(() => ({})); + const privilegeRaw = (body as Record).privilege; + const spaceRaw = (body as Record).space; + // If a filter is PROVIDED but doesn't parse, reject with 400 — otherwise the + // null fallthrough would silently claim from any bucket including + // privileged/legalink, which a caller filtering for e.g. "pii" definitely + // did not intend (e.g. typo "pi" → null → claim privileged work). + const privilege = parsePrivilege(privilegeRaw); + if (privilegeRaw !== undefined && privilegeRaw !== null && privilege === null) { + return c.json( + { error: `Invalid privilege; must be one of ${[...VALID_PRIVILEGE].join(',')}` }, + 400, + ); + } + const space = parseSpace(spaceRaw); + if (spaceRaw !== undefined && spaceRaw !== null && space === null) { + return c.json( + { error: `Invalid space; must be one of ${[...VALID_SPACE].join(',')}` }, + 400, + ); + } + const priorityLteRaw = (body as Record).priority_lte; + let priorityLte: number | undefined; + if (priorityLteRaw !== undefined && priorityLteRaw !== null) { + const n = Number(priorityLteRaw); + if (!Number.isFinite(n)) return c.json({ error: 'priority_lte must be a number' }, 400); + priorityLte = Math.floor(n); + } + + const intent = await claimNextIntent(c.env, { + privilege: privilege ?? undefined, + space: space ?? undefined, + priorityLte, + }); + + if (!intent) { + return c.json({ intent: null, message: 'Queue empty for the requested bucket' }, 404); + } + return c.json({ intent }); +}); + +// @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +// POST /api/triage/:id/complete — terminal transition. Respects the +// 'running'-only guard for done; allows claimed-or-running for failed. +triageRoutes.post('/:id/complete', async (c) => { + const id = c.req.param('id'); + if (!id) return c.json({ error: 'id required' }, 400); + const body = await c.req.json().catch(() => ({})); + const outcome = (body as Record).outcome; + if (outcome !== 'done' && outcome !== 'failed') { + return c.json({ error: "outcome must be 'done' or 'failed'" }, 400); + } + + if (outcome === 'done') { + const updated = await completeIntent(c.env, id); + if (!updated) { + return c.json( + { error: "Intent not in 'claimed' or 'running' state; refusing to mark done" }, + 409, + ); + } + return c.json({ intent: updated }); + } + + const errMsg = String((body as Record).error ?? 'failed via /complete'); + const updated = await failIntent(c.env, id, errMsg); + if (!updated) { + return c.json( + { error: "Intent not in 'claimed' or 'running' state; refusing to mark failed" }, + 409, + ); + } + return c.json({ intent: updated }); +}); diff --git a/tests/lib/dispute-sync-roux.spec.ts b/tests/lib/dispute-sync-roux.spec.ts new file mode 100644 index 0000000..1dd9ff4 --- /dev/null +++ b/tests/lib/dispute-sync-roux.spec.ts @@ -0,0 +1,141 @@ +/** + * Integration test for dispute-sync Roux gate + derive helpers. + * + * Covers: + * - deriveRouxFromType maps known dispute types to ratified Roux defaults. + * - Explicit caller-supplied privilege/space wins over derived defaults. + * - linkDisputeToNotion suppresses {privilege:'privileged'} and {space:'legalink'}. + * - Default (public/business) disputes pass the gate (they enter the + * notionClient code path — the actual Notion call is allowed to fail in + * the test environment; we only verify the gate did not short-circuit). + * + * Real Neon used for the gate-pass path. Skipped without DATABASE_URL. + * + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { deriveRouxFromType, linkDisputeToNotion } from '../../src/lib/dispute-sync'; +import { neon } from '@neondatabase/serverless'; +import type { NeonQueryFunction } from '@neondatabase/serverless'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +// Minimal stand-in for the Env binding shape — only fields linkDisputeToNotion +// actually reaches for. We do NOT set NOTION_TOKEN, so notionClient() returns +// null and the gate-pass case exits cleanly via "notionClient unavailable". +const env = { + DATABASE_URL, +} as unknown as Parameters[2]; + +describe('deriveRouxFromType (pure)', () => { + it("'legal' → privileged + legalink", () => { + expect(deriveRouxFromType('legal')).toEqual({ privilege: 'privileged', space: 'legalink' }); + }); + it("'insurance' → pii + business", () => { + expect(deriveRouxFromType('insurance')).toEqual({ privilege: 'pii', space: 'business' }); + }); + it("'property' → public + business", () => { + expect(deriveRouxFromType('property')).toEqual({ privilege: 'public', space: 'business' }); + }); + it("'vendor' → public + business", () => { + expect(deriveRouxFromType('vendor')).toEqual({ privilege: 'public', space: 'business' }); + }); + it("unknown type → public + business (safe default)", () => { + expect(deriveRouxFromType('something-new')).toEqual({ privilege: 'public', space: 'business' }); + }); +}); + +describe.skipIf(SKIP)('linkDisputeToNotion Roux gate (real Neon)', () => { + // The gate evaluates effective values BEFORE notionClient is constructed, + // so we can verify suppression without any Notion creds. The sql arg is + // only used by the post-gate UPDATE path; suppression returns early. + // neon() is initialized in beforeAll so module-load doesn't crash when + // DATABASE_URL is absent (describe body still executes to register tests). + let sql: NeonQueryFunction; + beforeAll(() => { + sql = neon(DATABASE_URL!); + }); + + it('suppresses when explicit privilege=privileged (regardless of dispute_type)', async () => { + const result = await linkDisputeToNotion( + 'test-dispute-priv', + { + title: 'X', + dispute_type: 'property', // would normally derive (public, business) + priority: 5, + description: null, + privilege: 'privileged', + space: 'business', + }, + env, + sql, + ); + expect(result).toBe(false); + }); + + it('suppresses when explicit space=legalink (regardless of privilege)', async () => { + const result = await linkDisputeToNotion( + 'test-dispute-legalink', + { + title: 'X', + dispute_type: 'property', + priority: 5, + description: null, + privilege: 'public', + space: 'legalink', + }, + env, + sql, + ); + expect(result).toBe(false); + }); + + it("suppresses 'legal' dispute by derived default (privileged, legalink)", async () => { + const result = await linkDisputeToNotion( + 'test-dispute-legal-derived', + { + title: 'Legal matter', + dispute_type: 'legal', + priority: 5, + description: null, + // no explicit privilege/space — derived from type + }, + env, + sql, + ); + expect(result).toBe(false); + }); + + it('explicit override beats derived default (legal dispute tagged public/business passes the gate)', async () => { + // Without explicit override: 'legal' would be suppressed. + // With explicit override (public/business), the gate should let it through. + // Without NOTION_TOKEN configured, notionClient() returns null and the + // function logs "notionClient unavailable" and returns false — but we've + // already proven we got PAST the gate (otherwise the result is the same + // false but the codepath is different). To distinguish, we assert the + // call resolves without throwing — the gate would have returned cleanly + // either way, but the non-gate path also returns false, so we instead + // verify that the inverse-direction test ALSO returns false but for the + // same reason. This is the documented limitation: in the test env, the + // observable difference is only in logs. The negative-direction tests + // above prove the gate is wired; this case proves the override is + // honored at the resolution-rules level by being a non-throwing call. + const result = await linkDisputeToNotion( + 'test-dispute-override', + { + title: 'Legal but actually public', + dispute_type: 'legal', + priority: 5, + description: null, + privilege: 'public', + space: 'business', + }, + env, + sql, + ); + // Result is false (Notion client unavailable in test), but it did not throw. + expect(result).toBe(false); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index f207113..f60ec1d 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -141,13 +141,15 @@ describe('MCP — tools/list', () => { expect(tools.length).toBeGreaterThanOrEqual(1); }); - it('exposes exactly 50 tools', async () => { + it('exposes exactly 54 tools', async () => { const { post } = buildApp(); const res = await post({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); const json = await res.json() as Record; const result = json.result as Record; const tools = result.tools as unknown[]; - expect(tools.length).toBe(50); + // 50 base + 4 triage tools added in PR #104: triage_list_intents, + // triage_claim_intent, triage_claim_next, triage_complete_intent + expect(tools.length).toBe(54); }); it('each tool has a name and inputSchema', async () => { diff --git a/tests/meta/intent-lifecycle.spec.ts b/tests/meta/intent-lifecycle.spec.ts index d2957fa..e61325b 100644 --- a/tests/meta/intent-lifecycle.spec.ts +++ b/tests/meta/intent-lifecycle.spec.ts @@ -46,7 +46,66 @@ describe.skipIf(SKIP)('meta/intent lifecycle (real Neon)', () => { await cleanup(); }); - it('completeIntent only succeeds when status=running (F6)', async () => { + // fixes codex-p2 PR#104 finding-4 — claim→complete path must work end-to-end. + it('completeIntent succeeds from claimed without an intervening running (F4)', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g-f4` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p-f4` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { test: TEST_TAG }, + }); + const sql = neon(DATABASE_URL!); + // Simulate the triage claim route's transition: pending → claimed. + await sql`UPDATE cc_intents SET status = 'claimed' WHERE id = ${intent.id}`; + // No intervening running transition — autonomous agent goes straight to done. + const completed = await completeIntent(env, intent.id); + expect(completed?.status).toBe('done'); + }); + + // fixes codex-p2 PR#104 finding-4 — failIntent symmetric path from claimed. + it('failIntent succeeds from claimed without an intervening running (F4)', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g-f4b` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p-f4b` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { test: TEST_TAG }, + }); + const sql = neon(DATABASE_URL!); + await sql`UPDATE cc_intents SET status = 'claimed' WHERE id = ${intent.id}`; + const failed = await failIntent(env, intent.id, 'agent error before running'); + expect(failed?.status).toBe('failed'); + expect(failed?.errorMessage).toBe('agent error before running'); + }); + + // fixes codex-p2 PR#104 finding-4 — token gate still rejects stale completions + // even on the relaxed claimed-or-running guard. + it('completeIntent token gate rejects stale token on claimed intent (F4 + P1-B)', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g-f4c` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p-f4c` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { test: TEST_TAG }, + }); + const sql = neon(DATABASE_URL!); + await sql` + UPDATE cc_intents + SET status = 'claimed', dispatched_task_id = 'token-live' + WHERE id = ${intent.id}`; + const stale = await completeIntent(env, intent.id, 'token-stale'); + expect(stale).toBeNull(); + const stillClaimed = await getIntent(env, intent.id); + expect(stillClaimed?.status).toBe('claimed'); + const live = await completeIntent(env, intent.id, 'token-live'); + expect(live?.status).toBe('done'); + }); + + it('completeIntent rejects pending and respects state guard (F6)', async () => { const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g1` }); const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p1` }); const intent = await createIntent(env, { @@ -175,4 +234,65 @@ describe.skipIf(SKIP)('meta/intent lifecycle (real Neon)', () => { const liveComplete = await completeIntent(env, intent.id, 'token-T2'); expect(liveComplete?.status).toBe('done'); }); + + // fixes codex-p2 PR#104 P2 (reclaim claim-token race) — reviewer flagged a + // possible race where client A claims (token A) → reclaimStuckIntents resets + // → client B re-claims (token B) → client A returns and completes with token + // A. The existing P1-B token gate covers both 'claimed' AND 'running' (see + // completeIntent: status IN ('claimed','running') AND dispatched_task_id = $token), + // and reclaimStuckIntents NULLs dispatched_task_id, so token A can never + // match again. This test pins that behavior so a future refactor can't + // regress it. + it('claim → reclaim → re-claim race: stale tokenA cannot complete tokenB-owned work', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g-race` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p-race` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'race', + payload: { test: TEST_TAG }, + }); + + const sql = neon(DATABASE_URL!); + // Client A claims with tokenA — and sat there past the reclaim window. + // The route doesn't currently expose dispatched_task_id on claim, so we + // simulate the "dispatched + stale" state directly. + await sql` + UPDATE cc_intents + SET status = 'claimed', + dispatched_task_id = 'tokenA', + updated_at = NOW() - INTERVAL '10 minutes' + WHERE id = ${intent.id}`; + + // Reclaim window expires; daemon resets to pending and clears the token. + const reclaimed = await reclaimStuckIntents(env, 60); + expect(reclaimed).toBe(1); + const afterReclaim = await getIntent(env, intent.id); + expect(afterReclaim?.status).toBe('pending'); + expect(afterReclaim?.dispatchedTaskId).toBeNull(); + + // Client B re-claims, getting tokenB. + await sql` + UPDATE cc_intents + SET status = 'claimed', dispatched_task_id = 'tokenB' + WHERE id = ${intent.id} AND status = 'pending'`; + + // Client A finally returns. completeIntent with tokenA must NOT update + // the row — neither marking B's work done nor overwriting B's token. + const staleA = await completeIntent(env, intent.id, 'tokenA'); + expect(staleA).toBeNull(); + const stillBs = await getIntent(env, intent.id); + expect(stillBs?.status).toBe('claimed'); + expect(stillBs?.dispatchedTaskId).toBe('tokenB'); + + // failIntent with tokenA is symmetric — also rejected. + const staleAFail = await failIntent(env, intent.id, 'A error', 'tokenA'); + expect(staleAFail).toBeNull(); + const stillBs2 = await getIntent(env, intent.id); + expect(stillBs2?.status).toBe('claimed'); + + // Client B completes with tokenB — succeeds. + const liveB = await completeIntent(env, intent.id, 'tokenB'); + expect(liveB?.status).toBe('done'); + }); }); diff --git a/tests/routes/triage-roux.spec.ts b/tests/routes/triage-roux.spec.ts new file mode 100644 index 0000000..5beb86e --- /dev/null +++ b/tests/routes/triage-roux.spec.ts @@ -0,0 +1,160 @@ +/** + * Integration test for ChittyTriage + Roux carry-through. + * + * Covers: + * - createIntent with privilege+space round-trips. + * - claimNextIntent filters by privilege+space (only the matching bucket). + * - 409 on second claim of an already-claimed intent (idempotent retry). + * - claim-next bucket filter respects priority ordering. + * + * Real Neon. Skipped without DATABASE_URL — mirrors tests/meta/intent-lifecycle.spec.ts. + * + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { neon } from '@neondatabase/serverless'; +import { + createGoal, + createPlan, + createIntent, + claimNextIntent, + getIntent, + type IntentEnv, +} from '../../meta/intent'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +const env: IntentEnv = { DATABASE_URL }; +const OWNER = '01-A-NB-0002-P-66-1-1'; +const TEST_TAG = `roux-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +async function cleanup() { + if (!DATABASE_URL) return; + const sql = neon(DATABASE_URL); + await sql`DELETE FROM cc_goals WHERE owner_chitty_id = ${OWNER} AND title LIKE ${TEST_TAG + '%'}`; +} + +describe.skipIf(SKIP)('ChittyTriage Roux carry-through (real Neon)', () => { + beforeAll(async () => { + await cleanup(); + }); + afterAll(async () => { + await cleanup(); + }); + + it('createIntent persists privilege + space and round-trips through getIntent', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g1` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p1` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { test: TEST_TAG }, + privilege: 'privileged', + space: 'legalink', + }); + expect(intent.privilege).toBe('privileged'); + expect(intent.space).toBe('legalink'); + + const round = await getIntent(env, intent.id); + expect(round?.privilege).toBe('privileged'); + expect(round?.space).toBe('legalink'); + }); + + it('claimNextIntent filters by privilege + space and leaves non-matching rows alone', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g2` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p2` }); + + const publicIntent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { bucket: 'public-business' }, + priority: 5, + privilege: 'public', + space: 'business', + }); + const piiIntent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { bucket: 'pii-legalink' }, + priority: 1, // higher priority — would normally win if not filtered out + privilege: 'pii', + space: 'legalink', + }); + + // Bucket = public/business should pick the public intent, NOT the higher- + // priority pii/legalink intent. + const claimed = await claimNextIntent(env, { privilege: 'public', space: 'business' }); + expect(claimed?.id).toBe(publicIntent.id); + expect(claimed?.status).toBe('claimed'); + + // Verify the pii/legalink intent is still pending. + const stillPending = await getIntent(env, piiIntent.id); + expect(stillPending?.status).toBe('pending'); + }); + + it('atomic claim cannot succeed twice on the same intent (409 semantic)', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g3` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p3` }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { test: '409-semantic' }, + privilege: 'public', + space: 'business', + }); + + const sql = neon(DATABASE_URL!); + + // First atomic claim succeeds. + const first = await sql` + UPDATE cc_intents SET status = 'claimed', updated_at = NOW() + WHERE id = ${intent.id} AND status = 'pending' + RETURNING id, status + `; + expect(first.length).toBe(1); + + // Second claim must affect zero rows — the route surfaces this as 409. + const second = await sql` + UPDATE cc_intents SET status = 'claimed', updated_at = NOW() + WHERE id = ${intent.id} AND status = 'pending' + RETURNING id, status + `; + expect(second.length).toBe(0); + }); + + it('claim-next bucket filter respects priority ordering within the matching bucket', async () => { + const goal = await createGoal(env, { ownerChittyId: OWNER, title: `${TEST_TAG}-g4` }); + const plan = await createPlan(env, { goalId: goal.id, title: `${TEST_TAG}-p4` }); + + const low = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { bucket: 'public-business', tier: 'low' }, + priority: 9, + privilege: 'public', + space: 'business', + }); + const high = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: 'noop', + payload: { bucket: 'public-business', tier: 'high' }, + priority: 1, + privilege: 'public', + space: 'business', + }); + + const claimed = await claimNextIntent(env, { privilege: 'public', space: 'business' }); + expect(claimed?.id).toBe(high.id); + + const stillPending = await getIntent(env, low.id); + expect(stillPending?.status).toBe('pending'); + }); +});