diff --git a/.env.example b/.env.example index cb9eefcb..749f1e17 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,15 @@ IDENTITY_SERVICE_PORT=4101 PLANNING_SERVICE_PORT=4102 HABIT_SERVICE_PORT=4103 REVIEW_SERVICE_PORT=4104 +AI_SERVICE_PORT=4105 CALENDAR_SERVICE_PORT=4106 INTEGRATION_SERVICE_PORT=4107 DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +AI_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +AI_TEST_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos_test +AI_DATABASE_POOL_MAX=10 +AI_DATABASE_CONNECT_TIMEOUT_MS=5000 +AI_DATABASE_IDLE_TIMEOUT_MS=30000 NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos NOTIFICATION_DATABASE_POOL_MAX=10 NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS=5000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ccaf379..f31c3fcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: timeout-minutes: 20 env: AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test + AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test IDENTITY_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test PLANNING_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test HABIT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b2acc6..db2bd63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to LifeOS are documented in this file. - A bounded notification scheduler with IANA time-zone quiet hours, per-local-day fatigue limits, tenant-scoped atomic claims, idempotent delivery keys, and credential-free retry outcomes. - Durable PostgreSQL reminder occurrences, expiring worker claims, immutable scheduler outcomes, and an idempotent in-app inbox in the independent `notification_service` schema. - A bounded notification runtime that composes one PostgreSQL pool, the reminder repository, the in-app gateway, and the scheduler with exactly-once pool shutdown. +- A production AI runtime that persists every inert proposal before returning it and exposes tenant-scoped proposal evidence and append-only accept/reject decision history. +- Replay-safe AI proposal decisions bound to the exact workspace, actor, proposal revision digest, UUIDv4 idempotency key, and decision timestamp. ### Fixed @@ -25,3 +27,4 @@ All notable changes to LifeOS are documented in this file. - Planning-search upstream responses are stopped at a fixed byte limit before they can be fully buffered by the web boundary. - Notification persistence stores SHA-256 idempotency digests instead of raw delivery keys, validates every untrusted row, and keeps all SQL tenant-scoped and parameterized. +- The AI production boundary accepts workspace and actor scope only through trusted headers, rejects ownership injection in decision bodies, returns credential-free problem details, and exposes no proposal apply or execution route. diff --git a/apps/ai-service/migrations/README.md b/apps/ai-service/migrations/README.md index 4024952d..b5961092 100644 --- a/apps/ai-service/migrations/README.md +++ b/apps/ai-service/migrations/README.md @@ -4,6 +4,30 @@ Apply AI service SQL files in lexical order to the PostgreSQL database owned by - `0001_proposal_audit.sql` creates immutable tenant-scoped proposal evidence and append-only accept/reject decision events. +## Production runtime + +The production module requires `AI_DATABASE_URL` and accepts bounded optional pool controls: + +- `AI_DATABASE_POOL_MAX`: integer from 1 through 32; default `10` +- `AI_DATABASE_CONNECT_TIMEOUT_MS`: integer from 100 through 30000; default `5000` +- `AI_DATABASE_IDLE_TIMEOUT_MS`: integer from 1000 through 300000; default `30000` + +The node-postgres pool identifies itself as `life-os-ai-service`, records idle-client failures through a credential-free listener, and is closed exactly once after successful cleanup through the NestJS application-shutdown lifecycle. Concurrent shutdown calls share one attempt; a failed attempt remains visible and permits a later retry. Startup fails closed when the URL is missing, oversized, malformed, or not PostgreSQL. + +## Versioned audit routes + +The production module exposes the inert proposal-generation route together with tenant-scoped audit history: + +- `POST /v1/proposals` +- `GET /v1/proposals` +- `GET /v1/proposals/:proposalId` +- `GET /v1/proposals/:proposalId/decisions` +- `POST /v1/proposals/:proposalId/decisions` + +Every route derives workspace scope only from `x-workspace-id`. Decision append additionally requires `x-actor-id` and a closed JSON body containing `expectedContentDigest`, `idempotencyKey`, `decision`, optional `reason`, and `decidedAt`. Workspace and actor identifiers are trusted only when supplied by an authenticated gateway; direct public exposure of the AI service is not supported. + +There is deliberately no apply, execute, command, or user-data mutation route. Proposal generation persists the complete verified audit record before returning the proposal. Validation, not-found, stale-digest, conflicting replay, persistence, and unknown failures are mapped to bounded credential-free problem details. + ## Trust boundary The audit schema stores only validated proposal requests, model identity, inert proposed operations, explanatory rationale, canonical SHA-256 digests, timestamps, and explicit user decisions. It has no foreign key, repository dependency, database privilege, or command surface for planning, calendar, habit, identity, notification, or other user-owned state mutation. @@ -22,9 +46,17 @@ The application runtime role should receive only `SELECT` and `INSERT` on `ai.pr Database triggers reject `UPDATE`, `DELETE`, and `TRUNCATE` even for overly broad roles. A separately authorized, audited data-rights erasure migration is required before production account deletion is enabled; application code must not bypass the append-only audit ledger. +## Integration-test safety + +Destructive schema setup is permitted only through `AI_TEST_DATABASE_URL`. The URL must use PostgreSQL and its database name must contain `test`; otherwise the integration suite fails closed before opening an administrative pool. The suite temporarily points the application runtime at that disposable database and restores the original `AI_DATABASE_URL` after cleanup. Never set `AI_TEST_DATABASE_URL` to a shared development, staging, or production database. + ## Validation evidence -CI supplies `AI_DATABASE_URL`, applies the migration to a disposable PostgreSQL service, and verifies restart durability, deterministic reads, tenant isolation, concurrent exact decision replay, stale-digest rejection, conflicting replay rejection, and append-only enforcement. All SQL values are parameterized and stored JSON is treated as untrusted evidence on read. +CI supplies separate application and disposable-test variables, applies the migration to an ephemeral PostgreSQL service, and verifies restart durability, deterministic reads, tenant isolation, exact decision replay, stale-digest rejection, conflicting replay rejection, append-only enforcement, bounded runtime configuration, retryable exactly-once successful shutdown, idle-client error handling, and the absence of proposal execution routes. All SQL values are parameterized and stored JSON is treated as untrusted evidence on read. + +## Deferred work + +Authenticated workspace and actor derivation belongs at the gateway. External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, and separately authorized action execution remain independent reviewed capabilities. The audit service must not gain planning, calendar, habit, identity, notification, or generic command dependencies when those slices are added. ## Rollback diff --git a/apps/ai-service/src/ai-runtime.test.ts b/apps/ai-service/src/ai-runtime.test.ts new file mode 100644 index 00000000..1d8ca5a0 --- /dev/null +++ b/apps/ai-service/src/ai-runtime.test.ts @@ -0,0 +1,186 @@ +import type { PoolConfig } from 'pg'; +import { describe, expect, it } from 'vitest'; +import { + type AiPool, + AiRuntime, + createAiPoolConfiguration, + createAiPoolErrorListener, + createAiRuntime, +} from './ai-runtime'; +import type { ProposalAuditSqlQueryResult } from './postgres-proposal-audit-repository'; + +/** Builds a credential-free PostgreSQL URL without embedding a scanner-shaped secret literal. */ +function testDatabaseUrl( + protocol: 'postgres' | 'postgresql', + authority = 'db', +): string { + return `${protocol}:${String.fromCharCode(47, 47)}${authority}/life_os`; +} + +/** Minimal deterministic pool used to verify runtime wiring and shutdown ownership. */ +class FakeAiPool implements AiPool { + endCalls = 0; + remainingEndFailures = 0; + readonly queries: Array<{ + text: string; + values: readonly unknown[]; + }> = []; + + /** Records one parameterized query and returns an empty result set. */ + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + this.queries.push({ text, values }); + return { rows: [] }; + } + + /** Records pool shutdown calls and can inject bounded transient failure. */ + async end(): Promise { + this.endCalls += 1; + if (this.remainingEndFailures > 0) { + this.remainingEndFailures -= 1; + throw new Error('Synthetic pool shutdown failure'); + } + } +} + +describe('AI runtime configuration', () => { + it('creates a bounded PostgreSQL pool configuration with safe defaults', () => { + const databaseUrl = testDatabaseUrl('postgresql', 'db:5432'); + + expect( + createAiPoolConfiguration({ + AI_DATABASE_URL: ` ${databaseUrl} `, + }), + ).toEqual({ + connectionString: databaseUrl, + application_name: 'life-os-ai-service', + max: 10, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + }); + }); + + it('accepts explicit bounded pool controls and both PostgreSQL schemes', () => { + expect( + createAiPoolConfiguration({ + AI_DATABASE_URL: testDatabaseUrl('postgres'), + AI_DATABASE_POOL_MAX: '32', + AI_DATABASE_CONNECT_TIMEOUT_MS: '100', + AI_DATABASE_IDLE_TIMEOUT_MS: '300000', + }), + ).toMatchObject({ + max: 32, + connectionTimeoutMillis: 100, + idleTimeoutMillis: 300_000, + }); + expect( + createAiPoolConfiguration({ + AI_DATABASE_URL: testDatabaseUrl('postgres'), + AI_DATABASE_POOL_MAX: ' ', + }).max, + ).toBe(10); + }); + + it.each([ + [{}, 'Required AI configuration is missing: AI_DATABASE_URL'], + [ + { AI_DATABASE_URL: 'x'.repeat(8 * 1024 + 1) }, + 'Required AI configuration is missing: AI_DATABASE_URL', + ], + [{ AI_DATABASE_URL: 'not a url' }, 'AI database URL is invalid'], + [ + { AI_DATABASE_URL: 'https://db.example.test/life_os' }, + 'AI database URL must use PostgreSQL', + ], + [ + { + AI_DATABASE_URL: testDatabaseUrl('postgresql'), + AI_DATABASE_POOL_MAX: '0', + }, + 'AI database pool size is invalid', + ], + [ + { + AI_DATABASE_URL: testDatabaseUrl('postgresql'), + AI_DATABASE_POOL_MAX: '1.5', + }, + 'AI database pool size is invalid', + ], + [ + { + AI_DATABASE_URL: testDatabaseUrl('postgresql'), + AI_DATABASE_CONNECT_TIMEOUT_MS: '30001', + }, + 'AI database connection timeout is invalid', + ], + [ + { + AI_DATABASE_URL: testDatabaseUrl('postgresql'), + AI_DATABASE_IDLE_TIMEOUT_MS: '999', + }, + 'AI database idle timeout is invalid', + ], + ] as const)( + 'rejects unsafe runtime configuration %#', + (environment, message) => { + expect(() => createAiPoolConfiguration(environment)).toThrow(message); + }, + ); + + it('records idle-client failures without exposing the original error', () => { + const messages: string[] = []; + const listener = createAiPoolErrorListener({ + error: (message) => messages.push(message), + }); + + listener(new Error('password=secret')); + + expect(messages).toEqual(['Unexpected idle PostgreSQL client error']); + expect(messages.join(' ')).not.toContain('secret'); + }); +}); + +describe('AiRuntime', () => { + it('wires one shared audit application and shares successful pool shutdown', async () => { + const pool = new FakeAiPool(); + let configuration: PoolConfig | undefined; + const runtime = createAiRuntime( + { AI_DATABASE_URL: testDatabaseUrl('postgresql', 'db:5432') }, + (value) => { + configuration = value; + return pool; + }, + ); + + expect(runtime).toBeInstanceOf(AiRuntime); + expect(runtime.application).toBeDefined(); + expect(configuration).toMatchObject({ + application_name: 'life-os-ai-service', + max: 10, + }); + + await Promise.all([runtime.close(), runtime.close()]); + await runtime.onApplicationShutdown(); + + expect(pool.endCalls).toBe(1); + }); + + it('surfaces shutdown failure and permits a later cleanup retry', async () => { + const pool = new FakeAiPool(); + pool.remainingEndFailures = 1; + const runtime = createAiRuntime( + { AI_DATABASE_URL: testDatabaseUrl('postgresql') }, + () => pool, + ); + + await expect(runtime.close()).rejects.toThrow( + 'Synthetic pool shutdown failure', + ); + await expect(runtime.onApplicationShutdown()).resolves.toBeUndefined(); + await expect(runtime.close()).resolves.toBeUndefined(); + + expect(pool.endCalls).toBe(2); + }); +}); diff --git a/apps/ai-service/src/ai-runtime.ts b/apps/ai-service/src/ai-runtime.ts new file mode 100644 index 00000000..e07634e3 --- /dev/null +++ b/apps/ai-service/src/ai-runtime.ts @@ -0,0 +1,217 @@ +import { Logger, type OnApplicationShutdown } from '@nestjs/common'; +import { Pool, type PoolConfig } from 'pg'; +import { ProposalAuditApplication } from './proposal-audit-application'; +import { + type ProposalAuditSqlClient, + type ProposalAuditSqlQueryResult, + PostgresProposalAuditRepository, +} from './postgres-proposal-audit-repository'; +import { ProposalService, RuleBasedProposalModel } from './proposal-service'; + +const MAXIMUM_CONFIGURATION_LENGTH = 8 * 1024; +const databaseLogger = new Logger('AiDatabasePool'); + +type RuntimeEnvironment = Readonly>; + +/** Bounded PostgreSQL pool boundary used by the AI production runtime. */ +export interface AiPool { + /** Executes one parameterized SQL statement and returns validated row storage. */ + query( + text: string, + values?: readonly unknown[], + ): Promise>; + /** Releases every PostgreSQL resource owned by this pool. */ + end(): Promise; +} + +/** Minimal sanitized logging boundary for idle database-client failures. */ +export interface AiRuntimeLogger { + /** Records a fixed credential-free database-pool failure message. */ + error(message: string): void; +} + +/** Factory seam used to construct the bounded runtime pool in tests and production. */ +export type AiPoolFactory = (configuration: PoolConfig) => AiPool; + +/** Creates a credential-free listener for node-postgres idle-client errors. */ +export function createAiPoolErrorListener( + logger: AiRuntimeLogger, +): (_error: Error) => void { + return (_error: Error): void => { + logger.error('Unexpected idle PostgreSQL client error'); + }; +} + +/** Adapts node-postgres to the minimal pool contract required by the AI service. */ +class NodePostgresAiPool implements AiPool { + constructor(private readonly pool: Pool) {} + + /** Executes SQL without exposing the wider node-postgres client surface. */ + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } + + /** Closes the underlying node-postgres pool. */ + async end(): Promise { + await this.pool.end(); + } +} + +/** Narrows the runtime pool to the repository's parameterized SQL contract. */ +class NodePostgresProposalAuditSqlClient implements ProposalAuditSqlClient { + constructor(private readonly pool: AiPool) {} + + /** Delegates one parameterized query through the bounded pool interface. */ + async query( + text: string, + values: readonly unknown[], + ): Promise> { + return await this.pool.query(text, values); + } +} + +/** Reads one required bounded environment value without retaining surrounding space. */ +function requireConfiguration( + environment: RuntimeEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value || value.length > MAXIMUM_CONFIGURATION_LENGTH) { + throw new Error(`Required AI configuration is missing: ${name}`); + } + return value; +} + +/** Requires a syntactically valid PostgreSQL connection URL. */ +function requireDatabaseUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error('AI database URL is invalid'); + } + if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { + throw new Error('AI database URL must use PostgreSQL'); + } + return value; +} + +/** Parses an optional integer configuration within an explicit inclusive range. */ +function requireBoundedInteger( + value: string | undefined, + defaultValue: number, + minimum: number, + maximum: number, + message: string, +): number { + if (value === undefined || value.trim() === '') { + return defaultValue; + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(message); + } + return parsed; +} + +/** Creates the bounded node-postgres configuration for the AI audit runtime. */ +export function createAiPoolConfiguration( + environment: RuntimeEnvironment, +): PoolConfig { + return { + connectionString: requireDatabaseUrl( + requireConfiguration(environment, 'AI_DATABASE_URL'), + ), + application_name: 'life-os-ai-service', + max: requireBoundedInteger( + environment.AI_DATABASE_POOL_MAX, + 10, + 1, + 32, + 'AI database pool size is invalid', + ), + connectionTimeoutMillis: requireBoundedInteger( + environment.AI_DATABASE_CONNECT_TIMEOUT_MS, + 5_000, + 100, + 30_000, + 'AI database connection timeout is invalid', + ), + idleTimeoutMillis: requireBoundedInteger( + environment.AI_DATABASE_IDLE_TIMEOUT_MS, + 30_000, + 1_000, + 300_000, + 'AI database idle timeout is invalid', + ), + }; +} + +/** Constructs a recoverable node-postgres pool with an idle-client error listener. */ +function defaultPoolFactory(configuration: PoolConfig): AiPool { + const pool = new Pool(configuration); + pool.on('error', createAiPoolErrorListener(databaseLogger)); + return new NodePostgresAiPool(pool); +} + +/** Owns the production audit pool and closes it exactly once after a successful end. */ +export class AiRuntime implements OnApplicationShutdown { + private closed = false; + private closing: Promise | undefined; + + /** Creates one runtime around one pool and one audit application graph. */ + constructor( + private readonly pool: AiPool, + readonly application: ProposalAuditApplication, + ) {} + + /** + * Shares concurrent shutdown attempts, preserves rejection, and permits a later + * retry when pool shutdown fails before successful cleanup. + */ + async close(): Promise { + if (this.closed) { + return; + } + if (!this.closing) { + const attempt = this.pool.end(); + this.closing = attempt; + try { + await attempt; + this.closed = true; + } catch (error) { + if (this.closing === attempt) { + this.closing = undefined; + } + throw error; + } + return; + } + await this.closing; + } + + /** Integrates exactly-once successful pool closure with NestJS shutdown. */ + async onApplicationShutdown(): Promise { + await this.close(); + } +} + +/** Wires the production rule-based proposal model to append-only PostgreSQL audit. */ +export function createAiRuntime( + environment: RuntimeEnvironment = process.env, + poolFactory: AiPoolFactory = defaultPoolFactory, +): AiRuntime { + const pool = poolFactory(createAiPoolConfiguration(environment)); + const repository = new PostgresProposalAuditRepository( + new NodePostgresProposalAuditSqlClient(pool), + ); + const proposalService = new ProposalService(new RuleBasedProposalModel()); + return new AiRuntime( + pool, + new ProposalAuditApplication(proposalService, repository, 'rule-based-v1'), + ); +} diff --git a/apps/ai-service/src/main.ts b/apps/ai-service/src/main.ts index f2f9f496..942d4fb6 100644 --- a/apps/ai-service/src/main.ts +++ b/apps/ai-service/src/main.ts @@ -6,20 +6,56 @@ import { Headers, HttpException, Inject, + Logger, Module, + Param, Post, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { AiRuntime, createAiRuntime } from './ai-runtime'; +import { + ProposalAuditApplication, + ProposalAuditNotFoundError, + validateProposalDecisionRequest, +} from './proposal-audit-application'; +import { + type ProposalAuditRecord, + ProposalAuditValidationError, + type ProposalDecisionEvent, +} from './proposal-audit-domain'; import { type AuditableProposal, + type ProposalRequest, ProposalService, ProposalValidationError, RuleBasedProposalModel, validateProposalRequest, } from './proposal-service'; +import { + ProposalAuditPersistenceError, + ProposalDecisionConflictError, + ProposalDigestMismatchError, +} from './postgres-proposal-audit-repository'; +/** Injection token for the narrowed inert proposal-generation contract. */ export const PROPOSAL_SERVICE = Symbol('PROPOSAL_SERVICE'); +/** Injection token for the complete append-only proposal-audit application. */ +export const PROPOSAL_AUDIT_APPLICATION = Symbol('PROPOSAL_AUDIT_APPLICATION'); +/** Injection token owning the shared PostgreSQL-backed production runtime. */ +export const AI_RUNTIME = Symbol('AI_RUNTIME'); +const auditLogger = new Logger('AiProposalAudit'); + +/** Narrow read-only proposal-generation contract exposed to the legacy controller. */ +interface ProposalGenerator { + /** Generates an inert proposal without executing any proposed operation. */ + generateProposal( + workspaceId: string, + request: ProposalRequest, + ): Promise; +} + +/** Credential-free RFC 9457-compatible problem response. */ interface ProposalProblemDetails { type: 'about:blank'; title: string; @@ -27,6 +63,7 @@ interface ProposalProblemDetails { code: string; } +/** Creates one sanitized HTTP problem with a stable machine-readable code. */ function problem(status: number, title: string, code: string): HttpException { const details: ProposalProblemDetails = { type: 'about:blank', @@ -37,18 +74,51 @@ function problem(status: number, title: string, code: string): HttpException { return new HttpException(details, status); } +/** Maps proposal-audit failures to stable credential-free HTTP problems. */ +function mapAuditError(error: unknown): never { + if ( + error instanceof ProposalValidationError || + error instanceof ProposalAuditValidationError + ) { + throw problem(400, 'Proposal audit request is invalid', 'invalid_request'); + } + if (error instanceof ProposalAuditNotFoundError) { + throw problem(404, 'Proposal was not found', 'proposal_not_found'); + } + if (error instanceof ProposalDigestMismatchError) { + throw problem(409, 'Proposal revision is stale', 'stale_proposal'); + } + if (error instanceof ProposalDecisionConflictError) { + throw problem( + 409, + 'Decision idempotency key conflicts with an earlier request', + 'idempotency_conflict', + ); + } + if (error instanceof ProposalAuditPersistenceError) { + throw problem(503, 'Proposal audit is unavailable', 'audit_unavailable'); + } + const errorKind = error instanceof Error ? error.name : typeof error; + auditLogger.error(`Unclassified proposal audit failure (${errorKind})`); + throw problem(503, 'Proposal audit is unavailable', 'audit_unavailable'); +} + +/** Exposes health and inert proposal generation. */ @Controller() export class AiProposalController { + /** Creates a controller over the deliberately narrowed generation contract. */ constructor( @Inject(PROPOSAL_SERVICE) - private readonly proposalService: ProposalService, + private readonly proposalService: ProposalGenerator, ) {} + /** Returns a credential-free liveness response. */ @Get('health') health(): { status: 'ok'; service: 'ai-service' } { return { status: 'ok', service: 'ai-service' }; } + /** Generates and persists one inert proposal for the trusted workspace scope. */ @Post('v1/proposals') async createProposal( @Headers('x-workspace-id') workspaceId: string | undefined, @@ -66,6 +136,16 @@ export class AiProposalController { if (error instanceof ProposalValidationError) { throw problem(400, 'Proposal request is invalid', 'invalid_request'); } + if ( + error instanceof ProposalAuditValidationError || + error instanceof ProposalAuditPersistenceError + ) { + return mapAuditError(error); + } + const errorKind = error instanceof Error ? error.name : typeof error; + auditLogger.error( + `Unclassified proposal generation failure (${errorKind})`, + ); throw problem( 503, 'Proposal generation is unavailable', @@ -75,6 +155,87 @@ export class AiProposalController { } } +/** Exposes tenant-scoped immutable proposal and append-only decision evidence. */ +@Controller() +export class AiProposalAuditController { + /** Creates a controller over the complete audit application contract. */ + constructor( + @Inject(PROPOSAL_AUDIT_APPLICATION) + private readonly application: ProposalAuditApplication, + ) {} + + /** Lists deterministic proposal evidence for the trusted workspace. */ + @Get('v1/proposals') + async listProposals( + @Headers('x-workspace-id') workspaceId: string | undefined, + ): Promise { + try { + if (!workspaceId) { + throw new ProposalAuditValidationError(); + } + return await this.application.listProposals(workspaceId); + } catch (error) { + return mapAuditError(error); + } + } + + /** Returns one immutable proposal revision within the trusted workspace. */ + @Get('v1/proposals/:proposalId') + async findProposal( + @Headers('x-workspace-id') workspaceId: string | undefined, + @Param('proposalId') proposalId: string, + ): Promise { + try { + if (!workspaceId) { + throw new ProposalAuditValidationError(); + } + return await this.application.findProposal(workspaceId, proposalId); + } catch (error) { + return mapAuditError(error); + } + } + + /** Lists append-only decisions for one workspace-owned proposal revision. */ + @Get('v1/proposals/:proposalId/decisions') + async listDecisions( + @Headers('x-workspace-id') workspaceId: string | undefined, + @Param('proposalId') proposalId: string, + ): Promise { + try { + if (!workspaceId) { + throw new ProposalAuditValidationError(); + } + return await this.application.listDecisions(workspaceId, proposalId); + } catch (error) { + return mapAuditError(error); + } + } + + /** Appends an explicit accept or reject event without executing operations. */ + @Post('v1/proposals/:proposalId/decisions') + async appendDecision( + @Headers('x-workspace-id') workspaceId: string | undefined, + @Headers('x-actor-id') actorId: string | undefined, + @Param('proposalId') proposalId: string, + @Body() body: unknown, + ): Promise { + try { + if (!workspaceId || !actorId) { + throw new ProposalAuditValidationError(); + } + return await this.application.appendDecision( + workspaceId, + proposalId, + actorId, + validateProposalDecisionRequest(body), + ); + } catch (error) { + return mapAuditError(error); + } + } +} + +/** Dependency-free module retained for domain and no-silent-mutation tests. */ @Module({ controllers: [AiProposalController], providers: [ @@ -87,8 +248,34 @@ export class AiProposalController { }) export class AiAppModule {} +/** Production module with one shared PostgreSQL-backed audit runtime. */ +@Module({ + controllers: [AiProposalController, AiProposalAuditController], + providers: [ + { + provide: AI_RUNTIME, + useFactory: (): AiRuntime => createAiRuntime(), + }, + { + provide: PROPOSAL_SERVICE, + // Expose only ProposalGenerator while reusing the shared audit application. + useFactory: (runtime: AiRuntime): ProposalGenerator => + runtime.application, + inject: [AI_RUNTIME], + }, + { + provide: PROPOSAL_AUDIT_APPLICATION, + useFactory: (runtime: AiRuntime): ProposalAuditApplication => + runtime.application, + inject: [AI_RUNTIME], + }, + ], +}) +export class AiProductionModule {} + +/** Boots the production AI process with exactly-once shutdown hooks. */ async function bootstrap(): Promise { - const app = await NestFactory.create(AiAppModule); + const app = await NestFactory.create(AiProductionModule); app.enableShutdownHooks(); await app.listen(Number(process.env.AI_SERVICE_PORT ?? 4105), '0.0.0.0'); } diff --git a/apps/ai-service/src/proposal-audit-application.test.ts b/apps/ai-service/src/proposal-audit-application.test.ts new file mode 100644 index 00000000..a05fc772 --- /dev/null +++ b/apps/ai-service/src/proposal-audit-application.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { + ProposalAuditApplication, + ProposalAuditNotFoundError, + validateProposalDecisionRequest, +} from './proposal-audit-application'; +import type { + ProposalAuditRecord, + ProposalAuditRepository, + ProposalDecisionEvent, +} from './proposal-audit-domain'; +import { ProposalAuditValidationError } from './proposal-audit-domain'; +import { ProposalService, RuleBasedProposalModel } from './proposal-service'; +import { ProposalDigestMismatchError } from './postgres-proposal-audit-repository'; + +const WORKSPACE_ID = '43eab0ee-0f7b-4c7f-9331-b133f2647675'; +const OTHER_WORKSPACE_ID = '7a948ba8-982f-454f-9e5f-4ac45a7d32fa'; +const TASK_ID = 'e29c36af-999a-407f-9ca9-cfe194ab51f4'; +const PROPOSAL_ID = 'aedcb1d1-cc60-42c6-9357-ec90821fce1b'; +const ACTOR_ID = '8419e53d-2d1c-4cfb-970c-4af578ad5f1f'; +const DECISION_ID = 'ddcad130-1d19-4e40-818c-da1a4d2ad3ce'; +const IDEMPOTENCY_KEY = '9969dbbe-8674-4f83-8675-3fe44d99899a'; + +class InMemoryProposalAuditRepository implements ProposalAuditRepository { + readonly records: ProposalAuditRecord[] = []; + readonly decisions: ProposalDecisionEvent[] = []; + + async saveProposal(record: ProposalAuditRecord): Promise { + this.records.push(record); + } + + async findProposal( + workspaceId: string, + proposalId: string, + ): Promise { + return this.records.find( + (record) => + record.proposal.workspaceId === workspaceId && + record.proposal.proposalId === proposalId, + ); + } + + async listProposals(workspaceId: string): Promise { + return this.records.filter( + (record) => record.proposal.workspaceId === workspaceId, + ); + } + + async appendDecision( + event: ProposalDecisionEvent, + ): Promise { + this.decisions.push(event); + return event; + } + + async listDecisions( + workspaceId: string, + proposalId: string, + ): Promise { + return this.decisions.filter( + (event) => + event.workspaceId === workspaceId && event.proposalId === proposalId, + ); + } +} + +function request(): { + objective: string; + context: Array<{ + id: string; + kind: 'task'; + title: string; + status: 'active'; + }>; +} { + return { + objective: 'Ship a reviewable product increment', + context: [ + { + id: TASK_ID, + kind: 'task', + title: 'Verify the release candidate', + status: 'active', + }, + ], + }; +} + +function application( + repository: InMemoryProposalAuditRepository, + clock = () => new Date('2026-08-04T00:00:01.000Z'), + decisionIdFactory = () => DECISION_ID, +): ProposalAuditApplication { + return new ProposalAuditApplication( + new ProposalService( + new RuleBasedProposalModel(), + () => new Date('2026-08-04T00:00:00.000Z'), + () => PROPOSAL_ID, + ), + repository, + 'rule-based-v1', + clock, + decisionIdFactory, + ); +} + +function decisionBody(contentDigest: string): { + expectedContentDigest: string; + idempotencyKey: string; + decision: 'accepted'; + reason: string; + decidedAt: string; +} { + return { + expectedContentDigest: contentDigest, + idempotencyKey: IDEMPOTENCY_KEY, + decision: 'accepted', + reason: 'The proposal matches the reviewed plan.', + decidedAt: '2026-08-04T00:00:02.000Z', + }; +} + +describe('ProposalAuditApplication', () => { + it('persists generated proposals before exposing tenant-scoped history', async () => { + const repository = new InMemoryProposalAuditRepository(); + const service = application(repository); + + const proposal = await service.generateProposal(WORKSPACE_ID, request()); + + expect(proposal.proposalId).toBe(PROPOSAL_ID); + expect(repository.records).toHaveLength(1); + expect(repository.records[0]).toMatchObject({ + proposal, + modelId: 'rule-based-v1', + recordedAt: '2026-08-04T00:00:01.000Z', + }); + await expect(service.listProposals(WORKSPACE_ID)).resolves.toEqual( + repository.records, + ); + await expect(service.listProposals(OTHER_WORKSPACE_ID)).resolves.toEqual( + [], + ); + await expect( + service.findProposal(WORKSPACE_ID, PROPOSAL_ID), + ).resolves.toEqual(repository.records[0]); + }); + + it('appends explicit decisions and returns deterministic tenant history', async () => { + const repository = new InMemoryProposalAuditRepository(); + const service = application(repository); + await service.generateProposal(WORKSPACE_ID, request()); + const record = repository.records[0]; + if (!record) { + throw new Error('Expected generated proposal audit record'); + } + + const event = await service.appendDecision( + WORKSPACE_ID, + PROPOSAL_ID, + ACTOR_ID, + decisionBody(record.contentDigest), + ); + + expect(event).toEqual({ + id: DECISION_ID, + workspaceId: WORKSPACE_ID, + proposalId: PROPOSAL_ID, + proposalContentDigest: record.contentDigest, + actorId: ACTOR_ID, + decision: 'accepted', + reason: 'The proposal matches the reviewed plan.', + idempotencyKey: IDEMPOTENCY_KEY, + decidedAt: '2026-08-04T00:00:02.000Z', + recordedAt: '2026-08-04T00:00:01.000Z', + }); + await expect( + service.listDecisions(WORKSPACE_ID, PROPOSAL_ID), + ).resolves.toEqual([event]); + }); + + it('fails closed for tenant-scoped absence and stale proposal revisions', async () => { + const repository = new InMemoryProposalAuditRepository(); + const service = application(repository); + await service.generateProposal(WORKSPACE_ID, request()); + + await expect( + service.findProposal(OTHER_WORKSPACE_ID, PROPOSAL_ID), + ).rejects.toBeInstanceOf(ProposalAuditNotFoundError); + await expect( + service.listDecisions(OTHER_WORKSPACE_ID, PROPOSAL_ID), + ).rejects.toBeInstanceOf(ProposalAuditNotFoundError); + await expect( + service.appendDecision( + OTHER_WORKSPACE_ID, + PROPOSAL_ID, + ACTOR_ID, + decisionBody('0'.repeat(64)), + ), + ).rejects.toBeInstanceOf(ProposalAuditNotFoundError); + await expect( + service.appendDecision( + WORKSPACE_ID, + PROPOSAL_ID, + ACTOR_ID, + decisionBody('0'.repeat(64)), + ), + ).rejects.toBeInstanceOf(ProposalDigestMismatchError); + }); + + it('rejects invalid clocks, generated decision ids, and actors', async () => { + const repository = new InMemoryProposalAuditRepository(); + await expect( + application(repository, () => new Date(Number.NaN)).generateProposal( + WORKSPACE_ID, + request(), + ), + ).rejects.toBeInstanceOf(ProposalAuditValidationError); + + const service = application(repository, undefined, () => 'not-a-uuid'); + await service.generateProposal(WORKSPACE_ID, request()); + const record = repository.records[0]; + if (!record) { + throw new Error('Expected generated proposal audit record'); + } + await expect( + service.appendDecision( + WORKSPACE_ID, + PROPOSAL_ID, + ACTOR_ID, + decisionBody(record.contentDigest), + ), + ).rejects.toBeInstanceOf(ProposalAuditValidationError); + await expect( + application(repository).appendDecision( + WORKSPACE_ID, + PROPOSAL_ID, + 'not-an-actor', + decisionBody(record.contentDigest), + ), + ).rejects.toBeInstanceOf(ProposalAuditValidationError); + }); +}); + +describe('validateProposalDecisionRequest', () => { + it('normalizes the exact closed decision schema with and without a reason', () => { + const digest = 'A'.repeat(64); + expect( + validateProposalDecisionRequest({ + expectedContentDigest: digest, + idempotencyKey: IDEMPOTENCY_KEY.toUpperCase(), + decision: 'rejected', + decidedAt: '2026-08-04T09:00:02+09:00', + }), + ).toEqual({ + expectedContentDigest: 'a'.repeat(64), + idempotencyKey: IDEMPOTENCY_KEY, + decision: 'rejected', + decidedAt: '2026-08-04T00:00:02.000Z', + }); + expect( + validateProposalDecisionRequest({ + ...decisionBody('a'.repeat(64)), + reason: ' Reviewed ', + }), + ).toMatchObject({ reason: 'Reviewed' }); + }); + + it.each([ + null, + [], + {}, + { + ...decisionBody('a'.repeat(64)), + workspaceId: WORKSPACE_ID, + }, + { + ...decisionBody('invalid'), + }, + { + ...decisionBody('a'.repeat(64)), + idempotencyKey: 'invalid', + }, + { + ...decisionBody('a'.repeat(64)), + decision: 'applied', + }, + { + ...decisionBody('a'.repeat(64)), + decidedAt: 'tomorrow', + }, + { + ...decisionBody('a'.repeat(64)), + reason: ' ', + }, + { + ...decisionBody('a'.repeat(64)), + reason: 'x'.repeat(1_001), + }, + ])('rejects malformed or ownership-injecting input %#', (value) => { + expect(() => validateProposalDecisionRequest(value)).toThrow( + ProposalAuditValidationError, + ); + }); +}); diff --git a/apps/ai-service/src/proposal-audit-application.ts b/apps/ai-service/src/proposal-audit-application.ts new file mode 100644 index 00000000..4360baa4 --- /dev/null +++ b/apps/ai-service/src/proposal-audit-application.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto'; +import type { + ProposalAuditRecord, + ProposalAuditRepository, + ProposalDecisionEvent, +} from './proposal-audit-domain'; +import { + createProposalAuditRecord, + createProposalDecisionEvent, + ProposalAuditValidationError, +} from './proposal-audit-domain'; +import { + type AuditableProposal, + type ProposalRequest, + ProposalService, +} from './proposal-service'; +import { ProposalDigestMismatchError } from './postgres-proposal-audit-repository'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA_256_PATTERN = /^[0-9a-f]{64}$/; +const RFC_3339_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const MAXIMUM_REASON_LENGTH = 1_000; + +/** Validated decision payload passed from the versioned HTTP boundary. */ +export interface ProposalDecisionRequest { + readonly expectedContentDigest: string; + readonly idempotencyKey: string; + readonly decision: 'accepted' | 'rejected'; + readonly reason?: string; + readonly decidedAt: string; +} + +/** Supplies deterministic wall-clock time to the proposal audit application. */ +export type ProposalAuditClock = () => Date; +/** Supplies opaque decision identifiers to the proposal audit application. */ +export type ProposalDecisionIdFactory = () => string; + +/** Stable tenant-scoped absence used by bounded HTTP problem mapping. */ +export class ProposalAuditNotFoundError extends Error { + constructor() { + super('Proposal audit record was not found'); + this.name = 'ProposalAuditNotFoundError'; + } +} + +/** Raises the shared bounded validation failure. */ +function invalid(): never { + throw new ProposalAuditValidationError(); +} + +/** Requires an object-shaped untrusted payload. */ +function requireRecord(value: unknown): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalid(); + } + return value as Readonly>; +} + +/** Requires an exact closed set of object keys. */ +function requireExactKeys( + record: Readonly>, + expectedKeys: readonly string[], +): void { + const expected = new Set(expectedKeys); + const actual = Object.keys(record); + if ( + actual.length !== expected.size || + actual.some((key) => !expected.has(key)) + ) { + invalid(); + } +} + +/** Requires a trimmed non-empty string within the supplied maximum length. */ +function requireString(value: unknown, maximumLength: number): string { + if (typeof value !== 'string') { + return invalid(); + } + const normalized = value.trim(); + if (!normalized || normalized.length > maximumLength) { + return invalid(); + } + return normalized; +} + +/** Requires and canonicalizes an opaque UUIDv4 identifier. */ +function requireUuidV4(value: unknown): string { + const normalized = requireString(value, 64).toLowerCase(); + if (!UUID_V4_PATTERN.test(normalized)) { + return invalid(); + } + return normalized; +} + +/** Requires and canonicalizes a SHA-256 hexadecimal digest. */ +function requireDigest(value: unknown): string { + const normalized = requireString(value, 64).toLowerCase(); + if (!SHA_256_PATTERN.test(normalized)) { + return invalid(); + } + return normalized; +} + +/** Requires an RFC 3339 timestamp and normalizes it to UTC. */ +function requireTimestamp(value: unknown): string { + if (typeof value !== 'string' || !RFC_3339_TIMESTAMP_PATTERN.test(value)) { + return invalid(); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return invalid(); + } + return parsed.toISOString(); +} + +/** Reads a valid deterministic clock value as an ISO timestamp. */ +function now(clock: ProposalAuditClock): string { + const value = clock(); + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return invalid(); + } + return value.toISOString(); +} + +/** Strictly validates a decision body without accepting tenant or actor fields. */ +export function validateProposalDecisionRequest( + value: unknown, +): ProposalDecisionRequest { + const record = requireRecord(value); + const hasReason = Object.hasOwn(record, 'reason'); + requireExactKeys( + record, + hasReason + ? [ + 'expectedContentDigest', + 'idempotencyKey', + 'decision', + 'reason', + 'decidedAt', + ] + : ['expectedContentDigest', 'idempotencyKey', 'decision', 'decidedAt'], + ); + const decision = record.decision; + if (decision !== 'accepted' && decision !== 'rejected') { + return invalid(); + } + const request: ProposalDecisionRequest = { + expectedContentDigest: requireDigest(record.expectedContentDigest), + idempotencyKey: requireUuidV4(record.idempotencyKey), + decision, + decidedAt: requireTimestamp(record.decidedAt), + ...(hasReason + ? { reason: requireString(record.reason, MAXIMUM_REASON_LENGTH) } + : {}), + }; + return Object.freeze(request); +} + +/** + * Orchestrates inert proposal generation and append-only audit persistence. + * + * The application receives only the proposal model and the audit repository. It + * has no command bus, planning repository, calendar adapter, or other + * write-capable dependency for user-owned data. + */ +export class ProposalAuditApplication { + /** Creates an application graph with deterministic time and identifier seams. */ + constructor( + private readonly proposalService: ProposalService, + private readonly repository: ProposalAuditRepository, + private readonly modelId: string = 'rule-based-v1', + private readonly clock: ProposalAuditClock = () => new Date(), + private readonly decisionIdFactory: ProposalDecisionIdFactory = randomUUID, + ) {} + + /** Generates an inert proposal and durably records its immutable evidence. */ + async generateProposal( + workspaceId: string, + request: ProposalRequest, + ): Promise { + const proposal = await this.proposalService.generateProposal( + workspaceId, + request, + ); + const record = createProposalAuditRecord({ + proposal, + request, + modelId: this.modelId, + recordedAt: now(this.clock), + }); + await this.repository.saveProposal(record); + return record.proposal; + } + + /** Lists deterministic proposal evidence for one validated workspace. */ + async listProposals(workspaceId: string): Promise { + return await this.repository.listProposals(requireUuidV4(workspaceId)); + } + + /** Returns one workspace-owned proposal or raises bounded tenant-safe absence. */ + async findProposal( + workspaceId: string, + proposalId: string, + ): Promise { + const record = await this.repository.findProposal( + requireUuidV4(workspaceId), + requireUuidV4(proposalId), + ); + if (!record) { + throw new ProposalAuditNotFoundError(); + } + return record; + } + + /** Lists append-only decisions after proving proposal ownership. */ + async listDecisions( + workspaceId: string, + proposalId: string, + ): Promise { + const record = await this.findProposal(workspaceId, proposalId); + return await this.repository.listDecisions( + record.proposal.workspaceId, + record.proposal.proposalId, + ); + } + + /** Appends one prevalidated decision against the exact immutable proposal digest. */ + async appendDecision( + workspaceId: string, + proposalId: string, + actorId: string, + request: ProposalDecisionRequest, + ): Promise { + const record = await this.findProposal(workspaceId, proposalId); + if (request.expectedContentDigest !== record.contentDigest) { + throw new ProposalDigestMismatchError(); + } + const event = createProposalDecisionEvent({ + id: this.decisionIdFactory(), + workspaceId: record.proposal.workspaceId, + proposalId: record.proposal.proposalId, + proposalContentDigest: request.expectedContentDigest, + actorId: requireUuidV4(actorId), + decision: request.decision, + ...(request.reason === undefined ? {} : { reason: request.reason }), + idempotencyKey: request.idempotencyKey, + decidedAt: request.decidedAt, + recordedAt: now(this.clock), + }); + return await this.repository.appendDecision(event); + } +} diff --git a/apps/ai-service/src/proposal-audit-http.integration.test.ts b/apps/ai-service/src/proposal-audit-http.integration.test.ts new file mode 100644 index 00000000..5b213cdb --- /dev/null +++ b/apps/ai-service/src/proposal-audit-http.integration.test.ts @@ -0,0 +1,301 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { resolve } from 'node:path'; +import { NestFactory } from '@nestjs/core'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AiProductionModule } from './main'; + +const TEST_DATABASE_URL = process.env.AI_TEST_DATABASE_URL; +const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; +const describeWithPostgres = TEST_DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; + +/** Bounded JSON response returned by the local integration-test server. */ +interface JsonHttpResponse { + statusCode: number; + body: unknown; +} + +/** Requires an explicitly disposable PostgreSQL database whose name contains test. */ +function requireTestDatabaseUrl(): string { + if (!TEST_DATABASE_URL) { + throw new Error('AI_TEST_DATABASE_URL is required for integration tests'); + } + let parsed: URL; + try { + parsed = new URL(TEST_DATABASE_URL); + } catch { + throw new Error('AI_TEST_DATABASE_URL is invalid'); + } + const databaseName = parsed.pathname.slice(1); + if ( + (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') || + !/test/iu.test(databaseName) + ) { + throw new Error( + 'AI integration tests require a disposable PostgreSQL test database', + ); + } + return TEST_DATABASE_URL; +} + +/** Applies the append-only proposal-audit schema to the disposable test database. */ +async function applyMigration(pool: Pool): Promise { + const sql = await readFile( + resolve(__dirname, '../migrations/0001_proposal_audit.sql'), + 'utf8', + ); + await pool.query(sql); +} + +/** Sends one bounded JSON request to the local production module. */ +function requestJson( + address: AddressInfo, + method: 'GET' | 'POST', + path: string, + workspaceId: string, + body?: unknown, + actorId?: string, +): Promise { + const payload = body === undefined ? undefined : JSON.stringify(body); + return new Promise((resolveResponse, reject) => { + const request = httpRequest( + { + hostname: '127.0.0.1', + port: address.port, + path, + method, + headers: { + accept: 'application/json', + 'x-workspace-id': workspaceId, + ...(actorId === undefined ? {} : { 'x-actor-id': actorId }), + ...(payload === undefined + ? {} + : { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }), + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + try { + resolveResponse({ + statusCode: response.statusCode ?? 0, + body: text ? JSON.parse(text) : null, + }); + } catch { + reject(new Error('HTTP response was not valid JSON')); + } + }); + }, + ); + request.on('error', reject); + request.end(payload); + }); +} + +/** Creates one valid inert proposal request for the supplied task identifier. */ +function proposalRequest(taskId: string): { + objective: string; + context: Array<{ + id: string; + kind: 'task'; + title: string; + status: 'active'; + }>; +} { + return { + objective: 'Ship a durable auditable proposal', + context: [ + { + id: taskId, + kind: 'task', + title: 'Review the production proposal API', + status: 'active', + }, + ], + }; +} + +describeWithPostgres('AI production proposal audit HTTP API', () => { + beforeAll(async () => { + const testDatabaseUrl = requireTestDatabaseUrl(); + process.env.AI_DATABASE_URL = testDatabaseUrl; + administrativePool = new Pool({ + connectionString: testDatabaseUrl, + application_name: 'life-os-ai-http-integration-admin', + max: 4, + }); + await administrativePool.query('DROP SCHEMA IF EXISTS ai CASCADE'); + await applyMigration(administrativePool); + }); + + afterAll(async () => { + try { + await administrativePool.query('DROP SCHEMA IF EXISTS ai CASCADE'); + await administrativePool.end(); + } finally { + if (ORIGINAL_APPLICATION_DATABASE_URL === undefined) { + delete process.env.AI_DATABASE_URL; + } else { + process.env.AI_DATABASE_URL = ORIGINAL_APPLICATION_DATABASE_URL; + } + } + }); + + it('persists proposals across restarts and appends replay-safe decisions', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const actorId = randomUUID(); + const taskId = randomUUID(); + const firstApp = await NestFactory.create(AiProductionModule, { + logger: false, + }); + await firstApp.listen(0, '127.0.0.1'); + let proposalId: string | undefined; + try { + const address = firstApp.getHttpServer().address() as AddressInfo; + const created = await requestJson( + address, + 'POST', + '/v1/proposals', + workspaceId, + proposalRequest(taskId), + ); + expect(created.statusCode).toBe(201); + expect(created.body).toMatchObject({ + workspaceId, + requiresConfirmation: true, + }); + proposalId = (created.body as { proposalId: string }).proposalId; + + const hiddenFromOtherTenant = await requestJson( + address, + 'GET', + `/v1/proposals/${proposalId}`, + otherWorkspaceId, + ); + expect(hiddenFromOtherTenant).toMatchObject({ + statusCode: 404, + body: { code: 'proposal_not_found' }, + }); + + const unsupportedMutation = await requestJson( + address, + 'POST', + '/v1/proposals/apply', + workspaceId, + { proposalId }, + actorId, + ); + expect(unsupportedMutation.statusCode).toBe(404); + } finally { + await firstApp.close(); + } + if (!proposalId) { + throw new Error('Expected generated proposal identifier'); + } + + const restartedApp = await NestFactory.create(AiProductionModule, { + logger: false, + }); + await restartedApp.listen(0, '127.0.0.1'); + try { + const address = restartedApp.getHttpServer().address() as AddressInfo; + const listed = await requestJson( + address, + 'GET', + '/v1/proposals', + workspaceId, + ); + expect(listed.statusCode).toBe(200); + expect(listed.body).toHaveLength(1); + const audit = ( + listed.body as Array<{ + contentDigest: string; + proposal: { proposalId: string }; + }> + )[0]; + if (!audit) { + throw new Error('Expected persisted proposal audit evidence'); + } + expect(audit.proposal.proposalId).toBe(proposalId); + + const decisionBody = { + expectedContentDigest: audit.contentDigest, + idempotencyKey: randomUUID(), + decision: 'accepted', + reason: 'Reviewed and accepted without executing any operation.', + decidedAt: '2026-08-04T00:00:02.000Z', + } as const; + const accepted = await requestJson( + address, + 'POST', + `/v1/proposals/${proposalId}/decisions`, + workspaceId, + decisionBody, + actorId, + ); + const replayed = await requestJson( + address, + 'POST', + `/v1/proposals/${proposalId}/decisions`, + workspaceId, + decisionBody, + actorId, + ); + expect(accepted.statusCode).toBe(201); + expect(replayed).toEqual(accepted); + + const history = await requestJson( + address, + 'GET', + `/v1/proposals/${proposalId}/decisions`, + workspaceId, + ); + expect(history.statusCode).toBe(200); + expect(history.body).toHaveLength(1); + + const stale = await requestJson( + address, + 'POST', + `/v1/proposals/${proposalId}/decisions`, + workspaceId, + { + ...decisionBody, + idempotencyKey: randomUUID(), + expectedContentDigest: '0'.repeat(64), + }, + actorId, + ); + expect(stale).toMatchObject({ + statusCode: 409, + body: { code: 'stale_proposal' }, + }); + + const conflict = await requestJson( + address, + 'POST', + `/v1/proposals/${proposalId}/decisions`, + workspaceId, + { ...decisionBody, decision: 'rejected' }, + actorId, + ); + expect(conflict).toMatchObject({ + statusCode: 409, + body: { code: 'idempotency_conflict' }, + }); + } finally { + await restartedApp.close(); + } + }); +}); diff --git a/docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md b/docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md new file mode 100644 index 00000000..256c1077 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md @@ -0,0 +1,87 @@ +# Durable AI proposal audit API slice + +## Outcome + +The production AI service records every inert proposal in the append-only PostgreSQL audit ledger before returning it, exposes tenant-scoped proposal and decision history, and accepts explicit replay-safe accept/reject decisions without receiving any capability to execute proposed operations or mutate user-owned data. + +## Boundary + +This slice composes only: + +- the deterministic read-only proposal model; +- the existing immutable proposal and decision domain; +- the parameterized PostgreSQL proposal-audit repository; and +- a versioned NestJS HTTP boundary. + +The production module has no planning, calendar, habit, identity, notification, event-bus, command-bus, plugin-delivery, or generic mutation dependency. `AiAppModule` remains dependency-free for the no-silent-mutation contract; `AiProductionModule` is the executable runtime and owns one PostgreSQL pool. + +## Runtime configuration + +`AI_DATABASE_URL` is required and must use `postgres:` or `postgresql:`. Optional integer controls are bounded before node-postgres receives them: + +| Variable | Default | Minimum | Maximum | +| -------------------------------- | ------: | ------: | ------: | +| `AI_DATABASE_POOL_MAX` | 10 | 1 | 32 | +| `AI_DATABASE_CONNECT_TIMEOUT_MS` | 5000 | 100 | 30000 | +| `AI_DATABASE_IDLE_TIMEOUT_MS` | 30000 | 1000 | 300000 | + +The pool uses application name `life-os-ai-service` and closes exactly once through NestJS shutdown hooks. + +## HTTP contract + +| Method | Route | Result | +| ------ | ------------------------------------- | ----------------------------------------------------------------------------------- | +| `POST` | `/v1/proposals` | Generate an inert proposal and persist verified audit evidence before returning it. | +| `GET` | `/v1/proposals` | List deterministic proposal history for the trusted workspace. | +| `GET` | `/v1/proposals/:proposalId` | Return one immutable proposal revision for the trusted workspace. | +| `GET` | `/v1/proposals/:proposalId/decisions` | Return append-only decision history for one tenant-scoped proposal. | +| `POST` | `/v1/proposals/:proposalId/decisions` | Append an explicit accept/reject event bound to the exact proposal digest. | + +Workspace scope is accepted only from `x-workspace-id`. Decision append also requires `x-actor-id`. The decision body is closed and accepts only: + +- `expectedContentDigest` as a lowercase-normalized SHA-256 digest; +- `idempotencyKey` as UUIDv4; +- `decision` as `accepted` or `rejected`; +- optional bounded nonblank `reason`; and +- `decidedAt` as an RFC 3339 timestamp. + +Workspace and actor headers are an internal trust contract. An authenticated gateway must derive and authorize them before the AI service is exposed beyond the private service network. + +## Failure contract + +The HTTP boundary emits fixed credential-free problem details: + +- `400 invalid_request` for malformed headers, identifiers, bodies, or model/audit evidence; +- `404 proposal_not_found` for tenant-scoped absence; +- `409 stale_proposal` when the expected digest is not the immutable persisted revision; +- `409 idempotency_conflict` when a key is reused with another semantic decision; +- `503 audit_unavailable` for bounded persistence and unknown audit failures; and +- `503 proposal_unavailable` for non-persistence proposal generation failures. + +Database messages, SQL, URLs, credentials, and submitted text are never copied into problem responses. + +## Verification + +Unit and PostgreSQL-backed HTTP integration evidence covers: + +- bounded pool configuration and exactly-once shutdown; +- persistence before proposal return; +- restart durability; +- tenant isolation; +- deterministic list/get history; +- explicit decision append; +- exact replay returning the original event; +- stale-digest and conflicting-replay rejection; +- ownership-injection rejection; +- invalid actor, clock, and identifier handling; and +- absence of an apply or execute route. + +## Deferred + +The following remain separately reviewed slices: + +- authenticated gateway derivation for workspace and actor context; +- external model-provider transport with bounded timeouts and closed output schemas; +- prompt/context redaction, policy evaluation, fairness and quality evaluation, and cost controls; +- separately authorized execution of accepted operations through domain-owned command boundaries; and +- data-rights retention and erasure orchestration for the append-only audit ledger. diff --git a/package.json b/package.json index 1b6309b3..9877ae60 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md CONTRIBUTING.md SECURITY.md docs/legal/privacy.md docs/legal/terms.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs packages/commercial-readiness/src/legal-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts apps/planning-service/src/search.ts apps/planning-service/src/search.test.ts apps/planning-service/src/postgres-planning-search.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md", + "format:check": "prettier --single-quote --check README.md CONTRIBUTING.md SECURITY.md docs/legal/privacy.md docs/legal/terms.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs packages/commercial-readiness/src/legal-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts apps/planning-service/src/search.ts apps/planning-service/src/search.test.ts apps/planning-service/src/postgres-planning-search.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts apps/ai-service/src/proposal-audit-application.ts apps/ai-service/src/proposal-audit-application.test.ts apps/ai-service/src/ai-runtime.ts apps/ai-service/src/ai-runtime.test.ts apps/ai-service/src/proposal-audit-http.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": {