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:
createToolLockAdaptersignatureMiddlewareStagetypeMiddlewareRequestshape — additive-only on optional fieldsAdmissionAuditEventshape — additive-only on optional fields; the four required fields (type,caller,tool,reason,at) are lockedToolAdmissionDeniedErrorexported class withname,reason,caller,tool,http_statusfieldsToolLockAdapterOptionsfield 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_denycallback. Fires exactly once on every deny; never on admit. Exceptions swallowed. - F5 —
on_eventcallback. Fires exactly once on every deny with frozenAdmissionAuditEvent. Order:on_eventbeforeon_deny(assert via shared journal). - F6 —
ToolAdmissionDeniedError. Hasname,reason,caller,tool,http_status: 403. Subclass ofError. Reason field preserved across rejection. - F7 —
rule_version_mismatchdenial path. Stalerule_versionproduces typedDenialReason.kind === 'rule_version_mismatch'. - F8 — Defensive: synthesized denial when admission throws. A registry stub whose
computeVersionHashthrows is caught; produceskind: 'rule_rejected'denial; no error escapes adapter boundary. - F9 — Determinism scanner clean.
inspectFunctionForbidden(createToolLockAdapter)andinspectFunctionForbidden(<returned stage>)both return[]. - F10 — Per-invocation
atmonotonicity. Multiple denies on one adapter instance see strictly-increasingat. - F11 — Default mode handling. Missing
req.modedefaults to'normal'(oroptions.default_modewhen present). - F12 —
next()rejection propagation. Whennext()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 andnext()’s return type are pairable. - F14 — Two adapters have independent
atcounters. - F15 — Listener exception isolation. Throwing
on_eventdoes not blockon_deny; throwingon_denydoes 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’swrappedHandlerchain (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).