Skip to content

feat(vault): abandon an unaccepted registration - #489

Draft
LKSNDRTMLKV wants to merge 1 commit into
feat/registry-record-handlefrom
feat/registry-abandon
Draft

LKSNDRTMLKV wants to merge 1 commit into
feat/registry-record-handlefrom
feat/registry-abandon

Conversation

@LKSNDRTMLKV

Copy link
Copy Markdown
Member

Summary

A registration the registry never accepted is retried with backoff for as long as the passport stays published. When the registry refuses the payload every time, or the product is withdrawn before it was ever registered, that is forever, and the only way off the queue was a database edit. This adds the write that ends it: POST /vault/api/v1/dpp/{dppId}/registry/abandon, with odal registry abandon <passport-id> --reason "…". It is the "give this one up" write #487 left out on purpose, because it is the more dangerous of the two.

Stacked on #487 (base feat/registry-record-handle), which is stacked on #484 and #479, and it uses core's find_submission from odal-node/dpp-core#486. Draft, and not mergeable yet: it builds against dpp-core 0.22.0 on crates.io, which does not have those types, so CI is expected to fail until core releases them and the pins move. It was developed and verified against the local core working tree (04a3843). Migration 0044 follows #487's 0043.

Related issue

The open decision in #487: "No 'give this one up' write."

Why this one is dangerous, and what the design does about it

A pending row whose first attempt got through and whose reply was lost looks exactly like one that never did: the registry holds a submission for the passport and this node has no record that it does. Abandoning that row would leave a registration the registry is checking, and may grant, that the node believes it dropped. So the route does not take the operator's word for it, and the write cannot race the drain.

The registry is checked. The task list is read through find_submission, and the request is refused unless all of it was read and none of it reports the unique product identifier the passport was submitted under (NotFound { exhaustive: true }).

What the registry shows Answer
a submission for the identifier (Found) 409, naming its correlation identifier
more than one (Ambiguous) 409, naming the candidates
part of the list not read, or a task still being checked (NotFound { exhaustive: false }) 422
an adapter that cannot look (Unsupported) 422
unreadable 502, safe to retry

A held submission is a 409 and not a 422 because the registration is there, not merely unproven.

It cannot race the drain. abandon is one conditional UPDATE that takes a row only while it is pending, has been attempted at least once, and is backed off (next_attempt_at > now()). The drain reads a row while it is due and only a finished attempt pushes it out, so an abandon cannot land between the drain's read and its register call. A submitted row is never abandoned from here: the registry holds it.

Nothing the drain writes can resurrect it. Every update the drain makes (mark_submitted, mark_submitted_noting, mark_deactivated, mark_registered, mark_rejected, mark_attempt_failed) now carry status <> 'abandoned', record_handle already required submitted, and the due set never offered the row. A pass that read a row before it was abandoned bounces off.

Changes

  • A new terminal state, abandoned, kept apart from rejected: a rejection is the registry refusing a submission it received; this is a person deciding to stop asking about one the registry never accepted. Counted separately in GET /vault/api/v1/registry and in a registry_outbox_abandoned gauge, and not counted as stalled, so it is not read as still owed and does not ask for a person.
  • The passport stays published. This ends the queue row, not the passport, and the response and the CLI say so. Publishing the passport again queues it afresh, as it does a rejected one.
  • A reason is required, kept on the row (abandoned by <actor>: <reason>) and in the audit trail: registry_abandoned, with who, why, how many attempts, and what the row had been saying. Migration 0044 admits the status and the action, keeping suspended and every action written before.
  • Idempotent without a key. Abandoning an already-abandoned row is a 200 with alreadyAbandoned: true that asks the registry nothing and writes nothing, so a retry after a lost response does not fail on its own success.
  • Statuses: 404 no registration, 409 a state this does not end, the registry holds a submission, or the row has not been tried / is due now, 422 not shown absent or a bad reason, 501 no registry queues, 502 registry unreadable.
  • The handle route (feat(vault): record a registry handle by hand #487) reads the new status too: recording a handle on an abandoned row is a 409 that says so.
  • Docs: the API description (path, request and response schemas, the new status and count, bundles regenerated), the runbook's pending-row paragraph, and CHANGELOG.md.

Decisions I would like a second opinion on

  • Only pending rows, only after a failed attempt. A fresh row cannot be abandoned until the drain has tried it once. Someone who published by mistake has to wait for the first failure (seconds to the first backoff, longer as it grows). The alternative, abandoning a due row, is exactly the race the lease prevents.
  • abandoned is a state of its own, not a rejected with a note. More migration and more surface, but a rejection and a giving-up are different facts, and a count that mixes them tells an operator the registry refused something it never saw.
  • Re-publish revives it. A suspended-then-republished passport owes a registration again; the upsert that requeues rejected rows now requeues abandoned ones.
  • Admin scope, as for recording a handle: it ends an obligation the node was tracking on a person's say-so.

Checklist

  • Tests added or updated for new behaviour: the outbox against Postgres (taken only while pending, tried and backed off; terminal against every drain write; counted and not stalled; re-publish queues it afresh), the route over HTTP (abandoned and audited, replay, every lookup answer that is not proof of absence leaves the row and the trail untouched, 404/409 told apart without asking the registry, 502, admin only, malformed reasons), the handle route reading the new status, and the CLI
  • The new tests were shown to bite: the drain guards, the backoff condition and the exhaustive-read requirement were each removed in turn and a test went red
  • just lint passes locally
  • just fmt applied
  • just test passes (unit) and the integration suites: just check exits 0 with 1572 tests, the feature-gated lint exits 0, the vault integration suite passes (508), the outbox suite passes (27), the OpenAPI contract passes (25), and just openapi-check passes with the bundles staged, against the local dpp-core
  • No println!/eprintln!/dbg! in service-crate src/
  • No secrets, credentials, or .env files in the diff
  • Docs updated if a public API, endpoint, or CLI command changed
  • If the DB schema changed: migration 0044 widens the registry status and audit action CHECKs only; no table or grant changes

@LKSNDRTMLKV
LKSNDRTMLKV force-pushed the feat/registry-record-handle branch from be6483e to 1420f86 Compare October 20, 2026 22:32
@LKSNDRTMLKV
LKSNDRTMLKV force-pushed the feat/registry-abandon branch from 70a9333 to 9ae6beb Compare October 20, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant