feat(ai-core): Introduce precheck context factory methods and dispatch service logic for handling precheck requests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-04 00:55:25 -07:00
parent 09d6b4c7b0
commit 8c41bdf61d
3 changed files with 235 additions and 76 deletions

View file

@ -1,7 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type {
AdapterContext,
AdapterLogger,
PrecheckContext,
SurfaceSession,
} from '@cocottetech/surface-adapter-contracts';
@ -9,47 +11,71 @@ import { AgentActionsClient } from './agent-actions.client.js';
import { BlocklistAccessorFactory } from './blocklist.accessor.js';
import { PlatformApiClient } from './platform-api.client.js';
/** Identity + live session a dispatch supplies to build its {@link AdapterContext}. */
export interface AdapterInvocation {
/** Identity a dispatch supplies to build a session-free {@link PrecheckContext}. */
export interface PrecheckInvocation {
userId: string;
orgId?: string;
/** The container-managed authenticated surface session (opaque to the contract). */
session: SurfaceSession;
}
/**
* Builds a fresh {@link AdapterContext} per dispatched action, wiring the
* Builds the per-invocation contexts every dispatched action receives, wiring the
* tenant-scoped blocklist accessor, the shared platform.api client, the audit
* writer, and a structured logger. This is the single construction point for the
* context every parallel agent's action receives agents never build it.
* context every parallel agent's action sees agents never build it.
*
* Two contexts mirror the split contract:
* - {@link buildPrecheckContext} session-free, for `precheck` (and for the
* dispatcher's declined-row write). No session is acquired to build it.
* - {@link buildExecuteContext} the precheck context plus the live session and
* the dispatcher's `autoExecuted` decision, for `execute`.
*
* The logger is surface-tagged from the configured `SURFACE_KIND` (not from a
* session), so precheck can be logged before any session exists mirroring how
* `AdapterRegistryService` / `ContainerSurfaceSessionProvider` read the surface.
*/
@Injectable()
export class AdapterContextFactory {
private readonly surface: string;
constructor(
config: ConfigService,
private readonly platformApi: PlatformApiClient,
private readonly blocklistFactory: BlocklistAccessorFactory,
private readonly agentActions: AgentActionsClient,
) {}
) {
this.surface = config.get<string>('SURFACE_KIND', 'tryst');
}
build(invocation: AdapterInvocation): AdapterContext {
const { userId, orgId, session } = invocation;
const logger = this.adapterLogger(userId, session.surface);
const context: AdapterContext = {
/** Session-free context for `precheck` and the dispatcher's declined-row write. */
buildPrecheckContext(invocation: PrecheckInvocation): PrecheckContext {
const { userId, orgId } = invocation;
const context: PrecheckContext = {
userId,
...(orgId !== undefined ? { orgId } : {}),
session,
platformApi: this.platformApi,
blocklist: this.blocklistFactory.forTenant(userId, orgId),
agentActions: this.agentActions,
logger,
logger: this.adapterLogger(userId),
};
return context;
}
/**
* Full execute context: the already-built {@link PrecheckContext} plus the live
* session and the dispatcher's `autoExecuted` decision. Reuses the precheck
* context's collaborators (blocklist accessor, logger) does NOT rebuild them.
*/
buildExecuteContext(
precheck: PrecheckContext,
session: SurfaceSession,
autoExecuted: boolean,
): AdapterContext {
return { ...precheck, session, autoExecuted };
}
/** A contract {@link AdapterLogger} backed by NestJS's Logger, surface-tagged. */
private adapterLogger(userId: string, surface: string): AdapterLogger {
const nest = new Logger(`adapter:${surface}`);
private adapterLogger(userId: string): AdapterLogger {
const nest = new Logger(`adapter:${this.surface}`);
const tag = (msg: string): string => `[user=${userId}] ${msg}`;
return {
log: (msg) => nest.log(tag(msg)),

View file

@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ActionDescriptor,
type AdapterContext,
type AgentActionWrite,
type PrecheckContext,
type PrecheckResult,
type SurfaceAdapterAction,
type SurfaceSession,
@ -53,18 +55,30 @@ function registryWith(descriptor: ActionDescriptor): AdapterRegistryService {
} as unknown as AdapterRegistryService;
}
/** A context factory stand-in that returns a minimal AdapterContext. */
function contextFactoryStub(): AdapterContextFactory {
/**
* A context factory stand-in mirroring the split contract. `buildPrecheckContext`
* returns a session-free {@link PrecheckContext}; `buildExecuteContext` adds the
* session and crucially sets `autoExecuted` FROM ITS ARGUMENT, so a test can
* verify the dispatcher passed its decision through (not a hardcode). Both contexts
* share ONE `record` spy via `agentActions`, so a test sees every audit write
* whether the dispatcher's declined row or the action's `execute` row.
*/
function contextFactoryStub(record: ReturnType<typeof vi.fn>): AdapterContextFactory {
const agentActions = { record } as unknown as PrecheckContext['agentActions'];
const base = (): PrecheckContext => ({
userId: USER_ID,
platformApi: {} as PrecheckContext['platformApi'],
blocklist: {} as PrecheckContext['blocklist'],
agentActions,
logger: { log: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
});
return {
build: (): AdapterContext =>
({
userId: USER_ID,
session,
platformApi: {} as AdapterContext['platformApi'],
blocklist: {} as AdapterContext['blocklist'],
agentActions: {} as AdapterContext['agentActions'],
logger: { log: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
}) satisfies AdapterContext,
buildPrecheckContext: (): PrecheckContext => base(),
buildExecuteContext: (
precheck: PrecheckContext,
sess: SurfaceSession,
autoExecuted: boolean,
): AdapterContext => ({ ...precheck, session: sess, autoExecuted }),
} as unknown as AdapterContextFactory;
}
@ -88,7 +102,22 @@ function buildAction(opts: {
precheckResult?: PrecheckResult;
}): BuiltAction {
const precheck = vi.fn(async () => opts.precheckResult ?? precheckResult([]));
const execute = vi.fn(async (): Promise<MockOutput> => ({ externalId: 'ext_123' }));
// The action writes its OWN audit row on execute, stamping `auto_executed` from
// the context the dispatcher built — so the persisted flag == the dispatcher's
// decision (GAP 1). The test inspects this via the shared `record` spy.
const execute = vi.fn(async (_input: MockInput, ctx: AdapterContext): Promise<MockOutput> => {
await ctx.agentActions.record({
userId: ctx.userId,
specialistId: 'bookings-tryst',
actionType: opts.verb,
targetKind: 'prospect',
stakes: 'medium',
confidence: 1,
autoExecuted: ctx.autoExecuted,
outcome: { external_id: 'ext_123' },
});
return { externalId: 'ext_123' };
});
const action: SurfaceAdapterAction<MockInput, MockOutput> = {
action: opts.verb,
schema: mockSchema,
@ -99,6 +128,8 @@ function buildAction(opts: {
surface: SURFACE,
verb: opts.verb,
action: action as SurfaceAdapterAction<unknown, unknown>,
auditTargetKind: 'prospect',
auditStakes: 'medium',
...(opts.autoExecutable !== undefined ? { autoExecutable: opts.autoExecutable } : {}),
};
return { descriptor, precheck, execute };
@ -107,14 +138,19 @@ function buildAction(opts: {
function makeService(action: BuiltAction): {
service: DispatchService;
acquire: ReturnType<typeof vi.fn>;
record: ReturnType<typeof vi.fn>;
} {
const { provider, acquire } = sessionProviderStub();
const record = vi.fn(async (_w: AgentActionWrite) => ({
id: 'aa_1',
createdAt: '2026-06-03T00:00:00.000Z',
}));
const service = new DispatchService(
registryWith(action.descriptor),
contextFactoryStub(),
contextFactoryStub(record),
provider,
);
return { service, acquire };
return { service, acquire, record };
}
const validInput = {
@ -139,7 +175,7 @@ describe('DispatchService', () => {
});
it('routes a valid verb to the registered action and executes it', async () => {
const { service } = makeService(action);
const { service, record } = makeService(action);
const res = await service.dispatch(req({ verb: 'reply' }));
@ -149,6 +185,10 @@ describe('DispatchService', () => {
expect(res.result).toEqual({ externalId: 'ext_123' });
expect(action.precheck).toHaveBeenCalledOnce();
expect(action.execute).toHaveBeenCalledOnce();
// GAP 1 (c): the persisted audit flag equals the dispatcher's decision — an
// auto action with no approval ran autonomously ⇒ auto_executed === true.
expect(record).toHaveBeenCalledOnce();
expect(record.mock.calls[0]?.[0]?.autoExecuted).toBe(true);
});
it('throws NotFound when no action is registered for the verb', async () => {
@ -172,7 +212,7 @@ describe('DispatchService', () => {
expect(action.execute).not.toHaveBeenCalled();
});
it('short-circuits execution when precheck rejects, surfacing the gates', async () => {
it('short-circuits execution when precheck rejects, surfacing the gates — WITHOUT acquiring a session, writing one declined row (gate labels only)', async () => {
action = buildAction({
verb: 'reply',
autoExecutable: true,
@ -180,7 +220,7 @@ describe('DispatchService', () => {
{ gate: 'K1', reason: 'prospect is blocklisted' },
]),
});
const { service } = makeService(action);
const { service, acquire, record } = makeService(action);
const res = await service.dispatch(req({ verb: 'reply' }));
@ -188,25 +228,64 @@ describe('DispatchService', () => {
expect(res.rejections).toEqual([{ gate: 'K1', reason: 'prospect is blocklisted' }]);
expect(action.precheck).toHaveBeenCalledOnce();
expect(action.execute).not.toHaveBeenCalled();
// GAP 2 (a): precheck runs and rejects with NO session acquisition.
expect(acquire).not.toHaveBeenCalled();
// GAP 3 (b): the dispatcher writes the SINGLE declined row — gate LABELS only,
// never the matched secret/draft. actionType is the verb; targetKind/stakes
// mirror the descriptor.
expect(record).toHaveBeenCalledOnce();
const declined = record.mock.calls[0]?.[0];
expect(declined?.actionType).toBe('reply');
expect(declined?.targetKind).toBe('prospect');
expect(declined?.stakes).toBe('medium');
expect(declined?.autoExecuted).toBe(false);
expect(declined?.outcome).toEqual({ declined: true, gates: ['K1'] });
// No leaked secret/draft fields on the declined row.
expect(declined?.outcome).not.toHaveProperty('draft');
expect(declined?.outcome).not.toHaveProperty('reason');
});
it('does NOT execute an approval-required action without the approval flag', async () => {
action = buildAction({ verb: 'reply', autoExecutable: false });
const { service, acquire } = makeService(action);
const { service, acquire, record } = makeService(action);
const res = await service.dispatch(req({ verb: 'reply' })); // approved omitted
expect(res.status).toBe('approval_required');
expect(res.autoExecuted).toBe(false);
// Approval gate fires before any session/precheck/execute cost.
// Precheck now runs FIRST (cleared here), but the approval gate still costs no
// session, no execute, and writes no audit row (only a DECLINE writes a row).
expect(action.precheck).toHaveBeenCalledOnce();
expect(acquire).not.toHaveBeenCalled();
expect(action.precheck).not.toHaveBeenCalled();
expect(action.execute).not.toHaveBeenCalled();
expect(record).not.toHaveBeenCalled();
});
it('executes an approval-required action WHEN approved, marking it not auto-executed', async () => {
it('returns rejected (not approval_required) when an approval-required action ALSO fails a K-gate — without a session', async () => {
action = buildAction({
verb: 'reply',
autoExecutable: false,
precheckResult: precheckResult([{ gate: 'K1', reason: 'prospect is blocklisted' }]),
});
const { service, acquire, record } = makeService(action);
const res = await service.dispatch(req({ verb: 'reply' })); // approved omitted
// Precheck runs before the approval gate, so the K-gate decline wins.
expect(res.status).toBe('rejected');
expect(res.rejections).toEqual([{ gate: 'K1', reason: 'prospect is blocklisted' }]);
expect(acquire).not.toHaveBeenCalled();
expect(action.execute).not.toHaveBeenCalled();
// One declined row, gate labels only.
expect(record).toHaveBeenCalledOnce();
expect(record.mock.calls[0]?.[0]?.outcome).toEqual({ declined: true, gates: ['K1'] });
});
it('executes an approval-required action WHEN approved, marking it not auto-executed in BOTH the response and the persisted row', async () => {
action = buildAction({ verb: 'reply', autoExecutable: false });
const { service } = makeService(action);
const { service, record } = makeService(action);
const res = await service.dispatch(req({ verb: 'reply', approved: true }));
@ -214,6 +293,10 @@ describe('DispatchService', () => {
expect(res.autoExecuted).toBe(false);
expect(action.precheck).toHaveBeenCalledOnce();
expect(action.execute).toHaveBeenCalledOnce();
// GAP 1 (c): an approval-required action that ran did so BECAUSE it was
// approved ⇒ the persisted auto_executed === false, matching the response.
expect(record).toHaveBeenCalledOnce();
expect(record.mock.calls[0]?.[0]?.autoExecuted).toBe(false);
});
it('treats a descriptor with no autoExecutable flag as approval-required', async () => {

View file

@ -4,7 +4,11 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import type { ActionDescriptor } from '@cocottetech/surface-adapter-contracts';
import type {
ActionDescriptor,
GateRejection,
PrecheckContext,
} from '@cocottetech/surface-adapter-contracts';
import { AdapterContextFactory } from '../context/adapter-context.factory.js';
import { AdapterRegistryService } from '../adapter/adapter-registry.service.js';
@ -12,21 +16,34 @@ import { AdapterRegistryService } from '../adapter/adapter-registry.service.js';
import type { DispatchRequestDto, DispatchResponseDto } from './dispatch.dto.js';
import { SurfaceSessionProvider } from './surface-session.provider.js';
/** Specialist this dispatcher serves; stamped on the declined audit row it owns. */
const SPECIALIST_ID = 'bookings-tryst';
/**
* The ONE dispatcher. Every turn ai-copilot routes into this specialist passes
* through {@link DispatchService.dispatch}; no adapter action invents its own
* invocation path. The canonical pipeline, in order:
*
* 1. resolve(verb) against the data-driven {@link AdapterRegistryService}.
* 2. parse `input` through the action's own zod `schema` (boundary validation).
* 3. acquire a live {@link SurfaceSession} for the tenant and build a fresh
* {@link AdapterContext} via {@link AdapterContextFactory}.
* 4. run `precheck` if NOT ok, surface the K-gate rejections and STOP. No
* session-backed work, no `agent_actions` row.
* 5. enforce the approval gate: if `descriptor.autoExecutable === false` and the
* request did not carry `approved === true`, STOP with `approval_required`.
* 6. `execute`. The action writes its own `agent_actions` audit row; the
* dispatcher NEVER double-writes the audit spine.
* 1. resolve(verb) against the data-driven {@link AdapterRegistryService};
* 404 (NotFound) if none.
* 2. parse `input` through the action's own zod `schema` (boundary validation);
* 400 (BadRequest) if it fails.
* 3. build a SESSION-FREE {@link PrecheckContext} (NO session acquisition) and
* run `precheck`. If NOT ok: the dispatcher writes the SINGLE declined
* `agent_actions` row (gate labels only never the matched secret/draft,
* per K3k) and returns `rejected`. No session was ever spun up.
* 4. approval gate: if `descriptor.autoExecutable === false` and the request did
* not carry `approved === true`, STOP with `approval_required` still no
* session. (A K-gate failure is caught in step 3 first, so an
* approval-required action that ALSO fails a gate correctly returns
* `rejected`, not `approval_required`.)
* 5. acquire a live {@link SurfaceSession}, build the full {@link AdapterContext}
* (carrying the dispatcher's `autoExecuted` decision), and `execute`.
*
* Audit invariant: the ACTION writes its own `agent_actions` row on `execute`
* (with `auto_executed: ctx.autoExecuted`); the DISPATCHER writes ONLY the
* declined row on a precheck rejection. Because `execute` never runs on a decline,
* there is no double-write this is the one case the dispatcher owns the audit.
*/
@Injectable()
export class DispatchService {
@ -48,8 +65,31 @@ export class DispatchService {
const input = this.validateInput(descriptor, req.input);
// Approval gate is evaluated BEFORE we spin up a session: an approval-required
// action with no approval must cost nothing (no container, no platform.api).
// Build the SESSION-FREE precheck context — NO session is acquired here. A
// K-gate rejection (and an approval-required hold) must cost no container.
const precheckCtx = this.contextFactory.buildPrecheckContext({
userId: req.userId,
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
});
const precheck = await descriptor.action.precheck(input, precheckCtx);
if (!precheck.ok) {
this.logger.log(
`dispatch rejected by precheck: verb=${req.verb} user=${req.userId} ` +
`gates=[${precheck.rejections.map((r) => r.gate).join(', ')}]`,
);
// The ACTION's execute never runs on a decline, so it cannot write the audit
// row — the dispatcher writes the SINGLE declined row here. Gate labels only:
// never echo the matched secret/draft (K3k).
await this.recordDeclined(descriptor, req, precheckCtx, precheck.rejections);
return this.response('rejected', req, {
autoExecuted: false,
rejections: precheck.rejections,
});
}
// Approval gate — still session-free. An approval-required action with no
// approval costs nothing (no container, no execute).
const autoExecutable = descriptor.autoExecutable === true;
const approved = req.approved === true;
if (!autoExecutable && !approved) {
@ -59,39 +99,49 @@ export class DispatchService {
return this.response('approval_required', req, { autoExecuted: false });
}
// `autoExecuted` is the dispatcher's single decision: the action ran without a
// per-call approval. An approval-required action that ran did so BECAUSE it was
// approved, so it is NOT auto-executed. The SAME expression feeds both the
// execute context (→ the action's audit row) and the response, guaranteeing the
// persisted `auto_executed` equals the dispatcher's decision (GAP 1).
const autoExecuted = autoExecutable && !approved;
const session = await this.sessionProvider.acquire({
userId: req.userId,
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
});
const ctx = this.contextFactory.build({
userId: req.userId,
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
session,
});
const precheck = await descriptor.action.precheck(input, ctx);
if (!precheck.ok) {
this.logger.log(
`dispatch rejected by precheck: verb=${req.verb} user=${req.userId} ` +
`gates=[${precheck.rejections.map((r) => r.gate).join(', ')}]`,
);
return this.response('rejected', req, {
autoExecuted: false,
rejections: precheck.rejections,
});
}
const ctx = this.contextFactory.buildExecuteContext(precheckCtx, session, autoExecuted);
const result = await descriptor.action.execute(input, ctx);
this.logger.log(
`dispatch executed: verb=${req.verb} user=${req.userId} ` +
`autoExecuted=${autoExecutable && !approved}`,
`dispatch executed: verb=${req.verb} user=${req.userId} autoExecuted=${autoExecuted}`,
);
// `autoExecuted` mirrors the audit semantics: the action ran without a per-call
// approval. An approval-required action that ran did so BECAUSE it was approved,
// so it is not auto-executed.
return this.response('executed', req, {
autoExecuted: autoExecutable && !approved,
result,
return this.response('executed', req, { autoExecuted, result });
}
/**
* Write the SINGLE declined `agent_actions` row on a precheck rejection. This is
* the one case the dispatcher owns the audit spine: `execute` never ran, so the
* action could not record it. The outcome carries gate LABELS only never the
* matched secret / draft body (K3k). `targetKind` / `stakes` come from the
* descriptor so the declined row mirrors the action's own execute row.
*/
private async recordDeclined(
descriptor: ActionDescriptor,
req: DispatchRequestDto,
ctx: PrecheckContext,
rejections: readonly GateRejection[],
): Promise<void> {
await ctx.agentActions.record({
userId: req.userId,
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
specialistId: SPECIALIST_ID,
actionType: req.verb,
targetKind: descriptor.auditTargetKind,
stakes: descriptor.auditStakes,
confidence: 1,
autoExecuted: false,
outcome: { declined: true, gates: rejections.map((r) => r.gate) },
});
}