fix(usage): let an operator clear a participation the collector never accepted

Probing the live collector to settle the delete-sequence question turned up
something else: usage.picpeak.app answers a valid usage.v2 registration with
INVALID_PACKET while the identical v1 flow is accepted. It does not speak v2
yet — which the deployment notes already require, but the consequence of
getting that order wrong was worse than "reports do not send".

Opting in to v2 against a v1-only collector left the installation stuck.
Registration was refused, so nothing existed at the collector at all; the row
sat in activation_pending, disable moved it to deletion_pending, retry was
futile forever, and enable refused because the row was not `disabled`. The
abandon hatch added earlier did not apply: it was gated on
SIGNING_KEY_UNREADABLE. So the most harmless possible failure — nothing
registered anywhere — was the one an operator could not clear.

The gate is now the property that actually matters: a participation the
collector has provably never accepted (sequence 0, no receipt) with a failing
delivery can be discarded, from activation_pending as well as
deletion_pending. Its receipt records `never-registered` rather than an
unconfirmed deletion, because nothing remote exists to be unsure about. A
participation the collector *did* accept keeps the old narrow gate and its
explicit warning — clearing local state while the collector still holds
reports must stay a deliberate, warned-about act.

A collector that rejects a registration or a deletion outright now reports
SCHEMA_NOT_ACCEPTED instead of DELIVERY_FAILED, and the settings page says the
collector does not accept this report version yet. Retrying cannot fix that,
and sending the operator to look for a network fault they do not have was
wrong.

Verified end to end against the live collector: v2 opt-in reports
SCHEMA_NOT_ACCEPTED, the exit is offered immediately, the receipt says
never-registered, and joining again on v1 registers, reports and withdraws
with a collector-confirmed deletion.
This commit is contained in:
Paul Nothaft
2026-09-06 18:54:58 +02:00
parent c741dc22c5
commit e40bc474bc
7 changed files with 186 additions and 22 deletions
@@ -278,7 +278,9 @@ export default function ProductUsageTab() {
{t(
data.last_error === 'SIGNING_KEY_UNREADABLE'
? 'productUsage.signingKeyUnreadable'
: 'productUsage.deliveryProblem'
: data.last_error === 'SCHEMA_NOT_ACCEPTED'
? 'productUsage.schemaNotAccepted'
: 'productUsage.deliveryProblem'
)}
</p>
)}
@@ -296,7 +298,13 @@ export default function ProductUsageTab() {
// The one dead end the operator cannot retry out of. Offered only
// here, and worded so nobody mistakes it for a confirmed deletion.
<div className="rounded border border-amber-300 dark:border-amber-700 p-3 space-y-2">
<p>{t('productUsage.abandonExplanation')}</p>
<p>
{t(
data.abandon_never_registered
? 'productUsage.abandonExplanationUnregistered'
: 'productUsage.abandonExplanation'
)}
</p>
<Button
variant="outline"
className={WRAPPING_BUTTON}
@@ -305,7 +313,11 @@ export default function ProductUsageTab() {
if (
await confirm({
title: t('productUsage.abandon'),
message: t('productUsage.abandonConfirm'),
message: t(
data.abandon_never_registered
? 'productUsage.abandonConfirmUnregistered'
: 'productUsage.abandonConfirm'
),
confirmLabel: t('productUsage.abandon'),
variant: 'danger'
})