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();—nextis 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
createToolLockAdapteris a function - F1.2
ToolAdmissionDeniedErroris a class extendingError - 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_denyoron_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_denyinvoked exactly once on every deny - F4.2
on_denyreceives the typedDenialReason - F4.3
on_denyis NOT invoked on admit - F4.4
on_denyexception is swallowed; rejection still happens
F5 — on_event callback semantics.
- F5.1
on_eventinvoked exactly once on every deny with a frozen event - F5.2
on_eventevent hastype: 'admission_deny',caller,tool,reason,at - F5.3
on_eventfires BEFOREon_deny(assert via shared journal) - F5.4
on_eventexception is swallowed
F6 — ToolAdmissionDeniedError.
- F6.1
name === 'ToolAdmissionDeniedError' - F6.2
instanceof Error === trueANDinstanceof ToolAdmissionDeniedError === true - F6.3
reasonfield referentially identical to evaluatedresult.reason - F6.4
callerandtoolpopulated from request - F6.5
http_status === 403 - F6.6
messagematchesrenderDenialReason(reason)
F7 — rule_version_mismatch denial.
- F7.1 stale
rule_versionproduceskind: '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
computeVersionHashthrows → caught, synthesizedrule_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.modeundefined defaults to'normal'(admission sees'normal') - F11.2
options.default_mode = 'admin'overrides default to'admin' - F11.3
req.mode = 'readonly'wins overoptions.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
createToolLockAdaptercalls have independentatcounters
F15 — Listener exception isolation.
- F15.1 throwing
on_eventdoes not skipon_deny - F15.2 throwing
on_denydoes not block rejection - F15.3 both throwing — rejection still fires
5. Step 4 procedure
- Write
src/domains/rules/tool-lock-adapter.tsper §3. - Write
src/__tests__/domains/rules/tool-lock-adapter.test.tsper §4. - Run from worktree root:
npm run build npm run lint npm test -- --testPathPattern='tool-lock-adapter|determinism|admission' - Iterate on lint/type errors until clean.
- Run full suite:
npm test - Record exact passing test count.
- Verify
git diff origin/main..HEAD -- src/server.tsis empty. - Commit as
feat(p1-4-4): tool-lock adapter.
6. Step 5 procedure
- Write
docs/verification/p1-4-4-tool-lock-adapter-verification.md. - Embed: full test output snippet, line counts of new files, AC mapping table,
git diffevidence forsrc/server.tsuntouched. - Commit as
verify(p1-4-4): test evidence.
7. PR + writeback procedure
- Push branch.
gh pr create --title "feat(p1-4-4-tool-lock-adapter): admission middleware adapter (R87 κ Wave 8 — Phase 1 close)".- Watch CI:
gh pr checks <num> --watch. - On green:
- Remove worktree (R76 quirk recovery).
gh pr merge <num> --squash --delete-branch.git -C E:/AMS fetch --prune origin.
- Writeback (modern MCP signature):
mcp__colibri__thought_recordfirst (type=reflection, task_id UUID, full content)mcp__colibri__task_updatesecond (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.