Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ab0dadb
feat(ai): add proposal audit application service
seonghobae Aug 4, 2026
410eb57
feat(ai): add PostgreSQL production runtime
seonghobae Aug 4, 2026
c494e1b
feat(ai): expose durable proposal audit API
seonghobae Aug 4, 2026
57c22ed
test(ai): cover proposal audit application
seonghobae Aug 4, 2026
d66c376
test(ai): cover production runtime configuration
seonghobae Aug 4, 2026
f8da7a8
test(ai): verify durable proposal audit HTTP API
seonghobae Aug 4, 2026
6cfba07
docs(ai): document audit runtime configuration
seonghobae Aug 4, 2026
ed6a718
docs(ai): describe durable audit API operations
seonghobae Aug 4, 2026
e761fdc
docs(ai): record durable proposal audit API
seonghobae Aug 4, 2026
0c11f0d
docs(ai): add durable audit API implementation plan
seonghobae Aug 4, 2026
79c9b2c
chore(ai): format-check audit API sources
seonghobae Aug 4, 2026
4feec31
test(ai): make restart identifier control-flow explicit
seonghobae Aug 4, 2026
7e2f55f
chore(ci): add temporary AI audit formatter
seonghobae Aug 4, 2026
4aa0d97
chore(ci): expose temporary formatter on pull requests
seonghobae Aug 4, 2026
1d5dbce
chore(ci): commit exact formatter output
seonghobae Aug 4, 2026
8c5e034
style(ai): format proposal audit API
github-actions[bot] Aug 4, 2026
336369d
docs(ai): complete runtime API documentation
seonghobae Aug 4, 2026
3995387
test(ai): remove hardcoded database URLs
seonghobae Aug 4, 2026
3f6e761
fix(ai): preserve validated decision literal type
seonghobae Aug 4, 2026
87f50d1
fix(ai): harden database pool lifecycle
seonghobae Aug 4, 2026
2588b42
test(ai): cover pool errors and retryable shutdown
seonghobae Aug 4, 2026
2161a7d
fix(ai): align audit error mapping and provider contracts
seonghobae Aug 4, 2026
b677fd6
ci(ai): isolate proposal audit integration database
seonghobae Aug 4, 2026
4939901
test(ai): require disposable audit database
seonghobae Aug 4, 2026
6564670
refactor(ai): accept prevalidated decision requests
seonghobae Aug 4, 2026
c9ff543
docs(ai): expose disposable integration database setting
seonghobae Aug 4, 2026
af51d9d
docs(ai): document audit runtime and test safety
seonghobae Aug 4, 2026
85b3f56
style(ai): format proposal audit controller
seonghobae Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
34 changes: 33 additions & 1 deletion apps/ai-service/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
186 changes: 186 additions & 0 deletions apps/ai-service/src/ai-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<Row>(
text: string,
values: readonly unknown[] = [],
): Promise<ProposalAuditSqlQueryResult<Row>> {
this.queries.push({ text, values });
return { rows: [] };
}

/** Records pool shutdown calls and can inject bounded transient failure. */
async end(): Promise<void> {
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);
});
});
Loading
Loading