# Lifecycle & Expiry Logic

> Shared expiry helpers used by Sandbox and SandboxClaim controllers to compute shutdown times, requeue durations, and policy-driven cleanup.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `internal/lifecycle/expiry.go`
- `internal/lifecycle/expiry_test.go`
- `api/v1beta1/sandbox_types.go`
- `extensions/api/v1beta1/sandboxclaim_types.go`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [internal/lifecycle/expiry.go](internal/lifecycle/expiry.go)
- [internal/lifecycle/expiry_test.go](internal/lifecycle/expiry_test.go)
- [api/v1beta1/sandbox_types.go](api/v1beta1/sandbox_types.go)
- [extensions/api/v1beta1/sandboxclaim_types.go](extensions/api/v1beta1/sandboxclaim_types.go)
- [extensions/controllers/sandboxclaim_controller.go](extensions/controllers/sandboxclaim_controller.go)
- [controllers/sandbox_controller.go](controllers/sandbox_controller.go)
</details>

# Lifecycle & Expiry Logic

This page documents how `Sandbox` and `SandboxClaim` resources reach the **expired** state, who computes that decision, and what shape the resulting reconciler behaviour takes (status updates, child-resource cleanup, requeue durations, and policy-driven deletion). The single source of truth for the math is `internal/lifecycle/expiry.go`, a small pure-function package that combines an absolute `shutdownTime` with the relative `ttlSecondsAfterFinished` timer and returns either an expiry verdict or a duration to wait.

The package exists to give controllers one consistent answer to *"is this resource expired, and if not, when should I look again?"* without duplicating the policy ordering between an explicit deadline and a finish-relative timer. In the current codebase the `SandboxClaim` controller is the only direct consumer of the helpers; the core `Sandbox` controller still keeps an inline `checkSandboxExpiry` that handles its narrower spec (`shutdownTime` only). Both controllers share the same vocabulary (the `Finished` condition, the `Expired` reason, the `ShutdownPolicy` enum) so the behaviour reads consistently from the outside, even though the implementations are split.

## Domain Model

`Lifecycle` is embedded into the spec of both CRDs but with different fields. The asymmetry is what motivates the shared helper: `SandboxClaim` carries an extra TTL timer that needs combining with the absolute deadline.

| Field | `Sandbox.spec.lifecycle` | `SandboxClaim.spec.lifecycle` |
| --- | --- | --- |
| `shutdownTime` (`*metav1.Time`) | yes | yes |
| `ttlSecondsAfterFinished` (`*int32`, min 0) | — | yes |
| `shutdownPolicy` (enum) | `Delete`, `Retain` (default `Retain`) | `Delete`, `DeleteForeground`, `Retain` (default `Retain`) |

The `Sandbox` `Lifecycle` struct is inlined into `SandboxSpec` at [api/v1beta1/sandbox_types.go:180-192](api/v1beta1/sandbox_types.go); the `SandboxClaim` variant adds `TTLSecondsAfterFinished` at [extensions/api/v1beta1/sandboxclaim_types.go:77-99](extensions/api/v1beta1/sandboxclaim_types.go) and exposes a third `DeleteForeground` policy that blocks claim removal until the Sandbox and Pod have actually terminated.

The terminal signal is the `Finished` condition (type `SandboxConditionFinished`). The core Sandbox controller raises it when the backing Pod reaches `PodSucceeded` or `PodFailed` (`computeFinishedCondition` at [controllers/sandbox_controller.go:394-417](controllers/sandbox_controller.go)). The Claim controller mirrors that condition onto the claim via `syncFinishedCondition` at [extensions/controllers/sandboxclaim_controller.go:562-576](extensions/controllers/sandboxclaim_controller.go), so the TTL timer measured by the shared helper reads the mirrored `LastTransitionTime` rather than reaching across into the Sandbox object.

Sources: [api/v1beta1/sandbox_types.go:46-54,168-192](api/v1beta1/sandbox_types.go), [extensions/api/v1beta1/sandboxclaim_types.go:57-99](extensions/api/v1beta1/sandboxclaim_types.go), [controllers/sandbox_controller.go:394-417](controllers/sandbox_controller.go), [extensions/controllers/sandboxclaim_controller.go:562-576](extensions/controllers/sandboxclaim_controller.go)

## The `internal/lifecycle` Package

Five small helpers compose into a single `TimeLeft` answer. Each is a pure function; nothing reads from the cluster, so they are trivially testable and free of side effects.

```text
                shutdownTime (absolute) ──┐
                                          ▼
ttlSecondsAfterFinished ─► NeedsCleanup ─► ExpireAt ─► TimeLeft ─► (expired, requeueAfter)
        ▲                          ▲
        └──────── FinishedCondition (terminal Finished=True with LastTransitionTime)
```

### `FinishedCondition`

Returns the terminal condition only when it is both present and `Status == True`; otherwise `nil`. This keeps callers from accidentally treating a `Finished=False` placeholder as a finish event.

```go
// internal/lifecycle/expiry.go
func FinishedCondition(conditions []metav1.Condition, conditionType string) *metav1.Condition {
    condition := meta.FindStatusCondition(conditions, conditionType)
    if condition == nil || condition.Status != metav1.ConditionTrue {
        return nil
    }
    return condition
}
```

### `NeedsCleanup` and `FinishedTime`

`NeedsCleanup` is a guard: TTL applies only when both the spec field and the terminal condition exist. `FinishedTime` extracts the `LastTransitionTime` as the TTL start, defending against a zero timestamp.

### `ExpireAt` — the policy ordering

`ExpireAt` returns the **earliest** of the two configured deadlines. If only `shutdownTime` is set, that's the answer. If only the TTL is configured and the resource has finished, the TTL deadline (`finishedAt + ttlSeconds`) is the answer. When both are set, the function picks whichever fires first:

```go
// internal/lifecycle/expiry.go
ttlExpireAt := finishedAt.Add(time.Duration(*ttlSecondsAfterFinished) * time.Second)
if expireAt == nil || ttlExpireAt.Before(*expireAt) {
    expireAt = &ttlExpireAt
}
```

This ordering is exercised by the table tests at [internal/lifecycle/expiry_test.go:37-93](internal/lifecycle/expiry_test.go): a `120s` TTL beats a five-minute shutdown (TTL fires at +90s from `now`), while a five-second shutdown beats the same `120s` TTL.

### `TimeLeft` — the controller-facing API

`TimeLeft(now, shutdownTime, ttl, finishedCondition)` returns `(expired bool, requeueAfter time.Duration)`. A `nil` expiry collapses to `(false, 0)`, so a Sandbox with no lifecycle configured never reports as expired and never asks for a timed requeue. When `now` has reached or passed the deadline it returns `(true, 0)`; otherwise the remaining duration so the controller can schedule a precise requeue.

| Input combination | `expired` | `requeueAfter` |
| --- | --- | --- |
| no `shutdownTime`, no TTL | `false` | `0` |
| TTL only, finished 30s ago, TTL=120s | `false` | `90s` |
| TTL only, finished 30s ago, TTL=0 | `true` | `0` |
| `shutdownTime` in 5s, TTL=120s after finish | `false` | `5s` (shutdown wins) |
| `shutdownTime` in 5m, TTL=120s after finish at -30s | `false` | `90s` (TTL wins) |

Sources: [internal/lifecycle/expiry.go:24-82](internal/lifecycle/expiry.go), [internal/lifecycle/expiry_test.go:25-94](internal/lifecycle/expiry_test.go)

## SandboxClaim Reconcile Flow

The Claim controller wraps `TimeLeft` in a small private helper that short-circuits when no lifecycle stanza is set:

```go
// extensions/controllers/sandboxclaim_controller.go
func (r *SandboxClaimReconciler) checkExpiration(claim *extensionsv1beta1.SandboxClaim) (bool, time.Duration) {
    if claim.Spec.Lifecycle == nil {
        return false, 0
    }
    finishedCondition := lifecycle.FinishedCondition(
        claim.Status.Conditions, string(v1beta1.SandboxConditionFinished))
    return lifecycle.TimeLeft(time.Now(),
        claim.Spec.Lifecycle.ShutdownTime,
        claim.Spec.Lifecycle.TTLSecondsAfterFinished,
        finishedCondition)
}
```

The reconcile loop calls `checkExpiration` **twice**: once at the top of the loop to decide which arm to take, and again after reconciling active state in case that reconcile pass mirrored a brand-new `Finished` condition that pushes the claim across the TTL boundary in the same tick. The second call drives `postTimeLeft` which becomes the final `RequeueAfter`.

```mermaid
stateDiagram-v2
    [*] --> CheckExpiration
    CheckExpiration --> MarkExpired: expired && !already marked
    MarkExpired --> [*]: requeue after immediateRequeueDelay
    CheckExpiration --> DeletePath: expired && policy in {Delete, DeleteForeground}
    DeletePath --> [*]: Delete(claim, [PropagationForeground?])
    CheckExpiration --> RetainCleanup: expired && policy = Retain
    RetainCleanup --> reconcileExpired: delete owned Sandbox, keep Claim
    CheckExpiration --> ReconcileActive: not expired
    ReconcileActive --> PostExpirationCheck: re-run checkExpiration
    PostExpirationCheck --> MarkExpired: now expired
    PostExpirationCheck --> [*]: requeue after postTimeLeft
```

Policy handling lives in [extensions/controllers/sandboxclaim_controller.go:178-261](extensions/controllers/sandboxclaim_controller.go). Notable details:

- `immediateRequeueDelay = time.Millisecond` (line 59) drives a near-instant requeue after the status update writes `Reason=ClaimExpired`, so the next pass enters the cleanup branch without waiting for a full controller resync.
- `ShutdownPolicyDelete` issues a normal `Delete`; `ShutdownPolicyDeleteForeground` adds `client.PropagationPolicy(metav1.DeletePropagationForeground)` so the claim object hangs around with a `deletionTimestamp` until the owned Sandbox and Pod are gone. The reconcile then returns to avoid touching status on an object that may already be gone.
- `ShutdownPolicyRetain` (the default) flows into `reconcileExpired` ([line 388-420](extensions/controllers/sandboxclaim_controller.go)), which deletes the controlled Sandbox but leaves the claim object in place with a `Ready=False / Reason=ClaimExpired` condition.
- `reconcileExpired` refuses to delete a Sandbox it does not control (`metav1.IsControlledBy`), returning `ErrSandboxNotOwned` which is suppressed downstream to avoid crash-looping.

Sources: [extensions/controllers/sandboxclaim_controller.go:59,176-261,309-317,388-420](extensions/controllers/sandboxclaim_controller.go)

## Sandbox Controller's Inline Expiry

The core `Sandbox` controller does **not** import `internal/lifecycle`. Because `Sandbox.spec.lifecycle` carries only `shutdownTime`, the controller uses its own minimal helper:

```go
// controllers/sandbox_controller.go
func checkSandboxExpiry(sandbox *sandboxv1beta1.Sandbox, now time.Time) (bool, time.Duration) {
    if sandbox.Spec.ShutdownTime == nil {
        return false, 0
    }
    shutdownTime := sandbox.Spec.ShutdownTime.Time
    if !now.Before(shutdownTime) {
        return true, 0
    }
    remainingTime := shutdownTime.Sub(now)
    requeueAfter := max(remainingTime, 2*time.Second)
    return false, requeueAfter
}
```

It differs from `lifecycle.TimeLeft` in two ways worth noting:

1. It applies a `2 * time.Second` floor to `requeueAfter`, presumably to avoid hot-looping on near-expiry; the shared helper returns the exact remaining duration and leaves rate-limiting to the manager.
2. It has no notion of TTL-after-finished, because the `Sandbox` CRD does not define that field.

The reconcile loop at [controllers/sandbox_controller.go:197-217](controllers/sandbox_controller.go) calls `checkSandboxExpiry` twice — once before reconciling child resources, once after — for the same "Finished may have just been set" reason as the Claim controller. On expiry it sets `Ready=False / Reason=SandboxExpired` via `setSandboxExpiredCondition`, then `handleSandboxExpiry` deletes owned child resources and, if `ShutdownPolicy=Delete`, the Sandbox object itself ([line 1065-1071](controllers/sandbox_controller.go)). After successful cleanup it strips live-resource status while preserving terminal conditions ([line 1075-1087](controllers/sandbox_controller.go)).

Sources: [controllers/sandbox_controller.go:49-53,192-227,1043-1127](controllers/sandbox_controller.go)

## Where the Two Implementations Meet

The Claim controller surfaces independent core-controller expiry into the claim's own `Ready` condition. After active reconcile it checks whether the underlying Sandbox carries `Reason=SandboxExpired` and, if so, reports the claim as not-ready with a distinguishing message:

```go
// extensions/controllers/sandboxclaim_controller.go:521-530
if hasSandboxExpiredCondition(sandbox.Status.Conditions) {
    return metav1.Condition{
        Type:    string(v1beta1.SandboxConditionReady),
        Status:  metav1.ConditionFalse,
        Reason:  v1beta1.SandboxReasonExpired,
        Message: "Underlying Sandbox resource has expired independently of the Claim.",
        ...
    }
}
```

This is the seam where the two reconcilers agree on a shared vocabulary even though they compute expiry separately: the Sandbox controller decides its own deadline and writes `SandboxReasonExpired`; the Claim controller decides its (potentially earlier) deadline using `lifecycle.TimeLeft` and writes `ClaimExpiredReason`; an observer looking at the claim's `Ready` condition can tell which side fired.

Sources: [extensions/controllers/sandboxclaim_controller.go:459-545](extensions/controllers/sandboxclaim_controller.go), [api/v1beta1/sandbox_types.go:46-54](api/v1beta1/sandbox_types.go), [extensions/api/v1beta1/sandboxclaim_types.go:26-31](extensions/api/v1beta1/sandboxclaim_types.go)

## Test Coverage and Boundary Cases

`internal/lifecycle/expiry_test.go` is a single table-driven test that anchors the policy ordering. The fixture uses a `now` of `2026-04-13 12:00:00 UTC` and a `Finished` condition transitioned thirty seconds before `now`, then varies `shutdownTime` and `ttlSecondsAfterFinished` independently. The cases worth calling out:

- A `ttlSecondsAfterFinished == 0` expires immediately upon finish — useful for "delete as soon as the pod terminates" claims.
- The "earlier shutdown time wins" case (5s vs 120s TTL) and the "later shutdown time loses to ttl" case (5m vs 120s TTL) together prove the min-of-deadlines behaviour in both directions.
- The "no expiry configured" case returns `(false, 0)`, which the controllers translate into "do not set a `RequeueAfter`" — without this guard, every reconcile of an unbounded Sandbox would re-arm a timer.

Sources: [internal/lifecycle/expiry_test.go:25-94](internal/lifecycle/expiry_test.go)

## Summary

`internal/lifecycle/expiry.go` is intentionally a thin policy library: it answers "which deadline wins, and how long until it fires" without touching the API server. The `SandboxClaim` controller leans on it directly because it has to reconcile two deadline sources (`shutdownTime` plus `ttlSecondsAfterFinished` against a mirrored `Finished` condition) and three shutdown policies; the `Sandbox` controller keeps its narrower deadline logic inline. Both controllers share the same condition types and `Expired` reasons, so consumers can reason about expiry uniformly across the two layers even though the helpers and the inline `checkSandboxExpiry` are not yet unified.
