feat(planning-state): DB-first VALIDATION.md migration (proposal MVP)
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions

Implements Phase 1 of docs/dev/proposals/db-first-planning-state.md
(commit f3571475d). VALIDATION.md is now a render target; DB is
canonical.

Three read sites switched to DB:
- tools/complete-milestone.js: getMilestoneValidationAssessment(id)?.status
  replaces readFile + extractVerdict (lines 126-137 → 126-140)
- workspace-index.js: same swap in the indexWorkspace loop (was
  resolveMilestoneFile → loadFile → extractVerdict per milestone)
- state-shared.js:readMilestoneValidationVerdict was already DB-first
  (prefers DB, file fallback only when no DB) — no change needed

Write path regenerates:
- tools/validate-milestone.js:renderValidationMarkdown now prepends
  <!-- generated from .sf/sf.db — do not edit directly; use the
  validate_milestone tool --> so the file is unambiguously a projection
- verdict-parser.js:extractVerdict strips the comment header before
  frontmatter parsing so legacy readers (reflection.js, auto-prompts.js)
  still work on generated files

Doctor check retired (clean delete):
- doctor-engine-checks.js: db_projection_validation_drift detector
  removed entirely. Drift is structurally impossible once the write
  path always regenerates from DB. Comment block explains the removal.

Tests:
- New: db-first-validation.test.mjs — 6 tests covering regeneration,
  three read-site overrides, hand-edit override, doctor non-emission
- Updated: doctor-db-projection-drift.test.mjs now asserts the check is
  NOT emitted (was previously asserting it WAS)

Full suite: 469 passed, 0 failed, 3 skipped. No regressions.

Closes the same class as the self-feedback DB/JSONL divergence pain —
the M001-6377a4-VALIDATION.md doctor warning that's been firing
repeatedly this session is gone by construction. Other planning
artifacts (CONTEXT.md, ROADMAP.md, SUMMARY.md) follow in later phases
per the proposal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-15 09:35:28 +02:00
parent 7dbf8ad430
commit ec65b4d881
7 changed files with 357 additions and 67 deletions

View file

@ -14,7 +14,6 @@ import {
_getAdapter, _getAdapter,
getAllMilestones, getAllMilestones,
getMilestoneSlices, getMilestoneSlices,
getMilestoneValidationAssessment,
isDbAvailable, isDbAvailable,
openDatabase, openDatabase,
} from "./sf-db.js"; } from "./sf-db.js";
@ -23,7 +22,6 @@ import {
summarizeParityHealth, summarizeParityHealth,
writeParityReport, writeParityReport,
} from "./uok/parity-report.js"; } from "./uok/parity-report.js";
import { extractVerdict } from "./verdict-parser.js";
import { readEvents } from "./workflow-events.js"; import { readEvents } from "./workflow-events.js";
import { renderAllProjections } from "./workflow-projections.js"; import { renderAllProjections } from "./workflow-projections.js";
import { getErrorMessage } from "./error-utils.js"; import { getErrorMessage } from "./error-utils.js";
@ -125,29 +123,10 @@ function projectionDriftIssues(basePath, milestoneId) {
}); });
} }
} }
const validationPath = resolveMilestoneFile( // db_projection_validation_drift check removed (#db-first-validation):
basePath, // VALIDATION.md is now a generated projection from assessments table.
milestoneId, // Drift is structurally impossible — the file is always regenerated from DB
"VALIDATION", // by handleValidateMilestone. No drift check needed.
);
if (validationPath && existsSync(validationPath)) {
const dbAssessment = getMilestoneValidationAssessment(milestoneId);
if (dbAssessment?.status) {
const fileVerdict = extractVerdict(readFileSync(validationPath, "utf-8"));
const dbVerdict = String(dbAssessment.status).trim().toLowerCase();
if (fileVerdict && fileVerdict !== dbVerdict) {
issues.push({
severity: "warning",
code: "db_projection_validation_drift",
scope: "milestone",
unitId: milestoneId,
message: `${milestoneId}-VALIDATION.md verdict "${fileVerdict}" differs from DB assessment "${dbVerdict}". DB remains authoritative.`,
file: validationPath,
fixable: false,
});
}
}
}
return issues; return issues;
} }

View file

@ -0,0 +1,304 @@
/**
* db-first-validation.test.mjs VALIDATION.md is a DB projection.
*
* Purpose: prove that after the DB-first migration:
* 1. Writing an assessments row with status=needs-attention and regenerating
* VALIDATION.md produces a file with verdict: needs-attention in frontmatter.
* 2. The three read sites (complete-milestone, workspace-index, state-shared)
* return the DB value, not the parsed .md.
* 3. If .md is hand-edited to disagree with DB, the next regeneration overrides.
* 4. Doctor does NOT register db_projection_validation_drift (check is retired).
*/
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest";
import { checkEngineHealth } from "../doctor-engine-checks.js";
import {
closeDatabase,
getMilestoneValidationAssessment,
insertAssessment,
insertMilestone,
insertSlice,
insertTask,
openDatabase,
} from "../sf-db.js";
import { invalidateStateCache } from "../state.js";
import { readMilestoneValidationVerdict } from "../state-shared.js";
import { handleCompleteMilestone } from "../tools/complete-milestone.js";
import { handleValidateMilestone } from "../tools/validate-milestone.js";
import { indexWorkspace } from "../workspace-index.js";
const tmpDirs = [];
afterEach(() => {
closeDatabase();
invalidateStateCache();
while (tmpDirs.length > 0) {
const dir = tmpDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
function makeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-db-first-validation-"));
tmpDirs.push(dir);
mkdirSync(join(dir, ".sf", "milestones", "M001"), { recursive: true });
openDatabase(join(dir, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "DB-first validation", status: "active" });
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Done slice",
status: "complete",
sequence: 1,
});
insertTask({
milestoneId: "M001",
sliceId: "S01",
id: "T01",
title: "Done task",
status: "complete",
});
return dir;
}
// ── Test 1: regeneration produces correct frontmatter ──────────────────────
test("handleValidateMilestone_regenerates_validation_md_with_db_verdict_in_frontmatter", async () => {
const project = makeProject();
const result = await handleValidateMilestone(
{
milestoneId: "M001",
verdict: "needs-attention",
remediationRound: 1,
successCriteriaChecklist: "- [ ] criterion one",
sliceDeliveryAudit: "S01: complete",
crossSliceIntegration: "no issues",
requirementCoverage: "100%",
verdictRationale: "minor attention item",
},
project,
{ uokGatesEnabled: false },
);
assert.equal(result.error, undefined, `handleValidateMilestone error: ${result.error}`);
assert.equal(result.verdict, "needs-attention");
// The file must exist and have the generated header
const validationPath = join(
project,
".sf",
"milestones",
"M001",
"M001-VALIDATION.md",
);
const content = readFileSync(validationPath, "utf-8");
assert.ok(
content.startsWith("<!-- generated from .sf/sf.db"),
"file must start with generated header",
);
assert.match(content, /verdict:\s*needs-attention/, "frontmatter must carry the verdict");
// DB row must agree
const assessment = getMilestoneValidationAssessment("M001");
assert.equal(assessment?.status, "needs-attention");
});
// ── Test 2a: readMilestoneValidationVerdict returns DB value ───────────────
test("readMilestoneValidationVerdict_returns_db_status_without_reading_file", async () => {
const project = makeProject();
const validationPath = join(
project,
".sf",
"milestones",
"M001",
"M001-VALIDATION.md",
);
// Write STALE file with different verdict than DB
writeFileSync(
validationPath,
"---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# stale",
);
// DB says needs-attention
insertAssessment({
path: validationPath,
milestoneId: "M001",
scope: "milestone-validation",
status: "needs-attention",
fullContent: "---\nverdict: needs-attention\n---\n",
});
const { terminal, verdict } = await readMilestoneValidationVerdict(
project,
"M001",
(p) => readFileSync(p, "utf-8"),
);
assert.equal(verdict, "needs-attention", "verdict must come from DB, not stale file");
assert.equal(terminal, true);
});
// ── Test 2b: workspace-index validationVerdict comes from DB ──────────────
test("buildWorkspaceIndex_populates_validationVerdict_from_db_not_file", async () => {
const project = makeProject();
const validationPath = join(
project,
".sf",
"milestones",
"M001",
"M001-VALIDATION.md",
);
// Stale file says needs-remediation, DB says pass
writeFileSync(
validationPath,
"---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# stale",
);
insertAssessment({
path: validationPath,
milestoneId: "M001",
scope: "milestone-validation",
status: "pass",
fullContent: "---\nverdict: pass\n---\n",
});
// Add ROADMAP.md so workspace-index can build the milestone entry
writeFileSync(
join(project, ".sf", "milestones", "M001", "M001-ROADMAP.md"),
"# M001: DB-first validation\n\n## Slice Overview\n| ID | Slice | Risk | Depends | Done | After this |\n|----|-------|------|---------|------|------------|\n| S01 | Done slice | low | - | ✅ | done |\n",
);
const index = await indexWorkspace(project);
const milestone = index.milestones?.find((m) => m.id === "M001");
assert.ok(milestone, "milestone must be in workspace index");
assert.equal(
milestone.validationVerdict,
"pass",
"validationVerdict must come from DB (pass), not stale file (needs-remediation)",
);
});
// ── Test 2c: complete-milestone blocks on DB verdict, not file ────────────
test("handleCompleteMilestone_blocks_when_db_verdict_is_not_pass_regardless_of_file", async () => {
const project = makeProject();
const validationPath = join(
project,
".sf",
"milestones",
"M001",
"M001-VALIDATION.md",
);
// File says pass, DB says needs-attention → must block on DB verdict
writeFileSync(
validationPath,
"---\nverdict: pass\nremediation_round: 1\n---\n\n# file says pass but is stale",
);
insertAssessment({
path: validationPath,
milestoneId: "M001",
scope: "milestone-validation",
status: "needs-attention",
fullContent: "---\nverdict: needs-attention\n---\n",
});
const result = await handleCompleteMilestone(
{
milestoneId: "M001",
title: "DB-first validation",
verificationPassed: true,
oneLiner: "Should be blocked",
narrative: "DB verdict is needs-attention.",
successCriteriaResults: "Checked.",
definitionOfDoneResults: "Checked.",
},
project,
);
assert.ok(
result.error?.includes("needs-attention"),
`expected block on DB needs-attention verdict, got: ${result.error}`,
);
});
// ── Test 3: stale hand-edit overridden by next regeneration ───────────────
test("handleValidateMilestone_overwrites_hand_edited_file_with_db_values", async () => {
const project = makeProject();
const validationPath = join(
project,
".sf",
"milestones",
"M001",
"M001-VALIDATION.md",
);
// Simulate hand-edit with wrong verdict
writeFileSync(
validationPath,
"---\nverdict: needs-remediation\nremediation_round: 99\n---\n\n# hand edited",
);
// Regenerate via tool (DB-first write then disk render)
const result = await handleValidateMilestone(
{
milestoneId: "M001",
verdict: "pass",
remediationRound: 1,
successCriteriaChecklist: "- [x] all good",
sliceDeliveryAudit: "S01: complete",
crossSliceIntegration: "none",
requirementCoverage: "100%",
verdictRationale: "everything passed",
},
project,
{ uokGatesEnabled: false },
);
assert.equal(result.error, undefined);
const content = readFileSync(validationPath, "utf-8");
assert.match(content, /verdict:\s*pass/, "regeneration must override hand-edit");
assert.ok(
!content.includes("needs-remediation"),
"stale verdict must be gone after regeneration",
);
});
// ── Test 4: db_projection_validation_drift is not registered ──────────────
test("checkEngineHealth_does_not_report_db_projection_validation_drift", async () => {
const project = makeProject();
const milestoneDir = join(project, ".sf", "milestones", "M001");
const validationPath = join(milestoneDir, "M001-VALIDATION.md");
// Set up a scenario that would previously have triggered drift:
// file says needs-remediation, DB says pass
writeFileSync(
validationPath,
"---\nverdict: needs-remediation\n---\n\n# stale",
);
insertAssessment({
path: validationPath,
milestoneId: "M001",
scope: "milestone-validation",
status: "pass",
fullContent: "---\nverdict: pass\n---\n",
});
const issues = [];
await checkEngineHealth(project, issues, [], () => false);
assert.equal(
issues.some((i) => i.code === "db_projection_validation_drift"),
false,
"db_projection_validation_drift must not be emitted — check is retired",
);
});

View file

@ -104,7 +104,11 @@ function makeProject() {
return dir; return dir;
} }
test("checkEngineHealth_when_projection_files_disagree_with_db_reports_drift", async () => { test("checkEngineHealth_when_roadmap_json_disagrees_with_db_reports_roadmap_drift_but_not_validation_drift", async () => {
// db_projection_validation_drift was removed (#db-first-validation):
// VALIDATION.md is a generated projection; drift is structurally impossible
// after handleValidateMilestone always regenerates from DB.
// Only db_projection_roadmap_drift is still checked (ROADMAP.json projection).
const project = makeProject(); const project = makeProject();
const milestoneDir = join(project, ".sf", "milestones", "M901"); const milestoneDir = join(project, ".sf", "milestones", "M901");
const validationPath = join(milestoneDir, "M901-VALIDATION.md"); const validationPath = join(milestoneDir, "M901-VALIDATION.md");
@ -120,6 +124,7 @@ test("checkEngineHealth_when_projection_files_disagree_with_db_reports_drift", a
2, 2,
)}\n`, )}\n`,
); );
// Even with a stale VALIDATION.md file on disk, no drift warning fires.
writeFileSync( writeFileSync(
validationPath, validationPath,
["---", "verdict: needs-remediation", "---", "", "# stale"].join("\n"), ["---", "verdict: needs-remediation", "---", "", "# stale"].join("\n"),
@ -138,9 +143,11 @@ test("checkEngineHealth_when_projection_files_disagree_with_db_reports_drift", a
assert.equal( assert.equal(
issues.some((issue) => issue.code === "db_projection_roadmap_drift"), issues.some((issue) => issue.code === "db_projection_roadmap_drift"),
true, true,
"roadmap drift check should still fire",
); );
assert.equal( assert.equal(
issues.some((issue) => issue.code === "db_projection_validation_drift"), issues.some((issue) => issue.code === "db_projection_validation_drift"),
true, false,
"validation drift check is retired — VALIDATION.md is a generated projection",
); );
}); });

View file

@ -5,7 +5,7 @@
* renders MILESTONE-SUMMARY.md to disk, stores rendered markdown in DB * renders MILESTONE-SUMMARY.md to disk, stores rendered markdown in DB
* for recovery, and invalidates caches. * for recovery, and invalidates caches.
*/ */
import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { clearParseCache, saveFile } from "../files.js"; import { clearParseCache, saveFile } from "../files.js";
import { clearPathCache, resolveMilestonePath } from "../paths.js"; import { clearPathCache, resolveMilestonePath } from "../paths.js";
@ -14,6 +14,7 @@ import { appendScheduleSpecs } from "../schedule/schedule-milestone.js";
import { import {
getMilestone, getMilestone,
getMilestoneSlices, getMilestoneSlices,
getMilestoneValidationAssessment,
getSliceTasks, getSliceTasks,
insertMilestoneEvidence, insertMilestoneEvidence,
transaction, transaction,
@ -21,7 +22,6 @@ import {
} from "../sf-db.js"; } from "../sf-db.js";
import { invalidateStateCache } from "../state.js"; import { invalidateStateCache } from "../state.js";
import { isClosedStatus } from "../status-guards.js"; import { isClosedStatus } from "../status-guards.js";
import { extractVerdict } from "../verdict-parser.js";
import { appendEvent } from "../workflow-events.js"; import { appendEvent } from "../workflow-events.js";
import { logError, logWarning } from "../workflow-logger.js"; import { logError, logWarning } from "../workflow-logger.js";
import { writeManifest } from "../workflow-manifest.js"; import { writeManifest } from "../workflow-manifest.js";
@ -123,23 +123,19 @@ export async function handleCompleteMilestone(params, basePath) {
"verification did not pass — milestone completion blocked. verificationPassed must be explicitly set to true after all verification steps succeed", "verification did not pass — milestone completion blocked. verificationPassed must be explicitly set to true after all verification steps succeed",
}; };
} }
// Resolve the milestone directory (needed for SUMMARY.md path below)
const milestoneDir = resolveMilestonePath(basePath, params.milestoneId); const milestoneDir = resolveMilestonePath(basePath, params.milestoneId);
const validationPath = milestoneDir // ── Validation verdict guard — DB-authoritative (#db-first-validation) ──
? join(milestoneDir, `${params.milestoneId}-VALIDATION.md`) // Read verdict from assessments table, not from VALIDATION.md on disk.
: join( // The file is a generated projection; the DB row is the canonical source.
basePath, const validationAssessment = getMilestoneValidationAssessment(
".sf", params.milestoneId,
"milestones", );
params.milestoneId, const verdict = validationAssessment?.status ?? undefined;
`${params.milestoneId}-VALIDATION.md`, if (verdict && verdict !== "pass") {
); return {
if (existsSync(validationPath)) { error: `milestone validation verdict is "${verdict}" — only "pass" may be completed automatically`,
const verdict = extractVerdict(readFileSync(validationPath, "utf-8")); };
if (verdict && verdict !== "pass") {
return {
error: `milestone validation verdict is "${verdict}" — only "pass" may be completed automatically`,
};
}
} }
// ── Guards + DB writes inside a single transaction (prevents TOCTOU) ─── // ── Guards + DB writes inside a single transaction (prevents TOCTOU) ───
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();

View file

@ -31,7 +31,8 @@ import { logWarning } from "../workflow-logger.js";
import { resolveCanonicalMilestoneRoot } from "../worktree-manager.js"; import { resolveCanonicalMilestoneRoot } from "../worktree-manager.js";
function renderValidationMarkdown(params) { function renderValidationMarkdown(params) {
let md = `--- let md = `<!-- generated from .sf/sf.db — do not edit directly; use validate_milestone tool -->
---
verdict: ${params.verdict} verdict: ${params.verdict}
remediation_round: ${params.remediationRound} remediation_round: ${params.remediationRound}
--- ---

View file

@ -16,8 +16,13 @@ import { extractUatType } from "./files.js";
* Returns `undefined` when frontmatter is absent or has no `verdict` field. * Returns `undefined` when frontmatter is absent or has no `verdict` field.
*/ */
export function extractVerdict(content) { export function extractVerdict(content) {
// Strip leading generated-file comment header before parsing frontmatter.
// VALIDATION.md is a DB projection; the header is always the first line.
const stripped = content.startsWith("<!--")
? content.replace(/^<!--[^\n]*-->\n/, "")
: content;
// Primary: YAML frontmatter verdict (canonical format) // Primary: YAML frontmatter verdict (canonical format)
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); const fmMatch = stripped.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) { if (fmMatch) {
const verdictMatch = fmMatch[1].match(/verdict:\s*([\w-]+)/i); const verdictMatch = fmMatch[1].match(/verdict:\s*([\w-]+)/i);
if (verdictMatch) { if (verdictMatch) {
@ -29,7 +34,7 @@ export function extractVerdict(content) {
} }
// Fallback: detect verdict in markdown body (LLM manual writes, #2960). // Fallback: detect verdict in markdown body (LLM manual writes, #2960).
// Matches patterns like: **Verdict:** PASS, **Verdict:** ✅ PASS, **Verdict** needs-remediation // Matches patterns like: **Verdict:** PASS, **Verdict:** ✅ PASS, **Verdict** needs-remediation
const bodyMatch = content.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i); const bodyMatch = stripped.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i);
if (bodyMatch) { if (bodyMatch) {
let v = bodyMatch[1].toLowerCase(); let v = bodyMatch[1].toLowerCase();
if (v === "passed") v = "pass"; if (v === "passed") v = "pass";

View file

@ -7,9 +7,13 @@ import {
resolveTaskFile, resolveTaskFile,
resolveTasksDir, resolveTasksDir,
} from "./paths.js"; } from "./paths.js";
import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js"; import {
getMilestoneSlices,
getMilestoneValidationAssessment,
getSliceTasks,
isDbAvailable,
} from "./sf-db.js";
import { deriveState } from "./state.js"; import { deriveState } from "./state.js";
import { extractVerdict } from "./verdict-parser.js";
import { detectWorktreeName, getSliceBranchName } from "./worktree.js"; import { detectWorktreeName, getSliceBranchName } from "./worktree.js";
// Extract milestone title from roadmap header without using parsers. // Extract milestone title from roadmap header without using parsers.
@ -192,24 +196,18 @@ export async function indexWorkspace(basePath, _opts = {}) {
} }
} }
} }
// Populate validationVerdict from VALIDATION files (#2807) // Populate validationVerdict from DB assessments table (#db-first-validation)
// DB is authoritative; VALIDATION.md is a generated projection only.
for (const milestone of milestones) { for (const milestone of milestones) {
const validationPath = resolveMilestoneFile( const assessment = getMilestoneValidationAssessment(milestone.id);
basePath, if (assessment?.status) {
milestone.id, const verdict = String(assessment.status).trim().toLowerCase();
"VALIDATION", if (
); verdict === "pass" ||
if (validationPath) { verdict === "needs-attention" ||
const validationContent = await loadFile(validationPath); verdict === "needs-remediation"
if (validationContent) { ) {
const verdict = extractVerdict(validationContent); milestone.validationVerdict = verdict;
if (
verdict === "pass" ||
verdict === "needs-attention" ||
verdict === "needs-remediation"
) {
milestone.validationVerdict = verdict;
}
} }
} }
} }