feat(surface-adapter): Add standardized safety contracts for k-gate, action verbs, platform API interactions, audit, blocklist, registry, context, and surface kinds

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:06:59 -07:00
parent f74f53b3b4
commit 8a76d8c1fe
11 changed files with 883 additions and 0 deletions

View file

@ -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);
}

View file

@ -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<I>`), 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<I, O> {
/** 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<I>;
/**
* 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<PrecheckResult>;
/**
* 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<O>;
/**
* 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<void>;
}
/** Convenience helper to build an ok / not-ok {@link PrecheckResult}. */
export function precheckResult(rejections: GateRejection[]): PrecheckResult {
return { ok: rejections.length === 0, rejections };
}

View file

@ -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<Record<string, unknown>>;
}
/** 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<AgentActionRef>;
}

View file

@ -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<readonly BlocklistEntry[]>;
}

View file

@ -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 15). 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;
}

View file

@ -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';

View file

@ -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 `<base>/<path>` with optional query params; resolves the JSON body. */
get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
/** POST `<base>/<path>` with a JSON body; resolves the JSON response body. */
post<T>(path: string, body: unknown): Promise<T>;
}
/**
* 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;
}

View file

@ -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/<verb>/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/<verb>/index.ts` exports exactly
* one of these as `export const descriptor: ActionDescriptor = { ... }`.
*
* `action` is intentionally `SurfaceAdapterAction<unknown, unknown>` so a
* heterogeneous set of actions can live in one registry; each action's own file
* keeps its precise `<I, O>` 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<unknown, unknown>;
/**
* 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 `<surface>/<verb>`.
* 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<string, ActionDescriptor>();
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<ActionDescriptor>;
return (
typeof d.surface === 'string' &&
typeof d.verb === 'string' &&
typeof d.action === 'object' &&
d.action !== null
);
}

View file

@ -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<BlocklistEntry> & Pick<BlocklistEntry, 'kind' | 'value'>): 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, its 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 1014, 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 1014, 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);
});
});

View file

@ -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;
}

View file

@ -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);
}