Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ describe("portal fallback is a virtual root, not employee authority", () => {
expect(approval.approvalTarget).toBe(legacyRoot);
expect(approval.approvalTargetKind).toBe("virtual");

(await import("../../shared/db.js")).initDb().prepare("UPDATE work_items SET approval_target_kind = NULL WHERE id = ?").run(approval.id);
(await import("../../shared/db.js")).initDb().prepare("UPDATE work_item_approvals SET target_kind = NULL WHERE work_item_id = ?").run(approval.id);
fs.writeFileSync(
legacyFile,
"name: Legacy Root\ndisplayName: Legacy Root\ndepartment: platform\nrank: employee\nreportsTo: platform-manager\nengine: codex\nmodel: gpt-5.5\npersona: Attempts to claim a legacy persisted approval target.\n",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import type { ServerResponse } from "node:http";
/**
* Todos v2 slice 4 — GOLDEN legacy byte-parity for the approval fields.
*
* The approval_* columns are frozen and every payload sources the legacy
* `approval*` fields from `work_item_approvals`. These pins hold the hard
* compatibility bar: for databases carrying pre-slice column values (simulated
* here exactly as the backfill finds them), the compact AND detail payloads
* must emit the SAME values the pre-slice column-backed implementation emitted
* — plus the new additive `approvals` history on the detail payload only.
* Every payload sources the legacy `approval*` fields from `work_item_approvals`.
* These pins hold the hard compatibility bar: for databases whose approvals came
* from pre-slice columns (seeded here exactly as the backfill leaves them), the
* compact AND detail payloads must emit the SAME values the pre-slice
* column-backed implementation emitted — plus the new additive `approvals`
* history on the detail payload only.
*/

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-wi-parity-"));
Expand All @@ -23,11 +23,9 @@ const dbModule = await import("../../shared/db.js");
type Api = typeof import("../api.js");
type Store = typeof import("../../work-items/store.js");
type Approvals = typeof import("../../work-items/approvals.js");
type Migrate = typeof import("../../work-items/migrate.js");
let api: Api;
let store: Store;
let approvals: Approvals;
let migrate: Migrate;

function makeRes() {
let status = 200;
Expand Down Expand Up @@ -88,27 +86,18 @@ interface LegacyApprovalColumns {
approvalDecidedAt: string | null;
}

/** Simulate a pre-slice-4 database row: write the approval COLUMNS directly
* (the write path no longer does), exactly what the backfill later consumes. */
/** Simulate a pre-slice-4 database's approvals: the exact `'legacy'` row the
* column backfill mints, so the payloads face what a migrated home carries. */
function seedLegacyColumns(id: string, legacy: LegacyApprovalColumns): void {
dbModule
.initDb()
.prepare(
`UPDATE work_items SET approval_state = ?, approval_request = ?, approval_ref = ?, approval_target = ?,
approval_target_kind = ?, approval_escalated_at = ?, approval_decided_by = ?, approval_decided_at = ?
WHERE id = ?`,
)
.run(
legacy.approvalState,
legacy.approvalRequest,
legacy.approvalRef,
legacy.approvalTarget,
legacy.approvalTargetKind,
legacy.approvalEscalatedAt,
legacy.approvalDecidedBy,
legacy.approvalDecidedAt,
id,
);
if (legacy.approvalState === null) return;
dbModule.initDb().prepare(
`INSERT INTO work_item_approvals (id, work_item_id, state, request, ref, target, target_kind,
requested_by, requested_at, escalated_at, decided_by, decided_at, note)
SELECT 'wap_' || lower(hex(randomblob(6))), w.id, @approvalState, COALESCE(@approvalRequest, ''), @approvalRef,
@approvalTarget, @approvalTargetKind, 'legacy', COALESCE(@approvalDecidedAt, w.updated_at),
@approvalEscalatedAt, @approvalDecidedBy, @approvalDecidedAt, NULL
FROM work_items w WHERE w.id = @id`,
).run({ ...legacy, id });
}

function legacySubset(payload: Record<string, unknown>): LegacyApprovalColumns {
Expand Down Expand Up @@ -202,17 +191,15 @@ beforeAll(async () => {
api = await import("../api.js");
store = await import("../../work-items/store.js");
approvals = await import("../../work-items/approvals.js");
migrate = await import("../../work-items/migrate.js");
(await import("../../shared/db.js")).initDb();
dbModule.initDb();
for (const fixture of FIXTURES) {
const item = store.createWorkItem({ title: `parity ${fixture.name}`, department: "parity-fixture" });
seedLegacyColumns(item.id, fixture.legacy);
itemIds.set(fixture.name, item.id);
}
migrate.backfillWorkItemApprovals((await import("../../shared/db.js")).initDb());
});

describe("legacy approval-field byte-parity across the dual-read window", () => {
describe("legacy approval-field byte-parity, sourced from work_item_approvals", () => {
it.each(FIXTURES.map((fixture) => [fixture.name, fixture.legacy] as const))(
"detail payload emits the exact pre-slice values for the %s fixture",
async (name, legacy) => {
Expand Down
30 changes: 4 additions & 26 deletions packages/jinn/src/work-items/__tests__/approvals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,32 +199,12 @@ describe("decideWorkItemApproval — native consequence rules", () => {

/* ── approvals off-row (Todos v2 slice 4) — the work_item_approvals table ───── */

describe("approvals off-row — writes land in work_item_approvals, columns stay frozen", () => {
function rawColumns(id: string): Record<string, unknown> {
return dbModule
.initDb()
.prepare(
`SELECT approval_state, approval_request, approval_ref, approval_target,
approval_target_kind, approval_escalated_at, approval_decided_by, approval_decided_at
FROM work_items WHERE id = ?`,
)
.get(id) as Record<string, unknown>;
}

function expectColumnsFrozenNull(id: string): void {
const cols = rawColumns(id);
for (const [column, value] of Object.entries(cols)) {
expect(value, `${column} must stay frozen (never written post-slice-4)`).toBeNull();
}
}

it("request writes ONLY the new table; the legacy approval_* columns never change", () => {
describe("approvals off-row — writes land in work_item_approvals", () => {
it("request writes the approvals table and the returned item reads back from it", () => {
const item = store.createWorkItem({ title: "Off-row request", status: "in_review", source: "human" });
const out = approvals.requestApproval(item.id, { request: "gate?", ref: "opaque-ref", target: null, actor: "session:sX" });
// The returned WorkItem still reads as pending (dual-read), but the columns are untouched.
expect(out.approvalState).toBe("pending");
expect(out.approvalRef).toBe("opaque-ref");
expectColumnsFrozenNull(item.id);
const row = approvals.currentApproval(item.id)!;
expect(row.state).toBe("pending");
expect(row.request).toBe("gate?");
Expand All @@ -239,7 +219,7 @@ describe("approvals off-row — writes land in work_item_approvals, columns stay
expect(out.version).toBe(item.version + 1);
});

it("decide + escalate write the pending row (note included); columns stay frozen", async () => {
it("decide + escalate write the pending row (note included)", async () => {
const item = store.createWorkItem({ title: "Off-row decide", status: "backlog", source: "human" });
approvals.requestApproval(item.id, { request: "plan ok?", target: null });
const escalated = approvals.escalateApproval(item.id, "coo", "needs the operator");
Expand All @@ -252,7 +232,6 @@ describe("approvals off-row — writes land in work_item_approvals, columns stay
expect(row.decidedBy).toBe("coo");
expect(row.decidedAt).toBeTruthy();
expect(row.note).toBe("fine");
expectColumnsFrozenNull(item.id);
});

it("keeps approval history: a fresh request after a decision is a NEW row; current = pending else latest decided", async () => {
Expand All @@ -277,7 +256,7 @@ describe("approvals off-row — writes land in work_item_approvals, columns stay
expect(current.request).toBe("round two");
expect(current.state).toBe("approved");

// the dual-read WorkItem view tracks the current row
// the hydrated WorkItem view tracks the current row
const roundTrip = store.getWorkItem(item.id)!;
expect(roundTrip.approvalState).toBe("approved");
expect(roundTrip.approvalRequest).toBe("round two");
Expand Down Expand Up @@ -334,7 +313,6 @@ describe("approvals off-row — writes land in work_item_approvals, columns stay
approvals.requestApproval(item.id, { request: "sign-off", target: "attention-target" });
const hits = store.listWorkItems({ needsAttentionFor: "attention-target" });
expect(hits.some((i) => i.id === item.id)).toBe(true);
expectColumnsFrozenNull(item.id);
});
});

Expand Down
114 changes: 57 additions & 57 deletions packages/jinn/src/work-items/__tests__/migrate-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe("v1 → v2 migration", () => {
});
});

/* ── Todos v2 slice 4: work_item_approvals backfill + self-heal + verifier ──── */
/* ── Todos v2 slice 4 + PLA-48: work_item_approvals as the sole owner ───────── */

function freshV2(file: string): Database.Database {
const fresh = new Database(file);
Expand All @@ -118,10 +118,13 @@ function freshV2(file: string): Database.Database {
return fresh;
}

function seedItemWithColumns(
fresh: Database.Database,
approval: Record<string, string | null> | null,
): string {
function approvalColumns(db: Database.Database): string[] {
return (db.prepare("PRAGMA table_info(work_items)").all() as Array<{ name: string }>)
.map((column) => column.name).filter((name) => name.startsWith("approval_"));
}

/** Seed one item; `approval` (legacy-shape databases only) fills the shadow columns. */
function seedItem(fresh: Database.Database, approval?: Record<string, string | null>): string {
const base = "2026-07-01T00:00:00.000Z";
const claim = migrate.allocateWorkItemId(fresh, base, "ACM");
migrate.useWorkItemAllocationClaim(fresh, claim, () => {
Expand All @@ -134,65 +137,62 @@ function seedItemWithColumns(
fresh.prepare(
`UPDATE work_items SET approval_state = @state, approval_request = @request, approval_ref = @ref,
approval_target = @target, approval_target_kind = @target_kind, approval_escalated_at = @escalated_at,
approval_decided_by = @decided_by, approval_decided_at = @decided_at
WHERE id = @id`,
approval_decided_by = @decided_by, approval_decided_at = @decided_at WHERE id = @id`,
).run({
state: null, request: null, ref: null, target: null, target_kind: null,
escalated_at: null, decided_by: null, decided_at: null,
...approval,
id: claim.id,
state: null, request: null, ref: null, target: null, target_kind: null, escalated_at: null, decided_by: null, decided_at: null, ...approval, id: claim.id,
});
}
return claim.id;
}

describe("work_item_approvals backfill", () => {
it("copies pending/decided/escalated column values into exactly one row per item, skips NULL items, and is idempotent", () => {
describe("legacy approval columns heal into work_item_approvals", () => {
it("a fresh v2 database carries no approval_% column and verifies clean", () => {
const db = freshV2(path.join(tmp, "registry-no-approval-columns.db"));
expect(approvalColumns(db)).toEqual([]);
migrate.verifyCurrentWorkItemSchema(db);
db.close();
});

it("classifies a legacy-shape database as current, moves the values off-row, drops the columns, and re-runs as a no-op", () => {
const file = path.join(tmp, "registry-backfill.db");
const db = freshV2(file);
const pendingId = seedItemWithColumns(db, {
state: "pending", request: "legacy pending gate", ref: "workflow-gate:old:run:g",
target: "coo", target_kind: "employee",
});
const decidedId = seedItemWithColumns(db, {
state: "approved", request: "legacy decided gate", target: "coo", target_kind: "employee",
escalated_at: "2026-07-02T00:00:00.000Z", decided_by: "operator", decided_at: "2026-07-03T00:00:00.000Z",
});
const plainId = seedItemWithColumns(db, null);
// A pre-PLA-48 home: work_items still carries the shadow approval_* columns,
// with the real indexes and triggers; the additive tables never shipped.
const legacy = new Database(file);
migrate.registerWorkItemIdentityFunctions(legacy);
for (const ddl of [migrate.WORK_ITEM_IDENTITY_TABLES_DDL, migrate.V2_APPROVAL_WORK_ITEMS_TABLE_DDL,
migrate.WORK_ITEMS_INDEX_DDL, migrate.WORK_ITEM_EVENTS_DDL, migrate.WORK_ITEM_EDIT_RECEIPTS_DDL,
migrate.WORK_ITEM_IDENTITY_TRIGGERS_DDL]) legacy.exec(ddl);
const pendingId = seedItem(legacy, { state: "pending", request: "legacy pending gate", ref: "workflow-gate:old:run:g", target: "coo", target_kind: "employee" });
const decidedId = seedItem(legacy, { state: "approved", request: "legacy decided gate", target: "coo", target_kind: "employee", escalated_at: "2026-07-02T00:00:00.000Z", decided_by: "operator", decided_at: "2026-07-03T00:00:00.000Z" });
const plainId = seedItem(legacy);
expect(approvalColumns(legacy).length).toBe(8);
legacy.close();

const first = migrate.backfillWorkItemApprovals(db);
expect(first).toBe(2);
expect(migrate.preflightWorkItemsDatabase(file)).toBe("current");
const db = new Database(file);
migrate.registerWorkItemIdentityFunctions(db);
expect(migrate.migrateWorkItemsSchema(db).rebuilt).toBe(false);
migrate.verifyCurrentWorkItemSchema(db);
expect(approvalColumns(db)).toEqual([]);

const rows = db
.prepare("SELECT * FROM work_item_approvals ORDER BY work_item_id")
.all() as Array<Record<string, unknown>>;
const rows = db.prepare("SELECT * FROM work_item_approvals ORDER BY work_item_id").all() as Array<Record<string, unknown>>;
expect(rows.length).toBe(2);
const pendingRow = rows.find((r) => r.work_item_id === pendingId)!;
expect(pendingRow.state).toBe("pending");
expect(pendingRow.request).toBe("legacy pending gate");
expect(pendingRow.ref).toBe("workflow-gate:old:run:g");
expect(pendingRow.target).toBe("coo");
expect(pendingRow.target_kind).toBe("employee");
expect(pendingRow.requested_by).toBe("legacy");
expect(pendingRow.requested_at).toBeTruthy();
expect(pendingRow.decided_by).toBeNull();
const decidedRow = rows.find((r) => r.work_item_id === decidedId)!;
expect(decidedRow.state).toBe("approved");
expect(decidedRow.escalated_at).toBe("2026-07-02T00:00:00.000Z");
expect(decidedRow.decided_by).toBe("operator");
expect(decidedRow.decided_at).toBe("2026-07-03T00:00:00.000Z");
expect(rows.find((r) => r.work_item_id === pendingId)).toMatchObject({
state: "pending", request: "legacy pending gate", ref: "workflow-gate:old:run:g", target: "coo",
target_kind: "employee", requested_by: "legacy", requested_at: "2026-07-01T00:00:00.000Z", decided_by: null,
});
expect(rows.find((r) => r.work_item_id === decidedId)).toMatchObject({
state: "approved", escalated_at: "2026-07-02T00:00:00.000Z",
decided_by: "operator", decided_at: "2026-07-03T00:00:00.000Z",
});
expect(rows.some((r) => r.work_item_id === plainId)).toBe(false);

// idempotent: a re-run inserts nothing and changes nothing
// a second boot inserts nothing and changes nothing
const before = JSON.stringify(rows);
expect(migrate.backfillWorkItemApprovals(db)).toBe(0);
const after = JSON.stringify(db.prepare("SELECT * FROM work_item_approvals ORDER BY work_item_id").all());
expect(after).toBe(before);

// the frozen columns themselves are untouched by the backfill
const cols = db.prepare("SELECT approval_state FROM work_items WHERE id = ?").get(pendingId) as { approval_state: string };
expect(cols.approval_state).toBe("pending");
expect(migrate.migrateWorkItemsSchema(db).rebuilt).toBe(false);
expect(JSON.stringify(db.prepare("SELECT * FROM work_item_approvals ORDER BY work_item_id").all())).toBe(before);
db.close();
expect(migrate.preflightWorkItemsDatabase(file)).toBe("current");
});

it("backfills during the v1 → v2 rebuild", () => {
Expand All @@ -213,15 +213,16 @@ describe("work_item_approvals backfill", () => {
expect(row.state).toBe("pending");
expect(row.request).toBe("v1 pending");
expect(row.requested_by).toBe("legacy");
expect(approvalColumns(db)).toEqual([]);
db.close();
});
});

describe("work_item_approvals self-heal + verifier", () => {
it("boots a v2 DB missing the approvals table additively and backfills column carriers", () => {
it("boots a v2 DB missing the approvals table additively", () => {
const file = path.join(tmp, "registry-heal-approvals.db");
const db = freshV2(file);
const carrierId = seedItemWithColumns(db, { state: "pending", request: "heal me", target: "coo", target_kind: "employee" });
seedItem(db);
db.exec("DROP TABLE work_item_approvals");
db.close();

Expand All @@ -231,15 +232,14 @@ describe("work_item_approvals self-heal + verifier", () => {
migrate.registerWorkItemIdentityFunctions(reopened);
expect(migrate.migrateWorkItemsSchema(reopened).rebuilt).toBe(false);
migrate.verifyCurrentWorkItemSchema(reopened);
const row = reopened.prepare("SELECT state, request FROM work_item_approvals WHERE work_item_id = ?").get(carrierId) as Record<string, unknown>;
expect(row).toEqual({ state: "pending", request: "heal me" });
expect(reopened.prepare("SELECT count(*) AS n FROM work_item_approvals").get()).toEqual({ n: 0 });
reopened.close();
});

it("verifier refuses a dangling approval row (unknown work item)", () => {
const file = path.join(tmp, "registry-verify-dangling.db");
const db = freshV2(file);
seedItemWithColumns(db, null);
seedItem(db);
db.pragma("foreign_keys = OFF");
db.prepare(
`INSERT INTO work_item_approvals (id, work_item_id, state, request, requested_by, requested_at)
Expand All @@ -252,7 +252,7 @@ describe("work_item_approvals self-heal + verifier", () => {
it("verifier refuses two pending rows for one item even if the unique index is gone (belt and suspenders)", () => {
const file = path.join(tmp, "registry-verify-dup-pending.db");
const db = freshV2(file);
const id = seedItemWithColumns(db, null);
const id = seedItem(db);
db.exec("DROP INDEX uq_wap_pending");
for (const rowId of ["wap_bbbbbbbbbbbb", "wap_cccccccccccc"]) {
db.prepare(
Expand All @@ -269,7 +269,7 @@ describe("work_item_approvals self-heal + verifier", () => {
it("boots a v2 DB missing the operator-only table additively", () => {
const file = path.join(tmp, "registry-heal-operator-only.db");
const db = freshV2(file);
seedItemWithColumns(db, { state: "pending", request: "reserve me", target: "coo", target_kind: "employee" });
seedItem(db);
db.exec("DROP TABLE work_item_approval_operator_only");
db.close();

Expand Down
Loading
Loading