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

§1. Module-level invariants

Id Invariant Enforcement
I1 Pure — no I/O, no DB, no network, no env reads, no console output, no async, no await Static scanner test
I2 Deterministic — same (decisionRecord, deps, lamportNow) produces same Advisory[] byte-for-byte 100-run determinism test
I3 Advisory-only — function returns Advisory[], NEVER throws on degenerate input, NEVER returns a “block” status to the caller Test: no throw in source body (static scan), unit tests verify return type only
I4 bigint arithmetic only for reputation_delta — comparisons use 0n, never 0 TS type checker enforces; static scanner asserts no float literals; unit tests cover the type boundary
I5 Adapter-injection — no direct import from ../rules/admission.js, ../rules/engine.js, or ../reputation/compute.js Static scanner test (negative import check)
I6 Evidence captures both presented AND available sets Unit test asserts evidence array contains both
I7 Three trigger conditions are independent — any one fires the advisory; all three may fire on the same call but produce only one combined advisory Contract §3.6 documents merge logic
I8 decision_hash is computed via P4.1.1 computeDecisionHash (NOT re-implemented locally) Source-content scan asserts the import

§2. Public surface

§2.1 Types

/**
 * Outcome of simulating ONE action through κ + λ. The detector treats this
 * opaquely; the adapter is responsible for the κ → μ projection.
 *
 * `reputation_delta` — bigint (BPS) per λ P2.1.2 I1.
 * `obligation_beyond_capacity` — boolean, true if applying this action would
 *   exceed the actor's current obligation capacity (λ P2.4.1).
 */
export interface Outcome {
  readonly reputation_delta: bigint;
  readonly obligation_beyond_capacity: boolean;
}

/**
 * The decision record the detector inspects. Generic over `TAction`
 * (opaque) + `TContext` (opaque, passed through to adapters).
 *
 * `actor` — string identifier, used by κ admission to determine the
 *   admissible action set.
 * `context` — opaque payload passed to admission + engine adapters.
 * `options` — what the agent saw (may differ from κ admission's view).
 */
export interface DecisionRecord<TAction, TContext> {
  readonly actor: string;
  readonly context: TContext;
  readonly options: readonly TAction[];
}

/**
 * Adapter injection — three pure functions the detector composes.
 *
 * `admission` — given (actor, context), returns the actions κ would admit.
 *   Pure: same input → same output. Must NOT mutate context. May return [].
 * `engine` — given (action, context), returns the Outcome of running that action.
 *   Pure: same input → same output. Must NOT mutate context. May return any
 *   bigint (positive, zero, negative).
 * `scoreCompute` — optional adapter for callers who want to chain a baseline
 *   adjustment. Detector does NOT invoke this in the default flow (it consumes
 *   the engine's reputation_delta directly). Kept in the interface for symmetry
 *   with the task prompt and future P4.4.1 wiring.
 */
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.2 Function

export function detectCoercion<TAction, TContext>(
  decisionRecord: DecisionRecord<TAction, TContext>,
  deps: CoercionDeps<TAction, TContext>,
  lamportNow: bigint,
): Advisory[];

Returns: array of zero or one Advisory (P4.1.1 envelope). At most one advisory per call (multiple trigger conditions on the same call combine into ONE advisory with a combined-trigger recommendation, per I7).

Throws: never (I3). Even if deps.engine(...) throws, the detector returns an EMPTY advisory list — the post-mortem belongs to the caller. (Note: in P4.2.2 we LET the engine adapter exception propagate, since suppressing it would silently mask κ bugs. This is documented in §3.4 as the only intentional “throws” path; the test suite covers it. Re-read of I3: “never throws on DEGENERATE INPUT” — degenerate input = empty option space, all-negative outcomes, etc., NOT “deps throw exceptions”. An adapter that throws is a CALLER bug.)

§3. Algorithm

§3.1 Step 1 — capture presented

const presented: readonly TAction[] = decisionRecord.options;

presented may be empty. Empty presented alone is NOT a trigger — only an empty AVAILABLE set is (per integrity.md L77 + task spec).

§3.2 Step 2 — enumerate available via κ admission

const available: readonly TAction[] = deps.admission(decisionRecord.actor, decisionRecord.context);

available is the truth-set per κ admission. The detector trusts this answer verbatim. Adapter contract: the admission function is PURE and returns a fresh array (no aliasing of caller-held state).

§3.3 Step 3 — simulate each available action via κ engine

const outcomes = new Map<TAction, Outcome>();
for (const action of available) {
  outcomes.set(action, deps.engine(action, decisionRecord.context));
}

Map insertion order = available array order. Iteration in §3.4 walks in this deterministic order.

§3.4 Step 4 — classify outcomes

const negative: TAction[] = [];
const obligates: TAction[] = [];
for (const action of available) {
  const outcome = outcomes.get(action);
  // outcome is non-null by construction (we just inserted it above for every
  // entry in `available`). The TS compiler does not know this; the cast is safe.
  if (outcome === undefined) continue;  // defensive; cannot happen
  if (outcome.reputation_delta < 0n) negative.push(action);
  if (outcome.obligation_beyond_capacity) obligates.push(action);
}

The two lists OVERLAP — an action can be both negative AND obligation-overflowing. We track them independently because the trigger conditions are independent.

§3.5 Step 5 — 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;

The available.length > 0 guard on isAllNegative and isAllObligates prevents an empty available set from spuriously triggering both. The empty-set trigger is a SEPARATE flag (isEmpty).

§3.6 Step 6 — emit advisory (combined trigger if multiple)

If isEmpty || isAllNegative || isAllObligates:

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, outcomes);
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: presented },
    { kind: 'available', items: available },
    { kind: 'outcomes', entries: Array.from(outcomes.entries()).map(([a, o]) => [a, o]) },
  ],
  recommendation,
  decision_hash,
  timestamp_logical: lamportNow,
};
return [advisory];

Otherwise: return [].

§3.7 projectInput — what feeds into decision_hash

The detector hashes a STABLE projection of (presented, available, outcomes). The projection MUST be canonical-JSON-representable per P4.1.1’s canonicalize:

function projectInput<TAction, TContext>(
  presented: readonly TAction[],
  available: readonly TAction[],
  outcomes: ReadonlyMap<TAction, Outcome>,
): unknown {
  return {
    presented_count: presented.length,
    available_count: available.length,
    // We project Outcome shape WITHOUT including the bigint values
    // directly in the hash preimage — the dedup invariant should fire on
    // option-set IDENTITY, not on the specific reputation deltas (which
    // can fluctuate run-to-run with λ score drift). Two identical
    // (presented, available) sets always produce the same hash.
    available_signatures: available.map((a) => stringifyOpaque(a)),
    presented_signatures: presented.map((p) => stringifyOpaque(p)),
  };
}

Discussion of dedup granularity: A coarser hash (just signatures) means that two runs over the same option-space with different λ outcomes (e.g. between scoring epochs) would collapse to ONE persisted advisory row. That’s the right semantics for a “trap signature” — the trap is the option-space, not the floating reputation.

The full outcomes map is in advisory.evidence for the operator; it just isn’t in the hash preimage.

stringifyOpaque(a) — calls String(a) for primitives, JSON.stringify(a) for object-shaped actions. The detector’s adapter contract forbids cyclic action structures (would crash the canonicalizer anyway).

§3.8 buildRecommendation — operator-readable explanation

function buildRecommendation(
  triggers: readonly string[],
  availableCount: number,
  negativeCount: number,
  obligatesCount: number,
): string {
  if (triggers.length === 0) return ''; // unreachable
  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 actions produce negative reputation delta (κ engine simulation).`);
  }
  if (triggers.includes('all-obligates')) {
    parts.push(`All ${availableCount} admissible actions exceed the actor's obligation capacity (λ P2.4.1).`);
  }
  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(' ');
}

The recommendation IS deterministic for given (triggers, counts). It is NOT in the decision_hash preimage (per P4.1.1 contract §I1 — recommendation is metadata, not identity).

§4. Determinism statement (I2)

For any two calls detectCoercion(D, deps, L):

D₁ === D₂ (structurally equal)
deps₁(...) returns same outputs for same inputs as deps₂(...)
L₁ === L₂
↓
output array byte-for-byte equal under canonical JSON encoding.

Sources of non-determinism the detector EXCLUDES:

  • No Date.now() / performance.now() / process.hrtime()
  • No Math.random() / crypto.randomBytes()
  • No setTimeout / setInterval
  • No async / await / fetch
  • No mutable global state
  • Map iteration order is insertion order in V8 (spec ECMA-262 §24.1.3.6)

Sources of non-determinism CALLERS must avoid:

  • deps.admission MUST be pure (caller’s responsibility per §2.1)
  • deps.engine MUST be pure (caller’s responsibility per §2.1)
  • lamportNow MUST be a deterministic Lamport tick (caller’s responsibility)

§5. Acceptance criteria

AC# Criterion Verification
1 Three trigger conditions exist (empty / all-negative / all-obligates), each independently fires the advisory 3 unit tests, one per trigger
2 Advisory shape matches P4.1.1 envelope verbatim (8 fields, all closed enums, valid hash) AdvisorySchema.parse(advisory) on output
3 Mixed-outcome (some positive, some negative) → 0 advisories Unit test
4 Single positive option → 0 advisories Unit test
5 Empty available set + non-empty presented → 1 advisory (with both sets in evidence) Unit test
6 presented ≠ available (legitimate filtering case) → advisory ONLY if filtered available is degenerate Unit test (presented={A,B}, available={B}, B is positive → no advisory)
7 Determinism: 100 runs on same input produce identical output (deep equal) Unit test loop
8 Pure function: no I/O beyond injected adapters (mock adapters intercept all access) Unit test with throwing mocks
9 Static scanner: no Date.now(), no Math.random(), no new Date(), no setTimeout, no performance.now(), no crypto.randomBytes in coercion.ts source Source-content scan test
10 Static scanner: no direct import of ../rules/admission, ../rules/engine, or ../reputation/compute Source-content scan test
11 Throws never on degenerate input (including empty available) — only on caller’s deps.engine exception path Unit test
12 npm run build && npm run lint && npm test all pass Final gate

§6. Edge cases

Case Expected behavior
available = [] and presented = [] 1 advisory (empty trigger). Evidence has empty arrays for both.
available = [] and presented = [A, B] 1 advisory (empty trigger). Evidence captures the presented mismatch for operator review.
available = [A] with engine(A) = {delta: 5n, obligation_beyond_capacity: false} 0 advisories.
available = [A] with engine(A) = {delta: -5n, obligation_beyond_capacity: false} 1 advisory (all-negative; only one option, and it’s negative).
available = [A] with engine(A) = {delta: 5n, obligation_beyond_capacity: true} 1 advisory (all-obligates; only one option, and it overflows).
available = [A, B] with engine(A) = {delta: -5n, obligation_beyond_capacity: true} and engine(B) = {delta: 5n, ...false} 0 advisories. B is the safe option. Mixed outcome.
available = [A, B] with both delta < 0n and both obligation_beyond_capacity 1 advisory with combined trigger “empty + all-negative + all-obligates”… wait, available is non-empty here. So: combined trigger “all-negative AND all-obligates”.
available = [A], engine(A) = {delta: 0n, obligation_beyond_capacity: false} 0 advisories. Zero is NOT negative (0n < 0n is false).
lamportNow = 0n Valid (bigint nonnegative); advisory has timestamp_logical: 0n.
lamportNow = -1n Invalid per P4.1.1 schema (AdvisorySchema rejects negative bigints). Detector emits the advisory anyway (it does NOT validate the input timestamp — that’s the caller’s responsibility AND AdvisorySchema.parse will catch it downstream).

§7. Interface migration discipline

P4.2.2 ships:

  • Outcome (interface)
  • DecisionRecord<TAction, TContext> (interface)
  • CoercionDeps<TAction, TContext> (interface)
  • detectCoercion<TAction, TContext>(...) (function)

Future Phase 4 slices may extend these:

  • Outcome may gain new fields (e.g. effect_invariant_violated: boolean). Additions are non-breaking; the detector ignores unknown fields.
  • CoercionDeps may gain optional adapters (e.g. obligationLookup). Optional additions are non-breaking.
  • detectCoercion signature is FROZEN at this slice. A breaking signature change requires a new check value (coercion_trap_v2) per P4.1.1’s algorithm-discriminator-in-enum pattern (contract §I7).

Step 2 of 5 complete. Step 3: packet.


Back to top

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

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