7.9 KiB
UOK Gate Call-Site Migration Template (ADR-0075 / R079)
Migrate inline new UokGateRunner() call sites to UokGateRegistry +
ctxFactory pattern. The process singleton in uok/gate-registry.js replaces
ad-hoc runner construction and enables cross-process gate health queries.
4-Step Pattern
Step 1 — Read the inline block
Find the new UokGateRunner() instantiation, the runner.register({id, type, execute: async () => { ... }}) call, and note every local variable the closure
captures. These become the ctx fields you will supply at run time.
Step 2 — Lift to module scope
Define a top-level const for the gate object. The execute function receives
ctx as its first argument; replace every closure reference with a ctx.*
read:
// module scope — defined once, not inside any function
const myGate = {
id: "my-gate-id",
type: "verification", // must be a valid UOK_GATE_TYPES value
execute: async (ctx) => ({
outcome: ctx.someFlag ? "manual-attention" : "pass",
failureClass: ctx.someFlag ? "manual-attention" : "none",
rationale: `gate result: ${ctx.someValue}`,
findings: ctx.someFlag ? ctx.details ?? "" : "",
}),
};
Step 3 — Register with a ctxFactory (or inline override)
Two sub-cases:
A. Per-call state (most common): supply the captured locals as a
ctxOverride object to registry.run(). No ctxFactory argument needed when
register() is called:
const registry = getGateRegistry();
if (!registry.has(myGate.id)) {
registry.register(myGate); // no ctxFactory — state comes from override
}
await registry.run(myGate.id, {
someFlag: localFlag, // previously-closed-over state
someValue: localValue,
// plus standard runner ctx fields:
basePath,
traceId: opts?.traceId ?? `my-trace`,
turnId: opts?.turnId ?? `my-turn`,
milestoneId, sliceId, unitType, unitId,
});
B. Stable per-module state: if the gate always needs the same context
object (e.g. a fixed config block), pass a ctxFactory to register() so
callers never have to supply it:
registry.register(myGate, () => ({ stableConfig }));
await registry.run(myGate.id, {}); // ctxFactory merged automatically
registry.run(id, override) merges as { ...ctxFactory(), ...override }, so
override values always win.
Step 4 — Remove the original inline block
Delete the new UokGateRunner() instantiation, the runner.register(...) call,
and the await runner.run(...) call. Import getGateRegistry from
"../uok/gate-registry.js" and remove the UokGateRunner import if it is no
longer used.
Worked Example — tools/validate-milestone.js
Before (199 lines)
import { UokGateRunner } from "../uok/gate-runner.js";
// ...inside handleValidateMilestone:
if (gatesEnabled) {
try {
const gateRunner = new UokGateRunner();
const nonPassVerdict = params.verdict !== "pass";
gateRunner.register({
id: "milestone-validation-gates",
type: "verification",
execute: async () => ({
outcome: nonPassVerdict ? "manual-attention" : "pass",
failureClass: nonPassVerdict ? "manual-attention" : "none",
rationale: `milestone validation verdict: ${params.verdict}`,
findings: nonPassVerdict
? [params.verdictRationale, params.remediationPlan ?? ""].filter(Boolean).join("\n")
: "",
}),
});
await gateRunner.run("milestone-validation-gates", { basePath, traceId, ... });
} catch (err) { logWarning(...); }
}
The closure captured params.verdict, params.verdictRationale,
params.remediationPlan, and the derived nonPassVerdict flag — all call-time
values that differed on every invocation.
After (222 lines)
import { getGateRegistry } from "../uok/gate-registry.js";
// module scope
const milestoneValidationGatesGate = {
id: "milestone-validation-gates",
type: "verification",
execute: async (ctx) => {
const nonPassVerdict = ctx.verdict !== "pass";
return {
outcome: nonPassVerdict ? "manual-attention" : "pass",
failureClass: nonPassVerdict ? "manual-attention" : "none",
rationale: `milestone validation verdict: ${ctx.verdict}`,
findings: nonPassVerdict
? [ctx.verdictRationale, ctx.remediationPlan ?? ""].filter(Boolean).join("\n")
: "",
};
},
};
// inside handleValidateMilestone:
if (gatesEnabled) {
try {
const registry = getGateRegistry();
if (!registry.has(milestoneValidationGatesGate.id)) {
registry.register(milestoneValidationGatesGate);
}
await registry.run(milestoneValidationGatesGate.id, {
verdict: params.verdict,
verdictRationale: params.verdictRationale,
remediationPlan: params.remediationPlan,
basePath, traceId: opts?.traceId ?? `...`, ...
});
} catch (err) { logWarning(...); }
}
The gate object is now registered once (guarded by registry.has()). The
previously-closed-over locals are passed as a plain object to registry.run(),
where they merge with the ctxFactory output (none here) before reaching
execute(ctx).
Per-Gate Scope Checklist (5 remaining inline gates)
| Gate id | File | Captured locals | Notes |
|---|---|---|---|
unit-verification-gate |
auto/phases-finalize.js |
deps, s, ctx, pi, pauseAuto |
Complex; ctx is the outer phase context — rename to phaseCtx in gate's execute(ctx) to avoid shadowing |
milestone-validation-post-check |
uok/auto-verification.js |
persistGate args from runValidateMilestonePostCheck |
Straightforward; all are primitive strings/booleans |
verification-gate |
uok/auto-verification.js |
local verification result + helper formatting state | Multiple gates in same file — see Pitfall section |
post-execution-checks |
uok/auto-verification.js |
postExecResult, strict-mode prefs, derived blocking state |
Three gates in same file — same Pitfall applies |
planning-flow-gate |
guided-flow.js |
persistGate args |
Clean; same pattern as milestone-validation-post-check |
When to Skip ctxFactory
If the gate's execute() needs no context at all (e.g. it reads only DB or
env state), register without a factory and run with an empty override:
registry.register(myStatelessGate); // no ctxFactory
await registry.run(myStatelessGate.id, { basePath, traceId, turnId });
uok/gate-registry-bootstrap.js uses this form for driftDetectionGate, which
sources everything from the DB.
Pitfall: Multiple Call Sites, Same Gate Id, Different State
uok/auto-verification.js contains three distinct gate registrations
(verification-gate, milestone-validation-post-check,
post-execution-checks). Each has its own unique id, so there is no collision.
However, if a future refactor discovers two call sites in the SAME file that run the SAME gate id with different state shapes, choose one of:
Option A — Two distinct gate ids (preferred when logic differs):
const myGateA = { id: "my-gate-pass-path", type: "verification", execute: async (ctx) => ... };
const myGateB = { id: "my-gate-fail-path", type: "verification", execute: async (ctx) => ... };
Each id carries its own circuit-breaker state and audit trail — cleaner semantics at the cost of a registry entry per variant.
Option B — Single id, discriminator field in ctx (preferred when logic is identical but input differs):
const myGate = {
id: "my-gate",
type: "verification",
execute: async (ctx) => {
if (ctx.variant === "foo") { ... }
else { ... }
},
};
// call site A:
await registry.run("my-gate", { variant: "foo", ... });
// call site B:
await registry.run("my-gate", { variant: "bar", ... });
Trade-off: Option A gives separate circuit breakers (fail on path A does not suppress path B). Option B shares a circuit breaker — one pathological failure mode can block the other path. Prefer Option A when the two paths have meaningfully different failure domains.