{"record":{"id":"e02986e30f99db04","repo":"santifer/career-ops","slug":"reservation-ownership-token-is-required-for-release","errorCode":null,"errorMessage":"Reservation ownership token is required for release","messagePattern":"Reservation ownership token is required for release","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"reserve-report-num.mjs","lineNumber":238,"sourceCode":"  throw new Error(`Could not claim ${count} report slot(s) after ${MAX_RETRIES} retries`);\n}\n\n/**\n * Release reservation sentinels after report creation or on failure.\n * Only the array returned by reserveReportNumbers owns its sentinels. The CLI\n * uses force mode as an explicit administrative cleanup path.\n */\nexport async function releaseReportNumbers(numbers, options = {}) {\n  const reportsDir = reportsDirFor(options);\n  const values = Array.isArray(numbers) ? numbers : [numbers];\n  for (const num of values) {\n    if (!Number.isSafeInteger(num) || num < 1) {\n      throw new TypeError(`Report number must be a positive integer, got ${num}`);\n    }\n  }\n  const force = options.force === true;\n  const token = options.reservationToken || numbers?.[RESERVATION_TOKEN];\n  if (!force && !token) throw new Error('Reservation ownership token is required for release');\n  if (!existsSync(reportsDir)) return 0;\n\n  const trackerPath = trackerPathFor(options);\n  const lock = await acquireTrackerLock(trackerLockDirFor(trackerPath), {\n    timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,\n    retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,\n    staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,\n    tracker: trackerPath,\n    ...options.lockOptions,\n  });\n  try {\n    return values.reduce(\n      (removed, num) => removed + Number(releaseSlot(reportsDir, num, { token, force })),\n      0,\n    );\n  } finally {\n    lock.release();\n  }","sourceCodeStart":220,"sourceCodeEnd":256,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/reserve-report-num.mjs#L220-L256","documentation":"Sentinels are owned by the process that reserved them, proven by a UUID token attached (as a hidden Symbol property) to the array returned by reserveReportNumbers. releaseReportNumbers refuses to unlink sentinels unless you pass that token (options.reservationToken or the returned array itself), or set options.force === true for explicit administrative cleanup. This prevents one worker from deleting another worker's live reservation.","triggerScenarios":"Calling releaseReportNumbers([42]) with a hand-built plain array instead of the array returned by reserveReportNumbers, and without options.reservationToken — the Symbol-owned token is missing. Releasing across process boundaries (a supervisor process that did not make the reservation) without force:true. Passing reservationToken as an empty string or undefined after destructuring a config object.","commonSituations":"Splitting the reserve call and release call across modules/services so the original array (with its Symbol token) is lost. Persisting IDs to disk and reconstructing [42] later — symbols do not survive JSON serialization. Releasing a colleague's/cron job's sentinels without the admin force path.","solutions":["Always release with the exact array returned by reserveReportNumbers: const ids = await reserveReportNumbers(n); ... await releaseReportNumbers(ids);","If the original array is gone, pass the saved token: await releaseReportNumbers([42], { reservationToken: token }).","For intentional administrative cleanup of someone else's sentinels, use options.force === true (or the CLI --release), after verifying the reservation is truly stale.","Persist the token (it is a plain UUID string) via options if you must cross process boundaries: read it as ids[RESERVATION_TOKEN] and store it in your job record.","Run node reserve-report-num.mjs --gc to clear stale sentinels instead of hand-releasing without a token."],"exampleFix":"// before\nconst ids = await reserveReportNumbers(4);\nawait releaseReportNumbers([ids[0], ids[1], ids[2], ids[3]]); // Error: token required\n// after\nconst ids = await reserveReportNumbers(4);\nawait releaseReportNumbers(ids); // token rides on the array via RESERVATION_TOKEN symbol","handlingStrategy":"try-catch","validationCode":"const RESERVATION_TOKEN = Symbol.for('career-ops-report-reservation-token'); // conceptual\nfunction canRelease(ids, options) {\n  return Boolean(options?.reservationToken) || Boolean(ids && Object.getOwnPropertySymbols(ids).length) || options?.force === true;\n}","typeGuard":"const hasReleaseAuth = (ids, options = {}) =>\n  options.force === true || (typeof options.reservationToken === 'string' && options.reservationToken.length > 0) ||\n  (Array.isArray(ids) && Object.getOwnPropertySymbols(ids).length > 0);","tryCatchPattern":"try {\n  await releaseReportNumbers(ids);\n} catch (err) {\n  if (err.message.includes('ownership token is required')) {\n    // no token available — only force if you verified the reservation is stale\n    if (isVerifiedStale(sentinelPath)) await releaseReportNumbers(nums, { force: true });\n    else throw err;\n  } else throw err;\n}","preventionTips":["Always keep the array returned by reserveReportNumbers and pass it back to release — the token rides on it as a Symbol.","Never rebuild the ID array by hand; symbols do not survive JSON round-trips.","If crossing process boundaries, persist the token UUID explicitly in your job record and pass options.reservationToken.","Reserve options.force for deliberate administrative cleanup, ideally via the CLI.","Use --gc for stale sentinels instead of hand-releasing without a token."],"tags":["authorization","ownership-token","concurrency","resource-cleanup"],"backgroundTag":"missing-ownership-token","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}