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'
})
+3
View File
@@ -402,6 +402,7 @@
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuchen Sie es erneut oder deaktivieren Sie die Teilnahme, um die Daten zu löschen.",
"invalidCollectorUrl": "Die konfigurierte Collector-URL ist ungültig, daher kann die Teilnahme weder gestartet noch übermittelt werden. Setzen Sie USAGE_COLLECTOR_URL auf einen https-Origin ohne Pfad, Query oder Zugangsdaten (oder lassen Sie sie leer, um den Standard zu verwenden).",
"signingKeyUnreadable": "Der Signaturschlüssel für die Nutzungsdaten kann nicht gelesen werden. Meist wurde USAGE_ENCRYPTION_KEY — oder das als Rückfallwert genutzte JWT_SECRET — geändert. Berichte können nicht gesendet und auch die Löschanfrage kann nicht signiert werden. Stellen Sie das ursprüngliche Schlüsselmaterial wieder her, um die Löschung abzuschließen; erneutes Senden oder Deaktivieren allein behebt dies nicht.",
"schemaNotAccepted": "Der Collector hat das Paket rundheraus abgelehnt — er nimmt diese Berichtsversion also noch nicht an, meist weil er nicht aktualisiert wurde. Erneutes Senden ändert daran nichts. Es wurde nichts registriert; Sie können die Teilnahme verwerfen und erneut beitreten, sobald der Collector sie unterstützt.",
"inspect": "Genau sehen, was geteilt wird",
"preview": "Nächsten Bericht ansehen",
"lastPacket": "Zuletzt angenommener signierter Nutzungsbericht",
@@ -443,7 +444,9 @@
"retryScheduled": "Der nächste automatische Versuch erfolgt um {{time}}. „Erneut versuchen“ sendet sofort.",
"abandon": "Lokale Identität verwerfen",
"abandonExplanation": "Die Löschanfrage kann ohne das ursprüngliche Schlüsselmaterial nicht signiert werden. Wenn Sie es nicht wiederherstellen können, lässt sich die lokale Identität verwerfen: Erfassung und Schlüssel werden hier entfernt, der Collector bestätigt die Löschung dabei aber nicht.",
"abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Sie können sie hier verwerfen und jederzeit neu beginnen.",
"abandonConfirm": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector wird nicht benachrichtigt und behält die bisher gesendeten Berichte — die Quittung hält das als unbestätigt fest. Danach ist eine neue Teilnahme wieder möglich.",
"abandonConfirmUnregistered": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector hat diese Teilnahme nie angenommen, es wird also nirgendwo sonst etwas entfernt. Danach ist eine neue Teilnahme wieder möglich.",
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
},
"userManagement": {
+3
View File
@@ -402,6 +402,7 @@
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
"invalidCollectorUrl": "The configured usage collector URL is not valid, so participation cannot be started or delivered. Set USAGE_COLLECTOR_URL to an https origin with no path, query or credentials (or leave it unset to use the default).",
"signingKeyUnreadable": "The usage signing key cannot be read, which usually means USAGE_ENCRYPTION_KEY — or JWT_SECRET, which it falls back to — was changed. Reports cannot be sent and the deletion request cannot be signed either. Restore the original encryption material to finish deletion; retrying or disabling will not resolve it on its own.",
"schemaNotAccepted": "The collector rejected the packet outright, which means it does not accept this report version yet — usually a collector that has not been upgraded. Retrying will not change that. Nothing has been registered, so you can discard the participation and join again once the collector supports it.",
"inspect": "See exactly what is shared",
"preview": "Preview next report",
"lastPacket": "Last accepted signed usage report",
@@ -443,7 +444,9 @@
"retryScheduled": "The next automatic attempt is at {{time}}. \"Retry\" sends immediately.",
"abandon": "Discard local identity",
"abandonExplanation": "The deletion request cannot be signed without the original encryption material. If you cannot restore it, you can discard the local identity: collection and keys are removed here, but the collector does not confirm the deletion.",
"abandonExplanationUnregistered": "This participation was never accepted by the collector, so nothing is stored there and there is nothing to delete. You can discard it here and start again at any time.",
"abandonConfirm": "This deletes the installation identity, the key material and every local marker. The collector is not notified and keeps the reports already sent — the receipt records that as unconfirmed. You can join again afterwards.",
"abandonConfirmUnregistered": "This deletes the local installation identity, the key material and every local marker. The collector never accepted this participation, so nothing is removed anywhere else. You can join again afterwards.",
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
},
"userManagement": {
@@ -19,8 +19,10 @@ export interface UsageStatus {
last_error: string | null;
/** Epoch ms the paced sender is waiting for, or null when nothing is paced. */
retry_after?: number | null;
/** True only for a withdrawal whose delete packet can never be signed. */
/** True when the participation cannot be completed and the only exit is to discard it. */
can_abandon?: boolean;
/** True when the collector never accepted anything, so discarding deletes nothing remote. */
abandon_never_registered?: boolean;
pending_action: string | null;
last_packet: unknown;
privacy_receipts?: Record<string, unknown>;