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
Mapiteration order is insertion order in V8 (spec ECMA-262 §24.1.3.6)
Sources of non-determinism CALLERS must avoid:
deps.admissionMUST be pure (caller’s responsibility per §2.1)deps.engineMUST be pure (caller’s responsibility per §2.1)lamportNowMUST 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:
Outcomemay gain new fields (e.g.effect_invariant_violated: boolean). Additions are non-breaking; the detector ignores unknown fields.CoercionDepsmay gain optional adapters (e.g.obligationLookup). Optional additions are non-breaking.detectCoercionsignature 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.