plan: "0031" title: "Gateway tenant-cluster nodes are managed through one Infra per Cluster; a NodeClaim keeps the join, the grant and the secrets" status: done source: > 2026-10-23, the user's request (gateway tenant-cluster nodes are managed through Infra), read against main at 1dc14655 (plans 0028 and 0029 merged). Today every node of a tenant cluster is one NodeClaim and one ComputeContract that the NodeClaim controller writes itself. The clusterprovider's node host writes the NodeClaim into the cluster's home workspace (internal/nodemarket/nodemarket.go: 167-216 Provision, claimLocked 218-237 with the Cluster ownerReference of 239-244) and deletes it to release the node (release 393-422, which waits for the claim to be gone). The NodeClaim controller ("the door", internal/controllers/nodeclaim/controller.go) lists every ComputeContract of the contract workspace on each reconcile (Reconcile 192-232), writes the join bundle into the gateway vault and applies a ComputeContract named nodeclaim-<20 hex of the claim uid> with spec.nodeClaimRef (ensure 234-274, mapper Contract internal/gateway/nodeclaim/nodeclaim.go:274-327), writes spec.release and deletes the contract on the claim's deletion (settle 276-312, mapper Settle 461-496), and releases or deletes contracts whose claim is gone or was recreated (orphan 314-337, gone 339-355, mapper Orphan 440-459). It maps contract events back to claims through spec.nodeClaimRef (contractClaims 523-538, resync 635-650). The contract workspace is the gateway root workspace (internal/app/gateway/gateway.go:482 gatewayNodeMarket(o, p.ws, p.r.Workspace); internal/services/gatewayapi/config.go:88). compose.Adopt and the GC report skip a contract with spec.nodeClaimRef (internal/app/compose/infra.go:175 and 558). Plan 0029 built Infra: a deployment object that owns its ComputeContracts in its own workspace (internal/controllers/infra/contracts.go:349-355 owned by labels, 437-448 create), with the Staged, Requested, Released states, the release finalizer and the plan 0028 order (controller.go 298-332 finalize), and a ComputeContract spec built only from the publishable contract fields and spec.secrets (internal/infra/lifecycle.go:98-107 ContractSpec), so a node's nodeJoin, ownerDid and adopt have no place in it yet. The Infra and NodeClaim controllers run in the same core (internal/stack/componentcore/controllers.go:145 and 149). The Gateway root's delete pass (internal/app/gateway/gateway.go:150-194) deletes every account Cluster (compose.DeleteAccountClusters, internal/app/compose/infra.go:678-716) and then waits while a ComputeContract still holds a guest (compose.HeldContracts, internal/app/compose/apply.go:408-428). The MarketRequester root's delete pass deletes its ComputeContracts first and has no step for Infra or EphemeralInfra (internal/app/marketrequester/marketrequester.go:121-142), plan 0029 R8-ROOT-DELETE-ORDER. Removing spec.record from an Infra deletes its AtprotoRecord child (internal/controllers/infra/controller.go:221-233, record.go:243-264), whose finalizer deletes the record from the repo (internal/controllers/atprotorecord/controller.go:204-224), plan 0029 U1-UNBIND-KEEPS-RECORD. principle: > A tenant cluster's nodes are one deployment. Each Cluster that has a NodeClaim has one Infra in the gateway's contract workspace, and each node is one contract entry of it. The Infra controller is the only writer of a node's ComputeContract and runs its whole lifecycle (Requesting, Deployed, Releasing, Released, Failed, the release finalizer and the plan 0028 order). The NodeClaim keeps what only a tenant cluster has: the guest grant, the join secrets, the apiserver ClusterLink ticket, adoption, and the loss and join graces of the clusterprovider. Deleting a Cluster deletes its Infra, which releases every node; deleting a root deletes its deployments before it stops the component that sends the releases. 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 ACC31 live rows below. - Fresh deploy (AGENTS.md, Prod deployments). No migration code for NodeClaims or ComputeContracts written before this plan; a contract named nodeclaim- left by an older core is not adopted. - Plan 0030 is rewiring the account identity and credentials code now. This plan edits none of internal/pds/identity, internal/services/bidder/credentials.go, internal/market/requester/identity.go, internal/pds/remote or internal/marketidentity. - This plan owns plan 0029's R8-ROOT-DELETE-ORDER and U1-UNBIND-KEEPS-RECORD and executes them against their done_when in plan 0029; their statuses stay in plan 0029. items: - id: A1-API title: "Infra carries the node fields and its Cluster; a NodeClaim names its entry; ComputeContract loses nodeClaimRef" depends_on: [] decision: > InfraSpec gains two kcp-only fields that are never written to a record, like spec.secrets: clusterRef (an ObjectRef to the tenant Cluster, kind Cluster, with its home workspace), and nodes (listType=map keyed by contract), one InfraContractNode per node entry with ownerDid, adopt, nodeJoin (the ComputeContractNodeJoin type) and nodeClaimRef (the NodeClaim the entry serves). NodeClaimStatus gains infra (workspace, name, contract, computeContract), written by the NodeClaim controller: this is how a NodeClaim refers to its Infra entry. It is status, not spec, because the NodeClaim controller chooses the entry and the clusterprovider writes the claim's spec. ComputeContractSpec.nodeClaimRef is removed rather than kept: a node's contract is now named by its ownerReference to the Infra and the labels infra.socialweb.computer/name and infra.socialweb.computer/contract, and the claim is named by the entry's spec.nodes[].nodeClaimRef; nothing in the requester reads nodeClaimRef. The constant InfraClusterLabel = "infra.socialweb.computer/cluster" (the Cluster's name) marks a cluster's Infra for kubectl. go generate ./api/... writes the schemas; a Revision bump follows if the generator asks. files: - api/v1alpha1/types_infra.go - api/v1alpha1/types_nodeclaim.go - api/v1alpha1/types_computecontract.go - api/v1alpha1/zz_generated.deepcopy.go - api/v1alpha1/resources.go - deploy/ done_when: "go generate ./api/... leaves no diff on a second run; go build ./... passes." status: done - id: I2-INFRA-NODE-FIELDS title: "The Infra controller puts a node entry's fields on its ComputeContract" depends_on: [A1-API] decision: > infra.ContractSpec takes the entry's InfraContractNode (nil for an ordinary entry) and sets ownerDid, adopt and nodeJoin on the ComputeContract spec, beside the publishable fields and spec.secrets. The Infra controller passes spec.nodes by contract name, as it passes spec.secrets. The record render and the declaration hash stay description plus contracts, so clusterRef, nodes and secrets never reach a record. ttlSecondsAfterFinished is not set: the schema default (691200) equals the NodeClaim mapper's ContractTTLSeconds. files: - internal/infra/lifecycle.go - internal/controllers/infra/contracts.go done_when: "ACC31-SCALE-UP-TWO passes: each node's ComputeContract carries nodeJoin, ownerDid and the bundle secretsRef." status: done - id: N3-CLAIM-ENTRY title: "The NodeClaim controller composes its Cluster's Infra and writes no ComputeContract" depends_on: [A1-API, I2-INFRA-NODE-FIELDS] decision: > Where. The Infra lives in the contract workspace of the binding (the gateway root workspace), beside the ComputeContracts it owns and the Requester that runs them; the Infra controller applies and lists its contracts only in its own workspace, and the tenant's home workspace must not hold an object that declares contracts on the gateway's account. It is named nodes-<20 hex of sha256(/)> (that name is the cluster key -