Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-fleet-terminal-reentry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofoftech/fleet-control": patch
---

Delete an already-decommissioned fleet ledger row without emitting a duplicate decommission event mislabeled as forced.
4 changes: 2 additions & 2 deletions docs/fleet-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ await forceDecommissionDeployment({
});
```

The function always acquires `store.withDeploymentLease()` before it reads the ledger. An absent deployment succeeds without a provider mutation. A terminal `decommissioned` record also succeeds and removes the retained ledger row.
The function always acquires `store.withDeploymentLease()` before it reads the ledger. An absent deployment succeeds without a provider mutation. A terminal `decommissioned` record also succeeds, removes the retained ledger row, and emits no audit event.

A concurrent provision or decommission receives the same lease-acquisition error as `decommissionDeployment()`. A `database-reserved` row has not authorized provider creation, so force decommission removes that reservation without a provider call.

Expand All @@ -199,7 +199,7 @@ The force path uses persisted resource identities only. It never fetches an arti

Each provider mutation receives the active lease fence. `WranglerLoopBackend` requires route API implementations of both `getDatabase` and `deleteDatabase` before the first D1 lookup. The exact-ID lookup, deletion, and confirmation all run within that route fence; force deletion never falls back to Wrangler. The ledger persists `decommissioning`, `traffic-removed`, `credentials-revoked`, and `database-deleting`, so an interrupted call repeats the incomplete idempotent stage.

Provider 404 responses mean the resource is already absent. After D1 is absent, the function persists `decommissioned`, emits `DecommissionAuditEvent` with `forced: true`, and calls `lease.delete()`. If audit delivery fails, the terminal row remains and a retry emits the event again before deleting the row. Normal decommission uses the same optional event with `forced: false`.
Provider 404 responses mean the resource is already absent. After D1 is absent, the function persists `decommissioned`, emits `DecommissionAuditEvent` with `forced: true`, and calls `lease.delete()`. If initial audit delivery fails, the terminal row remains. A retry deletes it without repeating provider mutations or redelivering the event. Normal decommission uses the same optional event with `forced: false`.

Force decommission does not delete the ordinary Worker script, application R2 buckets, or control-plane retention data. It removes the deployment’s ingress, live Worker secrets, database, and fleet ownership record. After the call returns, the host deletes its separate retention row and revokes its gateway key. Workers for Platforms fails closed unless its backend implements equivalent spec-free primitives; its dispatch route and trusted-resource topology cannot use the ordinary Worker route API contract.

Expand Down
2 changes: 1 addition & 1 deletion packages/fleet-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Use `forceDecommissionDeployment()` only when the host has lost the retained cre

Provider 404 responses converge as already absent, so retry the same call after an interrupted teardown. A `database-reserved` row has not authorized provider creation and can be removed without a provider call. A `database-create-authorized` row has an unresolved creation outcome and only a synthetic ID, so force decommission fails closed and retains that row for spec-aware recovery.

Pass `options.audit` to receive a `DecommissionAuditEvent`. Normal decommission emits `forced: false`; force decommission emits the same event with `forced: true`. Fleet Control emits the force event before it deletes the ledger row. If the sink fails, the terminal record remains and the next call retries audit delivery without repeating provider mutations.
Pass `options.audit` to receive a `DecommissionAuditEvent`. Normal decommission emits `forced: false`; an actual force teardown emits the same event with `forced: true`. Fleet Control emits the force event before it deletes the ledger row. If the sink fails, the terminal record remains, and the next call deletes it without repeating provider mutations or redelivering the event. Force decommission of an already-terminal record also deletes the ledger row without emitting an event.

The function never reads an artifact, computes a specification digest, deletes host-retained control-plane secrets, or deletes application R2 buckets. `WranglerLoopBackend` requires `PlainWorkerRouteApi.getDatabase` and `deleteDatabase`; it never falls back to Wrangler for force deletion. Other backends fail closed unless they implement the narrow `forceDecommissionStep` contract for equivalent provider primitives.

Expand Down
1 change: 0 additions & 1 deletion packages/fleet-control/src/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1677,7 +1677,6 @@ export async function forceDecommissionDeployment(
);
}
if (current.phase === 'decommissioned') {
await emitDecommissionAudit(input.options?.audit, current, true);
await lease.delete();
return;
}
Expand Down
14 changes: 12 additions & 2 deletions packages/fleet-control/test/provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2226,10 +2226,14 @@ describe('fleet provisioning', () => {
spec: spec(),
secrets,
});
const auditFlags: boolean[] = [];
await decommissionDeployment({
backend,
store: terminalStore,
spec: spec(),
audit: (event) => {
auditFlags.push(event.forced);
},
});
Object.defineProperty(backend, 'forceDecommissionStep', {
value: undefined,
Expand All @@ -2242,11 +2246,17 @@ describe('fleet provisioning', () => {
store: terminalStore,
tenantTag: 'acme',
environment: 'production',
options: {
audit: (event) => {
auditFlags.push(event.forced);
},
},
}),
).resolves.toBeUndefined();
expect(terminalStore.leaseCalls).toBe(priorLeaseCalls + 1);
expect(terminalStore.record).toBeUndefined();
expect(backend.forceSteps).toEqual([]);
expect(auditFlags).toEqual([false]);
});

it('uses one audit event shape for normal and forced decommission', async () => {
Expand Down Expand Up @@ -2388,7 +2398,7 @@ describe('fleet provisioning', () => {
await expect(force).resolves.toBeUndefined();
});

it('retries a completed force teardown when audit delivery fails', async () => {
it('silently removes a completed force teardown after audit delivery fails', async () => {
const backend = new FakeBackend('plain-worker');
const store = new MemoryStore();
await provisionDeployment({ backend, store, spec: spec(), secrets });
Expand Down Expand Up @@ -2417,7 +2427,7 @@ describe('fleet provisioning', () => {
'delete-database',
]);
expect(store.record).toBeUndefined();
expect(auditAttempts).toBe(2);
expect(auditAttempts).toBe(1);
});

it('rejects a concurrent lifecycle operation for the same deployment', async () => {
Expand Down
Loading