diff --git a/src/resources/extensions/sf/self-feedback.js b/src/resources/extensions/sf/self-feedback.js index 6ff18f29c..524d359fe 100644 --- a/src/resources/extensions/sf/self-feedback.js +++ b/src/resources/extensions/sf/self-feedback.js @@ -405,17 +405,53 @@ export function getBlockedEntries(basePath = process.cwd()) { (e) => e.blocking && !e.resolvedAt, ); } +/** + * Accepted resolution evidence kinds. Anything outside this set is rejected + * at the writer layer (sf-mp4qoby4-meiir7): the credibility check at the + * READER side (self-feedback-drain.js) was previously the only gate, and an + * agent that wrote DB rows directly via raw SQL or the wrong tool could + * bypass it, landing resolutions like `{file, line}` or `null` that the + * reader would then either trust (legacy carve-out) or quietly re-open. The + * writer-layer check makes the policy belt-and-suspenders for any caller + * going through markResolved or resolveSelfFeedbackEntry. Raw SQL bypass is + * a known limit documented in the entry; full coverage needs a DB CHECK + * constraint (schema migration, separate work). + */ +const ACCEPTED_EVIDENCE_KINDS = new Set([ + "agent-fix", + "human-clear", + "promoted-to-requirement", + "auto-version-bump", + // "agent-fix-unverified" is reserved for outcomes-verification (a future + // follow-up): if a caller wants to mark a resolution as agent-attributed + // but explicitly NOT verified, it should write this kind so the + // credibility check re-opens the entry until verification lands. + "agent-fix-unverified", +]); + +function isAcceptedEvidence(evidence) { + if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) + return false; + const kind = evidence.kind; + return typeof kind === "string" && ACCEPTED_EVIDENCE_KINDS.has(kind); +} + /** * Mark an entry as resolved. Updates SQLite for forge-local feedback when * available, otherwise rewrites the legacy JSONL fallback in place. Entries are * append-only otherwise; resolution is the one mutation we support so blocking * entries don't trigger re-queue forever. * - * Resolution requires structured `evidence` so the fix is traceable: + * Resolution requires structured `evidence` with one of the accepted kinds + * (sf-mp4qoby4-meiir7 — writer-layer constraint): * - `agent-fix` should cite a commit SHA or test path * - `auto-version-bump` is for session_start's automatic resolver * - `human-clear` is the catch-all for operator interventions * - `promoted-to-requirement` is for the threshold-promotion sweeper + * - `agent-fix-unverified` for outcomes-verification-pending resolutions + * + * Resolutions without an accepted evidence kind are REJECTED (return false) + * — no DB write, no JSONL write, no markdown regeneration. * * If the entry has `acceptanceCriteria`, callers SHOULD pass `criteriaMet` * naming which criteria were satisfied. (Not enforced — entries without @@ -424,6 +460,11 @@ export function getBlockedEntries(basePath = process.cwd()) { * After resolution, SELF-FEEDBACK.md is regenerated as a compact working view. */ export function markResolved(entryId, resolution, basePath = process.cwd()) { + if (!isAcceptedEvidence(resolution?.evidence)) { + // Writer-layer constraint: refuse non-canonical resolutions. The + // caller can recover by re-submitting with a valid evidence kind. + return false; + } if (isForgeRepo(basePath) && isDbAvailable()) { try { importLegacyJsonlToDb(basePath); diff --git a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs index 974c83c83..85aa25f86 100644 --- a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs +++ b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs @@ -140,6 +140,73 @@ test("markResolved_when_db_available_updates_sqlite_and_markdown_projection", () assert.match(markdown, /Recently Resolved/); }); +test("markResolved_rejects_resolution_with_non_canonical_evidence_kind", () => { + const project = makeForgeProject(); + const result = recordSelfFeedback( + { + kind: "writer-layer-test", + severity: "medium", + summary: "Must be rejected when evidence kind is non-canonical", + }, + project, + ); + assert.ok(result?.entry.id); + + // Non-canonical shape — exactly what the dogfood agent wrote (sf-mp4qoby4-meiir7) + const okBadKind = markResolved( + result.entry.id, + { + reason: "claims a fix", + evidence: { file: "src/foo.js", line: 42 }, + }, + project, + ); + assert.equal(okBadKind, false); + + // Missing evidence entirely + const okMissing = markResolved( + result.entry.id, + { reason: "no evidence" }, + project, + ); + assert.equal(okMissing, false); + + // Evidence kind not in the allowed set + const okWrongKind = markResolved( + result.entry.id, + { reason: "unknown kind", evidence: { kind: "spontaneous-resolution" } }, + project, + ); + assert.equal(okWrongKind, false); + + // All three rejections must have left the entry OPEN + const [entry] = readAllSelfFeedback(project); + assert.equal(entry.resolvedAt, undefined); +}); + +test("markResolved_accepts_each_canonical_evidence_kind", () => { + const project = makeForgeProject(); + const kinds = [ + { kind: "agent-fix", commitSha: "abc1234" }, + { kind: "human-clear" }, + { kind: "promoted-to-requirement", requirementId: "R042" }, + { kind: "auto-version-bump" }, + { kind: "agent-fix-unverified", reasonForUnverified: "no test yet" }, + ]; + for (const evidence of kinds) { + const filed = recordSelfFeedback( + { kind: `accepts-${evidence.kind}`, severity: "low", summary: "x" }, + project, + ); + const ok = markResolved( + filed.entry.id, + { reason: `using ${evidence.kind}`, evidence }, + project, + ); + assert.equal(ok, true, `expected ${evidence.kind} to be accepted`); + } +}); + test("markResolved_appends_resolution_event_to_jsonl_audit_log", () => { const project = makeForgeProject(); const result = recordSelfFeedback(