{"record":{"id":"8b5fb02b64f2dc2e","repo":"santifer/career-ops","slug":"payload-articledigest-requires-a-non-empty-dedupke","errorCode":null,"errorMessage":"payload.articleDigest requires a non-empty dedupKey (used for dedup/idempotency)","messagePattern":"payload\\.articleDigest requires a non-empty dedupKey \\(used for dedup/idempotency\\)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"add-entry.mjs","lineNumber":193,"sourceCode":"  if (payload.cv) {\n    const { section, dedupKey, entry } = payload.cv;\n    if (!section || !entry) throw new Error('payload.cv requires { section, entry }');\n    // dedupKey is what makes the insert idempotent — refuse to add without one\n    // rather than silently allowing duplicate re-runs.\n    if (!normalizeKey(dedupKey)) throw new Error('payload.cv requires a non-empty dedupKey (used for dedup/idempotency)');\n    if (cvText === null) throw new Error(`cv.md not found — cannot add to a CV that does not exist`);\n    if (cvHasEntry(cvText, section, dedupKey)) {\n      result.cv = { status: 'duplicate', section };\n    } else {\n      cv = insertIntoCvSection(cvText, section, entry);\n      result.cv = { status: 'added', section };\n    }\n  }\n\n  if (payload.articleDigest) {\n    const { dedupKey, entry } = payload.articleDigest;\n    if (!entry) throw new Error('payload.articleDigest requires { entry }');\n    if (!normalizeKey(dedupKey)) throw new Error('payload.articleDigest requires a non-empty dedupKey (used for dedup/idempotency)');\n    // article-digest.md is optional; create it from a header when missing.\n    const current = articleText === null\n      ? '# Article Digest -- Proof Points\\n\\nCompact proof points from portfolio projects. Read by career-ops at evaluation time.\\n'\n      : articleText;\n    if (articleDigestHasEntry(current, dedupKey)) {\n      result.articleDigest = { status: 'duplicate' };\n      articleDigest = articleText;\n    } else {\n      articleDigest = appendArticleDigest(current, entry);\n      result.articleDigest = { status: articleText === null ? 'created' : 'added' };\n    }\n  }\n\n  return { cv, articleDigest, result };\n}\n\nasync function readStdin() {\n  const chunks = [];","sourceCodeStart":175,"sourceCodeEnd":211,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/add-entry.mjs#L175-L211","documentation":"Emitted by Check 13 of verify-pipeline.mjs, which calls tracker-sync-check.mjs's checkTrackerSync({appsFile}) to compare data/applications.md against data/active-interviews.md. Mismatches classified resolution 'auto-tier1' are resolvable purely by the canonical lifecycle order (templates/states.yml): neither status is a rollback, so the stale file (m.staleIn) can be updated to m.suggestedStatus without human judgement. The message names both statuses, the suggestion, and the stale side.","triggerScenarios":"A round logged in active-interviews.md moved a company to Interview while the applications.md row still says Responded (or the mirror case) — canonical order (Responded → Interview) decides which file is stale and what the converged state is.","commonSituations":"Updating one file after an interview round and forgetting the other; editing active-interviews.md by hand after debriefs instead of going through set-status.mjs; batch workers writing the tracker without touching active-interviews.","solutions":["Run `node tracker-sync-check.mjs` for the full mismatch detail","Update the stale side with `node set-status.mjs <report#|company> <State>` so both files hold the suggested status","Re-run `node verify-pipeline.mjs` to confirm the files report in sync","Habit: change status only through set-status.mjs, never by hand-editing either table"],"exampleFix":"// before:\napplications.md #17: Responded | active-interviews.md: Interview\n\n// after:\nnode set-status.mjs 17 Interview\n// both files now say Interview","handlingStrategy":"validation","validationCode":"// Run BEFORE/AROUND any status change: compare the two files yourself using the\n// canonical lifecycle order, so drift never accumulates.\nconst ORDER = ['Evaluated', 'Applied', 'Responded', 'Interview', 'Offer', 'Hired']; // canonical prefix of templates/states.yml\nconst rank = (s) => ORDER.indexOf(s);\nexport function isAutoResolvable(appStatus, aiStatus) {\n  const a = rank(appStatus), b = rank(aiStatus);\n  return a >= 0 && b >= 0 && a !== b; // different forward states -> the older one is stale\n}","typeGuard":"const isCanonicalForwardPair = (a, b) => rank(a) >= 0 && rank(b) >= 0; // both on the forward ladder — sync is mechanical","tryCatchPattern":null,"preventionTips":["Change status ONLY through `node set-status.mjs <report#|company> <State>` — it is the canonical locked write path for exactly this reason","Run `node tracker-sync-check.mjs` after interview debriefs, when active-interviews.md is most likely to be updated by hand","Run `node verify-pipeline.mjs` as a pre-commit/CI gate so tier-1 drift is flagged the day it appears","Never hand-edit both files to 'fix' drift; fix one side and let the checker confirm convergence"],"tags":["status-drift","tracker-sync","active-interviews","pipeline-health"],"backgroundTag":"status-drift","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}