{"record":{"id":"e02986e30f99db04","repo":"santifer/career-ops","slug":"reservation-ownership-token-is-required-for-releas","errorCode":null,"errorMessage":"Reservation ownership token is required for release","messagePattern":"Reservation ownership token is required for release","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"reserve-report-num.mjs","lineNumber":224,"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":206,"sourceCodeEnd":242,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/reserve-report-num.mjs#L206-L242","documentation":"releaseReportNumbers refuses to release sentinels unless the caller proves ownership via a reservation token or explicitly opts into administrative force mode. The token is attached as a non-enumerable Symbol property (RESERVATION_TOKEN) on the array returned by reserveReportNumbers, so passing that exact array back satisfies the check. Throwing here prevents one caller from clobbering another's reservation by guessing report numbers.","triggerScenarios":"Calling releaseReportNumbers([42]) without options.reservationToken and without options.force === true; passing a plain array that was not the return value of reserveReportNumbers (so it lacks the Symbol); reconstructing the numbers array via spread or .map() which drops the non-enumerable Symbol property.","commonSituations":"Copying the reserved array with [...ids] or ids.map(x => x) strips the hidden Symbol token; serializing/deserializing the IDs through JSON loses the token; a cleanup script passing bare numbers from CLI args.","solutions":["Pass the exact array object returned by reserveReportNumbers: await releaseReportNumbers(reservedIds) — do not copy it first.","If you must reconstruct, capture the token explicitly: const token = reservedIds[Symbol.for('career-ops-report-reservation-token')] — but note the library uses a private Symbol, so prefer the options.reservationToken path.","For administrative cleanup, pass { force: true }: await releaseReportNumbers([42], { force: true }).","Store and forward the token via options.reservationToken if you obtained it out-of-band."],"exampleFix":"// before: copying the array drops the hidden token\nconst copy = [...reservedIds];\nawait releaseReportNumbers(copy); // throws\n\n// after: pass the original array or use force\nawait releaseReportNumbers(reservedIds);\n// or for admin cleanup:\nawait releaseReportNumbers([42], { force: true });","handlingStrategy":"type-guard","validationCode":"function canReleaseSafely(numbers, options = {}) {\n  if (options.force === true) return true;\n  const token = options.reservationToken || numbers?.[Symbol.for('career-ops-report-reservation-token')];\n  return Boolean(token);\n}\nif (!canReleaseSafely(ids)) { /* need force or token */ }","typeGuard":"function hasReservationToken(numbers) {\n  const sym = Object.getOwnPropertySymbols(numbers).find(\n    s => String(s) === 'Symbol(career-ops-report-reservation-token)'\n  );\n  return Boolean(sym && numbers[sym]);\n}","tryCatchPattern":"try {\n  await releaseReportNumbers(ids);\n} catch (err) {\n  if (err.message.includes('ownership token')) {\n    // administrative fallback\n    await releaseReportNumbers(ids, { force: true });\n  } else {\n    throw err;\n  }\n}","preventionTips":["Pass the exact array object returned by reserveReportNumbers — never [...ids] or ids.map().","For CLI/admin cleanup, always pass { force: true } explicitly.","Store the reservation token out-of-band if you plan to release from a different code path."],"tags":["reservation","ownership","locking","report-number","token"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}