From 8a76d8c1fee0fec47161ccb9d5ccddd45d7adfa2 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 23:06:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(surface-adapter):=20=E2=9C=A8=20Add=20stan?= =?UTF-8?q?dardized=20safety=20contracts=20for=20k-gate,=20action=20verbs,?= =?UTF-8?q?=20platform=20API=20interactions,=20audit,=20blocklist,=20regis?= =?UTF-8?q?try,=20context,=20and=20surface=20kinds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/action-verb.ts | 32 ++++ .../surface-adapter-contracts/src/action.ts | 71 +++++++ .../surface-adapter-contracts/src/audit.ts | 64 +++++++ .../src/blocklist.ts | 42 +++++ .../surface-adapter-contracts/src/context.ts | 53 ++++++ .../surface-adapter-contracts/src/index.ts | 75 ++++++++ .../src/platform-api.ts | 26 +++ .../surface-adapter-contracts/src/registry.ts | 128 +++++++++++++ .../src/safety/k-gate.spec.ts | 144 ++++++++++++++ .../src/safety/k-gate.ts | 178 ++++++++++++++++++ .../src/surface-kind.ts | 70 +++++++ 11 files changed, 883 insertions(+) create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/action-verb.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/action.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/audit.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/blocklist.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/context.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/index.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/platform-api.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/registry.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.spec.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.ts create mode 100644 @platform/codebase/@packages/surface-adapter-contracts/src/surface-kind.ts diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/action-verb.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/action-verb.ts new file mode 100644 index 0000000..9c92cf9 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/action-verb.ts @@ -0,0 +1,32 @@ +/** + * ActionVerb — the dispatchable operations a surface adapter can perform. + * + * This is the closed set the ai-copilot front door routes to and the registry + * indexes by. It is intentionally narrower than the Layer-6 design doc's example + * list: `login` is NOT a verb here — it is an internal adapter concern (session + * establishment, handled inside `execute`), never a separately-dispatched action. + */ +export type ActionVerb = + | 'bump' // refresh availability / re-list on a directory + | 'update-profile' // apply structured profile edits + | 'reply' // send a DM / message reply + | 'fetch-inbox' // poll inbound DMs + | 'fetch-metrics' // pull surface analytics (views, taps, ranking) + | 'tour-announce' // publish tour dates (city + date range) + | 'home-city'; // set / change the listed home city + +/** Runtime list of every {@link ActionVerb}. */ +export const ACTION_VERBS: readonly ActionVerb[] = [ + 'bump', + 'update-profile', + 'reply', + 'fetch-inbox', + 'fetch-metrics', + 'tour-announce', + 'home-city', +] as const; + +/** Type guard: narrows an arbitrary string to an {@link ActionVerb}. */ +export function isActionVerb(value: string): value is ActionVerb { + return (ACTION_VERBS as readonly string[]).includes(value); +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/action.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/action.ts new file mode 100644 index 0000000..2e01100 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/action.ts @@ -0,0 +1,71 @@ +/** + * SurfaceAdapterAction — the canonical contract every adapter action implements + * (Layer 6 of `_engineering-surface-adapter-container.md`). + * + * Deltas from the Layer-6 doc sketch, made deliberately and authoritative here: + * - `schema` is the INPUT schema only (a `z.ZodType`), not `{input, output}`. + * Output validation is the action's own concern; the dispatcher validates + * input at the boundary. + * - `rollback?(input, ctx)` takes the original INPUT (not the output) so a + * rollback can be reconstructed from the request alone. + * - `surface` is NOT on the action — within one specialist the surface is fixed. + * It lives on {@link ActionDescriptor} instead. + */ +import type { z } from 'zod'; + +import type { ActionVerb } from './action-verb.js'; +import type { AdapterContext } from './context.js'; + +/** One declined deterministic gate (brief K). `gate` is the rule label, e.g. `'K1'`, `'K3c-1'`. */ +export interface GateRejection { + /** Stable rule label from brief K (e.g. `'K1'`, `'K3c-1'`, `'K3f-2'`, `'K3f-3'`). */ + gate: string; + /** Human-readable, user-surfaceable reason the gate fired. */ + reason: string; +} + +/** + * Result of running an action's deterministic eligibility gates. + * `ok === true` iff `rejections` is empty. When not ok, the dispatcher declines + * the action WITHOUT calling `execute` (no container spin-up) and surfaces the + * rejections to chat per brief K3k. + */ +export interface PrecheckResult { + ok: boolean; + rejections: GateRejection[]; +} + +/** + * A self-contained adapter action over a single surface. + * + * @typeParam I - validated input shape (parsed from {@link SurfaceAdapterAction.schema}). + * @typeParam O - execution result shape. + */ +export interface SurfaceAdapterAction { + /** Which verb this action implements. The registry indexes by this. */ + readonly action: ActionVerb; + /** Zod schema validating the input `I` at the dispatch boundary. */ + readonly schema: z.ZodType; + /** + * Deterministic eligibility gates (blocklist, identity, location, jurisdiction + * per brief K). MUST be pure w.r.t. side effects beyond reads via `ctx`. If the + * result is not `ok`, `execute` is never called. + */ + precheck(input: I, ctx: AdapterContext): Promise; + /** + * Performs the action against the live surface session, then writes an + * `agent_actions` audit row via `ctx.agentActions`. Only invoked when + * `precheck` returned `ok`. + */ + execute(input: I, ctx: AdapterContext): Promise; + /** + * Optionally undoes a previously-executed action (e.g. delete a post, remove a + * bump) reconstructed from the original input. Absent for irreversible actions. + */ + rollback?(input: I, ctx: AdapterContext): Promise; +} + +/** Convenience helper to build an ok / not-ok {@link PrecheckResult}. */ +export function precheckResult(rejections: GateRejection[]): PrecheckResult { + return { ok: rejections.length === 0, rejections }; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/audit.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/audit.ts new file mode 100644 index 0000000..4f1f9e3 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/audit.ts @@ -0,0 +1,64 @@ +/** + * Audit-write contract — how an adapter action records a row on the + * `agent_actions` audit spine (DESIGN.md §10.6; INFRA.md §3). + * + * The columns here mirror `agent_actions` in + * `infrastructure/sql/migrations/0001_tenancy_and_content.sql`. Adapter actions + * NEVER write to the DB directly — they hand a write request to platform.api via + * this client, which owns the only connection to `platform.db` on black. The + * client is provided on {@link AdapterContext.agentActions}. + */ + +/** Stakes level. Mirrors the `action_stakes` ENUM in migration 0001. */ +export type ActionStakes = 'low' | 'medium' | 'high'; + +/** + * One `agent_actions` row, as an adapter action submits it. `id`, `created_at`, + * `approved_by`, and `approved_at` are assigned by platform.api / the approval + * flow — an action never sets them. + * + * `auto_executed` is the load-bearing audit flag: + * - `true` → the action ran without per-call human approval (policy-authorized + * autonomous execution). + * - `false` → the action was gated on an explicit Quinn approval before it ran. + * Either way the row is written; the flag records which path produced it. + */ +export interface AgentActionWrite { + /** Person tenant the action belongs to (`agent_actions.user_id`). */ + userId: string; + /** Optional Org overlay (`agent_actions.org_id`); null/undefined = Person-owned. */ + orgId?: string; + /** Specialist that decided the action, e.g. `'bookings-tryst'` (`specialist_id`). */ + specialistId: string; + /** What the action did, e.g. `'bump'`, `'update_profile'` (`action_type`). */ + actionType: string; + /** Kind of thing acted on, e.g. `'content_post'`, `'prospect'` (`target_kind`). */ + targetKind: string; + /** Row id of the target where one exists; null for external-only state. */ + targetId?: string; + /** Stakes classification (`stakes`). */ + stakes: ActionStakes; + /** Decision confidence in [0, 1] (`confidence`, NUMERIC(4,3)). */ + confidence: number; + /** Whether the action executed without per-call human approval (`auto_executed`). */ + autoExecuted: boolean; + /** Result payload: external IDs on success, error trace on failure (`outcome_json`). */ + outcome?: Readonly>; +} + +/** Identity of a persisted `agent_actions` row, returned by the write. */ +export interface AgentActionRef { + /** `agent_actions.id` (UUID). */ + id: string; + /** `agent_actions.created_at` (ISO 8601). */ + createdAt: string; +} + +/** + * Writes audit rows on the `agent_actions` spine through platform.api. + * Append-only by contract — there is no update or delete. + */ +export interface AgentActionsClient { + /** Persist one `agent_actions` row; resolves to its assigned id + timestamp. */ + record(write: AgentActionWrite): Promise; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/blocklist.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/blocklist.ts new file mode 100644 index 0000000..35df8ff --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/blocklist.ts @@ -0,0 +1,42 @@ +/** + * Blocklist accessor — the K-gate data source. + * + * Mirrors the blocklist entry shape in `K-safety-blocklist.brief.md §Inputs` + * (`GET /api/v1/safety/blocklist?user_id=...`). The accessor is provided on + * {@link AdapterContext.blocklist}; K-gate functions consume its data but perform + * NO I/O themselves — the action fetches the entries and passes them in. + */ +import type { SurfaceKind } from './surface-kind.js'; + +/** Categories of blocklist entry (brief K §Inputs `kind`). */ +export type BlocklistKind = 'prospect' | 'phrase' | 'topic' | 'surface_combo' | 'jurisdiction'; + +/** A single blocklist entry as returned by platform.api (brief K §Inputs). */ +export interface BlocklistEntry { + id: string; + userId: string; + orgId: string | null; + kind: BlocklistKind; + /** The actual blocked value — a prospect handle/id, a phrase, a topic, etc. */ + value: string; + /** `'global'` = everywhere, or the specific surfaces it applies to. */ + scope: 'global' | readonly SurfaceKind[]; + /** Quinn's note, surfaced when the block fires; null if none. */ + reason: string | null; + /** ISO 8601 expiry; null = permanent. */ + expiresAt: string | null; + createdAt: string; + /** Whether a human or an automated rule added it. */ + createdBy: 'user' | 'auto'; +} + +/** + * Reads the active blocklist for the current tenant. The accessor returns only + * non-expired entries scoped to the calling action's surface (plus `global` + * entries). Actions call this inside `precheck` and feed the result to the + * K-gate functions. + */ +export interface BlocklistAccessor { + /** All active entries for the tenant, optionally narrowed to a `kind`. */ + list(kind?: BlocklistKind): Promise; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/context.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/context.ts new file mode 100644 index 0000000..7f32485 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/context.ts @@ -0,0 +1,53 @@ +/** + * AdapterContext — the per-invocation execution environment handed to every + * adapter action's `precheck` / `execute` / `rollback`. + * + * Deliberately framework- and Playwright-free. The Layer-6 design doc listed + * `browserContext: BrowserContext`, `torCircuit`, and `captchaSolver` directly on + * the context; those are intentionally collapsed here into a single opaque + * {@link SurfaceSession} handle so that this shared contract — imported by every + * specialist and every action — never drags `playwright` into its dependency + * graph. The container/Tor/captcha machinery lives behind the session handle, + * owned by the adapter runtime, not exposed in the type. + */ +import type { AgentActionsClient } from './audit.js'; +import type { BlocklistAccessor } from './blocklist.js'; +import type { AdapterLogger, PlatformApiClient } from './platform-api.js'; +import type { SurfaceKind } from './surface-kind.js'; + +/** + * Opaque handle to the live, authenticated surface session (the + * container-managed browser context + Tor circuit + captcha solver from + * `_engineering-surface-adapter-container.md` Layers 1–5). The contract treats it + * as a black box; the surface-specific adapter runtime supplies and interprets + * it. Each concrete specialist augments this via declaration merging or casts to + * its own session type — the base shape carries only what every action can rely + * on without importing Playwright. + */ +export interface SurfaceSession { + /** Which surface this session is authenticated against. */ + readonly surface: SurfaceKind; + /** Whether the session currently holds live credentials (cookies still valid). */ + readonly authenticated: boolean; +} + +/** + * Per-invocation context. One {@link AdapterContext} is constructed per dispatched + * action and is scoped to a single (userId, [orgId], surface session). + */ +export interface AdapterContext { + /** Person tenant (`agent_actions.user_id`). Always present (person-first). */ + readonly userId: string; + /** Optional Org overlay (`agent_actions.org_id`). Absent = Person-owned. */ + readonly orgId?: string; + /** Live authenticated surface session handle (container-managed). */ + readonly session: SurfaceSession; + /** Client for reads/writes against platform.api on black. */ + readonly platformApi: PlatformApiClient; + /** Accessor for the tenant's safety blocklist (K-gate data source). */ + readonly blocklist: BlocklistAccessor; + /** Writer for the `agent_actions` audit spine. */ + readonly agentActions: AgentActionsClient; + /** Structured logger (never logs credentials or raw page content). */ + readonly logger: AdapterLogger; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/index.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/index.ts new file mode 100644 index 0000000..32ba5ed --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/index.ts @@ -0,0 +1,75 @@ +/** + * @cocottetech/surface-adapter-contracts + * + * The canonical, framework-free contract every surface specialist (bookings-*, + * content-*) and every adapter action conforms to. Defines: + * - the action contract (`SurfaceAdapterAction`, `PrecheckResult`, gates), + * - the per-invocation `AdapterContext` (userId/orgId, surface session, + * platform.api client, blocklist accessor, audit writer, logger), + * - the closed `ActionVerb` set + `SurfaceKind` mirror, + * - the data-driven action registry (`ActionDescriptor` + `ActionRegistry`), + * - the `agent_actions` audit-write contract, and + * - the deterministic K-gate safety functions. + * + * See `_engineering-surface-adapter-container.md` (Layer 6) and + * `K-safety-blocklist.brief.md` for the design authority behind these types. + */ + +// Surfaces + verbs +export { + type SurfaceKind, + SURFACE_KINDS, + isSurfaceKind, +} from './surface-kind.js'; +export { + type ActionVerb, + ACTION_VERBS, + isActionVerb, +} from './action-verb.js'; + +// Action contract +export { + type SurfaceAdapterAction, + type PrecheckResult, + type GateRejection, + precheckResult, +} from './action.js'; + +// Execution context + the abstractions it carries +export { + type AdapterContext, + type SurfaceSession, +} from './context.js'; +export { + type PlatformApiClient, + type AdapterLogger, +} from './platform-api.js'; +export { + type BlocklistAccessor, + type BlocklistEntry, + type BlocklistKind, +} from './blocklist.js'; +export { + type AgentActionsClient, + type AgentActionWrite, + type AgentActionRef, + type ActionStakes, +} from './audit.js'; + +// Data-driven registry +export { + type ActionDescriptor, + type ActionModule, + ActionRegistry, + DuplicateActionError, + isActionModule, +} from './registry.js'; + +// Deterministic safety gates (brief K) +export { + type BumpLocation, + gateK1ProspectBlocklist, + gateK3cGovtName, + gateK3fAccommodationName, + gateK3fBumpPrecision, +} from './safety/k-gate.js'; diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/platform-api.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/platform-api.ts new file mode 100644 index 0000000..498448a --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/platform-api.ts @@ -0,0 +1,26 @@ +/** + * Minimal platform.api client surface the adapter contract depends on. + * + * The concrete implementation lives in each ai-core specialist + * (`ai-core/src/context/platform-api.client.ts`, a NestJS provider wrapping + * axios). The contract package only declares the shape actions are allowed to + * use, so that an action file has no dependency on NestJS or axios. + */ +export interface PlatformApiClient { + /** GET `/` with optional query params; resolves the JSON body. */ + get(path: string, query?: Record): Promise; + /** POST `/` with a JSON body; resolves the JSON response body. */ + post(path: string, body: unknown): Promise; +} + +/** + * Structured logger the adapter context carries. Deliberately a tiny subset of + * NestJS's `LoggerService` so the contract stays framework-free. Implementations + * MUST never log credential values or raw page content (Layer 7 invariant). + */ +export interface AdapterLogger { + log(message: string): void; + warn(message: string): void; + error(message: string, trace?: string): void; + debug?(message: string): void; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/registry.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/registry.ts new file mode 100644 index 0000000..11ef8aa --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/registry.ts @@ -0,0 +1,128 @@ +/** + * Data-driven action registry. + * + * The constraint: a new action must self-register WITHOUT editing any shared + * import block (no central barrel, no NestJS `providers: [...]` list that every + * action touches). The solution has two halves: + * + * 1. Each action file `adapter//index.ts` exports a single + * {@link ActionDescriptor} as a named const `descriptor`. That is the entire + * contract a new action author must satisfy — no edits anywhere else. + * + * 2. The specialist's ai-core scans the sibling `adapter/` directory at + * bootstrap, dynamic-`import()`s each subdirectory's `index.ts`, reads its + * `descriptor`, and feeds it to {@link ActionRegistry.register}. Discovery is + * by filesystem presence, so adding a folder is adding an action. + * + * This module owns the descriptor type + the registry data structure. The + * filesystem scan (which needs `node:fs` and is therefore not appropriate for a + * platform-agnostic contract package) lives in ai-core's `AdapterRegistryModule`. + */ +import type { ActionVerb } from './action-verb.js'; +import type { SurfaceAdapterAction } from './action.js'; +import type { SurfaceKind } from './surface-kind.js'; + +/** + * The unit of self-registration. Each `adapter//index.ts` exports exactly + * one of these as `export const descriptor: ActionDescriptor = { ... }`. + * + * `action` is intentionally `SurfaceAdapterAction` so a + * heterogeneous set of actions can live in one registry; each action's own file + * keeps its precise `` types internally, and the dispatcher re-derives `I` + * by parsing through `action.schema`. + */ +export interface ActionDescriptor { + /** Surface this action targets (fixed per specialist; e.g. `'tryst'`). */ + readonly surface: SurfaceKind; + /** Verb this action implements; the registry key (with `surface`). */ + readonly verb: ActionVerb; + /** The action implementation. */ + readonly action: SurfaceAdapterAction; + /** + * Whether this action is allowed to run autonomously (sets `auto_executed=true` + * on its `agent_actions` row) absent per-call approval. Policy may still gate it; + * this is the action's own ceiling. Defaults to `false` (approval-required) when + * omitted. + */ + readonly autoExecutable?: boolean; +} + +/** Thrown when two descriptors claim the same (surface, verb) key. */ +export class DuplicateActionError extends Error { + constructor( + readonly surface: SurfaceKind, + readonly verb: ActionVerb, + ) { + super(`duplicate adapter action for ${surface}/${verb}`); + this.name = 'DuplicateActionError'; + } +} + +/** + * In-memory registry of {@link ActionDescriptor}s, keyed by `/`. + * Construct empty; `register` each discovered descriptor; `resolve` at dispatch. + * Booting with ZERO descriptors is valid (an empty registry is a no-op). + */ +export class ActionRegistry { + private readonly byKey = new Map(); + + private static key(surface: SurfaceKind, verb: ActionVerb): string { + return `${surface}/${verb}`; + } + + /** Register a descriptor. Throws {@link DuplicateActionError} on key collision. */ + register(descriptor: ActionDescriptor): void { + const key = ActionRegistry.key(descriptor.surface, descriptor.verb); + if (this.byKey.has(key)) { + throw new DuplicateActionError(descriptor.surface, descriptor.verb); + } + this.byKey.set(key, descriptor); + } + + /** Resolve a descriptor by (surface, verb); `undefined` if none registered. */ + resolve(surface: SurfaceKind, verb: ActionVerb): ActionDescriptor | undefined { + return this.byKey.get(ActionRegistry.key(surface, verb)); + } + + /** Every registered descriptor, in registration order. */ + all(): readonly ActionDescriptor[] { + return [...this.byKey.values()]; + } + + /** The (surface, verb) keys currently registered. Useful for health/introspection. */ + keys(): readonly string[] { + return [...this.byKey.keys()]; + } + + /** Number of registered descriptors. */ + get size(): number { + return this.byKey.size; + } +} + +/** + * Structural type a discovered action module must satisfy: a module-level + * `descriptor` export. The ai-core scanner validates each dynamic import against + * this before registering. + */ +export interface ActionModule { + readonly descriptor: ActionDescriptor; +} + +/** Type guard for {@link ActionModule} — validates a dynamic-import result. */ +export function isActionModule(mod: unknown): mod is ActionModule { + if (typeof mod !== 'object' || mod === null) { + return false; + } + const candidate = (mod as { descriptor?: unknown }).descriptor; + if (typeof candidate !== 'object' || candidate === null) { + return false; + } + const d = candidate as Partial; + return ( + typeof d.surface === 'string' && + typeof d.verb === 'string' && + typeof d.action === 'object' && + d.action !== null + ); +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.spec.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.spec.ts new file mode 100644 index 0000000..97172cb --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import type { BlocklistEntry } from '../blocklist.js'; + +import { + type BumpLocation, + gateK1ProspectBlocklist, + gateK3cGovtName, + gateK3fAccommodationName, + gateK3fBumpPrecision, +} from './k-gate.js'; + +function entry(overrides: Partial & Pick): BlocklistEntry { + return { + id: 'be-1', + userId: 'u-1', + orgId: null, + scope: 'global', + reason: null, + expiresAt: null, + createdAt: '2026-06-01T00:00:00.000Z', + createdBy: 'user', + ...overrides, + }; +} + +describe('K1 — prospect blocklist', () => { + it('rejects a prospect that is on the blocklist', () => { + const entries = [entry({ kind: 'prospect', value: 'fan-666', reason: 'two chargebacks' })]; + const rejections = gateK1ProspectBlocklist('fan-666', entries); + expect(rejections).toHaveLength(1); + expect(rejections[0]?.gate).toBe('K1'); + expect(rejections[0]?.reason).toContain('chargebacks'); + }); + + it('matches case-insensitively', () => { + const entries = [entry({ kind: 'prospect', value: 'Fan-666' })]; + expect(gateK1ProspectBlocklist('fan-666', entries)).toHaveLength(1); + }); + + it('passes a prospect that is not blocked', () => { + const entries = [entry({ kind: 'prospect', value: 'fan-001' })]; + expect(gateK1ProspectBlocklist('fan-777', entries)).toHaveLength(0); + }); + + it('ignores non-prospect entries with the same value', () => { + const entries = [entry({ kind: 'phrase', value: 'fan-666' })]; + expect(gateK1ProspectBlocklist('fan-666', entries)).toHaveLength(0); + }); + + it('passes on empty prospect id', () => { + expect(gateK1ProspectBlocklist(' ', [entry({ kind: 'prospect', value: 'x' })])).toHaveLength(0); + }); +}); + +describe('K3c-1 — govt / legal name in draft', () => { + const forbidden = ['Victoria Lackey', 'Victor Lackey']; + + it('rejects a draft containing the govt name', () => { + const rejections = gateK3cGovtName( + 'Hey, this is Victoria Lackey — book me in Berlin this weekend!', + forbidden, + ); + expect(rejections).toHaveLength(1); + expect(rejections[0]?.gate).toBe('K3c-1'); + }); + + it('does NOT echo the matched name back in the reason (brief K3k)', () => { + const rejections = gateK3cGovtName('signed, Victoria Lackey', forbidden); + expect(rejections[0]?.reason.toLowerCase()).not.toContain('victoria'); + expect(rejections[0]?.reason.toLowerCase()).not.toContain('lackey'); + }); + + it('is case- and whitespace-insensitive', () => { + expect(gateK3cGovtName('VICTORIA LACKEY', forbidden)).toHaveLength(1); + }); + + it('passes a clean draft', () => { + expect(gateK3cGovtName('Hey, it’s Quinn — Berlin this weekend!', forbidden)).toHaveLength(0); + }); + + it('does not false-positive on a substring inside a larger word', () => { + // "Victor" alone should not trip on "Victorian" (word-boundary aware). + expect(gateK3cGovtName('a lovely Victorian townhouse', ['Victor'])).toHaveLength(0); + }); +}); + +describe('K3f-2 — accommodation name in tour text', () => { + it('rejects tour text naming the hotel', () => { + const rejections = gateK3fAccommodationName( + 'Berlin June 10–14, staying at the Hotel Adlon, DMs open.', + ['Hotel Adlon'], + ); + expect(rejections).toHaveLength(1); + expect(rejections[0]?.gate).toBe('K3f-2'); + }); + + it('passes city + date range with no accommodation', () => { + expect( + gateK3fAccommodationName('Berlin June 10–14, DMs open.', ['Hotel Adlon']), + ).toHaveLength(0); + }); +}); + +describe('K3f-3 — bump location precision', () => { + it('passes a city-only bump', () => { + const loc: BumpLocation = { city: 'Berlin' }; + expect(gateK3fBumpPrecision(loc)).toHaveLength(0); + }); + + it('rejects a bump with a neighborhood', () => { + const loc: BumpLocation = { city: 'Berlin', neighborhood: 'Mitte' }; + const rejections = gateK3fBumpPrecision(loc); + expect(rejections).toHaveLength(1); + expect(rejections[0]?.gate).toBe('K3f-3'); + }); + + it('rejects a bump with lat/long', () => { + const loc: BumpLocation = { city: 'Berlin', latitude: 52.52, longitude: 13.405 }; + expect(gateK3fBumpPrecision(loc)).toHaveLength(1); + }); + + it('rejects a bump with a street or ZIP', () => { + expect(gateK3fBumpPrecision({ city: 'Berlin', street: 'Unter den Linden 1' })).toHaveLength(1); + expect(gateK3fBumpPrecision({ city: 'Berlin', zip: '10117' })).toHaveLength(1); + }); +}); + +describe('aggregate behavior the dispatcher relies on', () => { + it('a govt-name draft and a K1-blocked prospect are both rejected', () => { + const blocked = [entry({ kind: 'prospect', value: 'fan-666' })]; + const k1 = gateK1ProspectBlocklist('fan-666', blocked); + const k3c = gateK3cGovtName('hi from Victoria Lackey', ['Victoria Lackey']); + const all = [...k1, ...k3c]; + expect(all.map((r) => r.gate).sort()).toEqual(['K1', 'K3c-1']); + }); + + it('a clean city-only bump to a non-blocked prospect passes every gate', () => { + const blocked = [entry({ kind: 'prospect', value: 'fan-666' })]; + const k1 = gateK1ProspectBlocklist('fan-001', blocked); + const k3f = gateK3fBumpPrecision({ city: 'Berlin' }); + expect([...k1, ...k3f]).toHaveLength(0); + }); +}); diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.ts new file mode 100644 index 0000000..c02b5f3 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/safety/k-gate.ts @@ -0,0 +1,178 @@ +/** + * K-gate — deterministic safety gates from `K-safety-blocklist.brief.md`. + * + * These are pure functions: they take already-fetched data (draft text, the + * blocklist, a few flags) and return {@link GateRejection}s. They perform NO I/O — + * the calling action fetches the blocklist via `ctx.blocklist` in its `precheck` + * and passes it in. This keeps the gates unit-testable in isolation and identical + * across every specialist that dispatches. + * + * Implemented here (the subset the foundation owns; the rest land with their + * surfaces): + * - K1 — prospect on the blocklist → reject. + * - K3c-1 — govt / legal name appears in a draft → reject (hard, never disable). + * - K3f-2 — accommodation name appears in tour-announce text → reject/scrub. + * - K3f-3 — location more precise than city in a bump → reject. + * + * Each gate returns `GateRejection[]` (empty = pass). The aggregate helpers + * combine the relevant gates for a given action shape. + */ +import type { GateRejection } from '../action.js'; +import type { BlocklistEntry } from '../blocklist.js'; + +/** Normalize text for case-/whitespace-insensitive substring matching. */ +function normalize(text: string): string { + return text.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +/** + * Word-boundary-aware substring test on already-normalized haystack/needle. + * Avoids matching a name fragment inside an unrelated longer word. + */ +function containsTerm(normalizedHaystack: string, rawNeedle: string): boolean { + const needle = normalize(rawNeedle); + if (needle.length === 0) { + return false; + } + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:^|[^\\p{L}\\p{N}])${escaped}(?:$|[^\\p{L}\\p{N}])`, 'u').test( + ` ${normalizedHaystack} `, + ); +} + +/** + * K1 — Prospect blocklist. If the prospect being engaged is on the blocklist + * (kind `'prospect'`, non-expired), the action is rejected outright. Matching is + * by exact prospect identifier (the value the blocklist stores). + * + * @param prospectId - the prospect/fan id or handle the action would engage. + * @param entries - blocklist entries (caller fetches; may be pre-filtered). + */ +export function gateK1ProspectBlocklist( + prospectId: string, + entries: readonly BlocklistEntry[], +): GateRejection[] { + const target = prospectId.trim().toLowerCase(); + if (target.length === 0) { + return []; + } + const hit = entries.find( + (e) => e.kind === 'prospect' && e.value.trim().toLowerCase() === target, + ); + if (!hit) { + return []; + } + return [ + { + gate: 'K1', + reason: hit.reason + ? `prospect is on the blocklist (${hit.reason})` + : 'prospect is on the blocklist', + }, + ]; +} + +/** + * K3c-1 — Identity / govt-name leakage. No draft on any surface may reference + * Quinn's govt / legal name. Hard rule, can never be disabled. The caller passes + * the set of forbidden names (the deadname / govt-name terms Quinn maintains via + * the K2 phrase blocklist); any occurrence in the draft rejects. + * + * @param draftText - the outgoing draft (caption, bio, DM, tour copy). + * @param forbiddenNames - govt / legal / dead names to forbid. + */ +export function gateK3cGovtName( + draftText: string, + forbiddenNames: readonly string[], +): GateRejection[] { + const haystack = normalize(draftText); + if (haystack.length === 0) { + return []; + } + const tripped = forbiddenNames.filter((name) => containsTerm(haystack, name)); + if (tripped.length === 0) { + return []; + } + return [ + { + gate: 'K3c-1', + // Do NOT echo the matched name back (it is itself blocklisted content, + // brief K3k); report that the rule fired, not the value. + reason: 'draft references a name on your identity blocklist; held', + }, + ]; +} + +/** + * K3f-2 — Tour location privacy. Tour-announce text may reveal city + date + * range, never an accommodation (hotel) name. Hard rule, can never be disabled. + * The caller passes known accommodation names for the tour; any occurrence + * rejects (the specialist may then scrub-and-redraft). + * + * @param tourText - the tour-announce draft. + * @param accommodationNames - hotel / accommodation names that must not appear. + */ +export function gateK3fAccommodationName( + tourText: string, + accommodationNames: readonly string[], +): GateRejection[] { + const haystack = normalize(tourText); + if (haystack.length === 0) { + return []; + } + const tripped = accommodationNames.filter((name) => containsTerm(haystack, name)); + if (tripped.length === 0) { + return []; + } + return [ + { + gate: 'K3f-2', + reason: 'tour text names an accommodation; hotel names are confidential — scrub and redraft', + }, + ]; +} + +/** + * K3f-3 — Bump location precision. An "available now" bump must not include a + * current location more precise than city. The caller supplies the bump's + * structured location parts; anything below city granularity rejects. + * + * Precision is detected structurally (the caller knows what it set), not by + * parsing free text: presence of any sub-city field (street, ZIP, building, + * neighborhood, or explicit lat/long) trips the gate. + * + * @param location - structured location attached to the bump. + */ +export interface BumpLocation { + city?: string; + /** Anything below city granularity — presence of any of these trips K3f-3. */ + street?: string; + zip?: string; + building?: string; + neighborhood?: string; + latitude?: number; + longitude?: number; +} + +export function gateK3fBumpPrecision(location: BumpLocation): GateRejection[] { + const subCity = + isNonEmpty(location.street) || + isNonEmpty(location.zip) || + isNonEmpty(location.building) || + isNonEmpty(location.neighborhood) || + typeof location.latitude === 'number' || + typeof location.longitude === 'number'; + if (!subCity) { + return []; + } + return [ + { + gate: 'K3f-3', + reason: 'bump location is more precise than city; only city-level location is allowed', + }, + ]; +} + +function isNonEmpty(value: string | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/@platform/codebase/@packages/surface-adapter-contracts/src/surface-kind.ts b/@platform/codebase/@packages/surface-adapter-contracts/src/surface-kind.ts new file mode 100644 index 0000000..6c3bbb6 --- /dev/null +++ b/@platform/codebase/@packages/surface-adapter-contracts/src/surface-kind.ts @@ -0,0 +1,70 @@ +/** + * SurfaceKind — the external platform a specialist operates on. + * + * This is a verbatim mirror of `surface_kind` as declared in + * `infrastructure/sql/migrations/{0001_tenancy_and_content,0002_extend_surface_kind}.sql` + * and of `@cocottetech/platform-api`'s `entities/enums.ts`. The migrations are the + * source of truth; this package re-declares the union (rather than importing it + * from platform-api) so that the contract has ZERO runtime dependency on the data + * plane. Keep this list in lock-step with the migration when the roster changes. + */ +export type SurfaceKind = + // N1 — Content surfaces (post-driven) + | 'onlyfans' + | 'x' + | 'instagram' + | 'tiktok' + | 'threads' + | 'bluesky' + | 'reddit' + | 'fansly' + | 'youtube' + | 'twitch' + | 'facebook' + // N2 — Escort directories (listing + availability) + | 'tryst' + | 'seeking' + | 'ts4rent' + | 'privatedelights' + | 'tsescorts' + | 'adultsearch' + | 'adultlook' + | 'eros' + | 'eroticmonkey' + | 'skipthegames' + | 'megapersonals' + | 'slixa' + | 'ts_live'; + +/** Runtime list of every {@link SurfaceKind}, in the migration's declared order. */ +export const SURFACE_KINDS: readonly SurfaceKind[] = [ + 'onlyfans', + 'x', + 'instagram', + 'tiktok', + 'threads', + 'bluesky', + 'reddit', + 'fansly', + 'youtube', + 'twitch', + 'facebook', + 'tryst', + 'seeking', + 'ts4rent', + 'privatedelights', + 'tsescorts', + 'adultsearch', + 'adultlook', + 'eros', + 'eroticmonkey', + 'skipthegames', + 'megapersonals', + 'slixa', + 'ts_live', +] as const; + +/** Type guard: narrows an arbitrary string to a {@link SurfaceKind}. */ +export function isSurfaceKind(value: string): value is SurfaceKind { + return (SURFACE_KINDS as readonly string[]).includes(value); +}