singularity-forge/docs/dev/drafts/uok-gate-call-site-migration-template.md

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.