{"record":{"id":"bc1c8ec99ca551b5","repo":"stablyai/orca","slug":"relay-credential-rotation-pending-state-missing","errorCode":null,"errorMessage":"relay credential rotation pending state missing","messagePattern":"relay credential rotation pending state missing","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"mobile/src/transport/mobile-relay-credential-rotation.ts","lineNumber":47,"sourceCode":"  if (!bundle.pending) {\n    const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes\n    const token = encodeBase64Url(randomBytes(32))\n    bundle = MobileRelayCredentialBundleSchema.parse({\n      ...bundle,\n      pending: {\n        token,\n        hash: hashMobileRelayCredential(token),\n        reqId: `rotate-${encodeBase64Url(randomBytes(16))}`\n      }\n    })\n    // Why: a crash or lost response must leave enough material to query the\n    // one global install key before any second authorization attempt.\n    await args.writeBundle(bundle)\n  }\n\n  const pending = bundle.pending\n  if (!pending) {\n    throw new Error('relay credential rotation pending state missing')\n  }\n  let endpoints = await getEndpoints(args.client, pending.reqId)\n  if (endpoints.installStatus?.state !== 'committed') {\n    const response = await args.client.sendRequest('pairing.provisionRelay', {\n      reqId: pending.reqId,\n      newResumeTokenHash: pending.hash,\n      expectedCurrentHash: bundle.current.hash\n    })\n    if (!response.ok) {\n      throw new Error(`${response.error.code}: ${response.error.message}`)\n    }\n    const installed = DeviceCredentialInstalledSchema.parse(response.result)\n    endpoints = await getEndpoints(args.client, pending.reqId)\n    if (\n      endpoints.installStatus?.state !== 'committed' ||\n      JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)\n    ) {\n      throw new Error('relay credential rotation was not authoritatively committed')","sourceCodeStart":29,"sourceCodeEnd":65,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/mobile/src/transport/mobile-relay-credential-rotation.ts#L29-L65","documentation":"Thrown in `rotateMobileRelayCredential` when `bundle.pending` is missing after the block that creates and persists it. The preceding code writes `bundle` with a `pending` field via the schema-strict parse, so reaching this throw implies the schema stripped `pending` (e.g. the parser set it to undefined because the strict object rejected it) or a caller passed a pre-rotated bundle whose `pending` was already consumed. It is a defensive invariant guard.","triggerScenarios":"Caller passes a bundle where `pending` was set to `undefined` in a way that survived schema parse; `writeBundle` mutated the in-memory object; a schema change that made `pending` non-optional in the wrong direction.","commonSituations":"Internal logic error or future refactor that drops the pending write; extremely unlikely under normal control flow because the constructor block always sets `pending`.","solutions":["Confirm the bundle passed in either already has a valid `pending` or no `pending` (in which case rotation creates one).","If this fires, suspect a schema regression in `MobileRelayCredentialBundleSchema` around the `pending` field.","Add a regression test that asserts `pending` is present after the create-and-write block."],"exampleFix":"// before\nconst bundle = await readBundle() // pending undefined unexpectedly\nawait rotateMobileRelayCredential({ client, bundle, writeBundle })\n\n// after\nif (!bundle.pending && !bundle.current) throw new Error('bundle has neither pending nor current')\n// the rotation function itself is the correct path; this throw indicates corruption upstream","handlingStrategy":"validation","validationCode":"function bundleHasPending(b: MobileRelayCredentialBundle): boolean { return !!b.pending }\nif (!bundleHasPending(bundle) && !bundle.current) { throw new Error('bundle corrupted') }","typeGuard":"function hasPending(b: MobileRelayCredentialBundle): b is MobileRelayCredentialBundle & { pending: NonNullable<MobileRelayCredentialBundle['pending']> } { return !!b.pending }","tryCatchPattern":null,"preventionTips":["Treat this throw as an internal invariant violation — investigate schema regressions.","Never mutate the bundle object between writeBundle and the pending read.","Add regression tests covering the create-and-write path."],"tags":["relay","credentials","rotation","invariant"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}