Contract — P1.4.4 Tool-Lock Integration Spec

Round: R87 κ Wave 8 (FINAL — closes Phase 1 at 20/20) Branch: feature/p1-4-4-tool-lock-adapter Worktree: .worktrees/claude/p1-4-4-tool-lock-adapter Base SHA: 69bc4714 β task ID: a9b63b19-6781-4f1d-9a76-a7f8eb66ed27 Step: 2 of 5 — behavioral contract

This contract defines the public surface, observable semantics, and stability guarantees of src/domains/rules/tool-lock-adapter.ts. It is the gating reference for the implementation step (Step 4) and the verification step (Step 5).


1. Module identity

  • Path: src/domains/rules/tool-lock-adapter.ts
  • Tests: src/__tests__/domains/rules/tool-lock-adapter.test.ts
  • Phase: Phase 1 κ Rule Engine — Wave 8 (P1.4.4)
  • Predecessors: P1.4.1 admission (admission.ts) + P1.4.2 denial-reasons (denial-reasons.ts) + P1.4.3 budget (budget.ts) + P1.2.4 registry (registry.ts)
  • Phase 1.5+ follow-up (NOT this PR): α wiring — registering the adapter as the κ-aware tool-lock layer in src/server.ts. Per ADR-005 §Decision and the source prompt §P1.4.4 line 2087, that registration is explicitly deferred.

2. Public surface

2.1. Forward-compat types — locally defined, exported, marked

/**
 * MiddlewareRequest — the inbound shape a stage sees. NOT yet exported by
 * `src/server.ts`; defined locally here as a forward-looking contract.
 *
 * TODO(P1.5+): replace with the canonical export from `src/server.ts` once
 * the α 5-stage chain is refactored from inline IIFE blocks (server.ts:296)
 * into composable functions.
 */
export interface MiddlewareRequest {
  readonly caller: string;
  readonly tool: string;
  readonly args: unknown;
  readonly mode?: AdmissionMode;
  readonly rep_snapshot: ReadOnlyState;
  readonly rule_version?: string;
}

/** A single stage of the α middleware chain. See MiddlewareRequest TODO. */
export type MiddlewareStage = (
  req: MiddlewareRequest,
  next: () => Promise<unknown>,
) => Promise<unknown>;

AdmissionMode and ReadOnlyState are imported from existing κ modules (./admission.js and ./state-access.js respectively).

2.2. Audit-event seam

/**
 * Frozen, deterministic record handed to the optional `on_event` callback
 * on every admission decision. Carries the typed reason on deny; absent on
 * admit (the admit-side audit is owned by α stage 5 — out of κ scope).
 */
export interface AdmissionAuditEvent {
  readonly type: 'admission_deny';
  readonly caller: string;
  readonly tool: string;
  readonly reason: DenialReason;
  readonly at: bigint;
}

/** Optional observer for admission decisions. Sync, total — exceptions caught. */
export type AdmissionEventListener = (event: AdmissionAuditEvent) => void;

The listener is OPTIONAL. When absent, the adapter does no event work. When present, the listener fires exactly once per deny (never on admit — admits go through stages 2–5 normally and α stage 5 owns their audit). This differs slightly from the source prompt’s wording (which conflated admit-audit with deny-audit); the κ adapter only owns the deny-side observation.

Listener exception isolation: if the listener throws, the throw is caught and ignored — the deny-path proceeds to on_deny and the rejected Promise. Mirrors BudgetTracker.subscribe semantics (budget.ts:219: “exceptions caught and swallowed”).

2.3. Error class

/**
 * Thrown by the adapter on every admission denial. Carries the typed
 * DenialReason alongside operator-friendly fields. Subclass of Error so
 * `instanceof Error === true`.
 */
export class ToolAdmissionDeniedError extends Error {
  override readonly name = 'ToolAdmissionDeniedError';
  readonly reason: DenialReason;
  readonly caller: string;
  readonly tool: string;
  /** Spec-indicative HTTP equivalent. MCP transport doesn't use HTTP, but
   *  downstream integrations may translate. Always 403. */
  readonly http_status: 403;
  constructor(reason: DenialReason, caller: string, tool: string);
}

The message is computed from renderDenialReason(reason) (P1.4.2 helper) — operator-readable, no PII, deterministic.

2.4. Factory options

export interface ToolLockAdapterOptions {
  /** Called exactly once on every deny, before the rejected Promise
   *  is returned. Never called on admit. Listener exceptions caught. */
  readonly on_deny?: (reason: DenialReason) => void;

  /** Called exactly once on every deny, before `on_deny`. Carries the
   *  full structured AdmissionAuditEvent including the deterministic
   *  bigint `at` counter. Listener exceptions caught. */
  readonly on_event?: AdmissionEventListener;

  /** Default mode used when the inbound MiddlewareRequest does not carry
   *  one. Defaults to 'normal'. */
  readonly default_mode?: AdmissionMode;
}

Two listeners is intentional: on_deny is the source-prompt’s mandate (a flat reason callback); on_event is the structured audit-layer observer per the source prompt’s “structured audit event” mandate (line 2130). Both are optional and independent.

2.5. Factory function

/**
 * Build a stage-1 middleware function bound to the supplied registry and
 * options. The returned function is pure-by-construction (closure over
 * registry + options + at-counter); no globals; no side effects at
 * construction time.
 *
 * **The function is not registered with `src/server.ts`.** Phase 1.5+ α
 * wiring tasks will register it; this PR ships the adapter as a library.
 */
export function createToolLockAdapter(
  registry: RuleRegistry,
  options?: ToolLockAdapterOptions,
): MiddlewareStage;

3. Behavioral algorithm

For each invocation stage(req, next):

3.1. Synthesize the AdmissionRequest

admReq = {
  caller:        req.caller,
  tool:          req.tool,
  mode:          req.mode ?? options.default_mode ?? 'normal',
  rep_snapshot:  req.rep_snapshot,
  rule_version:  req.rule_version ?? registry.computeVersionHash(),
}

registry.computeVersionHash() is called at most once per invocation (cached on first read in this scope).

3.2. Evaluate

try {
  result = evaluateAdmission(admReq, registry)
} catch (surprise) {
  // unreachable per admission.ts §32-35 totality. Defensive only.
  result = {
    admitted: false,
    reason: { kind: 'rule_rejected', rule_name: '<adapter>', rule_reason: 'evaluator_threw:' + surprise.message },
    rule_version: <best-effort>,
  }
}

The catch arm is defensive — evaluateAdmission is documented total. We carry the synthesized denial through the same emission flow as a real denial, so observers see consistent shape.

3.3. Branch

Admit branch:

return next()

That’s it. The Promise from next() propagates faithfully — both fulfilment and rejection paths. The adapter does NOT wrap, transform, or instrument the next() Promise. Stages 2–5 own that.

Deny branch:

1. event = freeze({ type: 'admission_deny', caller: req.caller, tool: req.tool, reason: result.reason, at: ++_at })
2. emit(event):
     try { options.on_event?.(event) } catch { /* swallowed */ }
3. notify(reason):
     try { options.on_deny?.(result.reason) } catch { /* swallowed */ }
4. return Promise.reject(new ToolAdmissionDeniedError(result.reason, req.caller, req.tool))

Order is FIXED:

  • emit BEFORE on_deny (audit must land even if on_deny misbehaves)
  • on_deny BEFORE rejection (caller-policy hook fires before the throw boundary)

3.4. Stage 2–5 invariant

On the deny branch, next() is never invoked. This is the contractual guarantee that schema-validate / audit-enter / dispatch / audit-exit are short-circuited per §P1.4.4 line 2084. Test F3 covers this with a spy next that records its call count.

3.5. at counter

Closure-scoped bigint initialized to 0n at factory time. Incremented BEFORE constructing the event. Monotonic across the adapter instance’s lifetime. Two adapter instances have independent counters.

NOT wall-clock. NOT reset between invocations. This is the same idiom as BudgetTracker._at (budget.ts:323) — for θ consensus determinism (concept doc §”What κ is not”).


4. Invariants

ID Statement
I1 The adapter never modifies src/server.ts. (git diff origin/main..HEAD -- src/server.ts empty.)
I2 The adapter is a pure factory: no side effects at module load, no globals, no mutation of registry or options.
I3 On admit: next() is invoked exactly once and its return value is propagated unchanged (both fulfilment and rejection).
I4 On deny: next() is NEVER invoked.
I5 On deny: on_event (if present) is called exactly once with a frozen AdmissionAuditEvent.
I6 On deny: on_deny (if present) is called exactly once with result.reason.
I7 On deny: emission order is on_event → on_deny → reject. Listener exceptions DO NOT skip later steps.
I8 On admit: neither on_event nor on_deny is called.
I9 The rejected Promise carries a ToolAdmissionDeniedError whose reason field is referentially identical to result.reason (no defensive copy — DenialReason is already structurally immutable per P1.4.2).
I10 The at counter is monotonic per-adapter-instance, starts at 1n (post-increment-then-read), and never decrements.
I11 Adapter source contains no async keyword and no await keyword (corpus self-scan invariant).
I12 Adapter source contains no Math.*, Date.*, crypto.*, setTimeout, fetch, process.hrtime, or float literals (corpus self-scan invariant).
I13 inspectFunctionForbidden(createToolLockAdapter) and inspectFunctionForbidden(stage) both return [].
I14 evaluateAdmission is invoked exactly once per stage call (not zero, not more).
I15 registry.computeVersionHash() is invoked at most once per stage call (only when req.rule_version is absent).
I16 If evaluateAdmission somehow throws (defensive — should be unreachable), the adapter still produces a clean ToolAdmissionDeniedError deny path; it never lets a non-ToolAdmissionDeniedError Error escape.

5. Stability guarantees

The following are public surface and may not change without a contract amendment:

  • createToolLockAdapter signature
  • MiddlewareStage type
  • MiddlewareRequest shape — additive-only on optional fields
  • AdmissionAuditEvent shape — additive-only on optional fields; the four required fields (type, caller, tool, reason, at) are locked
  • ToolAdmissionDeniedError exported class with name, reason, caller, tool, http_status fields
  • ToolLockAdapterOptions field names

The internal algorithm is implementation detail; tests assert behaviour through the public surface, not internal call sites.


6. Test matrix (Step 4 will implement; Step 5 will record)

The test file exercises the contract through the following families:

  • F1 — Module shape. Exports exist; types are correctly shaped at runtime.
  • F2 — Admit path. next() invoked, return value propagated, no listener fired.
  • F3 — Deny path: stages 2-5 short-circuited. next() NOT invoked on deny.
  • F4 — on_deny callback. Fires exactly once on every deny; never on admit. Exceptions swallowed.
  • F5 — on_event callback. Fires exactly once on every deny with frozen AdmissionAuditEvent. Order: on_event before on_deny (assert via shared journal).
  • F6 — ToolAdmissionDeniedError. Has name, reason, caller, tool, http_status: 403. Subclass of Error. Reason field preserved across rejection.
  • F7 — rule_version_mismatch denial path. Stale rule_version produces typed DenialReason.kind === 'rule_version_mismatch'.
  • F8 — Defensive: synthesized denial when admission throws. A registry stub whose computeVersionHash throws is caught; produces kind: 'rule_rejected' denial; no error escapes adapter boundary.
  • F9 — Determinism scanner clean. inspectFunctionForbidden(createToolLockAdapter) and inspectFunctionForbidden(<returned stage>) both return [].
  • F10 — Per-invocation at monotonicity. Multiple denies on one adapter instance see strictly-increasing at.
  • F11 — Default mode handling. Missing req.mode defaults to 'normal' (or options.default_mode when present).
  • F12 — next() rejection propagation. When next() itself rejects, the adapter’s returned Promise rejects with the same error.
  • F13 — next() is awaited via Promise return (no async wrapping). The adapter’s return type and next()’s return type are pairable.
  • F14 — Two adapters have independent at counters.
  • F15 — Listener exception isolation. Throwing on_event does not block on_deny; throwing on_deny does not block the rejection.

Total: ≥ 5 acceptance-criteria fixtures + ≥ 10 additional cases = ≥ 15 distinct cases. Coverage target: ≥ 95% lines, ≥ 90% branches.


7. Risks and mitigations

Risk Mitigation
Corpus self-scan flags async/await in adapter Use Promise composition without keywords (§3 patterns)
computeVersionHash is non-trivial cost — calling per request is wasteful Caller passes rule_version on MiddlewareRequest for cached attestation; adapter only calls when absent
Listener throwing breaks deny flow Try/catch around each listener call (§3.3)
DenialReason mutation by callers via on_deny DenialReason is structurally immutable per P1.4.2; the typed surface has only readonly fields. No defensive copy needed (I9)
α future re-export of MiddlewareStage collides with our local export TODO marker in source flags the migration; MiddlewareStage will become a re-export. Public surface stable; only the import path differs.
next() may be called with arguments Contract specifies next: () => Promise<unknown> — zero arity. If α future changes that, adapter signature changes too, but TODO marker captures the dependency.

8. Out-of-scope (deferred)

  • Wiring the adapter into src/server.ts’s wrappedHandler chain (Phase 1.5+ α task)
  • Pre-deny rate limiting / circuit breaking (Phase 1.5+ ξ observability)
  • Audit-layer integration with η Proof Store (Phase 0 already shipped η; the κ adapter does NOT bind to η in this PR — that’s a separate observability wiring)
  • Per-mode admission shortcuts (admin-mode bypass) — admission.ts §1 explicitly leaves admin-mode handling to policy/rule layer
  • Effect-mutation propagation to β commit — admit branch does NOT carry effect_mutations onwards; Phase 1.5+ commit-time integration consumes that

9. Acceptance criteria (mapped to tests)

AC# Statement Test
AC1 createToolLockAdapter factory exported and constructible F1
AC2 MiddlewareStage signature compiles against the canonical α reality F1 (compile-time)
AC3 Admit path: next() invoked, result/throw propagated faithfully F2, F12
AC4 Deny path: next() NOT invoked F3
AC5 Deny path: throws ToolAdmissionDeniedError carrying {reason: DenialReason} F6
AC6 Deny path: structured audit event emitted before throw F5
AC7 on_deny callback invoked exactly once on every deny; never on admit F2, F4
AC8 Adapter is pure factory output — no global state, no auto-registration F9, F14 + I1 verified by git diff
AC9 Determinism scanner clean F9
AC10 ZERO modifications to src/server.ts I1 verified by git diff origin/main..HEAD -- src/server.ts

10. Closing — green light for Step 3

Public surface fully specified. Algorithm pinned. 16 invariants enumerated. 15 test cases planned. Risk register populated. Out-of-scope explicit.

Next: write the packet (docs/packets/p1-4-4-tool-lock-adapter-packet.md).


Back to top

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

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