feat(uok): slice 2 — schema-v2 metadata adapter + writer chain

Second slice of "Make UOK the SF Control Plane". Wires the DB-level
capability for schema-v2 gate metadata so future callers can flip
quality_gates rows from "legacy" to "ok"/"stale"/"incomplete" by
passing a canonical uokContext. No production caller passes ctx yet —
slice 3 wires producers (headless triage --apply, phases-pre-dispatch,
phases-unit).

Schema migration v66 (SCHEMA_VERSION bumped 65 → 66):
  - quality_gates gains 5 nullable columns: surface, run_control,
    permission_profile, trace_id, parent_trace.
  - Idempotent ALTERs via PRAGMA table_info probes — fresh-DB CREATE
    path already includes the columns; migration only ALTERs older DBs.
  - Existing rows keep NULL across the new columns, so classifyCoverage
    in headless-uok-status reads them as "legacy" — no day-one warning
    flood.

New adapter src/resources/extensions/sf/uok/run-context.js:
  - buildUokRunContext(opts) validates and normalizes the canonical
    camelCase shape: surface, runControl, permissionProfile, traceId
    (required), plus parentTrace, unitType, unitId, milestoneId,
    sliceId, taskId (optional). Frozen on success, null on any invalid
    or missing required field.
  - VALID_SURFACES / VALID_RUN_CONTROLS / VALID_PERMISSION_PROFILES
    enums reject typos at build time so we don't get silent schema-v2
    rows with garbage in the enum columns.
  - uokRunContextToGateColumns(ctx) translates camelCase → snake_case
    column shape used by sf-db-gates writers.

Writer chain (sf-db-gates.js):
  - insertGateRow now imports uokRunContextToGateColumns and translates
    g.uokContext (canonical camelCase) to the SQL column shape. Callers
    pass canonical ctx, the DB writer owns translation. NULL on legacy
    callers, NULL on malformed ctx.
  - saveGateResult mirrors the same translation; uses COALESCE(:col,
    col) so a missing ctx on a follow-up update preserves the row's
    existing schema-v2 metadata instead of nulling it.

Reader chain (headless-uok-status.ts):
  - getGateMeta SELECTs surface, run_control, permission_profile,
    trace_id alongside scope and evaluated_at. ORDER BY uses
    "evaluated_at IS NULL, evaluated_at DESC" for cross-SQLite safety
    (NULLS LAST is not portable).
  - classifyCoverage signature changed from (entry, metadataPresent:
    bool) to (entry, meta: GateMetadataRow). Returns "incomplete" when
    surface is set but runControl/permissionProfile/traceId missing —
    surfaces buggy writers instead of silently classifying as "ok".

Tests:
  - uok-run-context.test.mjs (12 tests): adapter validation, enum
    rejection, optional-field handling, frozen output, column
    translation.
  - uok-quality-gates-writer.test.mjs (5 tests): real DB round-trip
    proving insertGateRow + saveGateResult populate schema-v2 columns
    from canonical camelCase ctx, leave NULL on legacy/malformed,
    and preserve existing metadata via COALESCE on no-ctx updates.
  - headless-uok-status.test.mjs adjusted: classifier now takes
    GateMetadataRow; added test for "incomplete" classification.
  - sf-db-migration.test.mjs bumped expected version 65 → 66 and
    asserts the 5 new quality_gates columns exist.

Full SF suite: 1678/1678 ✓ (+17 from slice 2 + +9 from slice 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 17:48:05 +02:00
parent c058bef26d
commit f0c57b58c6
8 changed files with 786 additions and 49 deletions

View file

@ -93,32 +93,57 @@ export interface UokStatusResult {
}
/**
* A row is "legacy" when it lacks the schema-v2 metadata fields that the
* writer adapter (slice 2) will start producing. Today the quality_gates
* table has no surface/runControl/permissionProfile columns, so every row
* is legacy by definition. This guard lives here so the moment those
* columns appear, the classification flips automatically without a
* separate code edit.
* A row is "legacy" when it lacks the schema-v2 metadata that the writer
* adapter (slice 2 of the UOK control-plane plan) populates. Surface is
* the canonical indicator: NULL legacy, set schema-v2 row that
* should be classified ok/stale/incomplete based on the other fields.
*
* Schema v66 added the columns (surface, run_control, permission_profile,
* trace_id, parent_trace); pre-v66 rows have NULL for all of them.
*
* "incomplete" fires when surface is set but one of run_control,
* permission_profile, or trace_id is missing caller didn't populate the
* full required set, and the classifier surfaces that so operators can
* find the buggy writer.
*/
function hasSchemaV2Metadata(_row: unknown): boolean {
// Placeholder for slice 2. Will read row.surface / row.run_control /
// row.permission_profile when those columns ship.
return false;
interface GateMetadataRow {
surface: string | null;
runControl: string | null;
permissionProfile: string | null;
traceId: string | null;
}
function hasSchemaV2Metadata(meta: GateMetadataRow): boolean {
return typeof meta.surface === "string" && meta.surface.length > 0;
}
function isSchemaV2Complete(meta: GateMetadataRow): boolean {
return (
typeof meta.surface === "string" &&
meta.surface.length > 0 &&
typeof meta.runControl === "string" &&
meta.runControl.length > 0 &&
typeof meta.permissionProfile === "string" &&
meta.permissionProfile.length > 0 &&
typeof meta.traceId === "string" &&
meta.traceId.length > 0
);
}
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
function classifyCoverage(
entry: Omit<GateHealthEntry, "coverageStatus">,
metadataPresent: boolean,
meta: GateMetadataRow,
): GateCoverageStatus {
if (!metadataPresent) return "legacy";
if (!hasSchemaV2Metadata(meta)) return "legacy";
if (!isSchemaV2Complete(meta)) return "incomplete";
if (entry.total === 0) {
// Has metadata but no runs in window. If we ever saw a run, it's
// stale; otherwise it's never run (caller will mark "missing" when
// a configured-gate registry confirms it was expected). For slice
// 1, no registry exists, so the safer default is "stale".
return entry.lastEvaluatedAt ? "stale" : "stale";
// 1+2, no registry exists, so the safer default is "stale".
return "stale";
}
const last = entry.lastEvaluatedAt
? Date.parse(entry.lastEvaluatedAt)
@ -203,25 +228,52 @@ export async function handleUokStatus(
const gateIds: string[] = gatesDbModule.getDistinctGateIds();
// Fetch scope and last-evaluated from quality_gates DB for each gate
// Fetch scope, last-evaluated, and schema-v2 metadata from
// quality_gates DB for each gate. Picks the most-recent row's
// metadata (MAX(evaluated_at)) so the classifier sees current
// schema-v2 status rather than oldest. Returns null fields when
// no row exists or the columns haven't been migrated yet.
const sfDbModule = (await jiti.import(sfExtensionPath("sf-db"), {})) as any;
const getGateMeta = (
id: string,
): { scope: string; lastEvaluatedAt: string | null } => {
interface GateMetaQuery {
scope: string;
lastEvaluatedAt: string | null;
surface: string | null;
runControl: string | null;
permissionProfile: string | null;
traceId: string | null;
}
const getGateMeta = (id: string): GateMetaQuery => {
const empty: GateMetaQuery = {
scope: "unknown",
lastEvaluatedAt: null,
surface: null,
runControl: null,
permissionProfile: null,
traceId: null,
};
try {
const db = sfDbModule._getAdapter?.() ?? null;
if (!db) return { scope: "unknown", lastEvaluatedAt: null };
if (!db) return empty;
const row = db
.prepare(
"SELECT scope, MAX(evaluated_at) AS last_eval FROM quality_gates WHERE gate_id = ? LIMIT 1",
`SELECT scope, evaluated_at, surface, run_control,
permission_profile, trace_id
FROM quality_gates
WHERE gate_id = ?
ORDER BY evaluated_at IS NULL, evaluated_at DESC
LIMIT 1`,
)
.get(id);
return {
scope: row?.scope ?? "unknown",
lastEvaluatedAt: row?.last_eval ?? null,
lastEvaluatedAt: row?.evaluated_at ?? null,
surface: row?.surface ?? null,
runControl: row?.run_control ?? null,
permissionProfile: row?.permission_profile ?? null,
traceId: row?.trace_id ?? null,
};
} catch {
return { scope: "unknown", lastEvaluatedAt: null };
return empty;
}
};
@ -241,10 +293,7 @@ export async function handleUokStatus(
circuitBreaker: cb?.state ?? "closed",
failureStreak: cb?.failureStreak ?? 0,
};
const coverageStatus = classifyCoverage(
base,
hasSchemaV2Metadata(meta),
);
const coverageStatus = classifyCoverage(base, meta);
return { ...base, coverageStatus } satisfies GateHealthEntry;
});
} catch (err) {

View file

@ -1,6 +1,7 @@
import { dirname } from "node:path";
import { SF_STALE_STATE, SFError } from "../errors.js";
import { getGateIdsForTurn } from "../gate-registry.js";
import { uokRunContextToGateColumns } from "../uok/run-context.js";
import { readTraceEvents } from "../uok/trace-writer.js";
import { logWarning } from "../workflow-logger.js";
import {
@ -13,9 +14,20 @@ import {
export function insertGateRow(g) {
const currentDb = _getAdapter();
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
// Callers pass canonical camelCase uokContext (the shape returned by
// buildUokRunContext). This module owns the snake_case translation so
// callers don't need to know the column names. uokRunContextToGateColumns
// returns null when the context is invalid/incomplete, which leaves the
// columns NULL — same shape as pre-v66 (legacy) rows, so the classifier
// will mark the row "legacy" or "incomplete" rather than silently passing.
const uokCols = uokRunContextToGateColumns(g.uokContext) ?? null;
currentDb
.prepare(`INSERT OR IGNORE INTO quality_gates (milestone_id, slice_id, gate_id, scope, task_id, status)
VALUES (:mid, :sid, :gid, :scope, :tid, :status)`)
.prepare(`INSERT OR IGNORE INTO quality_gates (
milestone_id, slice_id, gate_id, scope, task_id, status,
surface, run_control, permission_profile, trace_id, parent_trace
)
VALUES (:mid, :sid, :gid, :scope, :tid, :status,
:surface, :run_control, :permission_profile, :trace_id, :parent_trace)`)
.run({
":mid": g.milestoneId,
":sid": g.sliceId,
@ -23,16 +35,32 @@ export function insertGateRow(g) {
":scope": g.scope,
":tid": g.taskId ?? "",
":status": g.status ?? "pending",
":surface": uokCols?.surface ?? null,
":run_control": uokCols?.run_control ?? null,
":permission_profile": uokCols?.permission_profile ?? null,
":trace_id": uokCols?.trace_id ?? null,
":parent_trace": uokCols?.parent_trace ?? null,
});
}
export function saveGateResult(g) {
const currentDb = _getAdapter();
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
// Schema-v2 metadata is updated only when the caller supplies a valid
// canonical context. Existing rows keep their metadata (or stay legacy/
// null) when no context is provided. COALESCE semantics on each column
// mean "only overwrite when non-null" — so a legacy row stays legacy
// unless this update brings a full schema-v2 ctx.
const uokCols = uokRunContextToGateColumns(g.uokContext) ?? null;
currentDb
.prepare(`UPDATE quality_gates
SET status = 'complete', verdict = :verdict, rationale = :rationale,
findings = :findings, evaluated_at = :evaluated_at
findings = :findings, evaluated_at = :evaluated_at,
surface = COALESCE(:surface, surface),
run_control = COALESCE(:run_control, run_control),
permission_profile = COALESCE(:permission_profile, permission_profile),
trace_id = COALESCE(:trace_id, trace_id),
parent_trace = COALESCE(:parent_trace, parent_trace)
WHERE milestone_id = :mid AND slice_id = :sid AND gate_id = :gid
AND task_id = :tid`)
.run({
@ -44,6 +72,11 @@ export function saveGateResult(g) {
":rationale": g.rationale,
":findings": g.findings,
":evaluated_at": new Date().toISOString(),
":surface": uokCols?.surface ?? null,
":run_control": uokCols?.run_control ?? null,
":permission_profile": uokCols?.permission_profile ?? null,
":trace_id": uokCols?.trace_id ?? null,
":parent_trace": uokCols?.parent_trace ?? null,
});
const outcome =
g.verdict === "pass"

View file

@ -15,7 +15,7 @@ function defaultQueryTimeout(operation, fallbackValue) {
}
}
const SCHEMA_VERSION = 65;
const SCHEMA_VERSION = 66;
function indexExists(db, name) {
return !!db
.prepare(
@ -1072,6 +1072,14 @@ export function initSchema(db, fileBacked, options = {}) {
rationale TEXT NOT NULL DEFAULT '',
findings TEXT NOT NULL DEFAULT '',
evaluated_at TEXT DEFAULT NULL,
-- Schema v2 metadata (v66): populated by the UOK adapter; NULL on
-- pre-v2 rows. headless-uok-status classifyCoverage reads surface
-- as the schema-v2 indicator.
surface TEXT,
run_control TEXT,
permission_profile TEXT,
trace_id TEXT,
parent_trace TEXT,
PRIMARY KEY (milestone_id, slice_id, gate_id, task_id),
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id)
)
@ -3399,6 +3407,58 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
if (ok) appliedVersion = 65;
}
if (appliedVersion < 66) {
const ok = runMigrationStep("v66", () => {
// Schema v66: UOK schema-v2 metadata on quality_gates.
//
// "Make UOK the SF Control Plane" plan, slice 2. Adds the columns
// that classifyCoverage in headless-uok-status.ts checks for to
// distinguish "ok"/"stale"/"incomplete" from "legacy":
//
// - surface headless | autonomous | interactive
// - run_control bare | supervised | autonomous
// - permission_profile high | medium | low
// - trace_id flow id from the journal
// - parent_trace parent flow id (nested traces)
//
// All columns are nullable so existing rows stay valid; the
// classifier reads them as "legacy" when surface IS NULL.
//
// Idempotent ALTERs: probe via PRAGMA table_info because the
// fresh-DB CREATE path may have already added them.
const cols = new Set(
db
.prepare("PRAGMA table_info(quality_gates)")
.all()
.map((r) => r.name),
);
if (!cols.has("surface")) {
db.exec("ALTER TABLE quality_gates ADD COLUMN surface TEXT");
}
if (!cols.has("run_control")) {
db.exec("ALTER TABLE quality_gates ADD COLUMN run_control TEXT");
}
if (!cols.has("permission_profile")) {
db.exec(
"ALTER TABLE quality_gates ADD COLUMN permission_profile TEXT",
);
}
if (!cols.has("trace_id")) {
db.exec("ALTER TABLE quality_gates ADD COLUMN trace_id TEXT");
}
if (!cols.has("parent_trace")) {
db.exec("ALTER TABLE quality_gates ADD COLUMN parent_trace TEXT");
}
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({
":version": 66,
":applied_at": new Date().toISOString(),
});
});
if (ok) appliedVersion = 66;
}
// Post-migration assertion: ensure critical tables created by historical
// migrations are actually present. If a prior migration claimed success but
// the table is missing (e.g., due to a rolled-back transaction that failed

View file

@ -57,10 +57,27 @@ function formatTable(gates) {
// Mirror of headless-uok-status.ts:classifyCoverage. Kept inline so the
// test file stays a pure unit test against the same logic shape (the
// .ts source isn't directly importable from this .mjs in the SF
// extension test tree). The icon mapping above mirrors coverageIcon.
// extension test tree). Slice 2 changed the second arg from a bool
// to the full meta row so we can distinguish "incomplete" from "ok".
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
function classifyCoverage(entry, metadataPresent, now = Date.now()) {
if (!metadataPresent) return "legacy";
function hasSchemaV2Metadata(meta) {
return typeof meta?.surface === "string" && meta.surface.length > 0;
}
function isSchemaV2Complete(meta) {
return (
typeof meta?.surface === "string" &&
meta.surface.length > 0 &&
typeof meta?.runControl === "string" &&
meta.runControl.length > 0 &&
typeof meta?.permissionProfile === "string" &&
meta.permissionProfile.length > 0 &&
typeof meta?.traceId === "string" &&
meta.traceId.length > 0
);
}
function classifyCoverage(entry, meta, now = Date.now()) {
if (!hasSchemaV2Metadata(meta)) return "legacy";
if (!isSchemaV2Complete(meta)) return "incomplete";
if (entry.total === 0) return "stale";
const last = entry.lastEvaluatedAt
? Date.parse(entry.lastEvaluatedAt)
@ -68,6 +85,18 @@ function classifyCoverage(entry, metadataPresent, now = Date.now()) {
if (last !== null && now - last > STALE_THRESHOLD_MS) return "stale";
return "ok";
}
const LEGACY_META = {
surface: null,
runControl: null,
permissionProfile: null,
traceId: null,
};
const COMPLETE_META = {
surface: "headless",
runControl: "supervised",
permissionProfile: "high",
traceId: "flow-123",
};
function makeGate(overrides = {}) {
return {
@ -207,47 +236,59 @@ describe("formatTable", () => {
});
});
describe("classifyCoverage (slice 1: legacy / ok / stale)", () => {
describe("classifyCoverage (slice 1+2: legacy / ok / stale / incomplete)", () => {
// "now" pinned so stale-threshold math is reproducible across runs.
const now = Date.parse("2026-05-14T12:00:00.000Z");
it("returns_legacy_when_metadata_is_absent", () => {
it("returns_legacy_when_surface_is_null", () => {
// All rows in the current quality_gates table predate schema v2.
// Without surface/runControl/permissionProfile columns there is
// no schema-v2 metadata to detect, so every existing row must
// classify as legacy and not warn the operator.
// Without surface set there is no schema-v2 metadata to detect,
// so every existing row must classify as legacy.
const status = classifyCoverage(
makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }),
false,
LEGACY_META,
now,
);
assert.equal(status, "legacy");
});
it("returns_legacy_even_when_rows_are_recent_and_metadata_missing", () => {
it("returns_legacy_even_when_rows_are_recent_and_surface_missing", () => {
// "Legacy" wins over freshness — we never page operators about
// rows that predate the schema-v2 writer.
const status = classifyCoverage(
makeGate({ total: 5, lastEvaluatedAt: "2026-05-14T11:59:00.000Z" }),
false,
LEGACY_META,
now,
);
assert.equal(status, "legacy");
});
it("returns_ok_when_metadata_present_and_recent_runs_exist", () => {
it("returns_ok_when_metadata_is_complete_and_runs_are_recent", () => {
const status = classifyCoverage(
makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }),
true,
COMPLETE_META,
now,
);
assert.equal(status, "ok");
});
it("returns_stale_when_metadata_present_but_no_runs_in_window", () => {
it("returns_incomplete_when_surface_is_set_but_other_required_fields_missing", () => {
// Writer started populating surface but forgot runControl — the
// classifier surfaces this so the operator can find the buggy
// writer instead of seeing a silently "ok" row.
const partial = { ...COMPLETE_META, runControl: null };
const status = classifyCoverage(
makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }),
partial,
now,
);
assert.equal(status, "incomplete");
});
it("returns_stale_when_metadata_complete_but_no_runs_in_window", () => {
const status = classifyCoverage(
makeGate({ total: 0, lastEvaluatedAt: "2026-05-12T00:00:00.000Z" }),
true,
COMPLETE_META,
now,
);
assert.equal(status, "stale");
@ -257,7 +298,7 @@ describe("classifyCoverage (slice 1: legacy / ok / stale)", () => {
// Has runs but the most recent one is > 24h old.
const status = classifyCoverage(
makeGate({ total: 3, lastEvaluatedAt: "2026-05-12T11:59:00.000Z" }),
true,
COMPLETE_META,
now,
);
assert.equal(status, "stale");

View file

@ -273,7 +273,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version")
.get();
assert.equal(version.version, 65);
assert.equal(version.version, 66);
// v61: intent_chapters table exists
const chaptersTable = db
.prepare(
@ -326,6 +326,22 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
colNames.includes("effort_estimate"),
"effort_estimate column should exist after v65 migration",
);
// v66: quality_gates gained UOK schema-v2 metadata columns (UOK
// control-plane plan, slice 2).
const qgColumns = db.prepare("PRAGMA table_info(quality_gates)").all();
const qgColNames = qgColumns.map((c) => c.name);
for (const col of [
"surface",
"run_control",
"permission_profile",
"trace_id",
"parent_trace",
]) {
assert.ok(
qgColNames.includes(col),
`${col} column should exist after v66 migration`,
);
}
const taskSpec = db
.prepare(
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",
@ -367,11 +383,11 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () =
initRoutingHistory(dbPath);
}, "initRoutingHistory should not throw on a v52 DB");
// Schema should have migrated to v65 (current head)
// Schema should have migrated to v66 (current head)
const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version")
.get();
assert.equal(version.version, 65);
assert.equal(version.version, 66);
});
test("openDatabase_when_fresh_db_supports_schedule_entries", () => {

View file

@ -0,0 +1,222 @@
/**
* uok-quality-gates-writer.test.mjs verify insertGateRow / saveGateResult
* round-trip canonical uokContext into the quality_gates table after the
* schema v66 migration (UOK control-plane plan, slice 2).
*
* Asserts the writer-side of the slice: a caller that supplies a valid
* uokContext from buildUokRunContext gets schema-v2 columns populated
* on the DB row, which is the prerequisite for the status uok classifier
* to flip its verdict from "legacy" to "ok"/"stale"/"incomplete".
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
closeDatabase,
getDatabase,
insertGateRow,
openDatabase,
saveGateResult,
} from "../sf-db.js";
import { buildUokRunContext } from "../uok/run-context.js";
let projectDir;
let dbPath;
function seedMilestoneSlice(db, milestoneId, sliceId) {
// quality_gates has a FOREIGN KEY into slices(milestone_id, id), so the
// parent milestone+slice rows have to exist before we insert a gate.
// All other columns have defaults; minimum required is the PK shape.
db.prepare(
`INSERT INTO milestones (id, title, created_at)
VALUES (?, ?, ?)`,
).run(milestoneId, "Test milestone", "2026-05-14T12:00:00Z");
db.prepare(
`INSERT INTO slices (milestone_id, id, title, created_at)
VALUES (?, ?, ?, ?)`,
).run(milestoneId, sliceId, "Test slice", "2026-05-14T12:00:00Z");
}
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "sf-uok-writer-test-"));
mkdirSync(join(projectDir, ".sf"), { recursive: true });
writeFileSync(
join(projectDir, "package.json"),
JSON.stringify({ name: "test-project" }),
);
dbPath = join(projectDir, ".sf", "sf.db");
openDatabase(dbPath);
});
afterEach(() => {
closeDatabase();
rmSync(projectDir, { recursive: true, force: true });
});
describe("insertGateRow writes schema-v2 columns from uokContext", () => {
test("populates surface / run_control / permission_profile / trace_id / parent_trace", () => {
const db = getDatabase();
seedMilestoneSlice(db, "M001", "S01");
const ctx = buildUokRunContext({
surface: "headless",
runControl: "supervised",
permissionProfile: "high",
traceId: "flow-abc",
parentTrace: "flow-root",
});
insertGateRow({
milestoneId: "M001",
sliceId: "S01",
gateId: "test-gate",
scope: "slice",
taskId: "T01",
status: "pending",
uokContext: ctx,
});
const row = db
.prepare(
`SELECT surface, run_control, permission_profile,
trace_id, parent_trace
FROM quality_gates WHERE gate_id = 'test-gate'`,
)
.get();
expect(row).toBeTruthy();
expect(row.surface).toBe("headless");
expect(row.run_control).toBe("supervised");
expect(row.permission_profile).toBe("high");
expect(row.trace_id).toBe("flow-abc");
expect(row.parent_trace).toBe("flow-root");
});
test("leaves columns NULL when uokContext is absent (legacy path)", () => {
const db = getDatabase();
seedMilestoneSlice(db, "M002", "S01");
insertGateRow({
milestoneId: "M002",
sliceId: "S01",
gateId: "legacy-gate",
scope: "slice",
taskId: "T01",
status: "pending",
});
const row = db
.prepare(
`SELECT surface, run_control, permission_profile,
trace_id, parent_trace
FROM quality_gates WHERE gate_id = 'legacy-gate'`,
)
.get();
expect(row.surface).toBe(null);
expect(row.run_control).toBe(null);
expect(row.permission_profile).toBe(null);
expect(row.trace_id).toBe(null);
expect(row.parent_trace).toBe(null);
});
test("leaves columns NULL when uokContext is malformed (invalid surface)", () => {
const db = getDatabase();
seedMilestoneSlice(db, "M003", "S01");
insertGateRow({
milestoneId: "M003",
sliceId: "S01",
gateId: "bad-ctx-gate",
scope: "slice",
taskId: "T01",
status: "pending",
// Bypass buildUokRunContext on purpose to test the adapter's
// last-line-of-defense rejection path.
uokContext: { surface: "rogue", runControl: "supervised" },
});
const row = db
.prepare(
`SELECT surface, run_control FROM quality_gates
WHERE gate_id = 'bad-ctx-gate'`,
)
.get();
expect(row.surface).toBe(null);
expect(row.run_control).toBe(null);
});
});
describe("saveGateResult merges schema-v2 columns via COALESCE", () => {
test("upgrades a legacy row to schema-v2 when uokContext supplied", () => {
const db = getDatabase();
seedMilestoneSlice(db, "M004", "S01");
insertGateRow({
milestoneId: "M004",
sliceId: "S01",
gateId: "upgrade-gate",
scope: "slice",
taskId: "T01",
status: "pending",
});
const ctx = buildUokRunContext({
surface: "autonomous",
runControl: "autonomous",
permissionProfile: "high",
traceId: "flow-upgrade",
});
saveGateResult({
milestoneId: "M004",
sliceId: "S01",
gateId: "upgrade-gate",
taskId: "T01",
verdict: "pass",
rationale: "ok",
findings: "",
uokContext: ctx,
});
const row = db
.prepare(
`SELECT surface, run_control, permission_profile, trace_id, verdict
FROM quality_gates WHERE gate_id = 'upgrade-gate'`,
)
.get();
expect(row.verdict).toBe("pass");
expect(row.surface).toBe("autonomous");
expect(row.run_control).toBe("autonomous");
expect(row.permission_profile).toBe("high");
expect(row.trace_id).toBe("flow-upgrade");
});
test("does NOT overwrite existing schema-v2 metadata when ctx is absent", () => {
const db = getDatabase();
seedMilestoneSlice(db, "M005", "S01");
insertGateRow({
milestoneId: "M005",
sliceId: "S01",
gateId: "preserve-gate",
scope: "slice",
taskId: "T01",
status: "pending",
uokContext: buildUokRunContext({
surface: "headless",
runControl: "supervised",
permissionProfile: "medium",
traceId: "flow-first",
}),
});
// Second write has no ctx — COALESCE should keep the original
// values, not blank them.
saveGateResult({
milestoneId: "M005",
sliceId: "S01",
gateId: "preserve-gate",
taskId: "T01",
verdict: "pass",
rationale: "",
findings: "",
});
const row = db
.prepare(
`SELECT surface, run_control, permission_profile, trace_id
FROM quality_gates WHERE gate_id = 'preserve-gate'`,
)
.get();
expect(row.surface).toBe("headless");
expect(row.run_control).toBe("supervised");
expect(row.permission_profile).toBe("medium");
expect(row.trace_id).toBe("flow-first");
});
});

View file

@ -0,0 +1,152 @@
/**
* uok-run-context.test.mjs verify the schema-v2 run-context adapter
* (slice 2 of "Make UOK the SF Control Plane").
*/
import { describe, expect, test } from "vitest";
import {
buildUokRunContext,
OPTIONAL_UOK_RUN_CONTEXT_KEYS,
REQUIRED_UOK_RUN_CONTEXT_KEYS,
uokRunContextToGateColumns,
VALID_PERMISSION_PROFILES,
VALID_RUN_CONTROLS,
VALID_SURFACES,
} from "../uok/run-context.js";
const VALID_BASE = {
surface: "headless",
runControl: "supervised",
permissionProfile: "high",
traceId: "flow-123",
};
describe("buildUokRunContext", () => {
test("returns_a_frozen_normalized_context_when_all_required_fields_are_present", () => {
const ctx = buildUokRunContext({
...VALID_BASE,
parentTrace: "flow-100",
unitType: "execute-task",
unitId: "M001/S01/T02",
milestoneId: "M001",
sliceId: "S01",
taskId: "T02",
});
expect(ctx).toBeTruthy();
expect(Object.isFrozen(ctx)).toBe(true);
expect(ctx.surface).toBe("headless");
expect(ctx.runControl).toBe("supervised");
expect(ctx.permissionProfile).toBe("high");
expect(ctx.traceId).toBe("flow-123");
expect(ctx.parentTrace).toBe("flow-100");
expect(ctx.unitType).toBe("execute-task");
});
test("returns_null_when_any_required_key_is_missing", () => {
for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) {
const opts = { ...VALID_BASE };
delete opts[key];
expect(
buildUokRunContext(opts),
`missing ${key} should return null`,
).toBe(null);
}
});
test("returns_null_on_invalid_surface_runControl_or_permissionProfile", () => {
expect(buildUokRunContext({ ...VALID_BASE, surface: "rogue" })).toBe(null);
expect(buildUokRunContext({ ...VALID_BASE, runControl: "rogue" })).toBe(
null,
);
expect(
buildUokRunContext({ ...VALID_BASE, permissionProfile: "rogue" }),
).toBe(null);
});
test("treats_empty_string_required_fields_as_missing", () => {
expect(buildUokRunContext({ ...VALID_BASE, surface: " " })).toBe(null);
expect(buildUokRunContext({ ...VALID_BASE, traceId: "" })).toBe(null);
});
test("omits_optional_keys_when_empty_or_absent", () => {
const ctx = buildUokRunContext({
...VALID_BASE,
parentTrace: "",
unitType: undefined,
});
expect(ctx).toBeTruthy();
expect(ctx.parentTrace).toBeUndefined();
expect(ctx.unitType).toBeUndefined();
});
test("rejects_non_object_input", () => {
expect(buildUokRunContext(null)).toBe(null);
expect(buildUokRunContext("not an object")).toBe(null);
expect(buildUokRunContext(undefined)).toBe(null);
});
test("only_recognized_optional_keys_are_kept", () => {
const ctx = buildUokRunContext({
...VALID_BASE,
randomField: "ignored",
anotherJunk: "also ignored",
});
expect(ctx).toBeTruthy();
for (const key of Object.keys(ctx)) {
expect(
[
...REQUIRED_UOK_RUN_CONTEXT_KEYS,
...OPTIONAL_UOK_RUN_CONTEXT_KEYS,
].includes(key),
`unexpected key in normalized ctx: ${key}`,
).toBe(true);
}
});
test("all_documented_enum_values_are_accepted", () => {
for (const surface of VALID_SURFACES) {
expect(buildUokRunContext({ ...VALID_BASE, surface })?.surface).toBe(
surface,
);
}
for (const runControl of VALID_RUN_CONTROLS) {
expect(
buildUokRunContext({ ...VALID_BASE, runControl })?.runControl,
).toBe(runControl);
}
for (const permissionProfile of VALID_PERMISSION_PROFILES) {
expect(
buildUokRunContext({ ...VALID_BASE, permissionProfile })
?.permissionProfile,
).toBe(permissionProfile);
}
});
});
describe("uokRunContextToGateColumns", () => {
test("translates_camelCase_to_snake_case_column_shape", () => {
const ctx = buildUokRunContext({
...VALID_BASE,
parentTrace: "flow-100",
});
const cols = uokRunContextToGateColumns(ctx);
expect(cols).toEqual({
surface: "headless",
run_control: "supervised",
permission_profile: "high",
trace_id: "flow-123",
parent_trace: "flow-100",
});
});
test("nulls_parent_trace_when_omitted", () => {
const ctx = buildUokRunContext(VALID_BASE);
const cols = uokRunContextToGateColumns(ctx);
expect(cols.parent_trace).toBe(null);
});
test("returns_null_for_invalid_ctx", () => {
expect(uokRunContextToGateColumns(null)).toBe(null);
expect(uokRunContextToGateColumns({})).toBe(null);
expect(uokRunContextToGateColumns({ surface: "headless" })).toBe(null);
});
});

View file

@ -0,0 +1,164 @@
/**
* uok/run-context.js UOK schema-v2 run-context adapter.
*
* Purpose: normalize existing runtime/headless metadata into the shape
* the UOK control plane expects (schema-v2 fields surface, runControl,
* permissionProfile, traceId, parentTrace, plus the already-existing
* unitType/unitId/milestoneId/sliceId). The adapter is intentionally
* thin it does NOT define a new parallel run model, it just collects
* fields the caller already has and emits one canonical shape.
*
* Slice 2 of "Make UOK the SF Control Plane". The classifier in
* headless-uok-status.ts treats `surface` as the schema-v2 indicator;
* rows with surface != null get classified as ok/stale/incomplete
* based on the other fields, rows with surface == null stay legacy.
*
* Consumer: uok/gate-runner.js (gate-run writes), sf-db-gates.js
* (quality_gates writes), future autonomous/headless adapters.
*/
/**
* Required keys for a schema-v2 UOK run-context. The classifier flags
* a row "incomplete" when any of these is missing on an otherwise
* schema-v2 row.
*/
export const REQUIRED_UOK_RUN_CONTEXT_KEYS = Object.freeze([
"surface",
"runControl",
"permissionProfile",
"traceId",
]);
/**
* Optional keys present on schema-v2 rows when the caller can
* supply them, but their absence does not flip the classification.
*/
export const OPTIONAL_UOK_RUN_CONTEXT_KEYS = Object.freeze([
"parentTrace",
"unitType",
"unitId",
"milestoneId",
"sliceId",
"taskId",
]);
/**
* Allowed `surface` values. Reject anything else at build time so we
* don't end up with `surface = "headless-something"` typos that the
* classifier silently treats as schema-v2.
*/
export const VALID_SURFACES = Object.freeze([
"headless",
"autonomous",
"interactive",
"hook",
]);
/**
* Allowed `runControl` values.
*
* - bare One-shot, no orchestrator gates
* - supervised Orchestrator-supervised dispatch with operator gates
* - autonomous Full autonomous loop
* - interactive Operator-driven session
*/
export const VALID_RUN_CONTROLS = Object.freeze([
"bare",
"supervised",
"autonomous",
"interactive",
]);
/**
* Allowed `permissionProfile` values.
*
* - high Production / write-allowed
* - medium Dev / write-with-confirmation
* - low Read-only / sandboxed
*/
export const VALID_PERMISSION_PROFILES = Object.freeze([
"high",
"medium",
"low",
]);
/**
* Build a schema-v2 UOK run-context from the operator-supplied inputs.
*
* Validates enum fields, normalizes empty strings to undefined, and
* returns a frozen plain object with only the keys the classifier
* understands. Returns null when required fields are missing callers
* that build a partial ctx should NOT pass that ctx to a gate write,
* otherwise the row lands as `incomplete` in the next status uok read.
*
* Consumer: headless commands, autonomous-loop phases, anywhere a UOK
* gate is about to be written.
*
* @param {object} opts
* @param {string} opts.surface One of VALID_SURFACES.
* @param {string} opts.runControl One of VALID_RUN_CONTROLS.
* @param {string} opts.permissionProfile One of VALID_PERMISSION_PROFILES.
* @param {string} opts.traceId Flow id from the journal.
* @param {string} [opts.parentTrace] Parent flow id (nested).
* @param {string} [opts.unitType] Unit type if dispatching one.
* @param {string} [opts.unitId] Unit id.
* @param {string} [opts.milestoneId] Milestone id.
* @param {string} [opts.sliceId] Slice id.
* @param {string} [opts.taskId] Task id.
*
* @returns {object | null} The normalized context, or null when a
* required field is missing or an enum is invalid.
*/
export function buildUokRunContext(opts) {
if (!opts || typeof opts !== "object") return null;
const required = {
surface: normalizeString(opts.surface),
runControl: normalizeString(opts.runControl),
permissionProfile: normalizeString(opts.permissionProfile),
traceId: normalizeString(opts.traceId),
};
for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) {
if (!required[key]) return null;
}
if (!VALID_SURFACES.includes(required.surface)) return null;
if (!VALID_RUN_CONTROLS.includes(required.runControl)) return null;
if (!VALID_PERMISSION_PROFILES.includes(required.permissionProfile)) {
return null;
}
const ctx = { ...required };
for (const key of OPTIONAL_UOK_RUN_CONTEXT_KEYS) {
const value = normalizeString(opts[key]);
if (value) ctx[key] = value;
}
return Object.freeze(ctx);
}
function normalizeString(value) {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/**
* Translate a schema-v2 UOK run-context into the column-name shape the
* SQLite writer expects. Snake_case keys, only the columns that exist
* on the quality_gates table (after v66 migration).
*
* Returns null when the context isn't a valid schema-v2 ctx (caller
* should fall through to legacy write, leaving columns NULL).
*
* Consumer: sf-db-gates.js insertGateRow / saveGateResult.
*/
export function uokRunContextToGateColumns(ctx) {
if (!ctx || typeof ctx !== "object") return null;
for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) {
if (typeof ctx[key] !== "string" || ctx[key].length === 0) return null;
}
return {
surface: ctx.surface,
run_control: ctx.runControl,
permission_profile: ctx.permissionProfile,
trace_id: ctx.traceId,
parent_trace: ctx.parentTrace ?? null,
};
}