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}): MiddlewareStagefactory- On admit: invoke
next()and propagate; on deny: emit structured audit event, invokeon_deny, throwToolAdmissionDeniedErrorcarrying typedDenialReason- 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:
- tool-lock —
runWithToolLock(per-tool async mutex). - schema-validate —
inputSchema.safeParse. - audit-enter —
ctx.auditSink.enter(ToolEnterEvent). - dispatch —
await handler(typedArgs). - audit-exit —
ctx.auditSink.exit(ToolExitEvent)infinally.
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 insrc/. Plan: usestringforcaller(matches admission’sAdmissionRequest.caller: string).Mode— closest type isRuntimeMode('interactive' | 'background' | 'admin'etc.) insrc/modes.ts, but the κ admission already has its ownAdmissionMode = 'normal' | 'readonly' | 'admin'. Plan: useAdmissionModefrom./admission.js.AuditHandle— not defined anywhere. The closest concept isAuditSink. Plan: use a minimal localAuditEventEmittercallback type, decoupled from the fullAuditSink.
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–35 — evaluateAdmission 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_rejectedBudgetAxis,AxiomId,PolicyDiscriminant,PolicySentinel— narrow string unionsserializeDenialReason(r): string— canonical-JSON encoderrenderDenialReason(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 }atis 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 anAdmissionRequestand must populaterule_versionfrom 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 stringmkState(overrides)— builds a minimalReadOnlyStateviamakeReadOnlyState'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
MiddlewareStageexport insrc/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).