{"record":{"id":"a3f7fdf29567b03b","repo":"affaan-m/ECC","slug":"claim-cannot-grant-another-dispatch","errorCode":null,"errorMessage":"claim cannot grant another dispatch","messagePattern":"claim cannot grant another dispatch","errorType":"validation","errorClass":"ClaimError","httpStatus":null,"severity":"error","filePath":"skills/operator-approval-loop/references/approval_claims.py","lineNumber":98,"sourceCode":"    with _transaction(db, now):\n        _snapshot(db, obligation_id, decision_id)\n        token = secrets.token_hex(32)\n        db.execute('''INSERT INTO obligation_delivery_claims\n            (obligation_id,decision_id,token,state,created_ts,updated_ts)\n            VALUES (?,?,?,'claimed',?,?)''', (obligation_id, decision_id, token, now, now))\n    return token\n\n\ndef begin_dispatch(db, token, *, now):\n    \"\"\"Return bound payload once, only after dispatching state has committed.\n\n    A crash after this boundary is uncertain even if transport has not started.\n    Do not cache/reuse this return value for another attempt.\n    \"\"\"\n    with _transaction(db, now):\n        row = _claim_row(db, token)\n        if row['state'] != 'claimed':\n            raise ClaimError('claim cannot grant another dispatch')\n        payload = _snapshot(db, row['obligation_id'], row['decision_id'])\n        changed = db.execute('''UPDATE obligation_delivery_claims SET state='dispatching',updated_ts=?\n            WHERE token=? AND state='claimed' ''', (now, token)).rowcount\n        if changed != 1:\n            raise ClaimError('dispatch transition lost')\n    return payload\n\n\ndef cancel(db, token, *, now):\n    \"\"\"Cancel only a not-yet-dispatched claim. Never reopen its decision key.\"\"\"\n    with _transaction(db, now):\n        row = _claim_row(db, token)\n        if row['state'] != 'claimed':\n            raise ClaimError('only a pre-dispatch claim can be cancelled')\n        db.execute(\"UPDATE obligation_delivery_claims SET state='cancelled',updated_ts=? WHERE token=?\",\n                   (now, token))\n\n","sourceCodeStart":80,"sourceCodeEnd":116,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/operator-approval-loop/references/approval_claims.py#L80-L116","documentation":"begin_dispatch raises this ClaimError when the claim row found for the given token is not in the 'claimed' state. A claim grants exactly one dispatch; once it has moved to 'dispatching', 'delivered', 'unknown', or 'cancelled', it can never start a new dispatch. This enforces at-most-once dispatch permission.","triggerScenarios":"Calling begin_dispatch(db, token) with a token whose obligation_delivery_claims row has any state other than 'claimed' — e.g. calling it twice with the same token, after cancel(), after mark_unknown(), or after complete()/reconcile() finished the claim.","commonSituations":"Retrying a dispatch after a crash or timeout using the cached token returned by a previous begin_dispatch (explicitly warned against in the docstring); worker restart that re-reads an old token; accidentally reusing a token after cancellation; two workers sharing one token.","solutions":["Do not reuse the token for a second attempt; create a fresh claim via claim(db, obligation_id, decision_id, now=...) for a new dispatch.","Check the claim's current state first (query obligation_delivery_claims WHERE token=?) and route: 'dispatching' -> treat as uncertain, mark_unknown; 'delivered' -> nothing to do; 'cancelled' -> claim a new token.","If the first dispatch crashed mid-send, call mark_unknown() then reconcile() with trusted evidence rather than re-dispatching the same token."],"exampleFix":"// before: retry with the same token after a failure\ntoken = claims.begin_dispatch(db, token, now=ts)\nsend(payload)  # failed\ntoken = claims.begin_dispatch(db, token, now=ts)  # ClaimError\n\n// after: mark uncertainty, then reconcile; new dispatch needs a new claim\ntry:\n    payload = claims.begin_dispatch(db, token, now=ts)\n    send(payload)\nexcept DeliveryError:\n    claims.mark_unknown(db, token, now=ts)\n    claims.reconcile(db, token, coordinate, evidence, now=ts)\n# a genuinely new dispatch:\nnew_token = claims.claim(db, obligation_id, decision_id, now=ts)","handlingStrategy":"validation","validationCode":"row = db.execute(\"SELECT state FROM obligation_delivery_claims WHERE token=?\", (token,)).fetchone()\nif row is None or row['state'] != 'claimed':\n    raise ValueError(f\"token not dispatchable (state={row['state'] if row else 'missing'})\")","typeGuard":null,"tryCatchPattern":"try:\n    payload = claims.begin_dispatch(db, token, now=ts)\nexcept claims.ClaimError:\n    handle_already_dispatched(token)  # new claim or reconcile path","preventionTips":["Treat the begin_dispatch return value as single-use; never cache it for retries","Track claim state locally before each dispatch attempt","Give each dispatch attempt its own claim token"],"tags":["state-machine","concurrency","at-most-once","claim-token"],"backgroundTag":"invalid-state-transition","analyzedSha":"8321021c54d670126ce3b2969d5deb880b4b0c2a","analyzedAt":"2026-09-16T10:08:13.343Z","contentChangedAt":"2026-09-16T10:08:13.343Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}