santifer/career-ops · warning
payload.articleDigest requires a non-empty dedupKey (used fo
Error message
payload.articleDigest requires a non-empty dedupKey (used for dedup/idempotency)
What it means
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.
Source
Thrown at add-entry.mjs:193
if (payload.cv) {
const { section, dedupKey, entry } = payload.cv;
if (!section || !entry) throw new Error('payload.cv requires { section, entry }');
// dedupKey is what makes the insert idempotent — refuse to add without one
// rather than silently allowing duplicate re-runs.
if (!normalizeKey(dedupKey)) throw new Error('payload.cv requires a non-empty dedupKey (used for dedup/idempotency)');
if (cvText === null) throw new Error(`cv.md not found — cannot add to a CV that does not exist`);
if (cvHasEntry(cvText, section, dedupKey)) {
result.cv = { status: 'duplicate', section };
} else {
cv = insertIntoCvSection(cvText, section, entry);
result.cv = { status: 'added', section };
}
}
if (payload.articleDigest) {
const { dedupKey, entry } = payload.articleDigest;
if (!entry) throw new Error('payload.articleDigest requires { entry }');
if (!normalizeKey(dedupKey)) throw new Error('payload.articleDigest requires a non-empty dedupKey (used for dedup/idempotency)');
// article-digest.md is optional; create it from a header when missing.
const current = articleText === null
? '# Article Digest -- Proof Points\n\nCompact proof points from portfolio projects. Read by career-ops at evaluation time.\n'
: articleText;
if (articleDigestHasEntry(current, dedupKey)) {
result.articleDigest = { status: 'duplicate' };
articleDigest = articleText;
} else {
articleDigest = appendArticleDigest(current, entry);
result.articleDigest = { status: articleText === null ? 'created' : 'added' };
}
}
return { cv, articleDigest, result };
}
async function readStdin() {
const chunks = [];View on GitHub (pinned to 60398d6549)
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
Example fix
// before: applications.md #17: Responded | active-interviews.md: Interview // after: node set-status.mjs 17 Interview // both files now say Interview
Defensive patterns
Strategy: validation
Validate before calling
// Run BEFORE/AROUND any status change: compare the two files yourself using the
// canonical lifecycle order, so drift never accumulates.
const ORDER = ['Evaluated', 'Applied', 'Responded', 'Interview', 'Offer', 'Hired']; // canonical prefix of templates/states.yml
const rank = (s) => ORDER.indexOf(s);
export function isAutoResolvable(appStatus, aiStatus) {
const a = rank(appStatus), b = rank(aiStatus);
return a >= 0 && b >= 0 && a !== b; // different forward states -> the older one is stale
} Type guard
const isCanonicalForwardPair = (a, b) => rank(a) >= 0 && rank(b) >= 0; // both on the forward ladder — sync is mechanical
Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Application answer state must be one of: ${[...VALID_STATES]
- Application Answers section has ${skipped.length} unreadable
- payload must include at least one of: cv, articleDigest
- payload.cv requires { section, entry }
- Missing value for ${arg}
AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20).
Data as JSON: /api/errors/8b5fb02b64f2dc2e.
Report an issue: GitHub.