Packet — P4.2.2 Coercion Trap Detector (option-set)

§1. Execution plan

Implement detectCoercion<TAction, TContext> at src/domains/integrity/detectors/coercion.ts per the contract, plus its test suite at src/domains/integrity/detectors/__tests__/coercion.test.ts. The implementation is a pure function over the κ admission + κ engine + λ compute shapes (injected via CoercionDeps).

§2. File layout

src/domains/integrity/
├── schema.ts                                    # P4.1.1 — unchanged
└── detectors/                                   # NEW (this slice)
    ├── coercion.ts                              # NEW (this slice)
    └── __tests__/                               # NEW (this slice)
        └── coercion.test.ts                     # NEW (this slice)

§3. coercion.ts skeleton (~300 lines)

/**
 * Colibri — Phase 4 μ Integrity Monitor — Coercion Trap Detector (P4.2.2).
 *
 * Pure, deterministic option-set detector. Given a decision record + adapter
 * triple (κ admission / κ engine / λ score-compute), enumerates available
 * actions, simulates each outcome, and emits an Advisory if the action space
 * is degenerate (empty / all-negative / all-obligation-overflowing).
 *
 * Advisory-only — NEVER blocks. The signal feeds P4.4.1 escalation FSM.
 *
 * Public surface:
 *   - Outcome                                — { reputation_delta: bigint; obligation_beyond_capacity: boolean }
 *   - DecisionRecord<TAction, TContext>      — { actor, context, options }
 *   - CoercionDeps<TAction, TContext>        — { admission, engine, scoreCompute? }
 *   - detectCoercion<TAction, TContext>(...) — Advisory[]
 *
 * Pure module — no I/O, no DB, no network, no env reads, no console output,
 * no async. No wall-clock reads. No randomness. Determinism mirrors P4.1.1.
 *
 * REUSE pattern — `computeDecisionHash` and the `Advisory` type are imported
 * directly from P4.1.1 (`../schema.js`). The detector does NOT re-implement
 * canonicalization or hashing.
 *
 * Canonical references:
 *   - docs/audits/p4-2-2-coercion-detector-audit.md
 *   - docs/contracts/p4-2-2-coercion-detector-contract.md
 *   - docs/packets/p4-2-2-coercion-detector-packet.md
 *   - docs/3-world/physics/enforcement/integrity.md §2 Coercion trap L57-85
 *   - docs/guides/implementation/task-prompts/p4.1-mu-integrity.md §P4.2.2
 *   - src/domains/integrity/schema.ts (P4.1.1)
 */

import {
  type Advisory,
  computeDecisionHash,
} from '../schema.js';

// ===== §1. Types =====

export interface Outcome {
  readonly reputation_delta: bigint;
  readonly obligation_beyond_capacity: boolean;
}

export interface DecisionRecord<TAction, TContext> {
  readonly actor: string;
  readonly context: TContext;
  readonly options: readonly TAction[];
}

export interface CoercionDeps<TAction, TContext> {
  readonly admission: (actor: string, context: TContext) => readonly TAction[];
  readonly engine: (action: TAction, context: TContext) => Outcome;
  readonly scoreCompute?: (delta: bigint, baseline: bigint) => bigint;
}

// ===== §2. detectCoercion =====

export function detectCoercion<TAction, TContext>(
  decisionRecord: DecisionRecord<TAction, TContext>,
  deps: CoercionDeps<TAction, TContext>,
  lamportNow: bigint,
): Advisory[] {
  const presented = decisionRecord.options;
  const available = deps.admission(decisionRecord.actor, decisionRecord.context);

  // §2.1 Simulate outcomes (Map preserves insertion order = available order)
  const outcomes = new Map<TAction, Outcome>();
  for (const action of available) {
    outcomes.set(action, deps.engine(action, decisionRecord.context));
  }

  // §2.2 Classify
  const negative: TAction[] = [];
  const obligates: TAction[] = [];
  for (const action of available) {
    const outcome = outcomes.get(action);
    if (outcome === undefined) continue; // defensive; cannot happen
    if (outcome.reputation_delta < 0n) negative.push(action);
    if (outcome.obligation_beyond_capacity) obligates.push(action);
  }

  // §2.3 Evaluate triggers
  const isEmpty = available.length === 0;
  const isAllNegative = available.length > 0 && negative.length === available.length;
  const isAllObligates = available.length > 0 && obligates.length === available.length;

  if (!isEmpty && !isAllNegative && !isAllObligates) {
    return [];
  }

  const triggers: string[] = [];
  if (isEmpty) triggers.push('empty');
  if (isAllNegative) triggers.push('all-negative');
  if (isAllObligates) triggers.push('all-obligates');

  const recommendation = buildRecommendation(
    triggers,
    available.length,
    negative.length,
    obligates.length,
  );

  const input = projectInput(presented, available);
  const decision_hash = computeDecisionHash('Sentinel', 'coercion_trap', input, 'WARN');

  const advisory: Advisory = {
    role: 'Sentinel',
    check: 'coercion_trap',
    result: 'WARN',
    severity: 'HIGH',
    evidence: [
      { kind: 'presented', items: Array.from(presented) },
      { kind: 'available', items: Array.from(available) },
      { kind: 'outcomes', entries: Array.from(outcomes.entries()).map(([a, o]) => [a, { ...o }]) },
    ],
    recommendation,
    decision_hash,
    timestamp_logical: lamportNow,
  };
  return [advisory];
}

// ===== §3. Private helpers =====

function projectInput<TAction>(
  presented: readonly TAction[],
  available: readonly TAction[],
): unknown {
  return {
    presented_count: presented.length,
    available_count: available.length,
    presented_signatures: presented.map(stringifyOpaque),
    available_signatures: available.map(stringifyOpaque),
  };
}

function stringifyOpaque(x: unknown): string {
  if (x === null) return 'null';
  if (typeof x === 'string') return x;
  if (typeof x === 'number' || typeof x === 'boolean' || typeof x === 'bigint') {
    return String(x);
  }
  // Objects: stable-ish — caller is contractually responsible for distinct
  // values per distinct action (no aliasing). We JSON-stringify but tolerate
  // throws by falling back to a guaranteed-distinct sentinel.
  try {
    return JSON.stringify(x);
  } catch {
    return '[unserializable]';
  }
}

function buildRecommendation(
  triggers: readonly string[],
  availableCount: number,
  negativeCount: number,
  obligatesCount: number,
): string {
  const parts: string[] = [];
  if (triggers.includes('empty')) {
    parts.push('Action space is empty; the actor has no admissible options at the κ admission tier.');
  }
  if (triggers.includes('all-negative')) {
    parts.push(`All ${availableCount} admissible action(s) produce negative reputation delta (κ engine simulation, ${negativeCount}/${availableCount}).`);
  }
  if (triggers.includes('all-obligates')) {
    parts.push(`All ${availableCount} admissible action(s) exceed the actor's obligation capacity (λ obligation tier, ${obligatesCount}/${availableCount}).`);
  }
  parts.push('Coercion trap suspected — review the surrounding rule context for legitimate filtering vs. silent option-pruning. Advisory only; no automatic block.');
  return parts.join(' ');
}

§4. Test plan — coercion.test.ts

Group test layout (mirrors P4.1.1’s G1..G12 convention):

Group Coverage Cases
G1 Empty trigger 2 (empty + nonempty presented, empty + empty presented)
G2 All-negative trigger 3 (single-option negative; multi negative; with mixed obligates)
G3 All-obligates trigger 3 (single-option obligate; multi obligate; with mixed negative)
G4 Mixed outcome (no trigger) 4 (some positive some negative; some positive some obligate; single positive; presented ≠ available with positive filtered set)
G5 Combined trigger 2 (all-negative AND all-obligates; empty alone)
G6 Advisory shape 4 (P4.1.1 schema parse; role=Sentinel; check=coercion_trap; severity=HIGH; result=WARN)
G7 Evidence content 3 (presented kind; available kind; outcomes kind)
G8 Determinism (100× loop) 2 (deterministic Advisory[] under identical inputs; deterministic decision_hash)
G9 Purity (mock adapter interception) 2 (admission throws → caller error; engine throws → caller error)
G10 Static scanner — no clock/RNG 1 source-content scan over coercion.ts
G11 Static scanner — no direct κ/λ imports 1 source-content scan
G12 bigint discipline 2 (delta = 0n is NOT negative; delta = -1n IS negative)

Total: ~29 tests.

§4.1 Fixture builder

function makeRecord<TAction, TContext>(actor: string, context: TContext, options: readonly TAction[]): DecisionRecord<TAction, TContext> {
  return { actor, context, options };
}

function makeDeps<TAction, TContext>(
  admissionFn: (actor: string, context: TContext) => readonly TAction[],
  engineFn: (action: TAction, context: TContext) => Outcome,
): CoercionDeps<TAction, TContext> {
  return { admission: admissionFn, engine: engineFn };
}

§4.2 Worked test sketch — G2.1 single-option negative

it('emits one advisory when the only admissible action has negative reputation_delta', () => {
  const record = makeRecord('agent-a', {}, ['A']);
  const deps = makeDeps<string, object>(
    () => ['A'],
    () => ({ reputation_delta: -10n, obligation_beyond_capacity: false }),
  );
  const advisories = detectCoercion(record, deps, 42n);
  expect(advisories).toHaveLength(1);
  const a = advisories[0]!;
  expect(a.check).toBe('coercion_trap');
  expect(a.severity).toBe('HIGH');
  expect(a.result).toBe('WARN');
  expect(a.role).toBe('Sentinel');
  expect(a.timestamp_logical).toBe(42n);
  expect(a.recommendation).toContain('all-negative' /* loose match — see G6 for stricter */);
});

§4.3 Worked test sketch — G4.1 mixed-outcome no advisory

it('returns [] when at least one action has positive reputation_delta', () => {
  const record = makeRecord('agent-a', {}, ['A', 'B']);
  const deps = makeDeps<string, object>(
    () => ['A', 'B'],
    (action) => action === 'A'
      ? { reputation_delta: -5n, obligation_beyond_capacity: false }
      : { reputation_delta: 5n, obligation_beyond_capacity: false },
  );
  const advisories = detectCoercion(record, deps, 1n);
  expect(advisories).toEqual([]);
});

§4.4 Worked test sketch — G8.1 determinism × 100

it('produces deep-equal advisories across 100 runs with identical inputs', () => {
  const record = makeRecord('agent-a', { ctx: 'fixed' }, ['A']);
  const deps = makeDeps<string, object>(
    () => ['A'],
    () => ({ reputation_delta: -1n, obligation_beyond_capacity: false }),
  );
  const first = detectCoercion(record, deps, 7n);
  for (let i = 0; i < 99; i += 1) {
    const next = detectCoercion(record, deps, 7n);
    expect(next).toEqual(first);
  }
});

§4.5 Worked test sketch — G10 static scanner

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const COERCION_PATH = join(__dirname, '..', 'coercion.ts');

it('contains no clock or RNG reads', () => {
  const src = readFileSync(COERCION_PATH, 'utf8');
  const stripped = src
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .replace(/\/\/[^\n]*/g, '');
  expect(stripped).not.toMatch(/\bDate\.\w+/);
  expect(stripped).not.toMatch(/\bnew\s+Date\b/);
  expect(stripped).not.toMatch(/\bMath\.random\b/);
  expect(stripped).not.toMatch(/\bperformance\.now\b/);
  expect(stripped).not.toMatch(/\bcrypto\.randomBytes\b/);
  expect(stripped).not.toMatch(/\bsetTimeout\b/);
  expect(stripped).not.toMatch(/\bsetInterval\b/);
  expect(stripped).not.toMatch(/\bsetImmediate\b/);
  expect(stripped).not.toMatch(/\bfetch\s*\(/);
});

§4.6 Worked test sketch — G11 no direct κ/λ imports

it('does not import directly from rules/admission, rules/engine, or reputation/compute', () => {
  const src = readFileSync(COERCION_PATH, 'utf8');
  expect(src).not.toMatch(/from\s+['"][^'"]*rules\/admission/);
  expect(src).not.toMatch(/from\s+['"][^'"]*rules\/engine/);
  expect(src).not.toMatch(/from\s+['"][^'"]*reputation\/compute/);
});

§5. Test count delta

Base @ 49560518: 3553 tests across 80 suites (1 new μ suite for P4.1.1 schema; coercion adds 1 more suite ~29 tests).

Expected post-merge: 3553 + ~29 = ~3582 tests across 81 suites.

§6. Build + lint risks

  • ESM .js import suffixesfrom '../schema.js' per repo convention (verified against P4.1.1 schema.ts importing '../rules/canonical.js').
  • TypeScript strict — interface fields are readonly; the test fixture builders return concrete DecisionRecord<...>. Generic inference should resolve cleanly with explicit type args at call sites.
  • ESLint @typescript-eslint/no-non-null-assertion — we use advisories[0]! in tests. P4.1.1 schema.test.ts already uses this pattern, so ESLint is configured to allow it (or it’s disabled for test files).
  • bigint comparisonoutcome.reputation_delta < 0n is well-formed (both sides bigint). TS would error on < 0 (number vs bigint comparison).

§7. Determinism scanner — one last check

The G10/G11 static scanners are inside the test file (not a separate determinism.ts hook). This mirrors P4.1.1 G9/G10. Should the repo gain a unified determinism corpus in a future slice, the coercion-detector source will be added to that corpus’s allowlist.

§8. Rollback plan

If the verify gate fails:

  1. Compile error → narrow type inference, ensure Outcome is exported.
  2. Lint error → review @typescript-eslint/... rules, may need to adjust the unused-vars or no-explicit-any config.
  3. Test failure → either spec drift (re-read contract §3.5 trigger logic) or test setup (fixture builders).
  4. Static scanner false positive → tighten the regex to avoid matching strings inside comments (we already strip block + line comments).

If multiple slices fail: rebase onto origin/main once more in case Wave 2 sibling agents have made coordinated changes (unlikely — file-disjoint per task spec).


Step 3 of 5 complete. Step 4: implement.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.