1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
plan: "0028"
title: "A deleted ComputeContract releases its guest: a requester finalizer, a runner that stops on Gone, and roots that release before they stop"
status: done
source: >
2026-10-23, read from the code at ba5d120f; not yet seen live. A ComputeContract carries no
finalizer. The marketintents forwarder registers the ComputeContract kind without
ctlruntime.Finalizer (internal/controllers/marketintents/controller.go:182-186), so a delete
removes the object at once and the requester sees only Gone. Requester.Observe handles Gone by
dropping its caches (internal/market/requester/observe.go:20-25: contracts.Remove,
forgetContract, forgetPending) and never calls contractRunner.Update, which is the only place a
release starts on deletion (internal/market/requester/runner.go:103-110: DeletionTimestamp or
spec.release). The runner stays in q.runners and keeps holding in waitForRelease
(runner.go:976-988), whose only exits are ctx done, releaseCh and a vm.terminated event; there is
no hold bound. Its writeStatus calls get NotFound and are dropped (runner.go:1266). After a
requester restart nothing resumes it, because the object is gone, so no vm.delete is ever sent.
On the bidder a won Bid never expires (internal/market/bidder/bids.go:220-222 expires only
Deciding and Published), so the guest keeps running and holds a host core until someone removes
it by hand.
Two deleters reach this path. A direct delete of a held contract (kubectl, an operator script).
And the deletion of a MarketRequester root: compose.Adopt puts the root's ownerReference on every
ComputeContract without spec.nodeClaimRef (internal/app/compose/infra.go:147-175), and the root's
delete pass (internal/app/marketrequester/marketrequester.go:121-137) stops the components and
deletes the vaults without releasing anything, so kcp garbage collection deletes the held
contracts after the Requester component has already stopped. The MarketBidder root does it in
the right order: it deletes its ComputeVMs and waits for them before it stops a component
(internal/app/marketbidder/marketbidder.go:169-177).
Deleters that are already safe: the NodeClaim controller writes spec.release first and deletes
only after Settle reports Done (internal/controllers/nodeclaim/controller.go:276-310); the TTL
controller deletes only a finished contract (internal/controllers/ttl/controller.go:108-131);
run request release and its cleanup remove a contract only once it is Released
(cmd/socialweb-computer/request.go:561-567). Not read: whether the Gateway root's delete pass
(internal/app/gateway/gateway.go:149-179) stops the Requester before the NodeClaim-linked
contracts of its tenant clusters are released.
principle: >
Deleting a ComputeContract is a request to release it. The requester holds a finalizer on every
ComputeContract it runs and lets go only when there is nothing left to release: the contract is
Released, it never reached an accept, or the release bound of plan 0020 has passed (then it is
Failed with the reason). A root releases the contracts it owns before it stops the component that
sends the release. The RFP flow and the release delivery of plan 0020 are unchanged; only the
trigger and the ordering change.
state_rules:
- Read AGENTS.md first. status is one of todo, doing, done, blocked. The lead flips statuses.
- No Go tests (AGENTS.md, plan 0027). Proof is the build gates and the live runs below.
items:
- id: R0-REPRO
title: "Reproduce the orphan on a market network before any change"
depends_on: []
decision: >
On an own market network (--bidders 1), run request --hold until the contract is Held, then
delete the ComputeContract directly. In a second run, delete the MarketRequester root of a
held invocation instead. Read the requester log, the bidder's Bid and ComputeVM objects, the
FirecrackerVM and its unit, and the requester repo's vm.delete records. If either run sends a
vm.delete or the guest goes, record what released it and revise this plan before M1.
files: []
done_when: >
Both runs are read: after each delete no vm.delete record exists for the contract, the
bidder's Bid stays Won, its ComputeVM and FirecrackerVM stay Running for at least 5m, and the
requester logs no requester_release line for the contract.
status: done
- id: M1-CONTRACT-FINALIZER
title: "The requester holds a release finalizer on every ComputeContract it runs"
depends_on: [R0-REPRO]
decision: >
Add FinalizerComputeContract = "computecontract.socialweb.computer/release" in
api/v1alpha1/types_computecontract.go, beside the other release finalizers. The ComputeContract
forwarder of internal/controllers/marketintents registers with ctlruntime.Finalizer of that
name in the requester's scope only, so the runtime adds it before the first Reconcile and
removes it only when Reconcile returns without Waiting on a deleting object
(internal/controllers/runtime/controller.go:405-423). contractmarket.Change gains no new
field: the observer's returned wait already reaches the forwarder, and a deleting contract
that still has something to release returns Waiting. Requester.Observe on a deleting
contract calls runner.Update (the existing DeletionTimestamp branch starts releaseContract)
and answers done only when one of these holds: the phase is Released; the contract never
wrote an accept (no status.contract.acceptUri, so no bidder holds a guest for it; the runner is
stopped); or status.release.deadline has passed (the contract is written Failed with the
plan 0020 bound message, then the finalizer goes). A requester that starts and finds a
deleting contract with the finalizer rehydrates the runner as today and takes the same
branch, so a delete while the requester is down is released when it comes back. If the
deadline wait is a requeue site, add its line to deploy/obligations.txt (kind deadline,
owner internal/controllers/marketintents, period status.release.deadline minus now).
Contracts created before this change have no finalizer; deploy fresh (AGENTS.md).
files:
- api/v1alpha1/types_computecontract.go
- internal/controllers/marketintents/controller.go
- internal/market/requester/observe.go
- internal/market/requester/runner.go
- deploy/obligations.txt
done_when: >
ACC28-DIRECT-DELETE, ACC28-DELETE-BEFORE-ACCEPT and ACC28-DELETE-WHILE-STOPPED pass; go
generate ./api/... leaves no diff; the bidder's forwarders carry no ComputeContract finalizer.
status: done
- id: M2-GONE-STOPS-RUNNER
title: "Gone stops and removes the contract's runner"
depends_on: [M1-CONTRACT-FINALIZER]
decision: >
The Gone branch of Requester.Observe cancels the runner's context and removes it from
q.runners through the path a finished runner takes (Requester.runnerDone, observe.go:193-196), so no goroutine
keeps holding a contract that no longer exists. When the last status the requester saw had an
accept and was not Released, it logs requester_contract_gone_unreleased with the contract,
the phase and the receipt uri; after M1 that line means the finalizer was removed by hand.
files:
- internal/market/requester/observe.go
- internal/market/requester/runner.go
done_when: >
After ACC28-FORCED-REMOVAL the requester's goroutine count for the contract is zero (no
further requester_contract lines for it) and requester_contract_gone_unreleased is logged once.
status: done
- id: M3-ROOT-RELEASES-FIRST
title: "A MarketRequester root releases its contracts before it stops its components"
depends_on: [M1-CONTRACT-FINALIZER]
decision: >
The delete pass of internal/app/marketrequester first deletes the ComputeContracts the root
owns in its workspace (c.DeleteAll or c.DeleteOwned on ComputeContractGVR) and reports
Deleting "<n> ComputeContract(s) releasing" while any is left, the same order as
marketbidder.go:169-177. The finalizer of M1 turns each delete into a release, so the root
writes no ComputeContract spec and the one-writer rule holds. Only when no contract is left
does it stop the components and delete the vaults. A contract whose release ends at the bound
is Failed and goes, so the root's deletion is bounded by the plan 0020 release deadline.
files:
- internal/app/marketrequester/marketrequester.go
done_when: "ACC28-ROOT-DELETE passes."
status: done
- id: M4-GATEWAY-ORDER
title: "Read the Gateway root's delete order for NodeClaim-linked contracts"
depends_on: [M1-CONTRACT-FINALIZER]
decision: >
Read internal/app/gateway/gateway.go:149-179 against the NodeClaim release path: the
NodeClaim finalizer writes spec.release and waits for Settle, which needs a running Requester.
If the Gateway root can stop its Requester while a tenant cluster's NodeClaims still hold
contracts, apply the M3 order there (release the contracts, or wait for the NodeClaims, before
StopComponents). If it cannot, record why in this item's decision and flip it done.
files:
- internal/app/gateway/gateway.go
done_when: >
Either the read shows the order is safe, or a Gateway root deleted on a market network with a
tenant cluster holding one node leaves no guest on the bidder within the release bound.
status: done
- id: ARCH-SECTION-14
title: "Open the defect in docs/ARCHITECTURE.md section 14 and move it to section 15 when it closes"
depends_on: [R0-REPRO]
decision: >
An item opens in section 14 with the R0-REPRO reading and moves to section 15 with the before
and after readings of the ACC28 rows (AGENTS.md, Standing constraints).
files:
- docs/ARCHITECTURE.md
done_when: "Section 15 holds the entry with R0-REPRO as before and the ACC28 rows as after."
status: done
- id: CATALOG-ROWS
title: "Add the ACC28 scenarios to docs/spec/integration-catalog.yaml for plan 0027"
depends_on: [M1-CONTRACT-FINALIZER, M3-ROOT-RELEASES-FIRST]
decision: >
One catalog entry per ACC28 row, written as actors, setup, asserts and cleanup in the
catalog's existing shape, so plan 0027 writes them as specification tests. No Go test here.
files:
- docs/spec/integration-catalog.yaml
done_when: "The catalog holds TestADeletedContractReleasesItsGuest and TestADeletedRequesterRootReleasesItsGuests."
status: done
- id: ACC28-DIRECT-DELETE
title: "A directly deleted held contract is released"
depends_on: [M1-CONTRACT-FINALIZER]
done_when: >
On an own market network: run request --hold reaches Held; kubectl delete of the
ComputeContract leaves it Terminating with the release finalizer; the requester logs the
release; the requester repo holds exactly one vm.delete record for the contract; within 3m the
bidder's ComputeVM, FirecrackerVM, VM unit and VM work dir are gone and the Bid is terminated;
then the ComputeContract object is gone.
status: done
- id: ACC28-DELETE-BEFORE-ACCEPT
title: "A contract deleted before any accept goes at once and sends nothing"
depends_on: [M1-CONTRACT-FINALIZER]
done_when: >
A contract deleted while it is collecting bids is gone within one watch event; no accept and no
vm.delete record exists for it; no ComputeVM appears on any bidder in the next 60s.
status: done
- id: ACC28-DELETE-WHILE-STOPPED
title: "A contract deleted while the requester is down is released when it comes back"
depends_on: [M1-CONTRACT-FINALIZER]
done_when: >
With a contract Held, the requester component is stopped, the contract is deleted (it stays
Terminating), the requester is started again; it sends the one vm.delete, the guest is gone
within 3m, then the contract object is gone.
status: done
- id: ACC28-ROOT-DELETE
title: "Deleting a MarketRequester root releases its held contracts before it stops"
depends_on: [M3-ROOT-RELEASES-FIRST]
done_when: >
A MarketRequester root with two Held contracts is deleted; its status reads Deleting
"2 ComputeContract(s) releasing", then "component(s) stopping"; both guests are gone on their
bidders before the Requester component stops; the root is gone; no guest is left.
status: done
- id: ACC28-BOUND
title: "A release that is never acknowledged ends at the bound and lets the object go"
depends_on: [M1-CONTRACT-FINALIZER]
done_when: >
With the winning bidder stopped and a short release bound, a deleted Held contract retries the
vm.delete as in plan 0020, ends Failed with the bound message at the deadline, and then the
object is gone.
status: done
- id: ACC28-FORCED-REMOVAL
title: "A finalizer removed by hand leaves no runner behind"
depends_on: [M2-GONE-STOPS-RUNNER]
done_when: >
A Held contract is deleted and its finalizer is removed by a merge patch before the release
ends; the requester logs requester_contract_gone_unreleased once and no further line for the
contract.
status: done