Audit — 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 (origin/main, R87 Wave 7 close — P1.4.2 denial-reasons landed) Owner: T3 executor (Claude) β task ID: a9b63b19-6781-4f1d-9a76-a7f8eb66ed27 Step: 1 of 5 — inventory the surface


1. Mandate (from task-prompts §P1.4.4)

Write the adapter that converts the κ admission evaluator into an α stage-1 middleware handler.

  • createToolLockAdapter(registry: RuleRegistry, options?: {on_deny?: (reason: DenialReason) => void}): MiddlewareStage factory
  • On admit: invoke next() and propagate; on deny: emit structured audit event, invoke on_deny, throw ToolAdmissionDeniedError carrying typed DenialReason
  • Stages 2–5 never invoked on denial
  • Zero modifications to src/server.ts — α wiring is deferred to Phase 1.5+ per ADR-005

Reference: docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.4.4 lines 2067–2206.

Override applied: the source prompt in §P1.4.4 (R76-era) speculates MiddlewareRequest = { caller: NodeId, tool, args, mode, audit: AuditHandle } (line 2122) using types — NodeId, Mode, AuditHandle — that do not exist in the actual codebase. The dispatch override directs me to inspect the real middleware contract and define matching local types where canonical exports are absent. §3 below maps the speculative type graph onto live code.


2. Pre-existing α middleware surface (live code at 69bc4714)

The α 5-stage middleware is inlined in src/server.ts per CLAUDE.md §9.1. There is no src/middleware/ directory. The relevant call site is registerColibriTool at src/server.ts:279–410.

2.1. Inline 5-stage chain

src/server.ts:296–391 builds wrappedHandler around each tool. Pseudocode:

wrappedHandler(rawArgs):
  return runWithToolLock(ctx, name, async () => {        # Stage 1
    parsed = inputSchema.safeParse(rawArgs)              # Stage 2
    if !parsed.success: emit exit event, return error envelope, RETURN
    typedArgs = parsed.data
    correlationId, enterTs = randomUUID(), nowMs()
    try {
      auditSink.enter(...)                               # Stage 3
      result = await handler(typedArgs)                  # Stage 4
      return success envelope
    } catch e {
      error = e
      return error envelope
    } finally {
      auditSink.exit({...})                              # Stage 5
    }
  })

The five stages, in the live code’s vocabulary:

  1. tool-lockrunWithToolLock (per-tool async mutex).
  2. schema-validateinputSchema.safeParse.
  3. audit-enterctx.auditSink.enter(ToolEnterEvent).
  4. dispatchawait handler(typedArgs).
  5. audit-exitctx.auditSink.exit(ToolExitEvent) in finally.

2.2. Stage signature reality

There is no exported MiddlewareStage type in src/server.ts. The five stages are inline IIFE-style code blocks inside wrappedHandler, not (req, next) => Promise<unknown> functions. The α architecture is “5-stage wrapper around handler”, not “5 composable functions”.

This means P1.4.4’s MiddlewareStage signature is a forward-looking contract that does not yet have a counterpart in src/server.ts. The signature must be defined locally in tool-lock-adapter.ts with a clear // TODO(P1.5+): align with α once exported marker, exactly per the prompt-override §”Acceptance Criteria” guidance.

2.3. Available types in src/server.ts

Exported and useful for the adapter:

  • ToolEnterEvent (src/server.ts:86–91) — { tool, args, timestamp, correlationId }
  • ToolExitEvent (src/server.ts:98–104) — { tool, correlationId, durationMs, result?, error? }
  • AuditSink (src/server.ts:111–114) — { enter(e), exit(e) }
  • ColibriToolConfig, CreateServerOptions, ColibriServerContext — server-side, not adapter-relevant

Not exported / not useful:

  • NodeId — not defined anywhere in src/. Plan: use string for caller (matches admission’s AdmissionRequest.caller: string).
  • Mode — closest type is RuntimeMode ('interactive' | 'background' | 'admin' etc.) in src/modes.ts, but the κ admission already has its own AdmissionMode = 'normal' | 'readonly' | 'admin'. Plan: use AdmissionMode from ./admission.js.
  • AuditHandle — not defined anywhere. The closest concept is AuditSink. Plan: use a minimal local AuditEventEmitter callback type, decoupled from the full AuditSink.

2.4. Determinism corpus self-scan reality

The corpus self-scan at src/__tests__/domains/rules/determinism.test.ts:833–890 walks every .ts file in src/domains/rules/ (excluding determinism.ts itself) and rejects forbidden tokens including \basync\s+(?:function|\()/g and \bawait\b.

Every existing file in src/domains/rules/ is synchronous — no async, no await, no Promises in the hot path.

The new tool-lock-adapter.ts lives in this directory per the task spec. The adapter’s stage function MUST return Promise<unknown> (the middleware contract requires it) and MUST call next() which returns Promise<unknown>. The adapter therefore CANNOT use async/await keywords — but it CAN return Promises via Promise.reject(...) and next()-return propagation. This is the technique used. See §6.


3. Pre-existing κ admission surface (live code at 69bc4714)

3.1. P1.4.1 — evaluateAdmission (src/domains/rules/admission.ts:191–305)

Signature:

function evaluateAdmission(
  req: AdmissionRequest,
  registry: RuleRegistry,
): AdmissionResult;

Inputs (AdmissionRequest, line 109–115):

interface AdmissionRequest {
  readonly caller: string;
  readonly tool: string;
  readonly mode: AdmissionMode;          // 'normal' | 'readonly' | 'admin'
  readonly rep_snapshot: ReadOnlyState;
  readonly rule_version: string;
}

Outputs (AdmissionResult, line 162–164):

type AdmissionResult =
  | { admitted: true; effect_mutations: readonly Mutation[]; rule_version: string }
  | { admitted: false; reason: DenialReason; rule_version: string };

Totality: documented at admission.ts:32–35evaluateAdmission never throws. Engine boundary catches all internal exceptions and surfaces them as rule_rejected.

Pure synchronous. No I/O. Determinism scanner clean.

3.2. P1.4.2 — DenialReason discriminated union (src/domains/rules/denial-reasons.ts)

Exported types relevant to the adapter:

  • DenialReason (line 261–269) — 8-variant union: no_rule_matched | budget | effect_invariant_violated | axiom_violation | policy | rule_version_mismatch | ambiguous_ruleset | rule_rejected
  • BudgetAxis, AxiomId, PolicyDiscriminant, PolicySentinel — narrow string unions
  • serializeDenialReason(r): string — canonical-JSON encoder
  • renderDenialReason(r): string — operator-readable renderer

The adapter consumes DenialReason as opaque (it does not switch on kind). It carries the reason verbatim into ToolAdmissionDeniedError.reason and the audit event payload.

3.3. P1.4.3 — BudgetTracker (src/domains/rules/budget.ts)

Not directly consumed by the adapter, but inspected for shape/idiom inspiration:

  • BudgetTick (line 211–215) — { kind: BudgetTickKind, at: bigint, counter_snapshot: BudgetSnapshot }
  • at is a logical bigint counter, NOT wall-clock — for determinism

The adapter borrows the same idea: the audit event’s at field is a logical sequence counter (closure-scoped, monotonic per adapter instance), NOT Date.now().

3.4. P1.2.4 — RuleRegistry (src/domains/rules/registry.ts)

Used by the adapter as the second factory argument:

  • static loadRuleset(source: string): RuleRegistry (factory)
  • instance.computeVersionHash(): string — needed because the adapter builds an AdmissionRequest and must populate rule_version from somewhere

The version-hash question. The adapter needs to fill AdmissionRequest.rule_version. Two choices:

A. Use registry.computeVersionHash() directly — guaranteed to match, version-mismatch path becomes unreachable from this caller. B. Read the version off the MiddlewareRequest — caller declares its expected version; mismatch path is reachable.

The κ design (admission.ts line 197–211) treats rule_version_mismatch as a caller-attestation failure: the caller affirms which ruleset version it believes is active, and a mismatch reveals out-of-band ruleset change. The adapter must therefore preserve this — accept a rule_version from the caller (or default to registry.computeVersionHash() when absent for ergonomic reasons).

Decision (locked in §6): the adapter accepts an optional rule_version field on its synthesized MiddlewareRequest shape; when absent, defaults to registry.computeVersionHash(). Tests cover both arms.


4. Test infrastructure available

Existing patterns mined from src/__tests__/domains/rules/admission.test.ts:

  • mkPair(source, overrides) — builds { registry, req } from a κ DSL source string
  • mkState(overrides) — builds a minimal ReadOnlyState via makeReadOnlyState
  • 'rule R { guards { true -> admit } effects { } }' — minimal admit-everything rule
  • 'rule R { guards { false -> admit } effects { } }' — minimal no_match
  • 'rule R { guards { true -> reject "<reason>" } effects { } }' — minimal explicit reject

These DSL sources will drive the adapter test fixtures.

The corpus self-scan at determinism.test.ts:833 will automatically include tool-lock-adapter.ts in its sweep — the new file MUST stay clean of forbidden patterns. See §6 for the implementation strategy that satisfies this.


5. What is missing vs §P1.4.4

§P1.4.4 mandate Status in pre-existing surface
createToolLockAdapter(registry, options?): MiddlewareStage factory ✗ missing — this PR ships it
MiddlewareStage type ✗ no canonical export — local type with TODO marker
MiddlewareRequest type ✗ no canonical export — local type with TODO marker
ToolAdmissionDeniedError class with {reason: DenialReason} field ✗ missing — this PR ships it
Admit-path: invokes next(), propagates result/throw faithfully ✗ missing — implemented in §6
Deny-path: emits audit event, calls on_deny, throws ✗ missing — implemented in §6
Stages 2–5 never invoked on denial ✗ — guaranteed by adapter semantics (§6)
options.on_deny callback semantics: exactly once on every deny, never on admit ✗ — implemented in §6
Audit event structure with deterministic at field ✗ — implemented in §6
Determinism scanner clean ✗ — strategy in §6 (no async/await keywords)
ZERO modifications to src/server.ts ✓ — this is a new file in src/domains/rules/
Dedicated module file src/domains/rules/tool-lock-adapter.ts ✗ missing — this PR ships it
Dedicated test file src/__tests__/domains/rules/tool-lock-adapter.test.ts ✗ missing — this PR ships it

6. Implementation strategy (constraints + technique)

6.1. Async without async/await keywords

Constraint: corpus self-scan rejects async and await tokens in any src/domains/rules/*.ts file. The middleware contract requires the stage function to return Promise<unknown> and to call next(): Promise<unknown>.

Technique: Promise composition without keywords.

// Admit path:
return next();   // returns the Promise from next() directly

// Deny path:
return Promise.reject(new ToolAdmissionDeniedError(reason));

The stage function is a plain function returning Promise<unknown>, NOT an async function. Both branches are pure value-returning expressions; no await, no async keywords appear.

6.2. Audit event determinism

Constraint: corpus scanner rejects Date.* and new Date. The audit event needs a logical-time field per the prompt’s req.audit.clock() hint.

Technique: closure-scoped monotonic bigint counter incremented per-emission. Same idiom as BudgetTracker._at (budget.ts:323).

// In factory closure:
let _at = 0n;

// Per emission:
_at = _at + 1n;
const event = { kind: 'admission_deny', reason, caller, tool, at: _at };

6.3. Deny-path ordering

The contract demands: emit audit event → call on_deny → throw. All three happen synchronously before the Promise rejects (the rejection itself is the “throw” boundary in Promise terms).

Order matters: if on_deny throws (callback misuse), should the audit event still be emitted? Yes — emit FIRST, then on_deny, then reject. This guarantees that even a misbehaving callback can’t suppress the audit record. Codified in tests F4.

6.4. Local types with TODO markers

// TODO(P1.5+): align with α `src/server.ts` once `MiddlewareStage` /
// `MiddlewareRequest` are exported there. The shape here is a
// forward-looking contract per docs/audits/p1-4-4-tool-lock-adapter-audit.md
// §3.

export interface MiddlewareRequest { /* ... */ }
export type MiddlewareStage = (req: MiddlewareRequest, next: () => Promise<unknown>) => Promise<unknown>;

Both types are exported so consumers and tests can declare middleware-shaped values without importing private symbols.

6.5. ZERO src/server.ts modifications

The whole point: this PR ships the adapter as a library that κ-aware code (Phase 1.5+ α wiring task) will register. No registration, no auto-wiring, no side effect at module load time. Verified post-implement by git diff origin/main..HEAD -- src/server.ts returning empty.


7. Forbidden moves in this audit’s scope

  • ✗ Modifying src/server.ts
  • ✗ Adding crypto.*, Date.*, Math.random, setTimeout, etc. to the new file
  • ✗ Marking the adapter async (would break corpus self-scan)
  • ✗ Auto-registering the adapter in any module’s top-level body
  • ✗ Importing from src/middleware/ (it doesn’t exist)
  • ✗ Inventing a MiddlewareStage export in src/server.ts

8. Open questions resolved before contract step

Q Resolution
Where does MiddlewareStage live? Locally exported from tool-lock-adapter.ts with a forward-compat // TODO(P1.5+) marker.
What is MiddlewareRequest.audit? Adapter does NOT consume an inbound audit field. Audit emission is via the optional on_event? factory option (a callback). The synthetic at: bigint field is closure-scoped.
rule_version source? Read from inbound MiddlewareRequest.rule_version if present; else default to registry.computeVersionHash(). Both arms tested.
mode source? Read from inbound MiddlewareRequest.mode if present; else default to 'normal'. Both arms tested.
rep_snapshot source? REQUIRED on MiddlewareRequest. No default — admission cannot evaluate without state. Tested by F2.
args field on MiddlewareRequest? Carried but UNUSED by the adapter (admission ignores args; only caller/tool/mode matter). Kept on the type for forward-compat with stage 2 (schema-validate).
What goes on ToolAdmissionDeniedError? name, reason: DenialReason, caller: string, tool: string, http_status: 403, message: renderDenialReason(reason). Matches the source prompt’s intent + augments with the source-prompt-required http_status.
Should the adapter call next() if evaluateAdmission somehow throws? NO — but this case is unreachable per admission.ts §32–35 totality. Defensive: a try-catch wraps evaluateAdmission and converts any thrown surprise into ToolAdmissionDeniedError with a synthesized rule_rejected reason carrying the error message. Tested by F8.

9. Closing — green light for Step 2

Surface inventoried. Live middleware code understood. Live admission code understood. Determinism constraint identified and a non-async/await technique chosen. ZERO src/server.ts modifications confirmed feasible.

Next: write the contract (docs/contracts/p1-4-4-tool-lock-adapter-contract.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.