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
.jsimport suffixes —from '../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 concreteDecisionRecord<...>. Generic inference should resolve cleanly with explicit type args at call sites. - ESLint
@typescript-eslint/no-non-null-assertion— we useadvisories[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 comparison —
outcome.reputation_delta < 0nis 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:
- Compile error → narrow type inference, ensure
Outcomeis exported. - Lint error → review
@typescript-eslint/...rules, may need to adjust theunused-varsorno-explicit-anyconfig. - Test failure → either spec drift (re-read contract §3.5 trigger logic) or test setup (fixture builders).
- 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.