diff --git a/CHARTER.md b/CHARTER.md index 432106c..d69c4f9 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -32,10 +32,18 @@ This section is a persistent discovery hint for humans and agents. It is not an ## Classification - **Canonical URI**: `chittycanon://core/services/chittycommand` -- **Tier**: 5 (Application) +- **Tier**: 2 (Platform) with Tier-5 dashboard surface - **Organization**: CHITTYOS - **Domain**: command.chitty.cc +Per [ADR-001](docs/architecture/ADR-001-meta-orchestrator-extension.md), ChittyCommand is reclassified from Tier 5 Application to **Tier 2 (Platform) with Tier-5 dashboard surface**. The platform tier covers the meta-orchestrator (`meta/`), executor registry, sovereignty gate, and cluster daemon (`daemon/`). The Tier-5 surface is the existing user-facing dashboard + ActionAgent + MCP at `command.chitty.cc`. + +Deployment artifacts split across two runtimes (see Cluster Runtime section below): +- **Cloudflare Worker** (`src/`, `meta/` HTTP routes, dashboard, ActionAgent, MCP): the Tier-5 surface and the meta-orchestrator's request-handling plane run from the same Worker at `command.chitty.cc`. +- **Persistent daemon** (`daemon/`): the cluster leader / intent dispatcher does **not** run as a Worker. It is supervised as a launchd (macOS) / systemd (Linux) process on ChittyServ nodes and connects to the same Neon DB as the Worker. + +The dashboard is one consumer of the platform among many. + ## Mission Provide a unified life management and action dashboard that ingests data from 15+ financial, legal, and administrative sources, scores urgency with AI, recommends actions, and executes them via APIs, email, or browser automation. @@ -80,12 +88,17 @@ Provide a unified life management and action dashboard that ingests data from 15 | Upstream | ChittyScrape | Browser-based scraping for portals without APIs | | Upstream | ChittyLedger | Evidence and document ledger sync | | Upstream | ChittyEvidence | Evidence facts, documents, entities for case timelines | -| Upstream | ChittyConnect | Inter-service connectivity and discovery | +| Upstream | ChittyConnect | Inter-service connectivity and discovery; **forever-context** read/write via ContextConsciousness + MemoryCloude; **sensitive-intent secret brokerage** path for all credential access (no inline secrets) | | Upstream | ChittyRouter | Unified ingestion gateway (scrape, email routing) | | Upstream | ChittySchema | Canonical schema validation and drift detection | | Upstream | ChittyCert | Certificate verification | | Upstream | ChittyRegister | Service registration, beacon, compliance | | Upstream | ChittyChat | Project/task data API | +| Upstream | ChittyTrust | Sovereignty gate β€” trust score / autonomy assessment for actor + intent (`meta/sovereignty.ts`) | +| Upstream | ChittyID | Identity minting β€” service ChittyID (re-mint pending: P-Synthetic) and per-node L-type IDs for cluster nodes | +| Upstream | chittyagent-orchestrator | Intent routing + cross-channel fanout via `POST agent.chitty.cc/agent/message` | +| Upstream | chittyagent-tasks | Durable distributed task queue pattern reused for intent dispatch + `node_leases` (mirrors `task_leases`) | +| Upstream | chittyagent-ch1tty | MCP portal / OAuth gateway β€” `command` listed as upstream in `AGENT_MCP_UPSTREAMS` | | Platform | Cloudflare Workers | Compute runtime | | Platform | Cloudflare R2 | Document storage | | Platform | Cloudflare KV | Sync state, auth tokens, service tokens | @@ -130,7 +143,32 @@ Provide a unified life management and action dashboard that ingests data from 15 | `/api/v1/jobs/:id/retry` | POST | Bearer | Retry failed scrape job | | `/api/v1/jobs/dead-letters` | GET | Bearer | Dead letter queue | | `/api/bridge/*` | Various | Service/Bearer | Inter-service bridge routes | -| `/mcp/*` | Various | Service | MCP server (48 tools across 12 domains) | +| `/api/v1/intents/:id/execute` | POST | Bearer | Dispatch a queued Intent through the executor registry (sovereignty re-checked at executor entry) | +| `/mcp/*` | Various | Service | MCP server (50 tools across 12 domains) | + +### Executor Registry + +Canonical URI namespace: `chittycanon://core/services/chittycommand/executors/{intent_type}` + +Executors self-register at module load (side-effect imports from `meta/executors/index.ts`). The dispatcher (`meta/executors/dispatch.ts`) looks executors up by `intent_type`; absence is a wiring bug, not a runtime error. Per ADR-001, the sovereignty gate (`meta/sovereignty.ts`) is invoked at executor entry and re-reckoned if the persisted snapshot is older than the configured freshness window. + +| `intent_type` | Canonical URI | Notes | +|---------------|---------------|-------| +| `update_obligation_status` | `chittycanon://core/services/chittycommand/executors/update_obligation_status` | Real Neon write to `cc_obligations`. Source: `meta/executors/update-obligation-status.ts` | + +> **Future executors** (e.g. `mercury_payment` β€” πŸ”’ REAL-MONEY, will require fresh `autonomous` sovereignty assessment and an enforced USD 500 per-intent cap) are tracked in ADR-001 but are NOT yet registered in `meta/executors/index.ts`. They will be added in follow-up PRs and listed here at the same time the executor file is committed. This table is the authoritative list of currently-registered executors β€” do not document executors that do not exist. + +### Cluster Runtime + +Per [ADR-001](docs/architecture/ADR-001-meta-orchestrator-extension.md), the persistent cluster daemon (`daemon/`) is **not** a Cloudflare Worker. It runs as a supervised long-lived process on each ChittyServ cluster node: + +- **Hosts**: `chittymini-01..06` (Mac Mini 2012 homelab) + `chittyserv-vm` +- **Supervision**: launchd on macOS Minis, systemd on Ubuntu Minis + VM +- **Leader election**: Float-free across cluster via Neon `node_leases` (atomic `UPDATE ... RETURNING`, mirrors `task_leases` shape) +- **Loop**: claim β†’ execute (via the same `meta/executors/*` registry the Worker uses) β†’ heartbeat β†’ release +- **Neon-loss fallback**: Park the node (MVP) β€” avoids split-brain; LAN gossip is a follow-up + +**Channel registration** for cluster nodes is explicitly **out of scope for the main ChittyRegister service payload**. Each node mints its own L-type ChittyID and registers as a **sub-channel** via `POST agent.chitty.cc/api/v1/channels` (per the ADR-001 preferred path). A future ChittyRegister submission for `chittycommand` must NOT attempt to model the cluster daemon as Worker compute, additional routes, or service bindings β€” the daemon is a peer execution surface to the Worker, not part of its deploy artifact. ### Cron Schedule | Schedule | Purpose | @@ -156,7 +194,7 @@ Source: `chittycanon://gov/governance#three-aspects` |--------|--------|----------|--------------------| | **Identity** | TY | What IS it? | Unified life management dashboard β€” ingests financial, legal, and administrative data from 15+ sources, scores urgency, recommends and executes actions | | **Connectivity** | VY | How does it ACT? | Cron-scheduled syncs (Plaid, Mercury, court dockets, utilities); bridge API to ChittyScrape, ChittyLedger, ChittyFinance; MCP server for Claude-driven queries; action execution via API, email, or browser automation | -| **Authority** | RY | Where does it SIT? | Tier 5 Application β€” consumer of upstream data, not source of truth; delegates scraping to ChittyScrape, identity to ChittyID, financials to ChittyFinance | +| **Authority** | RY | Where does it SIT? | Tier 2 (Platform) with Tier-5 dashboard surface β€” sovereign meta-orchestrator that enforces trust gates on intent execution, dispatches actions across registered executors and channels, and ingests from 15+ upstreams. Source of truth for: intent ladder (`cc_intents`), executor registry, sovereignty assessments, cluster node leases. Still delegates: identity to ChittyID, browser scraping to ChittyScrape, financial aggregation to ChittyFinance, forever-context storage to ChittyConnect (ContextConsciousness + MemoryCloude). | ## Document Triad @@ -175,12 +213,15 @@ This charter is part of a synchronized documentation triad. Changes to shared fi ## Compliance -- [x] Service registered in ChittyRegister (03-1-USA-3846-T-2602-0-57, pending_cert) +- [x] Service registered in ChittyRegister (03-1-USA-3846-T-2602-0-57, pending_cert) β€” ⚠️ **DEPRECATED PENDING RE-MINT** (see below) - [x] Health endpoint operational at /health -- [x] Status endpoint operational at /api/v1/status +- [x] Status endpoint operational at /api/v1/status (reflects Tier 2 + meta endpoints) - [x] CLAUDE.md development guide present - [x] CHARTER.md present - [x] CHITTY.md present +- [ ] **ChittyID re-mint required (operator action, blocks Tier 2 ChittyCertify).** The currently registered ChittyID `03-1-USA-3846-T-2602-0-57` encodes type `T` (Thing). Per `chittycanon://gov/governance#core-types` and the global "actors with agency are always Person" rule, a sovereign meta-orchestrator that takes autonomous action (intent execution, sovereignty enforcement, channel fanout) is a **Person β€” Synthetic** (P-Synthetic), not a Thing. A new ChittyID must be minted as `VV-G-USA-NNNN-P-YM-S-X` (T-slot = `P`, subtype Synthetic) and the registry record updated. The existing T-type ID is retained for historical lookup only and must NOT be cited as the service identity in new code, telemetry, or downstream contracts after the re-mint. **Blocks**: formal ChittyCertify at Tier 2; sovereign-intent signing; ChittyTrust score binding for the service-as-actor. +- [ ] Real-dependency `/health` probes (db / chittyconnect / daemon-heartbeat) β€” tracked in a separate PR; not in this docs PR. +- [ ] Service-level `tail_consumers` wiring (`chittytrack`) β€” tracked in a separate observability PR; not in this docs PR. --- -*Charter Version: 1.2.0 | Last Updated: 2026-03-24* +*Charter Version: 1.3.0 | Last Updated: 2026-06-04* diff --git a/CHITTY.md b/CHITTY.md index 4579ac0..3cfd50a 100644 --- a/CHITTY.md +++ b/CHITTY.md @@ -17,7 +17,7 @@ discovery_refs: # ChittyCommand -> `chittycanon://core/services/chittycommand` | Tier 5 (Application) | command.chitty.cc +> `chittycanon://core/services/chittycommand` | Tier 2 (Platform) with Tier-5 dashboard surface | command.chitty.cc ## Persistent Context @@ -71,7 +71,7 @@ Source: `chittycanon://gov/governance#three-aspects` |--------|--------|--------| | **Identity** | TY | Unified life management dashboard β€” ingests financial, legal, and administrative data from 15+ sources, scores urgency, recommends and executes actions | | **Connectivity** | VY | Cron-scheduled syncs (Plaid, Mercury, court dockets, utilities); bridge API to ChittyScrape, ChittyLedger, ChittyFinance; MCP server for Claude-driven queries; action execution via API, email, or browser automation | -| **Authority** | RY | Tier 5 Application β€” consumer of upstream data, not source of truth; delegates scraping to ChittyScrape, identity to ChittyID, financials to ChittyFinance | +| **Authority** | RY | Tier 2 (Platform) with Tier-5 dashboard surface β€” sovereign meta-orchestrator that enforces trust gates on intent execution, dispatches actions across registered executors and channels, and ingests from 15+ upstreams. Source of truth for: intent ladder (`cc_intents`), executor registry, sovereignty assessments, cluster node leases. Still delegates: identity to ChittyID, browser scraping to ChittyScrape, financial aggregation to ChittyFinance, forever-context storage to ChittyConnect (ContextConsciousness + MemoryCloude). _Canonical source: [CHARTER.md](CHARTER.md#three-aspects-ty-vy-ry)._ | ## ChittyOS Ecosystem @@ -85,6 +85,11 @@ Source: `chittycanon://gov/governance#three-aspects` - **DNA Hash**: -- - **Lineage**: root (life management) +### Ecosystem Position + +- **Upstream**: ChittyAuth, ChittyTrust, ChittyID, ChittyConnect (ContextConsciousness + MemoryCloude + sensitive-intent secret brokerage), chittyagent-orchestrator (routing + channel fanout), chittyagent-tasks (queue + lease pattern), chittyagent-ch1tty (MCP portal/OAuth), ChittyFinance, ChittyBooks, ChittyAssets, ChittyCharge, ChittyScrape, ChittyLedger, ChittyEvidence, ChittyRouter, ChittySchema, ChittyCert, ChittyRegister, ChittyChat. +- **Downstream / sub-channels**: ChittyServ cluster nodes (`chittymini-01..06`, `chittyserv-vm`) β€” each runs a supervised `daemon/` process and registers as an L-type sub-channel via `agent.chitty.cc/api/v1/channels`. ActionAgent (chat surface) and dashboard SPA at `app.command.chitty.cc` are sibling consumers of the same `meta/executors/*` registry. + ### Dependencies See [CHARTER.md](CHARTER.md) (Dependencies section) β€” canonical source for the full dependency graph. @@ -130,7 +135,8 @@ See [CHARTER.md](CHARTER.md) (Dependencies section) β€” canonical source for the | `/api/v1/jobs/:id` | GET | Bearer | Scrape job details | | `/api/v1/jobs/:id/retry` | POST | Bearer | Retry failed scrape job | | `/api/v1/jobs/dead-letters` | GET | Bearer | Dead letter queue | -| `/mcp/*` | Various | Service | MCP server (48 tools across 12 domains) | +| `/api/v1/intents/:id/execute` | POST | Bearer | Dispatch a queued Intent through the executor registry (sovereignty re-checked at executor entry) | +| `/mcp/*` | Various | Service | MCP server (50 tools across 12 domains) | ## Document Triad diff --git a/CLAUDE.md b/CLAUDE.md index 539c7e5..8aa5fe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ ChittyCommand is a unified life management and action dashboard for the ChittyOS **Repo:** `CHITTYOS/chittycommand` **Deploy:** Cloudflare Workers at `command.chitty.cc` (alias: `disputes.chitty.cc`) **Stack:** Hono TypeScript, React + Tailwind, Neon PostgreSQL (via Hyperdrive), Cloudflare R2/KV -**Canonical URI:** `chittycanon://core/services/chittycommand` | Tier 5 +**Canonical URI:** `chittycanon://core/services/chittycommand` | Tier 2 (Platform) with Tier-5 dashboard surface ## Common Commands @@ -41,7 +41,16 @@ wrangler secret put DATABASE_URL ## Architecture -Single Cloudflare Worker (`chittycommand`) serving API + cron. Frontend is a separate React SPA at `app.command.chitty.cc` (Cloudflare Pages). +Per [ADR-001](docs/architecture/ADR-001-meta-orchestrator-extension.md), ChittyCommand is a Tier-2 platform with a Tier-5 dashboard surface. The Cloudflare Worker (`chittycommand`) and a separate supervised cluster daemon both consume the same canonical executor registry. + +### Tier-2 Platform Layers + +- **`meta/`** β€” Goal β†’ Plan β†’ Intent ladder (`meta/intent.ts`), sovereignty gate (`meta/sovereignty.ts`, trust-score β†’ `autonomous | requires_human | blocked`), channel fanout (`meta/channels.ts`), forever-context wrapper (`meta/context.ts`), and the **executor registry** (`meta/executors/*`) with self-registration at module load. Canonical executor URIs: `chittycanon://core/services/chittycommand/executors/{intent_type}`. The sovereignty gate is invoked at (1) Intent creation (persisted into `cc_intents.sovereignty_assessment`) and (2) executor entry in `meta/executors/dispatch.ts` (re-reckoned if snapshot is older than the configured freshness window). Execution audit is additive columns on `cc_actions_log` β€” NOT a new table. +- **`daemon/`** β€” Persistent supervised cluster process (launchd / systemd) running on each ChittyServ node. Leader election via Neon `cc_node_leases` (mirrors `task_leases` shape, atomic `UPDATE ... RETURNING`). Loop: claim β†’ execute (same executor registry) β†’ heartbeat β†’ release. Float-free leadership; Neon-loss fallback is "park the node" (MVP). + +### Tier-5 Dashboard Surface + +Single Cloudflare Worker (`chittycommand`) serving API + cron + ActionAgent + MCP. Frontend is a separate React SPA at `app.command.chitty.cc` (Cloudflare Pages). ActionAgent (chat surface) and the cluster daemon (autonomous surface) are **siblings** that consume the same `meta/executors/*` registry; neither dispatches the other. ### Data Sources diff --git a/daemon/loop.ts b/daemon/loop.ts index 84c6de2..7015117 100644 --- a/daemon/loop.ts +++ b/daemon/loop.ts @@ -1,19 +1,31 @@ /** - * Cluster daemon β€” persistent leader loop skeleton. + * Cluster daemon β€” persistent leader loop. * * Lifecycle: * 1. Try to claim leadership (claimLeadership). * 2. On success: enter inner loop. * a. Heartbeat the lease every `heartbeatMs`. - * b. Claim the next pending Intent (claimNextIntent) and dispatch it - * through the supplied executor. - * c. Mark intent done/failed. + * b. Reclaim stuck intents (idempotent). + * c. Claim the next pending Intent (claimNextIntent). + * d. Drive it through `executeIntent`, which dispatches via the executor + * registry, applies the second sovereignty gate, and atomically updates + * `cc_intents.status` + writes `cc_actions_log`. * 3. On failure / lost lease: park `parkMs`, then retry. - * 4. On AbortSignal: heartbeat is interrupted, lease is released, loop exits. + * 4. On AbortSignal: lease released, loop exits. * - * The executor is injected so this PR introduces no coupling to the existing - * ActionAgent β€” the wiring to ActionAgent comes in a follow-up PR per - * ADR-001's out-of-scope list. + * The loop classifies four outcomes from `executeIntent`'s `ExecutorResult`: + * + * | Outcome | ok | replayed | Action | + * |---------------------------------|-------|----------|-----------------------------------| + * | Fresh successful execution | true | false | Heartbeat, count, continue | + * | Replayed (terminal audit exists)| true | true | Log, continue, no count, no retry | + * | Sovereignty refusal | false | false | Log refusal, continue, no backoff | + * | Executor error | false | false | Log error, bounded exp backoff | + * + * Refusals are distinguished from generic executor errors by inspecting + * `result.error` for the canonical refusal prefixes emitted by + * `meta/executors/dispatch.ts` (`sovereignty re-reckon:` and + * `sovereignty snapshot stale ...`). * * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 */ @@ -27,24 +39,14 @@ import { } from './leader'; import { claimNextIntent, - completeIntent, - failIntent, - markIntentDispatched, + executeIntent, reclaimStuckIntents, type Intent, type IntentEnv, } from '../meta/intent'; +import type { ExecutorResult } from '../meta/executors/types'; -export type LoopEnv = LeaderEnv & IntentEnv; - -export interface IntentExecutor { - /** - * Execute one claimed Intent. Implementations should be idempotent and - * return a `dispatchedTaskId` (e.g. an ActionAgent task ID) so the loop - * can persist it. Throwing causes the loop to mark the intent failed. - */ - (intent: Intent): Promise<{ dispatchedTaskId: string }>; -} +export type LoopEnv = LeaderEnv & IntentEnv & Record; export interface RunLeaderLoopOptions { /** ChittyID of the running node (Location type β€” L). */ @@ -59,21 +61,37 @@ export interface RunLeaderLoopOptions { heartbeatMs?: number; /** Park-and-retry interval ms when not leader. Default 5000. */ parkMs?: number; + /** Initial backoff ms for executor errors. Default 1000. */ + errorBackoffMs?: number; + /** Max backoff ms cap for executor errors. Default 30000. */ + errorBackoffMaxMs?: number; /** Optional cap on intent iterations β€” useful for tests. */ maxIntents?: number; + /** + * Optional cap on loop iterations regardless of intents processed β€” useful + * for tests when the queue might drain to empty before reaching maxIntents. + */ + maxIterations?: number; /** AbortSignal to terminate the loop cleanly. */ signal?: AbortSignal; /** Role to claim. Defaults to META_LEADER_ROLE. */ role?: string; /** Optional log sink. */ log?: (msg: string, meta?: Record) => void; - /** Executor for claimed intents. */ - executor: IntentExecutor; + /** + * Optional override for actorChittyId passed to executeIntent. When omitted, + * the dispatcher reads it from `intent.metadata.actorChittyId` / + * `intent.metadata.ownerChittyId` if needed. + */ + actorChittyId?: string; } export interface RunLeaderLoopResult { intentsProcessed: number; - reason: 'aborted' | 'maxIntents' | 'leaseLost' | 'error'; + intentsReplayed: number; + intentsRefused: number; + intentsErrored: number; + reason: 'aborted' | 'maxIntents' | 'maxIterations' | 'leaseLost' | 'error'; error?: string; } @@ -90,7 +108,12 @@ export async function runLeaderLoop( const parkMs = options.parkMs ?? 5_000; const signal = options.signal; - let intentsProcessed = 0; + const counters = { + intentsProcessed: 0, + intentsReplayed: 0, + intentsRefused: 0, + intentsErrored: 0, + }; while (!signal?.aborted) { // 1. Acquire leadership. @@ -116,26 +139,35 @@ export async function runLeaderLoop( continue; } - log('leader_acquired', { role, nodeId: options.nodeId, expiresAt: lease.leaseExpiresAt }); + log('leader_acquired', { + role, + nodeId: options.nodeId, + expiresAt: lease.leaseExpiresAt, + }); // 2. Inner loop: heartbeat + drain intents until lease lost or aborted. - const innerResult = await innerLoop(env, options, role, leaseSeconds, heartbeatMs, intentsProcessed); - intentsProcessed = innerResult.intentsProcessed; + const innerResult = await innerLoop(env, options, role, leaseSeconds, heartbeatMs, counters); - if (innerResult.reason === 'aborted' || innerResult.reason === 'maxIntents') { + if ( + innerResult.reason === 'aborted' || + innerResult.reason === 'maxIntents' || + innerResult.reason === 'maxIterations' + ) { // Best-effort release on clean exit β€” pass sessionId so the release - // refuses to clear a newer leader's lease (fixes codex-p2 PR#101 finding-2). + // refuses to clear a newer leader's lease. try { - await releaseLeadership(env, options.nodeId, { role, sessionId: options.sessionId }); + await releaseLeadership(env, options.nodeId, { + role, + sessionId: options.sessionId, + }); } catch (err) { log('release_error', { error: err instanceof Error ? err.message : String(err) }); } - return { intentsProcessed, reason: innerResult.reason }; + return { ...counters, reason: innerResult.reason }; } if (innerResult.reason === 'error') { log('inner_loop_error', { error: innerResult.error }); - // Park briefly, then attempt to reclaim. await sleep(parkMs, signal); continue; } @@ -145,7 +177,14 @@ export async function runLeaderLoop( await sleep(parkMs, signal); } - return { intentsProcessed, reason: 'aborted' }; + return { ...counters, reason: 'aborted' }; +} + +interface Counters { + intentsProcessed: number; + intentsReplayed: number; + intentsRefused: number; + intentsErrored: number; } async function innerLoop( @@ -154,22 +193,28 @@ async function innerLoop( role: string, leaseSeconds: number, heartbeatMs: number, - startCount: number, -): Promise<{ intentsProcessed: number; reason: 'aborted' | 'maxIntents' | 'leaseLost' | 'error'; error?: string }> { + counters: Counters, +): Promise<{ reason: 'aborted' | 'maxIntents' | 'maxIterations' | 'leaseLost' | 'error'; error?: string }> { const log = options.log ?? noopLog; const signal = options.signal; - let intentsProcessed = startCount; + const errorBackoffStartMs = options.errorBackoffMs ?? 1_000; + const errorBackoffMaxMs = options.errorBackoffMaxMs ?? 30_000; + let currentErrorBackoffMs = errorBackoffStartMs; let lastHeartbeat = Date.now(); + let iterations = 0; - // Heartbeat cadence inside executor.execute() β€” half the lease so a slow - // executor can't let the lease lapse mid-flight. - // fixes codex-p2 PR#101 finding-3 + // Heartbeat cadence inside executeIntent() β€” half the lease so a slow + // dispatch can't let the lease lapse mid-flight. const innerHeartbeatMs = Math.max(1_000, Math.floor((leaseSeconds * 1000) / 2)); while (!signal?.aborted) { + iterations += 1; + if (options.maxIterations && iterations > options.maxIterations) { + return { reason: 'maxIterations' }; + } + // Heartbeat if due. Session-scoped so a restarted process can't extend // a lease that already belongs to a newer leader. - // fixes codex-p2 PR#101 finding-5 if (Date.now() - lastHeartbeat >= heartbeatMs) { try { const renewed = await heartbeat(env, options.nodeId, { @@ -178,23 +223,19 @@ async function innerLoop( sessionId: options.sessionId, }); if (!renewed) { - return { intentsProcessed, reason: 'leaseLost' }; + return { reason: 'leaseLost' }; } lastHeartbeat = Date.now(); log('heartbeat_ok', { expiresAt: renewed.leaseExpiresAt }); } catch (err) { return { - intentsProcessed, reason: 'error', error: err instanceof Error ? err.message : String(err), }; } } - // Reclaim intents stuck in running/claimed past 2x the lease window before - // we ask for new work. Idempotent and cheap; if nothing is stuck this is - // a single UPDATE returning 0 rows. - // fixes codex-p2 PR#101 finding-1 + // Reclaim intents stuck past 2x the lease window before claiming new work. try { const reclaimed = await reclaimStuckIntents(env, leaseSeconds * 2); if (reclaimed > 0) log('intents_reclaimed', { count: reclaimed }); @@ -202,13 +243,13 @@ async function innerLoop( log('reclaim_error', { error: err instanceof Error ? err.message : String(err) }); } - // Claim and dispatch one intent. + // Claim one intent. The intent moves pending -> claimed atomically; + // executeIntent picks it up from there. let intent: Intent | null; try { intent = await claimNextIntent(env); } catch (err) { return { - intentsProcessed, reason: 'error', error: err instanceof Error ? err.message : String(err), }; @@ -221,13 +262,19 @@ async function innerLoop( continue; } - log('intent_claimed', { intentId: intent.id, intentType: intent.intentType }); + log('intent_claimed', { + intentId: intent.id, + intentType: intent.intentType, + }); + // Pre-execution heartbeat marker. The DB heartbeat is driven by + // `lastHeartbeat`; this log line marks the boundary for the operational + // record. + log('intent_heartbeat_before', { intentId: intent.id }); - // Background heartbeat ticker covering the executor.execute() span. - // Uses the current session token so the heartbeat is rejected if a newer - // leader has taken over. - // fixes codex-p2 PR#101 finding-3, finding-5 - const executorHeartbeat = setInterval(() => { + // Background heartbeat ticker covering the dispatch() span. Uses the + // current session token so the heartbeat is rejected if a newer leader + // has taken over. + const dispatchHeartbeat = setInterval(() => { heartbeat(env, options.nodeId, { role, leaseSeconds, @@ -248,63 +295,106 @@ async function innerLoop( }); }, innerHeartbeatMs); - // fixes codex-p2 PR#103 P1-B β€” capture the dispatched_task_id from - // markIntentDispatched as an execution token. completeIntent / failIntent - // gate on it so a stale leader returning from executor() after a fresher - // leader has reclaimed + redispatched the intent cannot mark the fresher - // execution done / failed. The token is set on dispatch and cleared by - // reclaimStuckIntents, so it's monotonic-per-execution. - let dispatchedTaskId: string | null = null; + let result: ExecutorResult; try { - const result = await options.executor(intent); - const dispatched = await markIntentDispatched(env, intent.id, result.dispatchedTaskId); - if (!dispatched) { - // Status was no longer 'claimed' β€” another leader reclaimed and is - // (re)driving this intent. Do not touch completion. - log('intent_dispatch_lost', { - intentId: intent.id, - dispatchedTaskId: result.dispatchedTaskId, - }); - } else { - dispatchedTaskId = result.dispatchedTaskId; - const completed = await completeIntent(env, intent.id, dispatchedTaskId); - if (!completed) { - log('intent_completion_ignored_stale', { - intentId: intent.id, - dispatchedTaskId, - }); - } else { - intentsProcessed += 1; - log('intent_completed', { - intentId: intent.id, - dispatchedTaskId, - }); - } - } + result = await executeIntent(env, intent.id, { + actorChittyId: options.actorChittyId, + }); } catch (err) { + // executeIntent / dispatch threw unhandled (e.g., DB connectivity, no + // executor registered β€” a wiring bug). cc_intents was NOT necessarily + // flipped to terminal; leave it as-is so reclaimStuckIntents recovers. const msg = err instanceof Error ? err.message : String(err); - // If we already captured a dispatch token, gate failure on it so we - // can't fail a fresher leader's running execution. Otherwise we failed - // before dispatch (intent still 'claimed') and the legacy unguarded - // status-only WHERE applies. - const failed = dispatchedTaskId - ? await failIntent(env, intent.id, msg, dispatchedTaskId).catch(() => null) - : await failIntent(env, intent.id, msg).catch(() => null); - if (!failed) { - log('intent_failure_ignored_stale', { intentId: intent.id, error: msg }); - } else { - log('intent_failed', { intentId: intent.id, error: msg }); - } + counters.intentsErrored += 1; + log('intent_dispatch_threw', { intentId: intent.id, error: msg }); + clearInterval(dispatchHeartbeat); + log('intent_heartbeat_after', { intentId: intent.id, outcome: 'threw' }); + await sleep(currentErrorBackoffMs, signal); + currentErrorBackoffMs = Math.min(currentErrorBackoffMs * 2, errorBackoffMaxMs); + continue; } finally { - clearInterval(executorHeartbeat); + clearInterval(dispatchHeartbeat); + } + + // Classify the outcome. + if (result.ok && result.replayed) { + // Replay short-circuit: prior terminal audit row exists. Not an error, + // not new work β€” just continue. Do NOT bump processed counter; do NOT + // trigger error backoff. + counters.intentsReplayed += 1; + log('intent_replayed', { + intentId: intent.id, + idempotencyKey: result.idempotencyKey, + actionLogId: result.actionLogId, + }); + currentErrorBackoffMs = errorBackoffStartMs; + } else if (result.ok) { + // Fresh successful execution. + counters.intentsProcessed += 1; + log('intent_completed', { + intentId: intent.id, + idempotencyKey: result.idempotencyKey, + actionLogId: result.actionLogId, + }); + currentErrorBackoffMs = errorBackoffStartMs; + } else if (isSovereigntyRefusal(result.error)) { + // executeIntent already flipped cc_intents to 'failed' via dispatch's + // refusal path. Log + continue, no backoff (refusals are a valid + // steady-state outcome, not a transient fault). + counters.intentsRefused += 1; + log('intent_refused', { + intentId: intent.id, + reason: result.error, + actionLogId: result.actionLogId, + }); + currentErrorBackoffMs = errorBackoffStartMs; + } else { + // Executor error path (payload validation failure, downstream API + // failure, domain failure like "Obligation not found"). cc_intents was + // flipped to 'failed' inside executeIntent. Apply bounded exponential + // backoff so a stream of failing intents doesn't hot-loop the daemon. + counters.intentsErrored += 1; + log('intent_failed', { + intentId: intent.id, + error: result.error, + actionLogId: result.actionLogId, + }); + await sleep(currentErrorBackoffMs, signal); + currentErrorBackoffMs = Math.min(currentErrorBackoffMs * 2, errorBackoffMaxMs); } - if (options.maxIntents && intentsProcessed >= options.maxIntents) { - return { intentsProcessed, reason: 'maxIntents' }; + log('intent_heartbeat_after', { + intentId: intent.id, + ok: result.ok, + replayed: !!result.replayed, + }); + + const totalTerminal = + counters.intentsProcessed + + counters.intentsReplayed + + counters.intentsRefused + + counters.intentsErrored; + if (options.maxIntents && totalTerminal >= options.maxIntents) { + return { reason: 'maxIntents' }; } } - return { intentsProcessed, reason: 'aborted' }; + return { reason: 'aborted' }; +} + +/** + * Identify sovereignty refusals emitted by meta/executors/dispatch.ts. + * Canonical refusal strings: + * - "sovereignty snapshot stale and no actorChittyId available for re-reckon" + * - "sovereignty re-reckon: requires_human (...)" + * - "sovereignty re-reckon: blocked (...)" + */ +function isSovereigntyRefusal(error: string | undefined): boolean { + if (!error) return false; + return ( + error.startsWith('sovereignty re-reckon:') || + error.startsWith('sovereignty snapshot stale') + ); } function sleep(ms: number, signal?: AbortSignal): Promise { diff --git a/docs/architecture/ADR-001-meta-orchestrator-extension.md b/docs/architecture/ADR-001-meta-orchestrator-extension.md index c451a36..6c24d36 100644 --- a/docs/architecture/ADR-001-meta-orchestrator-extension.md +++ b/docs/architecture/ADR-001-meta-orchestrator-extension.md @@ -114,8 +114,7 @@ CHITTYOS/chittycommand Tier-5 dashboard surface." The CHARTER.md needs a follow-up update to reflect this; that change is intentionally NOT in this foundation PR (charter update warrants its own review). -- The existing ActionAgent in `src/agents/` becomes one of multiple execution - surfaces the meta-orchestrator can route to. No code change required this PR. +- The existing ActionAgent in `src/agents/` is reclassified as one peer execution surface among several. Action-execution logic (`createActionTools` in `src/agents/tools/actions.ts`) is extracted into a canonical executor registry at `meta/executors/*`, addressable as `chittycanon://core/services/chittycommand/executors/{intent_type}`. ActionAgent (chat surface) and the meta-orchestrator daemon loop (autonomous surface) are siblings consuming this registry; neither dispatches the other. The sovereignty gate (`meta/sovereignty.ts`) is invoked at exactly two points: (1) at Intent creation, persisted into `cc_intents.sovereignty_assessment`; (2) at executor entry in `meta/executors/dispatch.ts`, where the score is re-reckoned if the snapshot is older than the configured freshness window. Execution audit lives in additive columns on `cc_actions_log` (NOT a new table), co-located with `cc_intents` and the existing per-tool audit trail. - The cluster-daemon runtime depends on Neon reachability for leader election; the "park the node" fallback is acceptable for MVP and will be revisited. @@ -163,3 +162,9 @@ 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. + +--- + +## ADR-001 amendments (chronological) + +- 2026-06-04 β€” Amended Consequences Β§2 per chittycanon-code-cardinal + chittyschema-overlord review. Recorded in PR-A. diff --git a/docs/registration/SUBMISSION_RUNBOOK.md b/docs/registration/SUBMISSION_RUNBOOK.md new file mode 100644 index 0000000..cefce07 --- /dev/null +++ b/docs/registration/SUBMISSION_RUNBOOK.md @@ -0,0 +1,88 @@ +# ChittyCommand Registration Submission Runbook + +Operator-facing runbook for submitting `chittycommand` to `register.chitty.cc` as a Tier-2 platform service. The payload draft lives alongside this file at `chittycommand-registration-payload.json`. + +This runbook does NOT submit. Submission is a separate, gated operator action routed through ChittyConnect (the Chico concierge). + +## Pre-requisites + +1. **Stacked PRs merged to `main`, in order:** + - PR #106 + - PR #107 + - PR #109 + - PR #110 (Tier-2 reclassification β€” CHARTER/CHITTY/CLAUDE updates) + + This PR (registration payload draft) is stacked on #110 and should merge AFTER #110 lands on main. + +2. **Live pre-flight health probe:** + + ```bash + curl -sS https://command.chitty.cc/health | jq . + ``` + + Must return the real-dependency probe JSON shape β€” fields for `db`, `chittyconnect`, and `daemon` heartbeat must reflect actual probed state. A static `{"status":"ok"}` response is a regression and blocks submission per the global "no fake/non-working endpoints" rule. + +3. **New P-Synthetic ChittyID minted** via the canonical Chico path: + + - Route: `ch1tty β†’ ChittyConnect β†’ chittyid` + - The previous ID `03-1-USA-3846-T-2602-0-57` is deprecated because the 5th field encoded `T` (Thing). `chittycommand` is a sovereign actor and must be `P` (Person, Synthetic characterization). + - Verify the minted ID's 5th `-`-separated field is `P` before substituting into the payload. + +## Substitutions Before Submission + +The committed payload contains two placeholder strings. Both must be substituted at submission time. NEITHER value is ever pasted into chat, committed to git, or stored in shell history in plaintext. + +| Placeholder | Substitution Source | Routing | +|---|---|---| +| `<>` | 1Password (cold source) β†’ Cloudflare Secrets (runtime) | ChittyConnect via Chico β€” operator never handles the bearer directly | +| `<>` | Newly minted via ChittyID service | Operator confirms `P` in 5th field, then injects | + +Per `/home/ubuntu/.ch1tty/canon/system-wide-sensitive-intent-contract-v1.md`, the operator does not paste secrets β€” the request must route through ChittyConnect. If the broker path is unavailable, fail closed with `POLICY_BLOCKED_CHITTYCONNECT_UNAVAILABLE`. + +## Submission Command (shape only) + +The actual injection uses `op run` per the operator manifest. The template below shows the request shape β€” do NOT run it verbatim with raw env vars. + +```bash +jq '.registrationToken="$CHITTY_REGISTER_TOKEN" | .service.chittyId="$NEW_CHITTYID"' \ + docs/registration/chittycommand-registration-payload.json | \ + curl -sS -X POST https://register.chitty.cc/api/v1/register \ + -H "Authorization: Bearer $CHITTY_REGISTER_TOKEN" \ + -H "content-type: application/json" \ + --data @- +``` + +Production invocation wraps the above under `op run --env-file=... --` with the token resolved by ChittyConnect at request time. + +## Verification + +After a 2xx response from `register.chitty.cc`: + +```bash +curl -sS 'https://registry.chitty.cc/api/v1/search?q=chittycommand' | jq . +``` + +Expected: the new entry is returned with `tier: 2`, `category: "core-infrastructure"`, and the new P-Synthetic ChittyID. + +Record the verification response (with the token field redacted) in a follow-up commit to `CHARTER.md` under a "Registration Evidence" section. + +## ChittyCertify Next Step + +Once registered, `chittycommand` is eligible for Tier-2 ChittyCertify audit. Open the audit request via the canonical ChittyCertify intake β€” do not self-assert the certification level in the payload (the payload's `certificationLevel` is `null` by design; ChittyCertify writes it). + +## Rollback / Failure Handling + +If `register.chitty.cc` rejects the submission: + +1. Capture the full response body (headers + JSON) β€” redact token-shaped fields before storing. +2. Do NOT retry blindly. +3. File an issue against `chittyos/chittyregistry` referencing this runbook, the response body, and the payload shape (NOT the resolved token). +4. Diagnose the schema or auth mismatch before any second attempt. The `ServiceRegistrationSchema` in `chittyregistry/src/types/index.ts` is the authoritative shape β€” payload must match. + +## What This Runbook Does NOT Do + +- Does not submit the registration. +- Does not handle the bearer token directly β€” Chico/ChittyConnect owns that. +- Does not modify CHARTER/CHITTY/CLAUDE (PR #110's lane). +- Does not deploy any worker. +- Does not enable auto-merge on this PR. diff --git a/docs/registration/chittycommand-registration-payload.json b/docs/registration/chittycommand-registration-payload.json new file mode 100644 index 0000000..7077f17 --- /dev/null +++ b/docs/registration/chittycommand-registration-payload.json @@ -0,0 +1,73 @@ +{ + "registrationToken": "<>", + "environment": "production", + "service": { + "chittyId": "<>", + "serviceName": "chittycommand", + "displayName": "ChittyCommand", + "description": "Sovereign meta-orchestrator and life management platform β€” ingests data from 15+ financial, legal, and administrative sources; scores urgency; enforces trust gates on intent execution; dispatches actions via registered executor registry, cluster daemon, and MCP server.", + "version": "0.1.0", + "baseUrl": "https://command.chitty.cc", + "endpoints": [ + { "path": "/health", "method": "GET", "description": "Real-dependency health probe (db + chittyconnect + daemon heartbeat)", "authenticated": false }, + { "path": "/api/v1/status", "method": "GET", "description": "Service metadata, tier, meta endpoint surface", "authenticated": false }, + { "path": "/api/v1/canon", "method": "GET", "description": "Canon info and registry status", "authenticated": false }, + { "path": "/api/v1/schema", "method": "GET", "description": "Lightweight schema references", "authenticated": false }, + { "path": "/api/v1/beacon", "method": "GET", "description": "Last beacon timestamp and status", "authenticated": false }, + { "path": "/api/v1/cert/verify", "method": "POST", "description": "Verify a ChittyCert certificate", "authenticated": false }, + { "path": "/api/v1/cert/:id", "method": "GET", "description": "Certificate details by ID", "authenticated": false }, + { "path": "/api/v1/whoami", "method": "GET", "description": "Identity: subject and scopes", "authenticated": true }, + { "path": "/api/v1/intents/:id/execute", "method": "POST", "description": "Atomic intent execution β€” claim, dispatch via executor registry, transition status", "authenticated": true }, + { "path": "/api/v1/context", "method": "GET", "description": "Get active persona, label, and tags", "authenticated": true }, + { "path": "/api/v1/context", "method": "POST", "description": "Set active persona, label, and tags", "authenticated": true }, + { "path": "/api/v1/connect/status", "method": "GET", "description": "ChittyConnect health", "authenticated": true }, + { "path": "/api/v1/connect/discover", "method": "POST", "description": "Resolve service URL via ChittyConnect", "authenticated": true }, + { "path": "/api/v1/ledger/evidence", "method": "GET", "description": "List evidence for a case via ChittyLedger", "authenticated": true }, + { "path": "/api/v1/ledger/record-custody", "method": "POST", "description": "Record custody entry for an evidence_id", "authenticated": true }, + { "path": "/api/v1/timeline/:caseId", "method": "GET", "description": "Unified case timeline (facts, deadlines, disputes, docs)", "authenticated": true }, + { "path": "/api/v1/litigation/synthesize", "method": "POST", "description": "AI fact synthesis from raw notes", "authenticated": true }, + { "path": "/api/v1/litigation/draft", "method": "POST", "description": "AI email drafting from synthesized facts", "authenticated": true }, + { "path": "/api/v1/litigation/qc", "method": "POST", "description": "AI risk scan of draft vs source notes", "authenticated": true }, + { "path": "/api/v1/jobs", "method": "GET", "description": "Scrape job queue", "authenticated": true }, + { "path": "/api/v1/jobs", "method": "POST", "description": "Enqueue scrape job", "authenticated": true }, + { "path": "/api/v1/jobs/:id", "method": "GET", "description": "Scrape job details", "authenticated": true }, + { "path": "/api/v1/jobs/:id/retry", "method": "POST", "description": "Retry failed scrape job", "authenticated": true }, + { "path": "/api/v1/jobs/dead-letters", "method": "GET", "description": "Dead letter queue", "authenticated": true }, + { "path": "/api/dashboard/summary", "method": "GET", "description": "Dashboard summary with urgency scores", "authenticated": true }, + { "path": "/api/accounts", "method": "GET", "description": "Financial account list", "authenticated": true }, + { "path": "/api/accounts", "method": "POST", "description": "Create financial account", "authenticated": true }, + { "path": "/api/obligations", "method": "GET", "description": "Bills, debts, recurring obligations", "authenticated": true }, + { "path": "/api/obligations", "method": "POST", "description": "Create obligation", "authenticated": true }, + { "path": "/api/disputes", "method": "GET", "description": "Active dispute management", "authenticated": true }, + { "path": "/api/disputes", "method": "POST", "description": "Create dispute", "authenticated": true }, + { "path": "/api/recommendations", "method": "GET", "description": "AI action recommendations", "authenticated": true }, + { "path": "/api/cashflow", "method": "GET", "description": "Cash flow analysis and projections", "authenticated": true }, + { "path": "/api/sync", "method": "POST", "description": "Manual data sync trigger", "authenticated": true }, + { "path": "/api/bridge/*", "method": "GET", "description": "Inter-service bridge passthrough (read)", "authenticated": true }, + { "path": "/api/bridge/*", "method": "POST", "description": "Inter-service bridge passthrough (write)", "authenticated": true }, + { "path": "/mcp/*", "method": "POST", "description": "MCP server β€” 50 tools across 12 domains (service-auth)", "authenticated": true } + ], + "healthCheck": { "path": "/health", "method": "GET", "expectedStatus": 200, "timeout": 5000 }, + "category": "core-infrastructure", + "dependencies": [ + "chittyauth","chittyid","chittycert","chittyconnect","chittyregistry","chittyschema","chittyrouter","chittyfinance","chittyscrape","chittyledger","chittyevidence","chittyassets","chittycharge","chittybooks","chittytrust","chittyagent-orchestrator","chittyagent-tasks","chittyagent-ch1tty","chittychat" + ], + "capabilities": [ + "intent-execution","executor-registry","sovereignty-gate","meta-orchestration","cluster-daemon","financial-ingest","legal-ingest","urgency-scoring","litigation-support","mcp-server","scrape-job-dispatch","document-storage","action-recommendations","bridge-api" + ], + "certificationLevel": null, + "metadata": { + "canonicalUri": "chittycanon://core/services/chittycommand", + "tier": 2, + "tierSurface": "Tier 2 (Platform) with Tier-5 dashboard surface", + "domain": "command.chitty.cc", + "adrs": ["ADR-001"], + "executors": ["update_obligation_status"], + "clusterDaemonRegistration": "sub-channel via agent.chitty.cc/api/v1/channels β€” NOT in this payload", + "previousChittyId": "03-1-USA-3846-T-2602-0-57", + "previousChittyIdDeprecationReason": "T-type (Thing) encoding β€” invalid for a P-Synthetic sovereign actor", + "mcpToolCount": 50, + "mcpDomains": 12 + } + } +} diff --git a/meta/executors/dispatch.ts b/meta/executors/dispatch.ts new file mode 100644 index 0000000..4d16b68 --- /dev/null +++ b/meta/executors/dispatch.ts @@ -0,0 +1,306 @@ +/** + * Executor dispatcher. + * + * Single entry point invoked by `meta/intent.ts::executeIntent` (and any + * future cron / event-driven path). Responsibilities: + * + * 1. Re-reckon sovereignty if the snapshot on the intent is stale + * (>= SOVEREIGNTY_FRESHNESS_MS). + * 2. Compute the attempt number + idempotency key. + * 3. Replay: if a prior cc_actions_log row exists for + * (intent_id, idempotency_key), short-circuit and return the prior result. + * 4. Look up executor by intent_type; absence is a wiring bug. + * 5. Call executor; write the cc_actions_log row carrying intent_id + + * attempt + idempotency_key. + * + * Per ADR-001 amendment (PR-A): this is the SECOND sovereignty gate call + * point (the first is at Intent creation in meta/intent.ts::createIntent). + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +import { neon, type NeonQueryFunction } from '@neondatabase/serverless'; +import type { Env } from '../../src/index'; +import type { Intent, SovereigntyAssessmentSnapshot } from '../intent'; +import { failIntent } from '../intent'; +import { assessSovereignty } from '../sovereignty'; +import { getExecutor } from './registry'; +import { SOVEREIGNTY_FRESHNESS_MS } from './types'; +import type { ExecutorContext, ExecutorResult, ExecutorRunOutput } from './types'; + +function getSql(env: Env): NeonQueryFunction { + const conn = + (env as unknown as { DATABASE_URL?: string }).DATABASE_URL || + (env as unknown as { HYPERDRIVE?: { connectionString: string } }).HYPERDRIVE + ?.connectionString; + if (!conn) { + throw new Error('[meta/executors/dispatch] No DATABASE_URL or HYPERDRIVE binding'); + } + return neon(conn); +} + +/** + * Stable, content-addressable idempotency key. + * Formula: sha256("{intent.id}:{attempt}:{intent.intentType}") + */ +async function computeIdempotencyKey( + intentId: string, + attempt: number, + intentType: string, +): Promise { + const data = new TextEncoder().encode(`${intentId}:${attempt}:${intentType}`); + const digest = await crypto.subtle.digest('SHA-256', data); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function isAssessmentFresh( + snapshot: SovereigntyAssessmentSnapshot | null, + windowMs: number, +): boolean { + if (!snapshot?.assessedAt) return false; + const t = Date.parse(snapshot.assessedAt); + if (!Number.isFinite(t)) return false; + return Date.now() - t < windowMs; +} + +export interface DispatchOptions { + /** Override the freshness window (ms). Defaults to SOVEREIGNTY_FRESHNESS_MS. */ + freshnessMs?: number; + /** + * Owner ChittyID for re-reckoning. Required when the snapshot is stale and + * no actor is encoded in the intent metadata. Read from intent.metadata + * (`actorChittyId` or `ownerChittyId`) if not provided. + */ + actorChittyId?: string; +} + +export async function dispatch( + intent: Intent, + env: Env, + options: DispatchOptions = {}, +): Promise { + const sql = getSql(env); + const freshnessMs = options.freshnessMs ?? SOVEREIGNTY_FRESHNESS_MS; + + // 1. Compute attempt number (prior rows + 1) and per-attempt idempotency + // key. The partial unique index on (intent_id, idempotency_key) backs + // the per-attempt invariant. + const [{ count: priorCount } = { count: 0 }] = (await sql` + SELECT COUNT(*)::int AS count FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid + `) as unknown as Array<{ count: number }>; + const attempt = (priorCount ?? 0) + 1; + const idempotencyKey = await computeIdempotencyKey( + intent.id, + attempt, + intent.intentType, + ); + + // 2. Replay short-circuit (FIX 1, PR #106 critical): match on + // (intent_id, idempotency_key) β€” NOT intent_id alone. Matching on + // intent_id alone would short-circuit any new attempt (whose key + // differs by `attempt`), making per-attempt retries unreachable for + // any intent that ever produced a terminal row. + const priorRows = (await sql` + SELECT id, status, response_payload, error_message + FROM cc_actions_log + WHERE intent_id = ${intent.id}::uuid + AND idempotency_key = ${idempotencyKey} + AND status IN ('completed', 'failed') + LIMIT 1 + `) as unknown as Array<{ + id: string; + status: string; + response_payload: Record | null; + error_message: string | null; + }>; + if (priorRows[0]) { + const prior = priorRows[0]; + return { + ok: prior.status === 'completed', + idempotencyKey, + actionLogId: String(prior.id), + data: prior.response_payload ?? undefined, + error: prior.error_message ?? undefined, + replayed: true, + }; + } + + // 3. Re-reckon sovereignty if snapshot stale. + let sovereignty: SovereigntyAssessmentSnapshot; + if (isAssessmentFresh(intent.sovereigntyAssessment, freshnessMs)) { + sovereignty = intent.sovereigntyAssessment!; + } else { + const actor = + options.actorChittyId || + (typeof intent.metadata?.actorChittyId === 'string' + ? (intent.metadata.actorChittyId as string) + : undefined) || + (typeof intent.metadata?.ownerChittyId === 'string' + ? (intent.metadata.ownerChittyId as string) + : undefined); + if (!actor) { + const errMsg = + 'sovereignty snapshot stale and no actorChittyId available for re-reckon'; + await writeAuditRow(sql, { + intentId: intent.id, + attempt, + idempotencyKey, + actionType: 'sovereignty_refusal', + targetType: 'intent', + targetId: intent.id, + description: errMsg, + status: 'failed', + errorMessage: errMsg, + responsePayload: null, + requestPayload: intent.payload, + metadata: { reason: 'no_actor_for_reckon' }, + }); + await failIntent(env, intent.id, errMsg).catch(() => null); + // FIX 2 (PR #106 critical): replayed:true tells executeIntent's + // `!result.replayed` guard to skip its own failIntent β€” dispatch has + // already written the audit row + transitioned status. + return { ok: false, idempotencyKey, error: errMsg, replayed: true }; + } + const result = await assessSovereignty( + actor, + { intentType: intent.intentType }, + env as unknown as { CHITTYTRUST_URL?: string; CHITTYTRUST_TOKEN?: string }, + ); + sovereignty = { + decision: result.decision, + trustScore: result.trustScore, + reasoning: result.reasoning, + assessedAt: new Date().toISOString(), + }; + if (result.decision !== 'autonomous') { + const refusal = `sovereignty re-reckon: ${result.decision} (${result.reasoning})`; + const auditId = await writeAuditRow(sql, { + intentId: intent.id, + attempt, + idempotencyKey, + actionType: 'sovereignty_refusal', + targetType: 'intent', + targetId: intent.id, + description: refusal, + status: 'failed', + errorMessage: refusal, + responsePayload: null, + requestPayload: intent.payload, + metadata: { sovereignty }, + }); + await failIntent(env, intent.id, refusal).catch(() => null); + // FIX 2 (PR #106 critical): replayed:true tells executeIntent's + // `!result.replayed` guard to skip its own failIntent. + return { + ok: false, + idempotencyKey, + actionLogId: auditId, + error: refusal, + replayed: true, + }; + } + } + + // 4. Look up executor. + const executor = getExecutor(intent.intentType); + if (!executor) { + const errMsg = `[meta/executors/dispatch] No executor registered for intent_type='${intent.intentType}' β€” this is a wiring bug`; + throw new Error(errMsg); + } + + // 5. Execute. + const ctx: ExecutorContext = { + env, + sql, + intent, + sovereignty, + attempt, + idempotencyKey, + }; + let runOutput: ExecutorRunOutput; + try { + runOutput = await executor.run(ctx); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + const auditId = await writeAuditRow(sql, { + intentId: intent.id, + attempt, + idempotencyKey, + actionType: 'executor_error', + targetType: 'intent', + targetId: intent.id, + description: `executor threw: ${errMsg}`, + status: 'failed', + errorMessage: errMsg, + responsePayload: null, + requestPayload: intent.payload, + metadata: { sovereignty, canonicalUri: executor.canonicalUri }, + }); + return { ok: false, idempotencyKey, actionLogId: auditId, error: errMsg }; + } + + // 6. Write audit row. + const auditId = await writeAuditRow(sql, { + intentId: intent.id, + attempt, + idempotencyKey, + actionType: runOutput.actionType, + targetType: runOutput.targetType, + targetId: runOutput.targetId ?? null, + description: runOutput.description, + status: runOutput.status, + errorMessage: runOutput.errorMessage ?? null, + responsePayload: runOutput.responsePayload ?? null, + requestPayload: intent.payload, + metadata: { + sovereignty, + canonicalUri: executor.canonicalUri, + ...(runOutput.metadata ?? {}), + }, + }); + + return { + ok: runOutput.ok, + idempotencyKey, + actionLogId: auditId, + data: runOutput.responsePayload, + error: runOutput.errorMessage, + }; +} + +interface AuditRow { + intentId: string; + attempt: number; + idempotencyKey: string; + actionType: string; + targetType: string; + targetId: string | null; + description: string; + status: string; + errorMessage: string | null; + responsePayload: Record | null; + requestPayload: Record | null; + metadata: Record; +} + +async function writeAuditRow( + sql: NeonQueryFunction, + row: AuditRow, +): Promise { + const rows = (await sql` + INSERT INTO cc_actions_log + (intent_id, attempt, idempotency_key, action_type, target_type, target_id, + description, status, error_message, request_payload, response_payload, metadata) + VALUES + (${row.intentId}, ${row.attempt}, ${row.idempotencyKey}, + ${row.actionType}, ${row.targetType}, ${row.targetId}, + ${row.description}, ${row.status}, ${row.errorMessage}, + ${row.requestPayload ? JSON.stringify(row.requestPayload) : null}::jsonb, + ${row.responsePayload ? JSON.stringify(row.responsePayload) : null}::jsonb, + ${JSON.stringify(row.metadata)}::jsonb) + RETURNING id + `) as unknown as Array<{ id: string }>; + return String(rows[0].id); +} diff --git a/meta/executors/index.ts b/meta/executors/index.ts new file mode 100644 index 0000000..f017800 --- /dev/null +++ b/meta/executors/index.ts @@ -0,0 +1,15 @@ +/** + * Executor registry barrel β€” importing this module guarantees all canonical + * executors have self-registered. + * + * Add new executors here so the registry is populated on first import. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +export * from './types'; +export * from './registry'; +export { dispatch } from './dispatch'; + +// Side-effect imports: each executor file calls registerExecutor() at top level. +import './update-obligation-status'; diff --git a/meta/executors/registry.ts b/meta/executors/registry.ts new file mode 100644 index 0000000..15b96bb --- /dev/null +++ b/meta/executors/registry.ts @@ -0,0 +1,41 @@ +/** + * Executor registry β€” map intent_type β†’ IntentExecutor. + * + * Executors self-register at module load via top-level `registerExecutor()`. + * The dispatcher (meta/executors/dispatch.ts) looks executors up by + * intent_type; absence is a wiring bug, not a runtime error. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +import type { IntentExecutor } from './types'; + +const REGISTRY = new Map(); + +export function registerExecutor(executor: IntentExecutor): void { + if (!executor.intentType || executor.intentType.trim().length === 0) { + throw new Error('[meta/executors] registerExecutor: intentType is required'); + } + if (REGISTRY.has(executor.intentType)) { + const existing = REGISTRY.get(executor.intentType)!; + if (existing === executor) return; // idempotent same-module reload + throw new Error( + `[meta/executors] Duplicate executor for intent_type='${executor.intentType}' ` + + `(existing: ${existing.canonicalUri}, new: ${executor.canonicalUri})`, + ); + } + REGISTRY.set(executor.intentType, executor); +} + +export function getExecutor(intentType: string): IntentExecutor | undefined { + return REGISTRY.get(intentType); +} + +export function listExecutors(): IntentExecutor[] { + return Array.from(REGISTRY.values()); +} + +/** Test-only: clear the registry. */ +export function __resetRegistryForTests(): void { + REGISTRY.clear(); +} diff --git a/meta/executors/types.ts b/meta/executors/types.ts new file mode 100644 index 0000000..3c662ee --- /dev/null +++ b/meta/executors/types.ts @@ -0,0 +1,78 @@ +/** + * Executor registry β€” shared types. + * + * Canonical URI per intent_type: + * chittycanon://core/services/chittycommand/executors/{intent_type} + * + * Per ADR-001 amendment (PR-A): the executor registry is the canonical home + * for action-execution logic. ActionAgent (chat surface) and the + * meta-orchestrator daemon loop (autonomous surface) are siblings consuming + * this registry; neither dispatches the other. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +import type { NeonQueryFunction } from '@neondatabase/serverless'; +import type { Env } from '../../src/index'; +import type { Intent, SovereigntyAssessmentSnapshot } from '../intent'; + +/** + * Re-reckon window. If `intent.sovereigntyAssessment.assessedAt` is older + * than this at executor entry, dispatch() re-runs the gate. + */ +export const SOVEREIGNTY_FRESHNESS_MS = 5 * 60 * 1000; // 5 minutes + +export interface ExecutorContext { + env: Env; + sql: NeonQueryFunction; + intent: Intent; + /** The assessment snapshot in force at execution time (possibly re-reckoned). */ + sovereignty: SovereigntyAssessmentSnapshot; + attempt: number; + idempotencyKey: string; +} + +export interface ExecutorResult { + ok: boolean; + /** Idempotency key used / would have been used. Always set. */ + idempotencyKey: string; + /** The cc_actions_log row id created (or replayed). */ + actionLogId?: string; + /** Free-form executor payload returned to caller. */ + data?: Record; + error?: string; + /** True iff the result was replayed from a prior cc_actions_log row. */ + replayed?: boolean; +} + +export interface IntentExecutor { + /** Canonical intent_type this executor handles. */ + intentType: string; + /** Canonical URI for the executor (for audit / discovery). */ + canonicalUri: string; + /** + * Execute the intent. MUST NOT write the cc_actions_log audit row β€” that is + * the dispatcher's responsibility (so attempt + idempotency_key + intent_id + * are populated consistently across all executors). The executor MAY write + * domain-specific side effects and return a description / payload that the + * dispatcher folds into the audit row. + */ + run(ctx: ExecutorContext): Promise; +} + +export interface ExecutorRunOutput { + ok: boolean; + /** Short audit description, e.g. "obligation X: pending -> paid". */ + description: string; + /** action_type for cc_actions_log (e.g., 'status_change', 'payment'). */ + actionType: string; + /** target_type for cc_actions_log (e.g., 'obligation', 'dispute'). */ + targetType: string; + /** target_id for cc_actions_log (nullable). */ + targetId?: string | null; + /** status for cc_actions_log row. */ + status: 'completed' | 'failed' | 'pending_approval'; + responsePayload?: Record; + errorMessage?: string; + metadata?: Record; +} diff --git a/meta/executors/update-obligation-status.ts b/meta/executors/update-obligation-status.ts new file mode 100644 index 0000000..442acd7 --- /dev/null +++ b/meta/executors/update-obligation-status.ts @@ -0,0 +1,118 @@ +/** + * Executor: update_obligation_status + * + * Canonical URI: chittycanon://core/services/chittycommand/executors/update_obligation_status + * + * Updates the status of a cc_obligations row and folds the change into the + * row's metadata for audit. DB-only, no external side effects β€” safe for + * autonomous execution from the meta-orchestrator AND from ActionAgent chat. + * + * The audit row in cc_actions_log is written by the dispatcher (with + * intent_id, attempt, idempotency_key populated). This file emits only the + * domain effect and returns the audit summary. + */ + +import { z } from 'zod'; +import type { NeonQueryFunction } from '@neondatabase/serverless'; +import type { Env } from '../../src/index'; +import type { ExecutorContext, ExecutorRunOutput, IntentExecutor } from './types'; +import { registerExecutor } from './registry'; + +export const UPDATE_OBLIGATION_STATUS_INTENT = 'update_obligation_status'; + +export const updateObligationStatusSchema = z.object({ + obligation_id: z.string().uuid().describe('Obligation ID to update'), + status: z.enum(['pending', 'paid', 'overdue', 'deferred']).describe('New status'), + notes: z.string().optional().describe('Reason for status change'), +}); + +export type UpdateObligationStatusArgs = z.infer; + +/** + * Pure runner β€” used by both ActionAgent (chat surface) and the dispatcher + * (autonomous surface). Does NOT write cc_actions_log; returns a structured + * result the caller folds into its own audit row. + */ +export async function runUpdateObligationStatus( + args: UpdateObligationStatusArgs, + sql: NeonQueryFunction, +): Promise<{ + success: boolean; + payee?: string; + old_status?: string; + new_status?: string; + error?: string; +}> { + const { obligation_id, status, notes } = args; + const [existing] = await sql` + SELECT id, payee, status as old_status + FROM cc_obligations + WHERE id = ${obligation_id}::uuid`; + if (!existing) return { success: false, error: 'Obligation not found' }; + + await sql` + UPDATE cc_obligations + SET status = ${status}, updated_at = NOW(), + metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ + status_change: { + from: existing.old_status, + to: status, + notes: notes ?? null, + date: new Date().toISOString(), + }, + })}::jsonb + WHERE id = ${obligation_id}::uuid`; + + return { + success: true, + payee: String(existing.payee ?? ''), + old_status: String(existing.old_status ?? ''), + new_status: status, + }; +} + +const executor: IntentExecutor = { + intentType: UPDATE_OBLIGATION_STATUS_INTENT, + canonicalUri: + 'chittycanon://core/services/chittycommand/executors/update_obligation_status', + async run(ctx: ExecutorContext): Promise { + const parsed = updateObligationStatusSchema.safeParse(ctx.intent.payload); + if (!parsed.success) { + return { + ok: false, + description: `payload validation failed for intent ${ctx.intent.id}`, + actionType: 'status_change', + targetType: 'obligation', + targetId: null, + status: 'failed', + errorMessage: parsed.error.message, + }; + } + const result = await runUpdateObligationStatus(parsed.data, ctx.sql); + if (!result.success) { + return { + ok: false, + description: result.error ?? 'unknown failure', + actionType: 'status_change', + targetType: 'obligation', + targetId: parsed.data.obligation_id, + status: 'failed', + errorMessage: result.error ?? 'unknown failure', + }; + } + const notesSuffix = parsed.data.notes ? ` (${parsed.data.notes})` : ''; + return { + ok: true, + description: `${result.payee}: ${result.old_status} β†’ ${result.new_status}${notesSuffix}`, + actionType: 'status_change', + targetType: 'obligation', + targetId: parsed.data.obligation_id, + status: 'completed', + responsePayload: { ...result }, + }; + }, +}; + +registerExecutor(executor); + +export default executor; diff --git a/meta/intent.ts b/meta/intent.ts index 4a2ba3a..7dbb7b3 100644 --- a/meta/intent.ts +++ b/meta/intent.ts @@ -465,6 +465,104 @@ export async function reclaimStuckIntents( return rows.length; } +/** + * Execute a specific intent by id. Atomically claims it (pending β†’ claimed), + * dispatches through the executor registry, then updates status based on the + * dispatcher's result. + * + * Idempotent: second call returns the prior replayed result via + * meta/executors/dispatch.ts's idempotency-key lookup. + * + * Per ADR-001 amendment (PR-A): this is the wiring between the Intent state + * machine and the executor registry. ActionAgent's chat path does NOT call + * this β€” it talks to tools directly. Both paths share executor implementations + * via meta/executors/*. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ +export async function executeIntent( + env: IntentEnv & Record, + intentId: string, + options: { actorChittyId?: string; freshnessMs?: number } = {}, +): Promise { + // Lazy import to avoid forcing the executor registry on every meta/intent + // consumer (and to keep the existing module's surface stable). + const { dispatch } = await import('./executors'); + + const sql = getSql(env); + + // First, see if the intent is already terminal β€” if so, replay via dispatch. + const current = await getIntent(env, intentId); + if (!current) { + return { + ok: false, + idempotencyKey: '', + error: `Intent ${intentId} not found`, + }; + } + + // If pending, atomically claim (single-id variant of claimNextIntent). + let intent = current; + if (intent.status === 'pending') { + const claimed = await sql` + UPDATE cc_intents + SET status = 'claimed', updated_at = NOW() + WHERE id = ${intentId} AND status = 'pending' + RETURNING *`; + if (!claimed[0]) { + // Someone else got it; reload to see current state and let dispatch + // decide (replay if there's a matching audit row, else not_claimable). + const fresh = await getIntent(env, intentId); + if (!fresh) { + return { + ok: false, + idempotencyKey: '', + error: 'Intent disappeared between read and claim', + }; + } + intent = fresh; + } else { + intent = rowToIntent(claimed[0]); + } + } + + // Refuse to drive a terminal intent forward; dispatch handles replay + // detection by (intent_id, idempotency_key). + if (intent.status === 'failed' || intent.status === 'blocked_human') { + return { + ok: false, + idempotencyKey: '', + error: `Intent ${intentId} is in terminal state '${intent.status}'`, + }; + } + + const result = await dispatch(intent, env as unknown as Parameters[1], { + actorChittyId: options.actorChittyId, + freshnessMs: options.freshnessMs, + }); + + // Reflect into cc_intents status. completeIntent / failIntent both guard on + // current status, so this is safe whether intent was 'claimed' (we claimed) + // or 'running'/'done' (already terminal β€” second call is a no-op). + if (result.ok) { + // Move to running then to done so completeIntent's status='running' guard + // matches (it's the canonical transition). + await sql` + UPDATE cc_intents + SET status = 'running', updated_at = NOW() + WHERE id = ${intentId} AND status = 'claimed'`; + await completeIntent(env, intentId); + } else if (!result.replayed) { + // Only mark failed on a fresh failure; replays should not overwrite + // terminal state. + await failIntent(env, intentId, result.error ?? 'unknown error').catch( + () => null, + ); + } + + return result; +} + // ── Row mappers ───────────────────────────────────────────── function rowToGoal(row: Record): Goal { diff --git a/migrations/0005_sour_dreadnoughts.sql b/migrations/0005_sour_dreadnoughts.sql new file mode 100644 index 0000000..977dc8f --- /dev/null +++ b/migrations/0005_sour_dreadnoughts.sql @@ -0,0 +1,6 @@ +ALTER TABLE "cc_actions_log" ADD COLUMN "intent_id" uuid;--> statement-breakpoint +ALTER TABLE "cc_actions_log" ADD COLUMN "attempt" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "cc_actions_log" ADD COLUMN "idempotency_key" text;--> statement-breakpoint +ALTER TABLE "cc_actions_log" ADD CONSTRAINT "cc_actions_log_intent_id_cc_intents_id_fk" FOREIGN KEY ("intent_id") REFERENCES "public"."cc_intents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_cc_actions_log_intent_executed" ON "cc_actions_log" USING btree ("intent_id","executed_at" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "uq_cc_actions_log_intent_idempotency" ON "cc_actions_log" USING btree ("intent_id","idempotency_key") WHERE intent_id IS NOT NULL AND idempotency_key IS NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0005_snapshot.json b/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..c8c0d7a --- /dev/null +++ b/migrations/meta/0005_snapshot.json @@ -0,0 +1,3526 @@ +{ + "id": "32cf64a8-71bc-4a69-96d0-9aa3e4304c68", + "prevId": "6e6fff66-21a5-4a75-b92d-be92b3b7035b", + "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()" + }, + "intent_id": { + "name": "intent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "idx_cc_actions_log_intent_executed": { + "name": "idx_cc_actions_log_intent_executed", + "columns": [ + { + "expression": "intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_cc_actions_log_intent_idempotency": { + "name": "uq_cc_actions_log_intent_idempotency", + "columns": [ + { + "expression": "intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "intent_id IS NOT NULL AND idempotency_key IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cc_actions_log_intent_id_cc_intents_id_fk": { + "name": "cc_actions_log_intent_id_cc_intents_id_fk", + "tableFrom": "cc_actions_log", + "tableTo": "cc_intents", + "columnsFrom": [ + "intent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 1324ef9..320fad2 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1780539161737, "tag": "0004_premium_toad_men", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781091511650, + "tag": "0005_sour_dreadnoughts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/agents/tools/actions.ts b/src/agents/tools/actions.ts index 3e4e8be..ea0a7f1 100644 --- a/src/agents/tools/actions.ts +++ b/src/agents/tools/actions.ts @@ -3,6 +3,13 @@ import { z } from 'zod'; import type { NeonQueryFunction } from '@neondatabase/serverless'; import type { Env } from '../../index'; import { mercuryClient } from '../../lib/integrations'; +// Canonical executor (registry-backed). ActionAgent's chat tool here wraps +// the same pure runner the meta-orchestrator dispatcher invokes β€” sibling +// surfaces, shared implementation. See ADR-001 amendment (PR-A). +import { + updateObligationStatusSchema, + runUpdateObligationStatus, +} from '../../../meta/executors/update-obligation-status'; /** * Create action execution tools bound to environment and SQL. @@ -95,31 +102,21 @@ export function createActionTools(env: Env, sql: NeonQueryFunction update_obligation_status: tool({ description: 'Update the status of an obligation (bill). Use after confirming a payment was made or to defer a bill.', - inputSchema: z.object({ - obligation_id: z.string().uuid().describe('Obligation ID to update'), - status: z.enum(['pending', 'paid', 'overdue', 'deferred']).describe('New status'), - notes: z.string().optional().describe('Reason for status change'), - }), - execute: async ({ obligation_id, status, notes }) => { - const [existing] = await sql`SELECT id, payee, status as old_status FROM cc_obligations WHERE id = ${obligation_id}::uuid`; - if (!existing) return { success: false, error: 'Obligation not found' }; - - await sql` - UPDATE cc_obligations - SET status = ${status}, updated_at = NOW(), - metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ - status_change: { from: existing.old_status, to: status, notes, date: new Date().toISOString() }, - })}::jsonb - WHERE id = ${obligation_id}::uuid - `; - + inputSchema: updateObligationStatusSchema, + execute: async (args) => { + // Delegates to the canonical executor's pure runner so chat + autonomous + // surfaces share the same implementation. The chat path still writes its + // own cc_actions_log row (without intent_id) so the existing chat audit + // trail behavior is preserved exactly. + const result = await runUpdateObligationStatus(args, sql); + if (!result.success) return result; + const notesSuffix = args.notes ? ` (${args.notes})` : ''; await sql` INSERT INTO cc_actions_log (action_type, target_type, target_id, description, status) - VALUES ('status_change', 'obligation', ${obligation_id}, - ${`${existing.payee}: ${existing.old_status} β†’ ${status}${notes ? ` (${notes})` : ''}`}, 'completed') + VALUES ('status_change', 'obligation', ${args.obligation_id}, + ${`${result.payee}: ${result.old_status} β†’ ${result.new_status}${notesSuffix}`}, 'completed') `; - - return { success: true, payee: existing.payee, old_status: existing.old_status, new_status: status }; + return result; }, }), diff --git a/src/db/schema.ts b/src/db/schema.ts index 8f8fdb5..3fe80de 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, text, numeric, boolean, integer, date, timestamp, jsonb, index, unique, foreignKey } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, text, numeric, boolean, integer, date, timestamp, jsonb, index, uniqueIndex, unique, foreignKey } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; // ── Accounts ────────────────────────────────────────────────── @@ -213,6 +213,9 @@ export const ccRecommendations = pgTable('cc_recommendations', { })); // ── Actions Log ─────────────────────────────────────────────── +// ADR-001 amendment (PR-A): intent-execution audit lives here, NOT in a +// separate `intent_executions` table. Three additive columns + two indexes +// per chittyschema-overlord review. export const ccActionsLog = pgTable('cc_actions_log', { id: uuid('id').primaryKey().defaultRandom(), actionType: text('action_type').notNull(), @@ -225,8 +228,20 @@ export const ccActionsLog = pgTable('cc_actions_log', { errorMessage: text('error_message'), metadata: jsonb('metadata').default({}), executedAt: timestamp('executed_at', { withTimezone: true }).defaultNow(), + // Intent-execution linkage (nullable β€” ActionAgent chat path writes rows + // without an intent context). + intentId: uuid('intent_id').references(() => ccIntents.id, { onDelete: 'set null' }), + attempt: integer('attempt').notNull().default(1), + idempotencyKey: text('idempotency_key'), }, (table) => ({ dateIdx: index('idx_cc_actions_log_date').on(table.executedAt), + intentExecutedIdx: index('idx_cc_actions_log_intent_executed').on( + table.intentId, + table.executedAt.desc(), + ), + intentIdempotencyUq: uniqueIndex('uq_cc_actions_log_intent_idempotency') + .on(table.intentId, table.idempotencyKey) + .where(sql`intent_id IS NOT NULL AND idempotency_key IS NOT NULL`), })); // ── Cash Flow Projections ───────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index cf4b77d..01093bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ import { transactionRoutes } from './routes/transactions'; import { timelineRoutes } from './routes/timeline'; import { triageRoutes } from './routes/triage'; import { workspaceStudioRoutes } from './routes/workspace-studio'; +import { runHealthProbes } from './routes/health'; // Re-export ActionAgent DO class so the runtime can find it export { ActionAgent } from './agents/action-agent'; @@ -101,13 +102,13 @@ app.notFound((c) => { return c.json({ error: 'Not Found' }, 404); }); -// Health endpoint (unauthenticated) -app.get('/health', (c) => c.json({ - status: 'ok', - service: 'chittycommand', - version: '0.1.0', - timestamp: new Date().toISOString(), -})); +// Health endpoint (unauthenticated) β€” real dependency probes (db, chittyconnect, +// daemon heartbeat). See src/routes/health.ts. Returns 503 only if the DB is +// down; chittyconnect/daemon problems surface as `degraded` with 200. +app.get('/health', async (c) => { + const { body, httpStatus } = await runHealthProbes(c.env); + return c.json(body, httpStatus); +}); // Service status (unauthenticated) β€” ChittyOS standard app.get('/api/v1/status', (c) => c.json({ @@ -115,7 +116,13 @@ app.get('/api/v1/status', (c) => c.json({ version: '0.1.0', environment: c.env.ENVIRONMENT || 'production', canonicalUri: 'chittycanon://core/services/chittycommand', - tier: 5, + tier: 2, + tierSurface: 'Tier 2 (Platform) with Tier-5 dashboard surface', + meta: { + endpoints: [ + '/api/v1/intents/:id/execute', + ], + }, })); // Canon/schema (unauthenticated) diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..aaa9301 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,173 @@ +/** + * /health β€” real-dependency probe handler. + * + * Extracted from src/index.ts so it can be unit/integration-tested in pure + * Node without dragging in `cloudflare:`-namespaced imports (Agents SDK, + * Durable Objects, etc.) that only resolve under wrangler/workerd. + * + * Probes (each per-dep timeout 2000ms; total ≀ ~5000ms via Promise.all): + * - db SELECT 1 via Neon HTTP driver. Critical: failure β†’ 503. + * - chittyconnect GET ${CHITTYCONNECT_URL}/health. Degraded if unreachable. + * - daemon max(heartbeat_at) FROM cc_node_leases. Stale if older than + * 2Γ— daemon/loop.ts default heartbeatMs (10000ms β†’ 20000ms). + * Missing table β†’ not_provisioned (degraded, not down) so + * deploys against bases without #101 don't 503. + * + * Runs unauthenticated; does not touch auth middleware. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/health + */ + +import { getDb } from '../lib/db'; + +export const SERVICE_VERSION = '0.1.0'; +// daemon/loop.ts default heartbeatMs is 10000; flag stale at 2Γ— = 20000ms. +const DAEMON_STALE_MS = 20_000; + +export type HealthEnv = { + DATABASE_URL?: string; + HYPERDRIVE?: { connectionString: string }; + CHITTYCONNECT_URL?: string; +}; + +export interface DbProbe { + status: 'ok' | 'down'; + latency_ms: number; + error?: string; +} +export interface ChittyConnectProbe { + status: 'ok' | 'degraded' | 'down'; + latency_ms: number; + error?: string; +} +export interface DaemonProbe { + status: 'ok' | 'stale' | 'not_provisioned'; + newest_heartbeat_age_ms: number | null; + error?: string; +} + +export interface HealthBody { + status: 'ok' | 'degraded' | 'down'; + service: 'chittycommand'; + version: string; + timestamp: string; + probes: { db: DbProbe; chittyconnect: ChittyConnectProbe; daemon: DaemonProbe }; +} + +async function withTimeout(p: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function probeDb(env: HealthEnv): Promise { + const t0 = Date.now(); + try { + // Cast: HealthEnv is a narrow subset of the worker Env type and getDb + // only reads DATABASE_URL / HYPERDRIVE.connectionString. + const sql = getDb(env as unknown as Parameters[0]); + await withTimeout(sql`SELECT 1 AS ok`, 2000, 'db'); + return { status: 'ok', latency_ms: Date.now() - t0 }; + } catch (err) { + return { + status: 'down', + latency_ms: Date.now() - t0, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +async function probeChittyConnect(env: HealthEnv): Promise { + const url = env.CHITTYCONNECT_URL; + const t0 = Date.now(); + if (!url) { + return { status: 'degraded', latency_ms: 0, error: 'CHITTYCONNECT_URL not configured' }; + } + try { + const r = await withTimeout( + fetch(`${url.replace(/\/$/, '')}/health`, { headers: { accept: 'application/json' } }), + 2000, + 'chittyconnect', + ); + return r.ok + ? { status: 'ok', latency_ms: Date.now() - t0 } + : { status: 'degraded', latency_ms: Date.now() - t0, error: `HTTP ${r.status}` }; + } catch (err) { + return { + status: 'degraded', + latency_ms: Date.now() - t0, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +async function probeDaemon(env: HealthEnv): Promise { + try { + const sql = getDb(env as unknown as Parameters[0]); + const rows = (await withTimeout( + // NOTE: cc_node_leases has no `released_at` column. Release is + // represented by NULLing `heartbeat_at`/`lease_expires_at` in + // daemon/leader.ts::releaseLeadership. We treat any row whose + // heartbeat_at is non-null as a currently-held lease and take the + // newest heartbeat across them. + sql`SELECT EXTRACT(EPOCH FROM (NOW() - max(heartbeat_at))) * 1000 AS age_ms + FROM cc_node_leases + WHERE heartbeat_at IS NOT NULL`, + 2000, + 'daemon', + )) as Array<{ age_ms: number | string | null }>; + const raw = rows[0]?.age_ms; + if (raw === null || raw === undefined) { + return { status: 'stale', newest_heartbeat_age_ms: null }; + } + const ageMs = typeof raw === 'string' ? parseFloat(raw) : Number(raw); + return { + status: ageMs > DAEMON_STALE_MS ? 'stale' : 'ok', + newest_heartbeat_age_ms: Math.round(ageMs), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/cc_node_leases|relation .* does not exist|does not exist/i.test(msg)) { + return { status: 'not_provisioned', newest_heartbeat_age_ms: null, error: msg }; + } + return { status: 'stale', newest_heartbeat_age_ms: null, error: msg }; + } +} + +export async function runHealthProbes(env: HealthEnv): Promise<{ body: HealthBody; httpStatus: 200 | 503 }> { + const [db, chittyconnect, daemon] = await Promise.all([ + probeDb(env), + probeChittyConnect(env), + probeDaemon(env), + ]); + + let status: HealthBody['status'] = 'ok'; + if (db.status === 'down') { + status = 'down'; + } else if ( + chittyconnect.status === 'degraded' || + chittyconnect.status === 'down' || + daemon.status === 'stale' || + daemon.status === 'not_provisioned' + ) { + status = 'degraded'; + } + + const body: HealthBody = { + status, + service: 'chittycommand', + version: SERVICE_VERSION, + timestamp: new Date().toISOString(), + probes: { db, chittyconnect, daemon }, + }; + + return { body, httpStatus: status === 'down' ? 503 : 200 }; +} diff --git a/src/routes/meta.ts b/src/routes/meta.ts index 3282b9d..7216cf3 100644 --- a/src/routes/meta.ts +++ b/src/routes/meta.ts @@ -19,7 +19,8 @@ metaPublicRoutes.get('/canon', async (c) => { environment: env.ENVIRONMENT || 'production', canonicalUri, namespace: 'chittycanon://core/services', - tier: 5, + tier: 2, + tierSurface: 'Tier 2 (Platform) with Tier-5 dashboard surface', registered_with: env.CHITTYREGISTER_URL || null, registration: { service_id: serviceId || null, last_beacon_at: lastBeaconAt || null, last_status: lastBeaconStatus || null }, }); @@ -116,6 +117,53 @@ metaPublicRoutes.get('/cert/:id', async (c) => { } }); +// Authenticated: execute a queued intent via the executor registry. +// Per ADR-001 amendment (PR-A): mirrors existing meta auth surface; calls +// real Neon via executeIntent β†’ meta/executors/dispatch. +metaRoutes.post('/intents/:id/execute', async (c) => { + const userId = c.get('userId') as string | undefined; + if (!userId) return c.json({ error: 'Unauthorized' }, 401); + const id = c.req.param('id'); + if (!id) return c.json({ error: 'Missing intent id' }, 400); + const body = await c.req.json().catch(() => ({} as Record)); + const actorChittyId = + typeof body?.actor_chitty_id === 'string' ? body.actor_chitty_id : userId; + + try { + const { executeIntent } = await import('../../meta/intent'); + const result = await executeIntent(c.env as unknown as Record, id, { + actorChittyId, + }); + if (!result.ok) { + return c.json( + { + ok: false, + intent_id: id, + idempotency_key: result.idempotencyKey, + action_log_id: result.actionLogId, + replayed: Boolean(result.replayed), + error: result.error, + }, + result.replayed ? 200 : 422, + ); + } + return c.json({ + ok: true, + intent_id: id, + idempotency_key: result.idempotencyKey, + action_log_id: result.actionLogId, + replayed: Boolean(result.replayed), + data: result.data ?? null, + }); + } catch (err) { + console.error('[meta] executeIntent failed:', err); + return c.json( + { error: 'executeIntent failed', detail: err instanceof Error ? err.message : String(err) }, + 500, + ); + } +}); + // Authenticated: identity resolution metaRoutes.get('/whoami', (c) => { const userId = c.get('userId') as string | undefined; diff --git a/tests/daemon/loop.spec.ts b/tests/daemon/loop.spec.ts new file mode 100644 index 0000000..37286f1 --- /dev/null +++ b/tests/daemon/loop.spec.ts @@ -0,0 +1,208 @@ +/** + * Integration test for daemon/loop.ts wired end-to-end through executeIntent. + * + * Covers (per stacked PR on #106): + * - runLeaderLoop acquires leadership against real cc_node_leases + * - Two seeded pending intents (real Goals / Plans / Obligations) are claimed + * and dispatched via executeIntent β†’ dispatch β†’ update_obligation_status + * - Each intent produces exactly one cc_actions_log row with intent_id set, + * attempt=1, idempotency_key set, action_type='status_change', + * status='completed' + * - cc_obligations rows actually move to the target status + * - cc_intents move to status='done' + * - cc_node_leases shows the leader released its hold on clean exit + * + * Real Neon only. Skipped without DATABASE_URL β€” same pattern as + * tests/meta/intent-lifecycle.spec.ts and tests/meta/executor.spec.ts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { neon } from '@neondatabase/serverless'; +import { runLeaderLoop } from '../../daemon/loop'; +import { + createGoal, + createPlan, + createIntent, + getIntent, + type IntentEnv, + type SovereigntyAssessmentSnapshot, +} from '../../meta/intent'; +import { META_LEADER_ROLE } from '../../daemon/leader'; +// Importing the executor barrel ensures registration side effects run. +import '../../meta/executors'; +import { UPDATE_OBLIGATION_STATUS_INTENT } from '../../meta/executors/update-obligation-status'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +const env: IntentEnv & Record = { DATABASE_URL }; +const OWNER = '01-A-NB-0001-P-66-1-1'; +// Use a Location-typed ChittyID for the node, per canon (L = Location). +const NODE_ID = '01-A-NB-T01-L-66-1-1'; +const TEST_TAG = `loop-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +const obligationIds: string[] = []; + +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 + '%'}`; + for (const id of obligationIds) { + await sql`DELETE FROM cc_actions_log WHERE target_id = ${id}::uuid`; + await sql`DELETE FROM cc_obligations WHERE id = ${id}::uuid`; + } + // Release any lease this test left behind so re-runs are clean. + await sql` + UPDATE cc_node_leases + SET node_id = NULL, node_descriptor = NULL, session_id = NULL, + lease_expires_at = NULL, heartbeat_at = NULL + WHERE node_id = ${NODE_ID}`; +} + +describe.skipIf(SKIP)('daemon/loop β€” end-to-end through executeIntent (real Neon)', () => { + beforeAll(async () => { + await cleanup(); + const sql = neon(DATABASE_URL!); + // Insert two real cc_obligations rows for the executor to update. + for (let i = 0; i < 2; i++) { + const rows = await sql` + INSERT INTO cc_obligations (payee, category, due_date, status, metadata) + VALUES (${TEST_TAG + '-payee-' + i}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb) + RETURNING id`; + obligationIds.push(String(rows[0].id)); + } + }); + + afterAll(async () => { + await cleanup(); + }); + + it('drains two pending intents, writes audit rows, heartbeats, releases on clean exit', async () => { + expect(obligationIds).toHaveLength(2); + + const goal = await createGoal(env, { + ownerChittyId: OWNER, + title: `${TEST_TAG}-goal`, + }); + const plan = await createPlan(env, { + goalId: goal.id, + title: `${TEST_TAG}-plan`, + }); + + // Pre-seed fresh sovereignty so dispatch() takes the autonomous path + // without calling out to trust.chitty.cc. + const sovereignty: SovereigntyAssessmentSnapshot = { + decision: 'autonomous', + trustScore: 0.95, + reasoning: 'pre-seeded for daemon loop integration test', + assessedAt: new Date().toISOString(), + }; + + const intentIds: string[] = []; + for (let i = 0; i < 2; i++) { + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: UPDATE_OBLIGATION_STATUS_INTENT, + payload: { + obligation_id: obligationIds[i], + status: 'deferred', + notes: `${TEST_TAG}-intent-${i}`, + }, + sovereigntyAssessment: sovereignty, + metadata: { actorChittyId: OWNER }, + priority: i, // lower number first + }); + expect(intent.status).toBe('pending'); + intentIds.push(intent.id); + } + + // Drive the loop until both intents reach terminal status or we hit the + // bounded iteration cap. maxIntents=2 lets the loop exit cleanly after + // both are processed; maxIterations provides a hard safety net. + const logLines: Array<{ msg: string; meta?: Record }> = []; + const controller = new AbortController(); + const sessionId = `${process.pid}@${Date.now()}-test`; + + const result = await runLeaderLoop(env, { + nodeId: NODE_ID, + nodeDescriptor: 'integration-test', + sessionId, + leaseSeconds: 30, + heartbeatMs: 1_000, + parkMs: 250, + maxIntents: 2, + maxIterations: 50, + signal: controller.signal, + actorChittyId: OWNER, + log: (msg, meta) => logLines.push({ msg, meta }), + }); + + expect(result.reason).toBe('maxIntents'); + expect(result.intentsProcessed).toBe(2); + expect(result.intentsErrored).toBe(0); + expect(result.intentsRefused).toBe(0); + + // Both intents should be terminal=done. + for (const id of intentIds) { + const after = await getIntent(env, id); + expect(after?.status).toBe('done'); + } + + // Each intent produced exactly one cc_actions_log row. + const sql = neon(DATABASE_URL!); + for (const id of intentIds) { + const rows = (await sql` + SELECT id, intent_id, attempt, idempotency_key, status, action_type + FROM cc_actions_log + WHERE intent_id = ${id}::uuid + `) as unknown as Array<{ + id: string; + intent_id: string; + attempt: number; + idempotency_key: string; + status: string; + action_type: string; + }>; + expect(rows.length).toBe(1); + expect(rows[0].attempt).toBe(1); + expect(rows[0].idempotency_key).toMatch(/^[0-9a-f]{64}$/); + expect(rows[0].status).toBe('completed'); + expect(rows[0].action_type).toBe('status_change'); + } + + // Obligations actually moved to 'deferred'. + for (const id of obligationIds) { + const rows = (await sql` + SELECT status FROM cc_obligations WHERE id = ${id}::uuid + `) as unknown as Array<{ status: string }>; + expect(rows[0].status).toBe('deferred'); + } + + // cc_node_leases shows leadership was released on clean exit (maxIntents + // path -> releaseLeadership). The row persists with node_id=NULL. + const leaseRows = (await sql` + SELECT node_id, session_id, heartbeat_at + FROM cc_node_leases WHERE role = ${META_LEADER_ROLE} + `) as unknown as Array<{ + node_id: string | null; + session_id: string | null; + heartbeat_at: string | null; + }>; + expect(leaseRows.length).toBeGreaterThan(0); + expect(leaseRows[0].node_id).toBeNull(); + expect(leaseRows[0].session_id).toBeNull(); + + // Log evidence of heartbeat activity bracketing each intent. + const heartbeatBefore = logLines.filter((l) => l.msg === 'intent_heartbeat_before').length; + const heartbeatAfter = logLines.filter((l) => l.msg === 'intent_heartbeat_after').length; + expect(heartbeatBefore).toBe(2); + expect(heartbeatAfter).toBe(2); + const leaderAcquired = logLines.filter((l) => l.msg === 'leader_acquired').length; + expect(leaderAcquired).toBeGreaterThanOrEqual(1); + + // Sanity: abort the controller β€” no-op for this run (already returned), + // but proves the wiring compiles. + controller.abort(); + }, 60_000); +}); diff --git a/tests/health/health-probe.spec.ts b/tests/health/health-probe.spec.ts new file mode 100644 index 0000000..48eb147 --- /dev/null +++ b/tests/health/health-probe.spec.ts @@ -0,0 +1,74 @@ +/** + * Integration test for the real-dependency /health probe handler. + * + * Runs against a REAL Neon branch (no mocks β€” chittyentity CLAUDE.md + * "No Mocks, Fake Data, or Placeholder Endpoints" rule). + * + * Usage: + * DATABASE_URL='postgres://...neon...' npx vitest run tests/health/health-probe.spec.ts + * + * Skipped automatically when DATABASE_URL is absent or SKIP_INTEGRATION=1 β€” + * same pattern as tests/daemon/leader.spec.ts and tests/meta/intent-lifecycle.spec.ts. + * + * We import the probe handler directly from src/routes/health.ts (pure Node + * compatible). Importing src/index.ts would drag in `cloudflare:` namespaced + * modules (Agents SDK / Durable Objects) which only resolve under workerd. + * + * Verifies: + * 1. probes.db.status === 'ok' against the real Neon branch with latency_ms > 0. + * 2. probes.daemon.status ∈ { ok, stale, not_provisioned } β€” not over-asserted + * because the branch may or may not have cc_node_leases provisioned or + * an active node holding a lease. + * 3. probes.chittyconnect.status is one of { ok, degraded, down }. + * 4. httpStatus is 200 when db.status === 'ok'; 503 only when db is down. + * 5. Response shape matches the documented spec. + */ + +import { describe, it, expect } from 'vitest'; +import { runHealthProbes } from '../../src/routes/health'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +describe.skipIf(SKIP)('/health real-dependency probe (real Neon)', () => { + it('runs all probes and reports DB ok against the real Neon branch', async () => { + const { body, httpStatus } = await runHealthProbes({ + DATABASE_URL, + // CHITTYCONNECT_URL intentionally omitted: probe will report 'degraded' + // with "not configured", which is the documented behavior. + }); + + // Spec: 200 unless db is down. DB is reachable on this branch. + expect(httpStatus).toBe(200); + + expect(body.service).toBe('chittycommand'); + expect(typeof body.version).toBe('string'); + expect(typeof body.timestamp).toBe('string'); + expect(['ok', 'degraded']).toContain(body.status); + + // DB probe must succeed. + expect(body.probes.db.status).toBe('ok'); + expect(body.probes.db.latency_ms).toBeGreaterThan(0); + + // ChittyConnect probe shape β€” value depends on env, just assert shape. + expect(['ok', 'degraded', 'down']).toContain(body.probes.chittyconnect.status); + expect(typeof body.probes.chittyconnect.latency_ms).toBe('number'); + + // Daemon probe β€” could be ok (active node), stale (no recent heartbeat), + // or not_provisioned (table missing on this branch). Don't over-assert. + expect(['ok', 'stale', 'not_provisioned']).toContain(body.probes.daemon.status); + }); + + it('marks status `down` and returns 503 when DB is unreachable', async () => { + // Real connection-refused β€” no mock, just an invalid host that fails fast. + const { body, httpStatus } = await runHealthProbes({ + DATABASE_URL: + 'postgresql://nobody:nobody@127.0.0.1:1/neondb?sslmode=disable', + }); + + expect(httpStatus).toBe(503); + expect(body.status).toBe('down'); + expect(body.probes.db.status).toBe('down'); + expect(typeof body.probes.db.error).toBe('string'); + }); +}); diff --git a/tests/meta/executor-pr106-criticals.spec.ts b/tests/meta/executor-pr106-criticals.spec.ts new file mode 100644 index 0000000..bb0351d --- /dev/null +++ b/tests/meta/executor-pr106-criticals.spec.ts @@ -0,0 +1,216 @@ +/** + * PR #106 critical-bug regression tests. + * + * FIX 1 β€” Replay short-circuit must match (intent_id, idempotency_key), + * not intent_id alone. A pre-existing terminal row for an OLD + * attempt's key must NOT short-circuit a new attempt whose + * computed key differs. + * + * FIX 2 β€” On a sovereignty refusal, dispatch writes the audit row and + * calls failIntent itself. It must return `replayed: true` so + * executeIntent's `!result.replayed` guard skips its own + * failIntent β€” yielding exactly ONE failIntent invocation + * (visible as a single status row in cc_intent_status_history, + * or by SQL-level counting of NULLβ†’failed transitions). + * + * Real Neon only β€” skipped without DATABASE_URL. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { neon } from '@neondatabase/serverless'; +import { + createGoal, + createPlan, + createIntent, + executeIntent, + type IntentEnv, + type SovereigntyAssessmentSnapshot, +} from '../../meta/intent'; +import '../../meta/executors'; +import { UPDATE_OBLIGATION_STATUS_INTENT } from '../../meta/executors/update-obligation-status'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +const env: IntentEnv & Record = { DATABASE_URL }; +const OWNER = '01-A-NB-0001-P-66-1-2'; +const TEST_TAG = `pr106-crit-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +const created: { goalIds: string[]; obligationIds: string[] } = { + goalIds: [], + obligationIds: [], +}; + +async function freshSovereignty(): Promise { + return { + decision: 'autonomous', + trustScore: 0.95, + reasoning: 'pre-seeded for PR #106 critical regression', + assessedAt: new Date().toISOString(), + }; +} + +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 + '%'}`; + for (const oid of created.obligationIds) { + await sql`DELETE FROM cc_actions_log WHERE target_id = ${oid}::uuid`; + await sql`DELETE FROM cc_obligations WHERE id = ${oid}::uuid`; + } +} + +describe.skipIf(SKIP)('PR #106 criticals β€” replay-by-key + single failIntent', () => { + beforeAll(async () => { + await cleanup(); + }); + afterAll(async () => { + await cleanup(); + }); + + it('FIX 1: a prior terminal row with a DIFFERENT key does NOT short-circuit a new attempt', async () => { + const sql = neon(DATABASE_URL!); + + // Seed a real obligation for the executor to update. + const oblRows = await sql` + INSERT INTO cc_obligations (payee, category, due_date, status, metadata) + VALUES (${TEST_TAG + '-fix1-payee'}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb) + RETURNING id`; + const obligationId = String(oblRows[0].id); + created.obligationIds.push(obligationId); + + const goal = await createGoal(env, { + ownerChittyId: OWNER, + title: `${TEST_TAG}-fix1-goal`, + }); + created.goalIds.push(goal.id); + const plan = await createPlan(env, { + goalId: goal.id, + title: `${TEST_TAG}-fix1-plan`, + }); + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: UPDATE_OBLIGATION_STATUS_INTENT, + payload: { obligation_id: obligationId, status: 'paid', notes: TEST_TAG }, + sovereigntyAssessment: await freshSovereignty(), + metadata: { actorChittyId: OWNER }, + }); + + // Hand-insert a terminal cc_actions_log row for this intent with a key + // that we know will NOT match the key dispatch() will compute for + // attempt=1 (sha256("{id}:1:{type}")). Use a sentinel hex string. + const bogusKey = 'a'.repeat(64); + await sql` + INSERT INTO cc_actions_log + (intent_id, attempt, idempotency_key, action_type, target_type, target_id, + description, status, error_message, request_payload, response_payload, metadata) + VALUES + (${intent.id}::uuid, 0, ${bogusKey}, 'status_change', 'obligation', + ${obligationId}::uuid, 'pre-existing terminal row from a different key', + 'completed', NULL, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb) + `; + + // Now execute. Pre-fix behavior: replay short-circuit fires on the bogus + // row, executor never runs, obligation stays 'pending'. + // Post-fix: key mismatch β†’ executor runs β†’ obligation becomes 'paid'. + const result = await executeIntent(env, intent.id, { actorChittyId: OWNER }); + expect(result.ok).toBe(true); + expect(result.replayed).toBeFalsy(); + expect(result.idempotencyKey).not.toBe(bogusKey); + + const oblStatus = (await sql` + SELECT status FROM cc_obligations WHERE id = ${obligationId}::uuid + `) as unknown as Array<{ status: string }>; + expect(oblStatus[0].status).toBe('paid'); + + // Two rows now: the bogus seed (attempt=0) + the real run (attempt=1). + const auditRows = (await sql` + SELECT attempt, idempotency_key FROM cc_actions_log + WHERE intent_id = ${intent.id}::uuid ORDER BY attempt ASC + `) as unknown as Array<{ attempt: number; idempotency_key: string }>; + expect(auditRows.length).toBe(2); + expect(auditRows[0].attempt).toBe(0); + expect(auditRows[0].idempotency_key).toBe(bogusKey); + expect(auditRows[1].attempt).toBe(1); + expect(auditRows[1].idempotency_key).toBe(result.idempotencyKey); + }); + + it('FIX 2: a sovereignty refusal results in exactly ONE failIntent transition', async () => { + const sql = neon(DATABASE_URL!); + + const goal = await createGoal(env, { + ownerChittyId: OWNER, + title: `${TEST_TAG}-fix2-goal`, + }); + created.goalIds.push(goal.id); + const plan = await createPlan(env, { + goalId: goal.id, + title: `${TEST_TAG}-fix2-plan`, + }); + + // Seed an obligation we can target (executor never runs on the refusal + // path, but createIntent's payload still needs a valid shape). + const oblRows = await sql` + INSERT INTO cc_obligations (payee, category, due_date, status, metadata) + VALUES (${TEST_TAG + '-fix2-payee'}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb) + RETURNING id`; + const obligationId = String(oblRows[0].id); + created.obligationIds.push(obligationId); + + // STALE snapshot forces dispatch to re-reckon sovereignty. We point + // CHITTYTRUST_URL at a non-routable address so assessSovereignty's + // catch branch returns decision='blocked' (real network failure β€” no + // mock, just a real DNS hole). Refusal path triggers. + const stale: SovereigntyAssessmentSnapshot = { + decision: 'autonomous', + trustScore: 0.95, + reasoning: 'pre-seeded stale', + assessedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 365).toISOString(), + }; + + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: UPDATE_OBLIGATION_STATUS_INTENT, + payload: { obligation_id: obligationId, status: 'paid', notes: TEST_TAG }, + sovereigntyAssessment: stale, + metadata: { actorChittyId: OWNER }, + }); + + const refusalEnv: IntentEnv & Record = { + DATABASE_URL, + // RFC 5737 TEST-NET-1 β€” guaranteed unroutable. Triggers fetch failure + // β†’ assessSovereignty returns decision='blocked' (refusal path). + CHITTYTRUST_URL: 'http://192.0.2.1:1/', + }; + + const result = await executeIntent(refusalEnv, intent.id, { + actorChittyId: OWNER, + freshnessMs: 1, // force stale-snapshot branch even if clock skews + }); + expect(result.ok).toBe(false); + // FIX 2: dispatch returned replayed:true so executeIntent did not + // re-call failIntent. + expect(result.replayed).toBe(true); + + // Exactly one sovereignty_refusal audit row was written by dispatch. + const auditRows = (await sql` + SELECT action_type, status FROM cc_actions_log + WHERE intent_id = ${intent.id}::uuid + `) as unknown as Array<{ action_type: string; status: string }>; + expect(auditRows.length).toBe(1); + expect(auditRows[0].action_type).toBe('sovereignty_refusal'); + expect(auditRows[0].status).toBe('failed'); + + // cc_intents reached 'failed' with the dispatch-side error message. + // (failIntent's WHERE clause guards on status IN ('claimed','running'), + // so a second invocation is a no-op at the DB; the canonical FIX 2 + // signal is `result.replayed === true` above.) + const intentRows = (await sql` + SELECT status, error_message FROM cc_intents WHERE id = ${intent.id}::uuid + `) as unknown as Array<{ status: string; error_message: string | null }>; + expect(intentRows[0].status).toBe('failed'); + expect(intentRows[0].error_message).toMatch(/sovereignty re-reckon/); + }); +}); diff --git a/tests/meta/executor.spec.ts b/tests/meta/executor.spec.ts new file mode 100644 index 0000000..590c4d9 --- /dev/null +++ b/tests/meta/executor.spec.ts @@ -0,0 +1,160 @@ +/** + * Integration test for meta/executors/dispatch.ts + meta/intent.ts::executeIntent. + * + * Covers (per ADR-001 amendment, PR-A): + * - executeIntent atomically claims a pending intent + * - dispatch() looks up the registered executor by intent_type + * - executor runs against real Neon (cc_obligations row updates) + * - cc_actions_log row appears with intent_id, attempt=1, idempotency_key set + * - second executeIntent call is idempotent (replays from audit row) + * - cc_intents.status moves to 'done' + * + * Real Neon only. Skipped without DATABASE_URL β€” same pattern as + * tests/meta/intent-lifecycle.spec.ts (PR #101) and tests/daemon/leader.spec.ts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { neon } from '@neondatabase/serverless'; +import { + createGoal, + createPlan, + createIntent, + getIntent, + executeIntent, + type IntentEnv, + type SovereigntyAssessmentSnapshot, +} from '../../meta/intent'; +// Importing the executor barrel ensures registration side effects run. +import '../../meta/executors'; +import { UPDATE_OBLIGATION_STATUS_INTENT } from '../../meta/executors/update-obligation-status'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; + +const env: IntentEnv & Record = { DATABASE_URL }; +const OWNER = '01-A-NB-0001-P-66-1-1'; +const TEST_TAG = `pra-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +let obligationId: string | null = null; + +async function cleanup() { + if (!DATABASE_URL) return; + const sql = neon(DATABASE_URL); + // cc_actions_log rows referencing test intents will null out via ON DELETE + // SET NULL when the intents cascade-delete from the goal teardown. + await sql`DELETE FROM cc_goals WHERE owner_chitty_id = ${OWNER} AND title LIKE ${TEST_TAG + '%'}`; + if (obligationId) { + await sql`DELETE FROM cc_actions_log WHERE target_id = ${obligationId}::uuid`; + await sql`DELETE FROM cc_obligations WHERE id = ${obligationId}::uuid`; + } +} + +describe.skipIf(SKIP)('meta/executors β€” executeIntent round-trip', () => { + beforeAll(async () => { + await cleanup(); + const sql = neon(DATABASE_URL!); + // Insert a real cc_obligations row for the executor to update. + const rows = await sql` + INSERT INTO cc_obligations (payee, category, due_date, status, metadata) + VALUES (${TEST_TAG + '-payee'}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb) + RETURNING id`; + obligationId = String(rows[0].id); + }); + + afterAll(async () => { + await cleanup(); + }); + + it('executes a pending intent, writes audit row, and is idempotent on replay', async () => { + expect(obligationId).toBeTruthy(); + + const goal = await createGoal(env, { + ownerChittyId: OWNER, + title: `${TEST_TAG}-goal`, + }); + const plan = await createPlan(env, { + goalId: goal.id, + title: `${TEST_TAG}-plan`, + }); + + // Pre-seed a fresh sovereignty snapshot so dispatch() does NOT re-reckon + // against trust.chitty.cc (advisor: freshness branch is the test's + // positive path; stale-snapshot branch deferred). + const sovereignty: SovereigntyAssessmentSnapshot = { + decision: 'autonomous', + trustScore: 0.95, + reasoning: 'pre-seeded for integration test', + assessedAt: new Date().toISOString(), + }; + + const intent = await createIntent(env, { + planId: plan.id, + goalId: goal.id, + intentType: UPDATE_OBLIGATION_STATUS_INTENT, + payload: { + obligation_id: obligationId!, + status: 'deferred', + notes: TEST_TAG, + }, + sovereigntyAssessment: sovereignty, + metadata: { actorChittyId: OWNER }, + }); + + expect(intent.status).toBe('pending'); + + // First execution β€” real run. + const first = await executeIntent(env, intent.id, { actorChittyId: OWNER }); + expect(first.ok).toBe(true); + expect(first.replayed).toBeFalsy(); + expect(first.idempotencyKey).toMatch(/^[0-9a-f]{64}$/); + expect(first.actionLogId).toBeTruthy(); + + // Verify cc_intents status moved to 'done'. + const after = await getIntent(env, intent.id); + expect(after?.status).toBe('done'); + + // Verify exactly one cc_actions_log row with intent_id, attempt=1, key set. + const sql = neon(DATABASE_URL!); + const auditRows = (await sql` + SELECT id, intent_id, attempt, idempotency_key, status, action_type + FROM cc_actions_log + WHERE intent_id = ${intent.id}::uuid + `) as unknown as Array<{ + id: string; + intent_id: string; + attempt: number; + idempotency_key: string; + status: string; + action_type: string; + }>; + expect(auditRows.length).toBe(1); + expect(auditRows[0].attempt).toBe(1); + expect(auditRows[0].idempotency_key).toBe(first.idempotencyKey); + expect(auditRows[0].status).toBe('completed'); + expect(auditRows[0].action_type).toBe('status_change'); + + // Verify the obligation actually moved to 'deferred'. + const oblig = (await sql` + SELECT status FROM cc_obligations WHERE id = ${obligationId}::uuid + `) as unknown as Array<{ status: string }>; + expect(oblig[0].status).toBe('deferred'); + + // Second execution β€” under FIX 1 (PR #106), idempotency is per-attempt-key, + // not per-intent. A second executeIntent against the same intent produces + // attempt=2 with a NEW key, the prior terminal row does NOT short-circuit + // it, and the executor re-runs (update-obligation-status is itself + // idempotent, so the obligation remains 'deferred'). Replay-by-key is + // exercised by the dedicated test below; this assertion captures the + // post-fix contract for same-intent re-invocation. + const second = await executeIntent(env, intent.id, { actorChittyId: OWNER }); + expect(second.replayed).toBeFalsy(); + expect(second.idempotencyKey).not.toBe(first.idempotencyKey); + expect(second.actionLogId).not.toBe(first.actionLogId); + + const auditRowsAfter = (await sql` + SELECT attempt FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid ORDER BY attempt ASC + `) as unknown as Array<{ attempt: number }>; + expect(auditRowsAfter.length).toBe(2); + expect(auditRowsAfter.map((r) => r.attempt)).toEqual([1, 2]); + }); +});