Packet — 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: 3 of 5 — execution plan (gates Step 4)

This packet is the gating artefact for implementation. Step 4 may NOT begin without packet approval (CLAUDE.md §6 gate rule).


1. Files to write (exact paths)

Path LOC budget Role
src/domains/rules/tool-lock-adapter.ts ~ 200 Adapter source — factory + types + error class
src/__tests__/domains/rules/tool-lock-adapter.test.ts ~ 600 Test fixtures (≥15 cases)

2. Files to NOT write / modify

Path Reason
src/server.ts THE central forbidden — α wiring deferred to Phase 1.5+ per ADR-005
src/domains/rules/admission.ts P1.4.1 already shipped; consume only
src/domains/rules/denial-reasons.ts P1.4.2 already shipped; consume only
src/domains/rules/budget.ts P1.4.3 already shipped; not directly consumed
src/domains/rules/registry.ts P1.2.4 already shipped; consume only
Anything in src/middleware/ Directory does not exist (CLAUDE.md §9.1)

git diff origin/main..HEAD -- src/server.ts MUST be empty at PR time. Verified by Step 5.


3. Source skeleton — src/domains/rules/tool-lock-adapter.ts

/**
 * Colibri — Phase 1 κ Rule Engine — Tool-Lock Integration Spec (P1.4.4).
 *
 * Adapter from κ admission (P1.4.1 evaluateAdmission) to the α 5-stage
 * middleware "tool-lock" stage. Pure factory: no I/O, no DB, no globals,
 * no auto-registration. Phase 1.5+ α wiring task will register the
 * returned stage with src/server.ts; this PR ships the adapter as a
 * library only.
 *
 * Async semantics WITHOUT async/await keywords:
 *   - The middleware contract requires the stage to return Promise<unknown>
 *     and to call next(): Promise<unknown>.
 *   - The corpus self-scan at src/__tests__/domains/rules/determinism.test.ts
 *     forbids `async` and `await` tokens in any src/domains/rules/*.ts file.
 *   - The adapter satisfies both: admit branch returns `next()` directly
 *     (Promise propagation); deny branch returns `Promise.reject(error)`.
 *
 * The `at` field on AdmissionAuditEvent is a logical bigint counter, NOT
 * wall-clock — for θ consensus determinism (matches BudgetTracker._at idiom
 * at budget.ts:323).
 *
 * TODO(P1.5+): align MiddlewareStage / MiddlewareRequest with the canonical
 * α exports from src/server.ts once the inline 5-stage chain is refactored.
 *
 * Canonical references:
 *   - docs/audits/p1-4-4-tool-lock-adapter-audit.md
 *   - docs/contracts/p1-4-4-tool-lock-adapter-contract.md
 *   - docs/packets/p1-4-4-tool-lock-adapter-packet.md
 *   - docs/3-world/physics/laws/rule-engine.md §Admission layer
 *   - docs/spec/s10-admission.md
 */

import {
  evaluateAdmission,
} from './admission.js';
import type {
  AdmissionMode,
  AdmissionRequest,
  AdmissionResult,
} from './admission.js';
import type { DenialReason } from './denial-reasons.js';
import { renderDenialReason } from './denial-reasons.js';
import type { RuleRegistry } from './registry.js';
import type { ReadOnlyState } from './state-access.js';

// =============================================================================
// §1. Forward-compat types — local with TODO marker
// =============================================================================

/**
 * MiddlewareRequest — inbound shape a stage sees.
 *
 * Locally defined; `src/server.ts` does not yet export this. Once the α
 * 5-stage chain is refactored from inline IIFE blocks (server.ts:296)
 * into composable functions, this type becomes a re-export.
 */
export interface MiddlewareRequest {
  readonly caller: string;
  readonly tool: string;
  readonly args: unknown;
  readonly mode?: AdmissionMode;
  readonly rep_snapshot: ReadOnlyState;
  readonly rule_version?: string;
}

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

// =============================================================================
// §2. Audit event shape
// =============================================================================

export interface AdmissionAuditEvent {
  readonly type: 'admission_deny';
  readonly caller: string;
  readonly tool: string;
  readonly reason: DenialReason;
  readonly at: bigint;
}

export type AdmissionEventListener = (event: AdmissionAuditEvent) => void;

// =============================================================================
// §3. ToolAdmissionDeniedError
// =============================================================================

export class ToolAdmissionDeniedError extends Error {
  override readonly name = 'ToolAdmissionDeniedError';
  readonly reason: DenialReason;
  readonly caller: string;
  readonly tool: string;
  readonly http_status: 403 = 403;

  constructor(reason: DenialReason, caller: string, tool: string) {
    super(renderDenialReason(reason));
    this.reason = reason;
    this.caller = caller;
    this.tool = tool;
  }
}

// =============================================================================
// §4. Factory options
// =============================================================================

export interface ToolLockAdapterOptions {
  readonly on_deny?: (reason: DenialReason) => void;
  readonly on_event?: AdmissionEventListener;
  readonly default_mode?: AdmissionMode;
}

// =============================================================================
// §5. Factory — createToolLockAdapter
// =============================================================================

export function createToolLockAdapter(
  registry: RuleRegistry,
  options?: ToolLockAdapterOptions,
): MiddlewareStage {
  // Closure-scoped logical-time counter. Monotonic per-adapter-instance.
  // Two adapter instances have independent counters (I14).
  let _at = 0n;

  // Resolve listeners + default mode once at factory time. Captures-by-value
  // so the returned stage's behaviour is independent of subsequent option
  // mutation by the caller.
  const onDeny = options !== undefined ? options.on_deny : undefined;
  const onEvent = options !== undefined ? options.on_event : undefined;
  const defaultMode: AdmissionMode =
    options !== undefined && options.default_mode !== undefined
      ? options.default_mode
      : 'normal';

  const stage: MiddlewareStage = (req, next) => {
    // Step 1: synthesize AdmissionRequest.
    const admReq: AdmissionRequest = {
      caller: req.caller,
      tool: req.tool,
      mode: req.mode !== undefined ? req.mode : defaultMode,
      rep_snapshot: req.rep_snapshot,
      rule_version:
        req.rule_version !== undefined
          ? req.rule_version
          : registry.computeVersionHash(),
    };

    // Step 2: evaluate. Defensive try/catch — admission.ts §32-35 documents
    // total semantics, so the catch arm is unreachable in current code, but
    // synthesizing a rule_rejected denial keeps the deny-flow shape uniform
    // even under hypothetical future regressions.
    let result: AdmissionResult;
    try {
      result = evaluateAdmission(admReq, registry);
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      const synthReason: DenialReason = {
        kind: 'rule_rejected',
        rule_name: '<adapter>',
        rule_reason: 'evaluator_threw:' + msg,
      };
      result = {
        admitted: false,
        reason: synthReason,
        rule_version: admReq.rule_version,
      };
    }

    // Step 3: branch.
    if (result.admitted) {
      // Admit — propagate next() Promise faithfully (I3).
      return next();
    }

    // Deny — emit, notify, reject (I4-I9).
    _at = _at + 1n;
    const event: AdmissionAuditEvent = Object.freeze({
      type: 'admission_deny',
      caller: req.caller,
      tool: req.tool,
      reason: result.reason,
      at: _at,
    });

    // Listener exception isolation (I7, I15). Each listener runs inside a
    // synchronous try/catch; a thrown error neither blocks the other listener
    // nor prevents the rejection.
    if (onEvent !== undefined) {
      try {
        onEvent(event);
      } catch {
        /* swallowed per contract §2.2 */
      }
    }
    if (onDeny !== undefined) {
      try {
        onDeny(result.reason);
      } catch {
        /* swallowed per contract §2.2 */
      }
    }

    return Promise.reject(
      new ToolAdmissionDeniedError(result.reason, req.caller, req.tool),
    );
  };

  return stage;
}

3.1. Why no async keyword

The factory createToolLockAdapter returns a plain function stage. The function body’s return points are all expressions of type Promise<unknown>:

  • return next();next is typed () => Promise<unknown>; result is a Promise.
  • return Promise.reject(...); — explicit Promise.

No await is needed in this code path because the adapter doesn’t inspect the resolved value of next() — it propagates it as-is. No async is needed because the function is not built around suspending on awaited values.

3.2. Why no crypto.randomUUID

The audit event has no UUID — at: bigint is the deterministic identifier. UUIDs would inject host non-determinism. Future α wiring may add a UUID at stage 3; that’s a separate concern.

3.3. Why two listeners (on_deny + on_event)

The source prompt §P1.4.4 line 2130 says “structured audit event {type, reason, caller, tool, timestamp}” AND line 2131 says “Call options.on_deny callback if provided”. These are two distinct hooks. We split them: on_event for the structured emission (audit-layer wiring), on_deny for the flat reason callback (caller-policy hook). Either or both may be supplied; both are optional.


4. Test skeleton — src/__tests__/domains/rules/tool-lock-adapter.test.ts

4.1. Imports

import {
  createToolLockAdapter,
  ToolAdmissionDeniedError,
} from '../../../domains/rules/tool-lock-adapter.js';
import type {
  AdmissionAuditEvent,
  AdmissionEventListener,
  MiddlewareRequest,
  MiddlewareStage,
  ToolLockAdapterOptions,
} from '../../../domains/rules/tool-lock-adapter.js';
import { RuleRegistry } from '../../../domains/rules/registry.js';
import { makeReadOnlyState } from '../../../domains/rules/state-access.js';
import type { ReadOnlyState } from '../../../domains/rules/state-access.js';
import { inspectFunctionForbidden } from '../../../domains/rules/determinism.js';
import type { DenialReason } from '../../../domains/rules/denial-reasons.js';
import type { AdmissionMode } from '../../../domains/rules/admission.js';

4.2. Helpers

const HEX64 = 'a'.repeat(64);

function mkState(overrides: Partial<{
  epoch: bigint; event_count: bigint; fork_id: string; rule_version: string;
}> = {}): ReadOnlyState {
  return makeReadOnlyState({
    epoch: overrides.epoch ?? 1n,
    event_count: overrides.event_count ?? 0n,
    fork_id: overrides.fork_id ?? HEX64,
    rule_version: overrides.rule_version ?? HEX64,
  });
}

function mkReq(overrides: Partial<MiddlewareRequest> = {}): MiddlewareRequest {
  return {
    caller: overrides.caller ?? 'alice',
    tool: overrides.tool ?? 'create_task',
    args: overrides.args ?? { x: 1 },
    mode: overrides.mode,
    rep_snapshot: overrides.rep_snapshot ?? mkState(),
    rule_version: overrides.rule_version,
  };
}

const ADMIT_ALL_SOURCE = 'rule R { guards { true -> admit } effects { } }';
const REJECT_SOURCE = 'rule R { guards { true -> reject "denied_by_rule" } effects { } }';
const NO_MATCH_SOURCE = 'rule R { guards { false -> admit } effects { } }';

4.3. Test families (per contract §6)

Each family corresponds to one test or a small describe block. Total cases: ≥ 15 (well above the 5-fixture minimum in the source prompt).

F1 — Module shape.

  • F1.1 createToolLockAdapter is a function
  • F1.2 ToolAdmissionDeniedError is a class extending Error
  • F1.3 returned stage is a function with arity 2

F2 — Admit path.

  • F2.1 admit invokes next() exactly once
  • F2.2 admit returns the Promise from next() (await it; assert resolved value)
  • F2.3 admit does NOT invoke on_deny or on_event

F3 — Deny path stages 2-5 short-circuited.

  • F3.1 deny does NOT invoke next()
  • F3.2 deny rejects with ToolAdmissionDeniedError

F4 — on_deny callback semantics.

  • F4.1 on_deny invoked exactly once on every deny
  • F4.2 on_deny receives the typed DenialReason
  • F4.3 on_deny is NOT invoked on admit
  • F4.4 on_deny exception is swallowed; rejection still happens

F5 — on_event callback semantics.

  • F5.1 on_event invoked exactly once on every deny with a frozen event
  • F5.2 on_event event has type: 'admission_deny', caller, tool, reason, at
  • F5.3 on_event fires BEFORE on_deny (assert via shared journal)
  • F5.4 on_event exception is swallowed

F6 — ToolAdmissionDeniedError.

  • F6.1 name === 'ToolAdmissionDeniedError'
  • F6.2 instanceof Error === true AND instanceof ToolAdmissionDeniedError === true
  • F6.3 reason field referentially identical to evaluated result.reason
  • F6.4 caller and tool populated from request
  • F6.5 http_status === 403
  • F6.6 message matches renderDenialReason(reason)

F7 — rule_version_mismatch denial.

  • F7.1 stale rule_version produces kind: 'rule_version_mismatch' reason
  • F7.2 expected/actual fields populated correctly

F8 — Defensive: synthesized denial when evaluateAdmission (via computeVersionHash) throws.

  • F8.1 mock registry whose computeVersionHash throws → caught, synthesized rule_rejected
  • F8.2 no exception escapes adapter boundary

F9 — Determinism scanner clean.

  • F9.1 inspectFunctionForbidden(createToolLockAdapter) returns []
  • F9.2 inspectFunctionForbidden(stage) returns [] (the returned function)

F10 — at monotonicity.

  • F10.1 three sequential denies on one adapter see at = 1n, 2n, 3n
  • F10.2 admits in between do not increment at

F11 — Default mode handling.

  • F11.1 req.mode undefined defaults to 'normal' (admission sees 'normal')
  • F11.2 options.default_mode = 'admin' overrides default to 'admin'
  • F11.3 req.mode = 'readonly' wins over options.default_mode

F12 — next() rejection propagation.

  • F12.1 next() rejects → adapter returns rejection with same error
  • F12.2 next() resolves → adapter returns same resolution

F13 — Promise return shape.

  • F13.1 admit branch return type is Promise<unknown> (await it)
  • F13.2 deny branch return type is Promise<unknown> (await rejection)

F14 — Adapter-instance independence.

  • F14.1 two createToolLockAdapter calls have independent at counters

F15 — Listener exception isolation.

  • F15.1 throwing on_event does not skip on_deny
  • F15.2 throwing on_deny does not block rejection
  • F15.3 both throwing — rejection still fires

5. Step 4 procedure

  1. Write src/domains/rules/tool-lock-adapter.ts per §3.
  2. Write src/__tests__/domains/rules/tool-lock-adapter.test.ts per §4.
  3. Run from worktree root:
    npm run build
    npm run lint
    npm test -- --testPathPattern='tool-lock-adapter|determinism|admission'
    
  4. Iterate on lint/type errors until clean.
  5. Run full suite:
    npm test
    
  6. Record exact passing test count.
  7. Verify git diff origin/main..HEAD -- src/server.ts is empty.
  8. Commit as feat(p1-4-4): tool-lock adapter.

6. Step 5 procedure

  1. Write docs/verification/p1-4-4-tool-lock-adapter-verification.md.
  2. Embed: full test output snippet, line counts of new files, AC mapping table, git diff evidence for src/server.ts untouched.
  3. Commit as verify(p1-4-4): test evidence.

7. PR + writeback procedure

  1. Push branch.
  2. gh pr create --title "feat(p1-4-4-tool-lock-adapter): admission middleware adapter (R87 κ Wave 8 — Phase 1 close)".
  3. Watch CI: gh pr checks <num> --watch.
  4. On green:
    • Remove worktree (R76 quirk recovery).
    • gh pr merge <num> --squash --delete-branch.
    • git -C E:/AMS fetch --prune origin.
  5. Writeback (modern MCP signature):
    • mcp__colibri__thought_record first (type=reflection, task_id UUID, full content)
    • mcp__colibri__task_update second (id UUID, patch={status: “DONE”})

8. Risk register (updated for Step 4)

Risk Status
npm run lint flags // TODO comments Should be fine — TODO comments in JSDoc are accepted; eslint config doesn’t ban them.
Corpus self-scan rejects new file Mitigated by no-async/await pattern. Verified by F9 + the corpus test.
Test fixture builds invalid κ DSL Reusing ADMIT_ALL_SOURCE / REJECT_SOURCE / NO_MATCH_SOURCE proven by admission.test.ts mining.
evaluateAdmission returning admit + populated effect_mutations propagates them where? Mutations dropped by adapter; admit branch ONLY calls next(). Future α wiring will route effects to β commit. Out-of-scope per §8 of contract.
Listener typing too narrow for callers Both listeners take well-typed shapes (DenialReason and AdmissionAuditEvent). Callers that want raw values can wrap.
ToolAdmissionDeniedError http_status: 403 literal type TypeScript readonly http_status: 403 = 403 — narrows correctly without ceremony.

9. Closing — green light for Step 4

Skeleton drafted. Test families enumerated. Determinism strategy locked. ZERO src/server.ts mods preserved.

Next: implement.


Back to top

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

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