Skip to content

Preview governance: TTL, idle teardown, caps and connection budget - #265

Merged
simpros merged 10 commits into
mainfrom
gh-241-preview-governance-ttl-idle-tear
Oct 2, 2026
Merged

simpros merged 10 commits into
mainfrom
gh-241-preview-governance-ttl-idle-tear

Conversation

@simpros

@simpros simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Closes #241

Why the change

Previews lived until PR close with no bound, so this PR makes the gateway enforce TTL, idle teardown, per-repo/global caps, and a Postgres connection budget while keeping existing deployments unchanged apart from a loud unbounded boot warning.

Special things to note

  • Trade-off stated: gateway code defaults are all off (unbounded, safe direction — never deletes more), while the self-serve compose stack ships SPROUT_PREVIEW_TTL=7d so new operators are safe without configuring anything.
  • Activity signal is exactly the last successful deploy (including reseed/reset); there is no access-log pipeline, so a quiet preview with HTTP traffic but no deploy still expires — documented in docs/previews.md.
  • Additive config changes only (apps/server/src/config.ts untouched in shape for Install telemetry: the published image reports anonymous usage and deploy outcomes, on by default #261); rebased on origin/main before pushing; sweep knob stays SPROUT_SWEEP_CRON (Bun.cron, never setInterval).

Change outline

Stored deadlines drive sweep expiry through the normal teardown path.

ALTER TABLE previews ADD last_activity_at TEXT;
ALTER TABLE previews ADD expires_at TEXT;
ALTER TABLE previews ADD expiry_reason TEXT;
ALTER TABLE previews ADD ttl_ms INTEGER;
ALTER TABLE previews ADD idle_ms INTEGER;
type GovernanceConfig = {
  previewTtlMs?: number | null;      // SPROUT_PREVIEW_TTL / preview.ttl
  previewIdleMs?: number | null;     // SPROUT_PREVIEW_IDLE_TEARDOWN / preview.idle_teardown
  maxPreviewsPerRepo?: number | null;
  maxPreviews?: number | null;
  previewMaxDbConnections?: number | null;
  postgresMaxConnections?: number | null;
}
type PreviewSnapshot += { last_activity_at, expires_at, expiry_reason }
 deploy(repo, pr)
   resolveGovernance(manifest.ttl/idle, gateway)
   checkPreviewCaps(repo) -> 429 preview_limit_reached (cap, value, count)
   checkConnectionBudget(active+1 x perPreview vs ceiling) -> 429 preview_connection_budget_exceeded
   acceptAsyncDeploy -> runAsyncDeploy -> closeRunning sets lastActivity/expires

 sweepPass (Bun.cron SPROUT_SWEEP_CRON)
   planGovernanceExpiry(lastActivity + ttl/idle vs now) -> sweep:ttl-expired / sweep:idle-expired
   legacy SPROUT_TTL_HOURS fallback preserved
   tryRemovePreview skips when per-preview lock held (never expires mid-deploy)
   logOverCap for caps + connection budget
  packages/preview-env/src/
  └── governance.ts        # duration/cap parsing, resolve, expiry, budget arithmetic
  apps/cli/src/
  ├── yaml.ts              # preview.ttl / preview.idle_teardown validation
  ├── commands/deploy-core.ts  # forwards ttl/idle to gateway
  ├── commands/deploy-outcome.ts + forge-note.ts  # expires_at + "- Expires:" note line
  apps/server/src/
  ├── config.ts            # 6 new SPROUT_* vars (all off), unbounded boot warning
  ├── preview/             # locks try-lock, lifecycle caps/budget/expiry, snapshot fields
  ├── sweep/               # reconcile expiry + over-cap logs, live-ports mapping
  ├── http/                # deploy validation + 429s, list/get new fields
  docs/                    # previews.md semantics, adopting-a-repo keys, operator-deploy arithmetic
  compose.env.example + docker-compose.yml  # local stack 7d TTL on

Verified serially, one command at a time: bun test (1078 pass), bun run typecheck, bun run test:guards (110 pass), bun run docs:check.

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 1) — REQUESTCHANGES · e37cd72d

  • Findings
    1. closeRunning silently erases governance on the close bring-up path (correctness regression)
    1. planGovernanceExpiry has a redundant stored-deadline branch, non-null assertions, and an unreachable fallback
    1. Caps and connection budget are admission policy living in the lifecycle-mutation module, and deploy calls them serially against duplicate reads
    1. connectionProjection is a canonical helper that production code doesn't use
    1. ProvisionInput.governance / GovernanceInput is a dead pass-through field
    1. removePreview and tryRemovePreview are near-identical 30-line copies
    1. GovernanceConfig is copied/renamed at every hop instead of being passed once
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

Findings

1. closeRunning silently erases governance on the close bring-up path (correctness regression)

apps/server/src/preview/bring-up.ts:427 still calls closeRunning(deps, row) with no input, while finishAfterPromote (line 245) and syncThenCloseRunning (line 236) pass it. Inside closeRunning (apps/server/src/preview/bring-up.ts:187-208) the new governance fields are written unconditionally from an optional param:

expiresAt: expiryForActivity(now, input?.governanceMs),  // null when input missing
ttlMs: input?.governanceMs?.ttlMs ?? null,               // wipes stored ttl
idleMs: input?.governanceMs?.idleMs ?? null,             // wipes stored idle

When the close plan is selected (a resume after seed already completed — exercised by preview-services.test.ts:444), an operator with SPROUT_PREVIEW_TTL/preview.ttl set gets expires_at, ttl_ms, and idle_ms reset to null. The preview then falls through planGovernanceExpiry's back-compat branch to no expiry: it becomes unbounded until some later full_replace deploy, which may never come.

Remedy: make input a required parameter and pass it at line 427. Optionality here is what hid the missed call site; a required param makes this class of bug a type error. The raw-manifest governance field is unused (finding 5), so governanceMs is the only thing needed.

2. planGovernanceExpiry has a redundant stored-deadline branch, non-null assertions, and an unreachable fallback

apps/server/src/sweep/reconcile.ts:291-326 has ~35 lines with two branches. The first branch trusts stored expiresAtMs, then recomputes both deadlines anyway to pick a reason, using ttlDeadline!/idleDeadline! (lines 307-309) and ending in a return "sweep:ttl-expired" fallback (line 313) that is only reachable when the stored expires_at disagrees with last_activity_at/ttl_ms/idle_ms — i.e. only in an inconsistent state the design permits by storing both. The stored expires_at is derivable: computeExpiresAtMs already computes the min of the same two deadlines.

Remedy (code-judo): delete the expiresAtMs !== null special case entirely and always derive from activityBaseMs + ttlMs/idleMs — this is the existing back-compat branch. Results are identical on consistent data and strictly more correct on stale data, the ! assertions disappear, and the function roughly halves. Keep writing expires_at only for the read surface. Add a focused unit test that a row with a stale expires_at and refeshed last_activity_at is handled.

3. Caps and connection budget are admission policy living in the lifecycle-mutation module, and deploy calls them serially against duplicate reads

apps/server/src/preview/lifecycle.ts:643-748 adds countActivePreviews, countRepoPreviews, checkPreviewCaps, and checkConnectionBudget to an already-busy lifecycle file (609 → 798 lines). apps/server/src/http/deploy.ts:565-599 then builds a six-field gatewayGov copy and calls the two checks back-to-back. Both checks independently call getPreviewRow, and the budget check calls countActivePreviews; the Result<true> + value: true shape adds nothing.

Remedy: extract a single preview/admission.ts owning cap + budget policy, with one checkDeployAdmission(db, {repo, prId}, gov, manifest) that loads the row once, counts once, and returns Result<{ ttlMs; idleMs }> (the effective governance). deploy.ts then has one call and one error mapping instead of ~35 lines, and lifecycle.ts stays a lifecycle module. This is where the file-size pressure lands too — keeping admission out of lifecycle.ts avoids it creeping toward 1k.

4. connectionProjection is a canonical helper that production code doesn't use

packages/preview-env/src/governance.ts:134-144 is exported and tested, but only referenced by governance.test.ts. The exact (active + 1) * perPreview > ceiling logic is re-implemented in checkConnectionBudget (apps/server/src/preview/lifecycle.ts:733) and a second, subtly different live.length * perPreview in logOverCap (apps/server/src/sweep/reconcile.ts:358).

Remedy: either call connectionProjection from checkConnectionBudget, or delete the helper. Do not leave tested-but-dead production logic. While there, the over-cap logging in logOverCap duplicates the cap arithmetic of checkPreviewCaps; a shared governanceStatus(previews, gov) would let deploy and sweep agree.

5. ProvisionInput.governance / GovernanceInput is a dead pass-through field

apps/server/src/preview/types.ts:53-58 defines GovernanceInput; types.ts:82 puts governance?: GovernanceInput on ProvisionInput; apps/server/src/http/deploy.ts:640 sets it. Nothing reads it — bring-up only reads input.governanceMs (verified by grep). It carries raw manifest strings that are already resolved into ms.

Remedy: remove the field and the type (and its re-export from lifecycle.ts:47). This was likely an earlier shape that survived the switch to governanceMs; deleting it removes an ambiguity about which representation is authoritative.

6. removePreview and tryRemovePreview are near-identical 30-line copies

apps/server/src/preview/lifecycle.ts:565-593 and 596-625 duplicate the row lookup, status === "removed" guard, dbName/createdAt identity guards, status parse, and destroyPreviewRow call. The only difference is the lock wrapper. Worse, tryWithPreviewLock returns { acquired: boolean; value?: T }, forcing the attempt.value! cast at line 623.

Remedy: extract removePreviewUnlocked(deps, input), then removePreview = withPreviewLock(... removePreviewUnlocked) and tryRemovePreview = tryWithPreviewLock(...).then(...). Type tryWithPreviewLock as { acquired: true; value: T } | { acquired: false } so the cast becomes unnecessary and the contract is honest.

7. GovernanceConfig is copied/renamed at every hop instead of being passed once

The same six fields are declared in apps/server/src/config.ts:119-130 (?: number | null), apps/server/src/preview/types.ts:53-66, apps/server/src/sweep/reconcile.ts:55-60 (four fields, inline), and rebuilt as fresh objects in apps/server/src/http/app.ts:25-32, apps/server/src/http/deploy.ts:565-573, and apps/server/src/sweep/start.ts:30-35. The explicit normalization in deploy.ts (deps.governance?.x ?? null six times) is pure tax on ?: number | null optionality.

Remedy: make the Config governance fields number | null (non-optional; loadConfig always sets them), define one GovernanceConfig in @sprout/preview-env and reuse it for SweepPorts.governance and the providers, and pass config/the struct through directly. app.ts can pass governance: deps.config structurally; the six-line rebuild in deploy.ts and app.ts disappears, and the ?? null noise goes with it.

Residual risks

  1. Cap/budget race across repos. checkPreviewCaps/checkConnectionBudget are read-then-act outside any global lock, so two concurrent deploys for different PRs can both pass and exceed SPROUT_MAX_PREVIEWS / the connection ceiling. Probe: SELECT count(*) FROM previews WHERE status <> 'removed' after two simultaneous deploys; watch for the sweep sweep over cap log.
  2. Close-plan governance wipe (finding 1) reaching production. Probe: after a resume deploy, GET /v1/previews and assert expires_at is non-null when a TTL is configured.
  3. Legacy TTL sweep now uses the try-lock, so a preview under continuous deploys never expires via SPROUT_TTL_HOURS. Probe: watch for absence of deleted (sweep:ttl-expired) for frequently redeployed PRs.
  4. expiry_reason is set to sweep:pr-not-open, conflating eviction with expiry and clearing the seed watermark on closed-PR sweeps (live-ports.ts:54 → destroyPreviewRow). Probe: inspect expiry_reason after a closed-PR sweep, then redeploy and confirm whether a reseed was intended.
  5. Silent null on malformed governance timestamps. listPreviews logs invalid createdAt but silently maps bad last_activity_at/expires_at to null (live-ports.ts:86-87), which makes a preview look ungoverned rather than misconfigured. Probe: insert a bad last_activity_at and check for a log line; currently none.

Unverifiable from here

Drizzle migration application against a live pre-existing SQLite state DB, and the full bun test/typecheck suite across the monorepo (only the three new test files were run here, all green).


Automated agent review (opencode) · worktree /home/sim/.herdr/worktrees/sprout/gh-241-preview-governance-ttl-idle-tear · subsequent rounds will reply in this thread.

@simpros
simpros force-pushed the gh-241-preview-governance-ttl-idle-tear branch from e37cd72 to 9387212 Compare October 2, 2026 12:42
simpros added a commit that referenced this pull request Oct 2, 2026
Require closeRunning input so the close bring-up path refreshes
governance instead of wiping it; derive sweep expiry from activity
plus stored durations and drop the stored-deadline branch; extract
preview/admission as the single deploy admission gate over one row
load; share connectionProjection/governanceStatus between deploy
and sweep; remove the dead ProvisionInput.governance field; dedupe
removePreview/tryRemovePreview and type the try-lock honestly;
unify on one required-field GovernanceConfig from preview-env.
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 2) — APPROVE · 9387212b

  • Findings
    1. ProvisionInput.governanceMs is still optional, so the wipe class from round-1 finding 1 is still type-permitted
    1. destroyPreviewRow's optional expiryReason is a nullable mode flag, and its seed-watermark reset is redundant
    1. Dead scaffolding left behind by the simplification
    1. Config re-declares the six governance fields instead of composing GovernanceConfig
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: APPROVE

Scope note: HEAD was verified at 9387212 at start but moved to d635600 ("address review feedback on #265") while a concurrent mr-fix-sprout-265 implementer was active in this worktree. Per the brief I reviewed git diff origin/main...HEAD at the current HEAD d635600 (the combined MR plus the round-1 fixes). Both package builds typecheck clean; the four affected test files (61 tests) pass. The seven round-1 findings are genuinely fixed, not papered over: closeRunning now requires input, planGovernanceExpiry derives instead of trusting the stored deadline, admission is a single gate in preview/admission.ts, connectionProjection/governanceStatus are shared by deploy and sweep, the dead ProvisionInput.governance field is gone, the two removePreview* variants share removePreviewUnlocked, and GovernanceConfig has one definition in @sprout/preview-env with Config fields made non-optional. No file crosses 1k lines (lifecycle.ts 609→697, admission.ts 99). Remaining items are cleanups, not structural regressions.

Findings

1. ProvisionInput.governanceMs is still optional, so the wipe class from round-1 finding 1 is still type-permitted

apps/server/src/preview/types.ts:73 keeps governanceMs?: {...}, and apps/server/src/preview/bring-up.ts:202-204 still resolves it with input.governanceMs?.ttlMs ?? null. The fix made input required at closeRunning (good), but any caller that constructs a ProvisionInput without governanceMs still silently resets ttl_ms/idle_ms/expires_at to null — the exact regression round 1 found, now only unreachable by inspection of the single production constructor apps/server/src/http/deploy.ts:676. Remedy: make governanceMs required on ProvisionInput ({ ttlMs: number | null; idleMs: number | null }); the only production constructor always supplies it (from checkDeployAdmission), so this is a one-line type change that turns the whole bug class into a compile error instead of a convention. If a governance-free caller must exist, have closeRunning preserve the stored row values rather than writing null.

2. destroyPreviewRow's optional expiryReason is a nullable mode flag, and its seed-watermark reset is redundant

apps/server/src/preview/lifecycle.ts:441 + :487-494: the presence of a string both records a reason and flips on seededAt: null, seededSeedImage: null, expiresAt: null. That means sweep:pr-not-open — eviction, not expiry — is silently treated as an expiry (apps/server/src/sweep/live-ports.ts:54), and a plain teardown is not. Worse, the seed-watermark clears are dead weight: every redeploy of a removed row goes through writeProvisioningIntent (apps/server/src/preview/lifecycle.ts:193-194), which already nulls seededAt/seededSeedImage, and no read surface exposes them. Remedy: model the tombstone explicitly — pass a discriminated { reason: SweepReason; clearSeed: boolean } or just always record expiryReason and clear expiresAt for a tombstone, dropping the two seed fields. That deletes the conditional-spread magic and removes the "is pr-not-open an expiry?" ambiguity.

3. Dead scaffolding left behind by the simplification

The code-judo removed concepts but kept their plumbing; deleting it makes the new design read as the only design:

  • apps/server/src/sweep/reconcile.ts:26 SweepPreview.expiresAtMs is no longer read anywhere (planGovernanceExpiry derives from lastActivityMs/ttlMs/idleMs), yet apps/server/src/sweep/live-ports.ts:87 still parses expires_at on every row of every sweep. Remove the field and the parse; the round-1 test that populates it to prove it is ignored still passes once the field is gone.
  • apps/server/src/preview/locks.ts:60 isPreviewLocked is exported and re-exported by apps/server/src/preview/lifecycle.ts:54, but its only consumer is tryWithPreviewLock in the same file. Drop both exports.
  • GovernanceConfig now has three import paths: lifecycle.ts:40 and types.ts:54 re-export it, and deploy.ts:49/routes.ts:7 consume it through those re-exports while admission.ts:8/reconcile.ts:1 import it from @sprout/preview-env. Keep one canonical import (@sprout/preview-env) and delete the re-export chain, so the ownership of the type is unambiguous.

4. Config re-declares the six governance fields instead of composing GovernanceConfig

apps/server/src/config.ts:133-144 duplicates the exact field list now defined at packages/preview-env/src/governance.ts:24. deps.config is already passed structurally as GovernanceConfig (apps/server/src/http/app.ts:25, apps/server/src/sweep/start.ts:30), so the relationship is real but invisible. Compose it (export type Config = { ... } & GovernanceConfig, or a nested governance: GovernanceConfig) so adding a seventh knob is a one-place edit and the two lists cannot drift.

Residual risks

  1. Cap/budget admission is read-then-act outside the lock. checkDeployAdmission (admission.ts:28) loads live rows before provisionPreview takes withPreviewLock, so two concurrent deploys for different PRs can both pass and overshoot SPROUT_MAX_PREVIEWS / the connection ceiling. Probe: fire two simultaneous deploys past the cap, then SELECT count(*) FROM previews WHERE status <> 'removed'; the second should be rejected with preview_limit_reached.
  2. Finding 1 reaching production through a future caller. A ProvisionInput built without governanceMs makes a resume/close deploy reset expires_at/ttl_ms/idle_ms to null. Probe: after a resume deploy with SPROUT_PREVIEW_TTL=7d, GET /v1/previews and assert expires_at is non-null.
  3. Legacy SPROUT_TTL_HOURS sweep now takes the try-lock (live-ports.ts:125), so a preview under continuous/frequent deploys is never removed by creation age. Probe: watch for absence of deleted (sweep:ttl-expired) for a frequently redeployed PR older than SPROUT_TTL_HOURS.
  4. Stored expires_at is no longer authoritative for the sweep, and a row whose last_activity_at fails to parse silently falls back to createdAt instead of surfacing misconfiguration (live-ports.ts:86-87). Probe: insert a malformed last_activity_at and check whether any log line or metric is emitted (currently none).
  5. sweep:pr-not-open is recorded as an expiry reason and triggers the tombstone path through removeControlPlane (live-ports.ts:54). Probe: close a PR, inspect expiry_reason on the tombstone, reopen, and confirm whether a fresh reseed is actually intended.

Unverifiable from here

Applying the two new Drizzle migrations against a live pre-existing SQLite state DB, and the full monorepo bun test/turbo typecheck across every package (I ran only the four affected test files and the @sprout/server / @sprout/preview-env builds).

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 3) — REQUESTCHANGES · afa6ea5e

  • Findings
    1. Two owners for the connection budget, and the 429 detail is off by one for refreshes
    1. expires_at has three owners and none of them is load-bearing; delete the stored copy
    1. planGovernanceExpiry hand-rolls the min-deadline logic that computeExpiresAtMs already owns
    1. Tombstone policy is smuggled through expiryReason?: string, and the two "PR not open" paths now disagree
    1. The {ttlMs, idleMs} shape is redeclared four times, DeployAdmission is an identity alias, and governance? optionality silently disables enforcement
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

The feature is coherent and the module split (preview-env policy, preview/admission.ts gate, sweep/reconcile.ts expiry planning) is the right shape. But the diff leaves two competing owners for the connection budget and three competing owners for the expiry deadline, and it smuggles a tombstone policy through an untyped optional string — with a real behavior asymmetry between the two "PR not open" removal paths. These are structural, not cosmetic.

Findings

1. Two owners for the connection budget, and the 429 detail is off by one for refreshes

packages/preview-env/src/governance.ts:166-176 (connectionProjection, (active+1)*per) and governance.ts:204-213 (governanceStatus computing connections as total*per) are the same arithmetic with two different +1 conventions, exported side by side. apps/server/src/preview/admission.ts:39 calls governanceStatus (which computes connections) and then throws it away, calling connectionProjection again at admission.ts:76-93. Worse, the detail at admission.ts:86-91 passes active: status.total while the projection for a refresh used status.total - 1 (admission.ts:77), so connectionBudgetDetail (governance.ts:224-234) prints "N active previews + 1 × per" for a projected total of N × per — the message and the arithmetic disagree by one preview.

Concrete remedy: make one function authoritative. Drop connections from GovernanceStatus (keep only total/byRepo) and let both callers compute the budget from the same connectionProjection input; pass the same active value used in the projection into connectionBudgetDetail. This also surfaces the second question this duplication hides: should a refresh be rejected when the instance is already over budget? Caps are correctly gated behind if (isNew) (admission.ts:41), and the comment says refreshing never counts — but the budget doesn't honor that. Decide the policy once, next to the projection.

2. expires_at has three owners and none of them is load-bearing; delete the stored copy

The same deadline is (a) computed and stored in apps/server/src/preview/bring-up.ts:173-186,202, (b) re-derived in sweep/reconcile.ts:289-316, and (c) parsed back out into a SweepPreview.expiresAtMs field at apps/server/src/sweep/live-ports.ts:87 that no production code reads (grep: only the test file and live-ports set it). The comment at reconcile.ts:284-285 states the stored value is deliberately ignored for decisions — so the column is a display cache that is also recomputed.

Concrete remedy: keep last_activity_at + ttl_ms/idle_ms as the single source, and derive expires_at at the read surface (preview/snapshot.ts already has the whole PreviewRow and can call computeExpiresAtMs). That deletes the expires_at column and its migration line (apps/server/drizzle/20261002113607_acoustic_deathstrike/migration.sql), schema.ts expiresAt, expiryForActivity, the write in closeRunning, the expiresAt: null tombstone branch (lifecycle.ts:492), SweepPreview.expiresAtMs, and its parse. If you must keep the stored column for tombstone display, at minimum delete the dead sweep field. While here, squash the two same-feature migrations into one.

3. planGovernanceExpiry hand-rolls the min-deadline logic that computeExpiresAtMs already owns

reconcile.ts:295-315 builds ttlDeadline/idleDeadline and then runs a three-branch cascade to decide which fired, duplicating exactly what computeExpiresAtMs (already imported into this package and used by bring-up.ts) computes with Math.min. Collapse to one call plus one label selection:

const expiry = computeExpiresAtMs(base, preview.ttlMs ?? null, preview.idleMs ?? null);
if (expiry === null || nowMs < expiry) return null;
return ttlDeadline !== null && (idleDeadline === null || ttlDeadline <= idleDeadline)
  ? "sweep:ttl-expired" : "sweep:idle-expired";

Same behavior, ~10 fewer lines, one concept instead of two.

4. Tombstone policy is smuggled through expiryReason?: string, and the two "PR not open" paths now disagree

lifecycle.ts:441,487-494 clears seededAt/seededSeedImage and nulls expiresAt for any truthy expiryReason. live-ports.ts:54 sets expiryReason: deletion.reason for every sweep deletion, including "sweep:pr-not-open". But the explicit teardown path (lifecycle.ts:548) calls destroyPreviewRow(..., "tombstone") with no reason and preserves the seed watermark. So the same logical event — PR is no longer open — produces two different tombstones depending on whether the webhook or the sweep caught it. The comment at lifecycle.ts:484-486 claims "PR-close teardown keeps the same tombstone shape," which the code does not do.

Concrete remedy: make the policy explicit instead of inferred from string presence. Model the input as a typed union (e.g. { kind: "expiry"; reason: "sweep:ttl-expired" | "sweep:idle-expired" } | { kind: "pr-close" } | { kind: "manual" }) and decide seed reset once. Type RemovePreviewInput.expiryReason to the sweep reason union rather than free-form string (types.ts:86), so the read surface can't store arbitrary text.

5. The {ttlMs, idleMs} shape is redeclared four times, DeployAdmission is an identity alias, and governance? optionality silently disables enforcement

The same shape appears as DeployAdmission (admission.ts:16), ProvisionInput.governanceMs (types.ts:73), the expiryForActivity parameter (bring-up.ts:175), and the return of resolveEffectiveGovernanceMs (governance.ts:~250). Declare one named type in preview-env and reuse it; DeployAdmission then disappears. Relatedly, governance? is threaded through RouteDeps (http/routes.ts:42), the conditional spread at routes.ts:81, deploy deps (http/deploy.ts:541), LiveSweepDeps (live-ports.ts:28), and SweepPorts (reconcile.ts:56). Since "null means off" is already the model and loadConfig always supplies all six fields, undefined and all-null are behaviorally identical — the optional only adds silent-fallback risk (a wiring path that forgets to pass it disables every cap) and the conditional-spread noise. Make it a required GovernanceConfig and delete the guards (logOverCap's if (!gov) return included).

Residual risks

  • Pre-existing running previews have ttl_ms/idle_ms = null, so enabling SPROUT_PREVIEW_TTL does not govern them until each is redeployed; only the legacy creation-age SPROUT_TTL_HOURS sweep catches them. Probe: SELECT count(*) FROM previews WHERE status='running' AND ttl_ms IS NULL.
  • tryRemovePreview returns ok,false when a deploy holds the lock (lifecycle.ts:636-647), and dropSettled treats false as a stale plan and logs nothing — a long deploy can defer expiry silently. Probe: metric/log count of sweeps skipped due to lock contention.
  • The connection budget is a static estimate (SPROUT_PREVIEW_MAX_DB_CONNECTIONS × previews); an app that ignores the injected pool cap bypasses it. Probe: compare SELECT count(*) FROM pg_stat_activity against the gateway's projection.
  • Refreshes are rejected by the budget when the instance is already over the ceiling (finding 1), which can block the very redeploy that would reduce connections. Probe: preview_connection_budget_exceeded 429s on existing previews.
  • Seeded tombstone state now differs by removal path (finding 4), so a swept pr-not-open preview reseeds on reopen while a webhook-torn-down one does not. Probe: SELECT expiry_reason, seeded_at FROM previews WHERE status='removed'.

Unverifiable from here

Concurrent deploy-vs-sweep lock behavior, real Postgres connection pressure, and the Docker/compose acceptance path cannot be exercised from this checkout; only the unit suites (preview-env/governance, sweep/governance, preview/locks) were run here and pass.

simpros added a commit that referenced this pull request Oct 2, 2026
Round-3 findings plus the remaining round-2 cleanups:

- Single connection-budget owner: drop connections from
  GovernanceStatus; admission and sweep project through
  connectionProjection. Refresh deploys are exempt from caps and
  budget alike, fixing the off-by-one 429 detail.
- Delete the stored expires_at column: last_activity_at plus
  ttl_ms/idle_ms is the single source, derived at the read surface
  (snapshot expiresAtForRow) and in planGovernanceExpiry via
  computeExpiresAtMs. Squash the two same-feature migrations.
- Tombstone policy is explicit: PreviewExpiryReason types the removal
  cause; destroyPreviewRow always writes the same tombstone shape and
  keeps the seed watermark for writeProvisioningIntent to reset.
- One EffectiveGovernanceMs in preview-env replaces DeployAdmission
  and the redeclared shapes; ProvisionInput.governanceMs is required.
- GovernanceConfig is required end to end; Config composes it instead
  of re-declaring the fields; single canonical import path.
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 4) — REQUESTCHANGES · 37026459

  • Findings
    1. Sweep can delete a preview that was just refreshed (stale-plan guard ignores the new activity signal)
    1. Expiry policy is written twice, with two different base rules (missed consolidation)
    1. connectionProjection hides a +1 that callers must cancel with -1
    1. SweepReason is dead and duplicates the expiry union
    1. expiry_reason is not cleared when a removed row is re-provisioned
    1. Test hygiene: a >1k-line test file keeps growing, and two assertions were weakened
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

The feature is coherent, well-documented, and its tests pass. But the new expiry path has a stale-plan guard that doesn't check the signal the expiry is now derived from, and the "which bound wins" policy is implemented twice with different base rules. Both are fixable without behavior change; the rubric treats the missed consolidation as a blocker.

Findings

1. Sweep can delete a preview that was just refreshed (stale-plan guard ignores the new activity signal)

apps/server/src/sweep/live-ports.ts:42-59, apps/server/src/preview/lifecycle.ts:594-620

planGovernanceExpiry (sweep/reconcile.ts:293) now plans deletions from last_activity_at + stored ttl_ms/idle_ms, but the only staleness guard at drop time is expectedCreatedAt (plus expectedDbName). A refresh of a live preview (close/sync_close plans) does not change createdAt; it only advances lastActivityAt in closeRunning (bring-up.ts:185). So a plan built at T0 can still match at T2 after the preview was refreshed at T1, and the sweep tears down a preview that just got a fresh lease.

The new tryWithPreviewLock only closes the in-flight half: provisionPreview runs pullImagesOutsideLock before taking the lock (lifecycle.ts:551-552), so during a multi-minute image pull the lock is free, inFlightDeploys is not consulted by removePreview, and a same-generation refresh is removable. The docs claim "holding the per-preview lock so a preview mid-deploy is never expired" — that is only true after the pull.

Remedy: thread the activity watermark into the removal contract. Add expectedLastActivityAt: string | null to RemovePreviewInput (preview/types.ts:92) and compare it in removePreviewUnlocked exactly like expectedCreatedAt; a refreshed row then mismatches and the drop returns { ok: true, value: false } as a stale plan. This is the same guard shape already used for generation, and it makes tryRemovePreview correct regardless of where the lock is held.

2. Expiry policy is written twice, with two different base rules (missed consolidation)

packages/preview-env/src/governance.ts:165-175, apps/server/src/preview/snapshot.ts:35-48, apps/server/src/sweep/reconcile.ts:280-311

computeExpiresAtMs is shared, but everything around it is not:

  • expiresAtForRow picks base = Date.parse(lastActivityAt) (null ⇒ no expiry) and returns an ISO string.
  • planGovernanceExpiry picks base = activityBaseMs (lastActivityAt ?? createdAtMs) and then re-derives ttlDeadline/idleDeadline to re-decide the race that computeExpiresAtMs already decided, including its own <= tie-break.

So the "earlier of ttl and idle" rule and the tie-break live in two modules, and the two base rules can diverge. Fold this into one function in governance.ts:

// one place decides base, the earlier bound, and which bound won
export function resolvePreviewExpiry(input: {
  lastActivityMs: number | null;
  createdAtMs: number | null;
  ttlMs: number | null;
  idleMs: number | null;
}): { expiresAtMs: number | null; bound: "ttl" | "idle" | null }

expiresAtForRow becomes resolvePreviewExpiry(...).expiresAtMs → ISO; planGovernanceExpiry becomes bound === "ttl" ? "sweep:ttl-expired" : "sweep:idle-expired". That deletes activityBaseMs, the duplicated min/deadline re-derivation, and the tie-break duplicate. It also forces the read-vs-sweep base-rule difference to be either intentional or gone (today it is an undocumented divergence).

3. connectionProjection hides a +1 that callers must cancel with -1

packages/preview-env/src/governance.ts:177-187, apps/server/src/sweep/reconcile.ts:336-345

The helper computes (activePreviews + 1) * perPreview, but the sweep passes activePreviews: status.total - 1 with a comment explaining that the +1 inside counts an existing preview. That is an off-by-one convention smuggled through an API and cancelled at the call site; the docstring ("the +1 convention lives in one place") is false — it leaks. Admission passes status.total, the sweep passes status.total - 1, and a future caller has no way to know which it needs.

Remedy: make the helper pure arithmetic — projected = previews * perPreview — and let the two callers express intent directly: admission passes status.total + 1 ("the candidate"), sweep's log passes status.total. The parameter name activePreviews can then be previews.

4. SweepReason is dead and duplicates the expiry union

apps/server/src/sweep/reconcile.ts:10-16

SweepReason has zero references (grep confirms only its declaration), and its three expiry strings are duplicated verbatim by PreviewExpiryReason (preview/types.ts:87-90), which SweepDeletion now uses. The pure planner also gains a type-only import of the lifecycle preview/types.ts for no reason. Delete SweepReason. If the reason strings need a canonical home shared by planner and lifecycle, move PreviewExpiryReason into preview-env next to the other governance types rather than keeping two copies.

5. expiry_reason is not cleared when a removed row is re-provisioned

apps/server/src/preview/lifecycle.ts:184-199

writeProvisioningIntent is the removed→provisioning path and it resets seededAt, seededSeedImage, containerId, createdAt, and error fields, but not expiryReason. previewSnapshotFromRow (snapshot.ts:77) and presentListedPreview (snapshot.ts:134) emit expiry_reason unconditionally, so a re-deploying preview advertises the previous sweep:ttl-expired/sweep:pr-not-open until closeRunning clears it. Add expiryReason: null alongside the other intent resets (and to patchAccept for symmetry) so a live row never carries a tombstone reason.

6. Test hygiene: a >1k-line test file keeps growing, and two assertions were weakened

apps/server/src/http/preview-lifecycle.test.ts (1027 → 1065 lines), apps/server/src/http/health-deploy.test.ts:142, apps/server/src/http/preview-lifecycle.test.ts:149

Appending the new governed-ttl test to an already 1000+ line lifecycle suite grows the file the rubric wants decomposed; it belongs in a focused governance lifecycle test (or the existing sweep/governance.test.ts server surface). Separately, toEqual was relaxed to toMatchObject; the added explicit expires_at/last_activity_at asserts compensate, but the exact-shape guarantee is gone. Prefer asserting the full object with the three new fields rather than loosening the matcher.

Residual risks

  • Refreshed preview swept anyway (Finding 1): probe — log every tryRemovePreview acquired:false skip and every deleted (sweep:ttl-expired) and alert when a deletion lands within the pull window of a deploy for the same slug:prId.
  • Per-preview durations frozen at deploy time: changing SPROUT_PREVIEW_TTL does not retune existing rows. Probe — after an env change, GET /v1/previews and confirm old rows' expires_at is unchanged until their next deploy.
  • Stale expiry_reason on re-provision (Finding 5): probe — GET /v1/previews for a row that was removed and is now provisioning/seeding; expiry_reason must be null.
  • +1/-1 budget arithmetic drift (Finding 3): probe — boundary test that a new preview with existing == ceiling/perPreview - 1 is admitted and existing == ceiling/perPreview is rejected; cross-check the sweep log's projected value equals total * perPreview.
  • Silent no-activity previews expiring under traffic: documented, but the top support risk. Probe — count deleted (sweep:idle-expired) for previews that also show proxy traffic in the same window.

Unverifiable from here

Real Postgres connection counts, actual Docker/registry pull timing, and whether the migration applies cleanly to a populated operator state DB cannot be exercised from this checkout; only unit/integration tests were run (205 pass across the touched server suites, plus the new governance tests).

simpros added a commit that referenced this pull request Oct 2, 2026
Round-4 findings: thread last-activity watermark into the removal
contract so a refreshed preview is a stale plan; fold expiry base,
min-deadline and tie-break into preview-env resolvePreviewExpiry used
by both the read surface and the sweep planner; make
connectionProjection pure previews * perPreview with the +1 at the
admission call site; delete dead SweepReason; clear expiry_reason on
re-provision intent and accept; move the governed-ttl HTTP test into a
focused preview-governance suite and assert full response shapes.
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 5) — REQUESTCHANGES · 63bf22ba

  • Findings
    1. SPROUT_TTL_HOURS still reaps every preview at 72h, so the new TTL/caps story is wrong and SPROUT_PREVIEW_TTL=7d is silently capped
    1. The sweep builds the same SweepDeletion object four times
    1. GovernanceFieldIssue.code is write-only and the kind === "ttl" ? … ternary is repeated three times
    1. SweepPreview.lastActivityMs / ttlMs / idleMs are optional purely for test fixtures
    1. previewLockCounts is a second lock structure kept in sync by hand
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

HEAD verified at 63bf22ba7908930a2ee536150ee1625562b27ca4; the round-4 review items are genuinely resolved (shared resolvePreviewExpiry, pure connectionProjection, expectedLastActivityAt staleness guard, typed tombstone reason, cleared expiry_reason). The module split is right. But the sweep now runs two expiry policies that race, and the narrower one silently wins for governed previews; that plus a few surviving duplications keeps this below the approval bar.

Findings

1. SPROUT_TTL_HOURS still reaps every preview at 72h, so the new TTL/caps story is wrong and SPROUT_PREVIEW_TTL=7d is silently capped

apps/server/src/sweep/reconcile.ts:189-213, apps/server/src/config.ts:424-439, compose.env.example:81-85, docs/previews.md:57, docs/operator-deploy.md:551-553

The loop tries planGovernanceExpiry first and then falls through to the legacy createdAtMs < cutoff branch. The legacy branch is not scoped to pre-migration rows: it applies to every live row, including ones that already carry ttl_ms/idle_ms. Because SPROUT_TTL_HOURS defaults to 72 and the shipped example sets SPROUT_PREVIEW_TTL=7d, the legacy branch is always the tighter deadline — the governance bound can never fire first, and every preview is deleted at 3 days with reason sweep:ttl-expired (the same string the new TTL uses, so the tombstone can't even say which policy fired). The tests hide this by passing ttlHours: 72 * 365 (apps/server/src/sweep/governance.test.ts:99,126). This directly contradicts governanceUnboundedWarning ("previews live until PR close"), .env.example:83 ("unbounded until PR close"), and docs/operator-deploy.md:551 ("keeps every preview until PR close … the self-serve compose stack sets 7d so a new operator is safe").

Remedy: make governance authoritative per row instead of racing it. Apply the legacy cutoff only when the row has neither bound (preview.ttlMs === null && preview.idleMs === null), i.e. only for un-migrated rows, and let governed rows be decided solely by resolvePreviewExpiry. If you want the deeper consolidation, fold ttlHours into the effective governance resolution at deploy time and delete the second branch entirely. Either way, fix governanceUnboundedWarning's predicate so the warning is true.

2. The sweep builds the same SweepDeletion object four times

apps/server/src/sweep/reconcile.ts:190-199, :201-209, :257-265 (and the shape is the same in live-ports.ts:46-53)

Four sites repeat canonicalRepoId/prId/slug/dbName/createdAt/lastActivityAt with only reason differing. Extract a single previewDeletion(preview, reason) helper and have all branches call it; the "which reason" decision becomes the only variable, and the TTL-vs-idle logic of Finding 1 collapses to one conditional. This is ~40 lines of copy-paste that should be ~15, and it is the exact spot a future fifth reason would get bolted onto.

3. GovernanceFieldIssue.code is write-only and the kind === "ttl" ? … ternary is repeated three times

packages/preview-env/src/governance.ts:52-104, :57-62

governanceIssueMessage reads only issue.raw; code is never consulted anywhere (grep confirms). Every sibling issue type in this package is consumed by a switch (issue.code) that is the canonical message mapping (labels.ts:44, db.ts:132, mail.ts:115, volumes.ts:87, auth.ts:52). This one both adds a dead discriminant and repeats kind === "ttl" ? "invalid_ttl" : "invalid_idle_teardown" in three branches. Follow the package's own pattern: derive once at the top of parsePreviewGovernanceField, switch on code in the message builder, or drop code and the ternary entirely and return the message. Right now the type pretends to carry information it never delivers.

4. SweepPreview.lastActivityMs / ttlMs / idleMs are optional purely for test fixtures

apps/server/src/sweep/reconcile.ts:23-25

The sole production producer (createLiveSweepPorts.listPreviews, live-ports.ts:84-88) always sets all three, yet they are optional so test literals can omit them; planGovernanceExpiry then launders every one through ?? null (:288-291). Make them required number | null and default them in the test factory (sweep/reconcile.test.ts fixtures, governance.test.ts:8), so the "always present, null means off" invariant is stated by the type instead of re-asserted at every read. This is exactly the silent-optionality pattern the rubric flags; all-null and absent are behaviorally identical but only one is honest.

5. previewLockCounts is a second lock structure kept in sync by hand

apps/server/src/preview/locks.ts:2-62

withKeyedLock never removes keys from previewLocks, so tryWithPreviewLock needs a parallel depth map that must exactly mirror the queue's lifecycle; correctness now depends on the increment at :35 and the release attached at :42 staying paired with withKeyedLock's internal tail. It is currently correct, but it is two sources of truth for "is this lock held". Encapsulate both in one registry that owns the depth and exposes runExclusive plus tryRunExclusive, so the predicate and the queue cannot drift.

Residual risks

  • 7d TTL silently becomes 72h (Finding 1): probe — boot with SPROUT_PREVIEW_TTL=7d and default SPROUT_TTL_HOURS, deploy, and confirm a deleted (sweep:ttl-expired) log before the 7-day expires_at; it currently fires at 72h.
  • Refresh mid-pull still swept: the activity watermark only advances in closeRunning, after pullImagesOutsideLock; probe — count tryRemovePreview skips and correlate deleted (sweep:ttl-expired) against a deploy for the same slug:prId within the pull window.
  • Idle teardown misclassifies traffic as idle: the signal is deploy-only; probe — alert when deleted (sweep:idle-expired) coincides with proxy traffic for that hostname.
  • Per-preview durations frozen at deploy: env changes don't retune existing rows; probe — after changing SPROUT_PREVIEW_TTL, GET /v1/previews and confirm old rows' expires_at is unchanged until redeploy.
  • Lock-count drift: probe — unit test that a tryWithPreviewLock acquired while another holder's fn rejects still releases the count to zero (isPreviewLocked false afterwards).

Unverifiable from here

No bun/typecheck in this environment and no real Postgres, Docker, or populated operator state DB, so test execution, connection pressure, pull timing, and migration-on-populated-DB behavior were reviewed only by reading.

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 6) — REQUESTCHANGES · 63bf22ba

    1. [HIGH] Read-surface expiry uses a different timestamp parser than the sweep, breaking the stated invariant
    1. [MEDIUM] computeExpiresAtMs is a redundant layer over the arithmetic already inside resolvePreviewExpiry
    1. [MEDIUM] Cap/budget policy is implemented twice: once to reject, once to log
    1. [MEDIUM] Optional SweepPreview fields only exist to spare test fixtures, and hide a real footgun
    1. [LOW-MEDIUM] The manifest bound is validated, discarded, then re-parsed and can throw on dead input
    1. [LOW] Snapshot wire fields are optional though always populated
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

The governance feature is structurally sound for the most part: policy lives in one shared module (preview-env/governance.ts), admission is a single gate, the sweep reuses the same expiry resolution, and no file crosses the 1k-line boundary. The findings below are the places where the implementation reintroduces divergence or duplicates a policy it already extracted.

1. [HIGH] Read-surface expiry uses a different timestamp parser than the sweep, breaking the stated invariant

apps/server/src/preview/snapshot.ts:42-50

expiresAtForRow parses lastActivityAt/createdAt with Date.parse, while the sweep deliberately uses the canonical parseUnambiguousUtcMs (apps/server/src/sweep/live-ports.ts:71,86). live-ports.test.ts explicitly feeds createdAt: "2026-09-02 12:00:00" as an invalid value. For exactly that row, Date.parse resolves a local-time instant and the read surface advertises a plausible expires_at, while the sweep treats createdAtMs as null and keeps the preview. The function comment claims "the displayed deadline and the deletion decision cannot diverge" — they do. Remedy: parse both fields with parseUnambiguousUtcMs from ../infrastructure/db/instant.ts (return null expiry when it yields null), and delete the Number.isFinite fallback.

2. [MEDIUM] computeExpiresAtMs is a redundant layer over the arithmetic already inside resolvePreviewExpiry

packages/preview-env/src/governance.ts:165-204

resolvePreviewExpiry builds ttlDeadline/idleDeadline, then calls computeExpiresAtMs(base, ttlMs, idleMs), which rebuilds the same two deadlines and takes the min. computeExpiresAtMs has no production caller other than resolvePreviewExpiry (only its own test). Two copies of the deadline math can disagree with the bound decision. Remedy: delete computeExpiresAtMs, compute the two deadlines once, and return { expiresAtMs: min, bound } from a single function (keep the ttl tie-break). This removes an export and ~15 lines with no behavior change.

3. [MEDIUM] Cap/budget policy is implemented twice: once to reject, once to log

apps/server/src/preview/admission.ts:42-94 and apps/server/src/sweep/reconcile.ts:297-335

Admission walks per-repo cap, total cap, and connection projection to produce 429s; logOverCap then re-walks the same three conditions to produce log strings, with a hand-rolled traversal and thresholds (>= in admission, > in the sweep). A new cap or a rename must be threaded through both, and the sweep bypasses the previewLimitDetail/connectionBudgetDetail helpers. Remedy: add one evaluateGovernance(gov, status, { pendingConnections }) returning typed violations; admission maps them to errors, the sweep logs them. The governanceStatus/connectionProjection primitives are already there — this just stops re-deriving the policy on top of them.

4. [MEDIUM] Optional SweepPreview fields only exist to spare test fixtures, and hide a real footgun

apps/server/src/sweep/reconcile.ts:14-26

lastActivityMs?, ttlMs?, idleMs? are optional because fixtures set lastActivityAt and omit the rest; production (live-ports.ts:84-88) always sets all three. A caller that sets lastActivityAt but forgets lastActivityMs silently disables governance expiry for that row. Remedy: make all three required and fill null in fixtures, or derive lastActivityMs inside planGovernanceExpiry from the string and drop the duplicate field entirely.

5. [LOW-MEDIUM] The manifest bound is validated, discarded, then re-parsed and can throw on dead input

packages/preview-env/src/governance.ts:69-106,138-149

parsePreviewGovernanceField validates off/duration; resolveGovernanceMs then re-runs parseDurationMs on the same string and throws if it fails. That throw is unreachable from live paths (CLI yaml.ts and server deploy.ts both validate first) yet sits on the request path as a potential 500. Three places independently re-implement the off check. Remedy: have GovernanceManifest carry the parsed bound ("off" | { ms: number }, produced once at the boundary) and reduce resolveGovernanceMs to a lookup with no parse and no throw.

6. [LOW] Snapshot wire fields are optional though always populated

apps/server/src/preview/types.ts:118-120

last_activity_at?, expires_at?, expiry_reason? are optional on PreviewSnapshot, but previewSnapshotFromRow always sets them, and ListedPreview already declares them required. Make them required string | null so the wire contract matches what is returned.

Residual risks

  • Read/sweep timestamp divergence (finding 1): probe — insert a preview whose created_at lacks a zone and compare GET /v1/previews expires_at with the sweep's action for the same row.
  • Expiry skipped mid-deploy is not retried in the same pass: probe — look for a deleted (sweep:ttl-expired) log line on the pass after a deploy that held the lock, and confirm the row is not permanently stranded.
  • Activity only advances on successful bring-up: probe — capture last_activity_at before and after a failed reseed/reset and confirm an in-use preview cannot expire out from under the operator.
  • Connection budget depends on apps honoring the injected cap: probe — sample Postgres pg_stat_activity connection count against SPROUT_POSTGRES_MAX_CONNECTIONS after several previews are up.
  • Migration leaves existing rows inert: probe — SELECT count(*) FROM previews WHERE status != 'removed' AND ttl_ms IS NULL after upgrade; those previews have no governance until their next deploy.

Unverifiable from here

simpros added a commit that referenced this pull request Oct 2, 2026
Require closeRunning input so the close bring-up path refreshes
governance instead of wiping it; derive sweep expiry from activity
plus stored durations and drop the stored-deadline branch; extract
preview/admission as the single deploy admission gate over one row
load; share connectionProjection/governanceStatus between deploy
and sweep; remove the dead ProvisionInput.governance field; dedupe
removePreview/tryRemovePreview and type the try-lock honestly;
unify on one required-field GovernanceConfig from preview-env.

# Conflicts:
#	apps/server/src/config.test.ts
#	apps/server/src/config.ts
#	apps/server/src/http/app.ts
#	apps/server/src/preview/runtime.mail.test.ts
simpros added a commit that referenced this pull request Oct 2, 2026
Round-3 findings plus the remaining round-2 cleanups:

- Single connection-budget owner: drop connections from
  GovernanceStatus; admission and sweep project through
  connectionProjection. Refresh deploys are exempt from caps and
  budget alike, fixing the off-by-one 429 detail.
- Delete the stored expires_at column: last_activity_at plus
  ttl_ms/idle_ms is the single source, derived at the read surface
  (snapshot expiresAtForRow) and in planGovernanceExpiry via
  computeExpiresAtMs. Squash the two same-feature migrations.
- Tombstone policy is explicit: PreviewExpiryReason types the removal
  cause; destroyPreviewRow always writes the same tombstone shape and
  keeps the seed watermark for writeProvisioningIntent to reset.
- One EffectiveGovernanceMs in preview-env replaces DeployAdmission
  and the redeclared shapes; ProvisionInput.governanceMs is required.
- GovernanceConfig is required end to end; Config composes it instead
  of re-declaring the fields; single canonical import path.

# Conflicts:
#	apps/server/src/config.ts
#	apps/server/src/http/auth.test.ts
#	apps/server/src/http/deploy.ts
#	apps/server/src/http/routes.ts
#	apps/server/src/http/test-helpers.ts
simpros added a commit that referenced this pull request Oct 2, 2026
Round-4 findings: thread last-activity watermark into the removal
contract so a refreshed preview is a stale plan; fold expiry base,
min-deadline and tie-break into preview-env resolvePreviewExpiry used
by both the read surface and the sweep planner; make
connectionProjection pure previews * perPreview with the +1 at the
admission call site; delete dead SweepReason; clear expiry_reason on
re-provision intent and accept; move the governed-ttl HTTP test into a
focused preview-governance suite and assert full response shapes.
simpros added a commit that referenced this pull request Oct 2, 2026
Round-5 findings: scope the legacy SPROUT_TTL_HOURS creation-age sweep to
rows with neither ttl nor idle bound so a 7d TTL is never capped by the 72h
default; collapse the three SweepDeletion builders into previewDeletion;
drop the write-only GovernanceFieldIssue.code (and the now-unused kind
param); make SweepPreview activity/bound fields required; unify the lock
queue and held-depth in one keyed registry. Truthful boot warning plus doc
and example updates for the legacy fallback; realistic ttlHours in sweep
tests with new governed-survives-legacy and unbound-falls-to-legacy cases.
Also fix the dashboard test's createRoutes call for required governance
(rebase fallout) and drop the now-unimported computeExpiresAtMs re-export.
@simpros
simpros force-pushed the gh-241-preview-governance-ttl-idle-tear branch from 63bf22b to 47c0b4d Compare October 2, 2026 14:16
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 7) — REQUESTCHANGES · 47c0b4d0

  • Findings
    1. Read surface and sweep genuinely diverge for rows that never reach running — apps/server/src/preview/snapshot.ts:30-55, apps/server/src/sweep/reconcile.ts:200-209
    1. connectionProjection publishes a null sentinel that every caller has to re-narrow — packages/preview-env/src/governance.ts:211-221 (call sites admission.ts:81, reconcile.ts:329)
    1. SweepPreview makes required governance signals optional — apps/server/src/sweep/reconcile.ts:22-25
    1. Cap admission is read-then-insert without serialization and the caps are untested — apps/server/src/preview/admission.ts:24-101, called from apps/server/src/http/deploy.ts:616
    1. computeExpiresAtMs is a second copy of arithmetic already inside resolvePreviewExpiry — packages/preview-env/src/governance.ts:165-204
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

The overall shape of this change is sound: the new policy lives in the shared @sprout/preview-env package where both CLI and server can consume it, admission.ts is a single gate, no changed file crosses 1000 lines, and the sweep/read paths both route through resolvePreviewExpiry. The blockers below are not cosmetic — one is a real read/write consistency hole the PR's own comment claims cannot exist, and the rest are type-boundary and atomicity problems that will fossilize if left.

Findings

1. Read surface and sweep genuinely diverge for rows that never reach running — apps/server/src/preview/snapshot.ts:30-55, apps/server/src/sweep/reconcile.ts:200-209

expiresAtForRow derives the deadline only from row.ttlMs ?? null / row.idleMs ?? null. Those columns are written exclusively in closeRunning (bring-up.ts:185-187), i.e. only after a successful bring-up. The sweep has a second, load-bearing bound: when governance gives no deadline, it falls back to the legacy createdAtMs < cutoff (ttlHours, default 72h) and deletes with reason sweep:ttl-expired.

Consequence: a preview stuck in provisioning/failed (or one whose deploy never settles) has ttl_ms = null, so GET /v1/previews reports expires_at: null, yet the sweep silently deletes it at the legacy age. That directly contradicts the function's own comment ("the displayed deadline and the deletion decision cannot diverge") and the feature's single-source-of-truth story. The legacy path is not removable — without it, never-running rows would never be collected — so the read surface must account for it rather than the comment being softened.

Remedy: fold the legacy fallback into the same derivation the sweep uses (e.g. have expiresAtForRow/resolvePreviewExpiry accept the legacy ttlHours bound, or compute expires_at from createdAtMs + ttlHours when both governance bounds are null), so one function yields both the displayed deadline and the sweep reason. Then delete the duplicated if (governanceExpiry) … else if (createdAtMs < cutoff) branch in runSweepPass.

2. connectionProjection publishes a null sentinel that every caller has to re-narrow — packages/preview-env/src/governance.ts:211-221 (call sites admission.ts:81, reconcile.ts:329)

The return { projected: number | null; over: boolean } encodes an invariant the type does not: when perPreview/ceiling are set, projected is always a number; when unset it is null and over is always false. Both call sites paper over this with if (budget.over && budget.projected !== null) (admission) and if (over && projected !== null) (sweep) — a redundant guard that exists only to satisfy the checker and that a future reader must decode.

Remedy: return a discriminated result, e.g. { kind: "no-budget" } | { kind: "budget"; projected: number; over: boolean }, or return null for "no budget configured". Both call sites collapse to if (result !== null && result.over), the impossible projected !== null branches disappear, and the "unbounded" case becomes explicit rather than a triple-nullable convention.

3. SweepPreview makes required governance signals optional — apps/server/src/sweep/reconcile.ts:22-25

lastActivityMs, ttlMs, and idleMs are declared optional while createdAtMs (the same class of field) is required, and live-ports.ts:86-88 always populates all three. planGovernanceExpiry then immediately defends against the impossible with preview.lastActivityMs ?? null, preview.ttlMs ?? null, preview.idleMs ?? null. Optionality here weakens a real contract: a future SweepPorts implementation that forgets a field would silently disable expiry instead of failing to compile.

Remedy: make the three fields number | null (required), matching createdAtMs. Delete the ?? null coalescing in planGovernanceExpiry, and the test fixture helper (governance.test.ts:8-20) already defaults them, so this is cheap.

4. Cap admission is read-then-insert without serialization and the caps are untested — apps/server/src/preview/admission.ts:24-101, called from apps/server/src/http/deploy.ts:616

checkDeployAdmission loads all live rows, decides, and returns; the insert happens later under the per-preview lock, never a shared lock. Two concurrent deploys for two new (repo, prId) targets can both observe count == cap - 1 and both insert, exceeding SPROUT_MAX_PREVIEWS/SPROUT_MAX_PREVIEWS_PER_REPO/the connection budget — in a feature whose entire purpose is bounded resource use. Separately, neither the per-repo cap nor the total cap has a single test: preview_limit_reached appears nowhere under test, while the connection-budget branch is covered.

Remedy: either serialize admission+intent under one admission lock (or perform the check atomically in the insert path), or state explicitly in the code/docs that caps are advisory under concurrent submits and accept the overshoot. Either way add tests for preview_limit_reached (per-repo and total) alongside the existing connection-budget test so the wiring into mapResult/429 is pinned.

5. computeExpiresAtMs is a second copy of arithmetic already inside resolvePreviewExpiry — packages/preview-env/src/governance.ts:165-204

resolvePreviewExpiry computes ttlDeadline/idleDeadline at lines 190-191 and then calls computeExpiresAtMs, which recomputes the same two deadlines and Math.mins them. The exported helper is consumed only by resolvePreviewExpiry and its own unit test — no production caller. That is a redundant public surface that will drift from the bound-selection logic it sits beside.

Remedy: either derive the bound directly from the two deadlines and drop computeExpiresAtMs (deleting its export, its test, and the duplicated deadlines array), or have resolvePreviewExpiry be a thin wrapper that compares ttlDeadline/idleDeadline to pick bound without calling back into the helper. One function should own this arithmetic.

Residual risks

  • Concurrent cap overrun (TOCTOU). Probe: fire two simultaneous POST /v1/deploy for two new PRs when the live count is exactly SPROUT_MAX_PREVIEWS - 1; assert exactly one 202 and one 429.
  • Legacy ttlHours deletes rows the API says never expire. Probe: seed a provisioning row ~73h old with null ttl_ms/idle_ms, run a sweep pass, and confirm it logs deleted (sweep:ttl-expired) while GET /v1/previews showed expires_at: null.
  • Config changes don't reach live rows. ttl_ms/idle_ms are frozen at deploy completion, so changing SPROUT_PREVIEW_TTL has no effect on existing previews until their next deploy. Probe: flip the env value, restart, and read expires_at for an untouched running preview.
  • Idle means "no deploy", not "no traffic". last_activity_at only advances in closeRunning, so a preview serving traffic but not redeployed is evicted. Probe: hold a preview past SPROUT_PREVIEW_IDLE_TEARDOWN while issuing HTTP requests and confirm the idle sweep still removes it.
  • Duration overflow / boundary values at boot. parseDurationMs guards with Number.isSafeInteger, but very large-but-safe amounts multiply then re-check; probe boot with SPROUT_PREVIEW_TTL=999999999999d and confirm a clean fail-fast rather than a silent wrap.

Unverifiable from here

HEAD moved off the brief's 47c0b4d to 63bf22b, and this checkout has no bun and no node_modules, so I could not run bun test or bun run typecheck, nor confirm the branch's relationship to the current origin/main tip (the three-dot diff includes commits #241/#269 that also appear to exist on main under other SHAs).

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 8) — REQUESTCHANGES · 47c0b4d0

  • Findings
    1. BLOCKER — commit be1dc7b truncated config.ts, deleting the gateway config loader
    1. BLOCKER — committed syntax error in app.ts
    1. Structural — previewLockCounts is a second source of truth that must mirror previewLocks
    1. Boundary — gateway operational policy was put in the shared wire-grammar package
    1. Type boundary — resolveGovernanceMs re-parses and throws on already-validated input
    1. Type boundary — optional sweep fields hide a required invariant
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

Reviewed git diff origin/main...HEAD at HEAD a6c7af3. The brief expected 47c0b4d; the branch advanced by three address review feedback on #265 commits (be1dc7b, be1dc7b-family, a6c7af3), so this reviews the current HEAD. Note also that apps/server/src/http/app.ts has an uncommitted working-tree edit fixing the syntax error below; the committed range still contains it.

Findings

1. BLOCKER — commit be1dc7b truncated config.ts, deleting the gateway config loader

apps/server/src/config.ts:157 is now } & GovernanceConfig; — 543 lines (from the prior HEAD 47c0b4d) were deleted, including loadConfig, configSummary, governanceUnboundedWarning, parsePostgresConfig, parseMailConfig, parsePreviewAuthConfig, parseExtraGitlabHosts, missingPostgresEnv, isPostgresConfigured, and the three *NotConfiguredDetail helpers. Nothing in the diff replaces them (no new file; git diff --name-status shows no config/*), so the branch cannot typecheck:

  • apps/server/src/index.ts:3-7 imports loadConfig, configSummary, governanceUnboundedWarning — none exist; used at :20-22.
  • apps/server/src/http/deploy.ts:43-45 imports postgresNotConfiguredDetail, mailNotConfiguredDetail, previewAuthNotConfiguredDetail — none exist; used at :207,230,449.
  • apps/server/src/config.test.ts:2-15 imports them too.
  • config.ts:6-10 even keeps parseGatewayCap/parseGatewayDurationMs imports that can no longer be used — dead imports that confirm this was a truncated edit, not a deliberate move.

This is the opposite of a code-judo simplification: a load-bearing module was deleted to make room for governance wiring. Remedy: restore config.ts to its 47c0b4d content and layer the governance change as the small delta it should be — add ...GOVERNANCE_ENV_KEYS to GATEWAY_ENV_DOC_KEYS, compose GovernanceConfig into the Config type, and parse the six new env vars inside loadConfig with the already-imported parseGateway* helpers. Do not delete the loader. If the intent was a decomposition, split config.ts into a config/ module in a separate, complete change with imports updated — not by emptying the file.

2. BLOCKER — committed syntax error in app.ts

apps/server/src/http/app.ts:27 in HEAD reads < dashboard: deps.config.dashboard, — a stray < that is a parse error. The working tree already contains the one-line removal as an unstaged edit, but the commit range under review does not. Remedy: stage/commit the existing fix so git diff origin/main...HEAD no longer contains the <.

3. Structural — previewLockCounts is a second source of truth that must mirror previewLocks

apps/server/src/preview/locks.ts:3,35-42,60-62: withPreviewLock now maintains a parallel refcount map purely so tryWithPreviewLock can answer "busy?", and the release logic (?? 1, .then(release, release)) is easy to drift from the lock map. Two maps that must agree is exactly the kind of incidental complexity the "try lock" feature should not introduce. Remedy: encapsulate both maps behind one small keyed-lock helper that exposes withLock/tryWithLock/isBusy, so no caller touches counters. (A naive self-cleaning Set is not a safe substitute — the cleanup microtask can delete a key a new waiter just added — so keep the count, but own it in one place.) This hides the mechanism and deletes the drift risk.

4. Boundary — gateway operational policy was put in the shared wire-grammar package

packages/preview-env/src/governance.ts:211-264: connectionProjection, connectionBudgetDetail, governanceStatus, and previewLimitDetail are deploy-admission/sweep policy used only by apps/server (admission.ts, sweep/reconcile.ts). @sprout/preview-env is documented as preview wire grammar shared with the CLI, and the CLI never calls these. Remedy: keep the duration/manifest grammar (parsePreviewGovernanceField, parseGatewayDurationMs, parseGatewayCap, governanceIssueMessage, the Governance* types) in preview-env, and move the four policy functions into apps/server/src/preview/governance.ts (or fold them into admission.ts, their only consumer). That also keeps packages/preview-env/src/governance.ts from becoming the dumping ground for server-side policy.

5. Type boundary — resolveGovernanceMs re-parses and throws on already-validated input

packages/preview-env/src/governance.ts:138-150 parses the manifest string a second time and throws on failure, even though every caller has already run parsePreviewGovernanceField (which rejects invalid values). The throw is unreachable and the double-parse muddies the contract. Remedy: have the manifest carry the parsed result (ttl/idle_teardown as a small discriminated shape or resolved ms) so resolveGovernanceMs is pure arithmetic with no parse and no throw.

6. Type boundary — optional sweep fields hide a required invariant

apps/server/src/sweep/reconcile.ts:23-25: lastActivityMs?, ttlMs?, idleMs? are optional while lastActivityAt is required and createLiveSweepPorts always populates all of them (live-ports.ts:85-88). planGovernanceExpiry (:283-290) then sprinkles ?? null. Remedy: make them number | null/string | null required so fixtures and callers state the invariant instead of relying on optional fallbacks.

Residual risks

  • Idle teardown kills a quiet-but-busy preview. The only activity signal is the last successful deploy, so a preview with steady HTTP traffic but no push expires at idle_teardown. Probe: count deleted (sweep:idle-expired) events and correlate with gateway request logs for that hostname.
  • Connection budget under-counts app pools that ignore the injected cap. Projection is (active + 1) × SPROUT_PREVIEW_MAX_DB_CONNECTIONS; the docs concede a repo with larger pools bypasses it. Probe: alert when Postgres rejects a connection with SQLSTATE 53300 despite the budget check passing.
  • Existing rows have ttl_ms = NULL after migration (migration.sql adds nullable columns), so turning on SPROUT_PREVIEW_TTL does not bound pre-existing previews until their next deploy. Probe: SELECT count(*) FROM previews WHERE status!='removed' AND ttl_ms IS NULL while the TTL env is set.
  • Try-lock expiry can starve under a hot deploy loop. tryRemovePreview returns "stale" whenever the lock is held, so a preview that is continuously redeployed never gets swept. Probe: alert when a preview remains past expires_at for more than two sweep intervals.
  • Stale-plan guard is exact string equality on last_activity_at. Any writer that updates the column outside closeRunning would silently block all sweeps for that preview. Probe: log skips where the planned lastActivityAt differs from the current row.

Unverifiable from here

Cannot run bun test/bun run typecheck (no bun and no node_modules in this checkout), so I could not empirically confirm the build break or exercise the Docker/Postgres integration paths; the failures in findings 1–2 are established by static reading of committed HEAD.

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 9) — REQUESTCHANGES · 47c0b4d0

  • Findings
    1. Committed HEAD does not build — the rebase dropped config.ts's entire body (blocker)
    1. Current worktree also fails to parse — truncated test file (blocker)
    1. The policy boundary move is half-done — preview-env still owns server-only resolution/parsing
    1. Two parallel violation→message mappings, and connectionBudgetDetail hides the +1 it disclaims
    1. kind / issue.code is parse machinery no production code reads
    1. legacyTtlMs is a config constant threaded as a bare primitive through six signatures
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

Scope note: HEAD has moved from the brief's 47c0b4d to a6c7af3 (rebased onto origin/main; 47c0b4d is no longer an ancestor). The worktree also carries a large uncommitted rewrite that was still being written while I reviewed. Neither revision compiles, so this is a gate failure first; the structural findings below are on the effective worktree diff (git diff origin/main → worktree), which is the newest coherent feature content.

Findings

1. Committed HEAD does not build — the rebase dropped config.ts's entire body (blocker)

apps/server/src/config.ts (HEAD a6c7af3), consumed at apps/server/src/index.ts:4.
git show HEAD:apps/server/src/config.ts ends at } & GovernanceConfig; — no loadConfig, configSummary, missingPostgresEnv, parseExtraGitlabHosts, or resolveTelemetryState exists anywhere in HEAD:apps/server/src. Yet index.ts imports loadConfig/configSummary and calls both. git diff 47c0b4d..HEAD shows 831 deletions across 21 files, all from the rebase. The worktree has an uncommitted 541-line restoration of this file.
Remedy: commit the worktree restoration (or redo the rebase resolution), then require bun run typecheck as a merge gate. Do not review/merge a revision whose config.ts is missing loadConfig.

2. Current worktree also fails to parse — truncated test file (blocker)

apps/server/src/preview/runtime.mail.test.ts:281.
The file ends mid-object at postgresMaxConnections: null, with no closing braces; ./node_modules/.bin/tsc -p apps/server/tsconfig.build.json --noEmit fails with TS1005: '}' expected. This is an unfinished edit, not a design decision.
Remedy: finish the edit (restore the }); expect(bare.previewAuth).toBeUndefined(); }); }); tail). Re-run the full build before requesting review again.

3. The policy boundary move is half-done — preview-env still owns server-only resolution/parsing

packages/preview-env/src/governance.ts:119 (parseGatewayDurationMs), :134 (parseGatewayCap), :150 (resolveEffectiveGovernanceMs), :171 (resolvePreviewExpiry).
The worktree correctly moved the evaluators (evaluateGovernance, governanceStatus, detail strings) out of preview-env into apps/server/src/preview/governance.ts, because they are server-only — but left the resolvers and gateway-env parsers behind. Grep confirms every one of these is called only from apps/server (admission.ts:84, snapshot.ts:46, reconcile.ts:279, config.ts:541-561); none is used by apps/cli. So the package now straddles the seam it was in the middle of crossing: evaluation lives in the server, resolution/parsing of the same policy lives in the shared package.
Remedy: pick one line and hold it. Keep in preview-env only the shared wire grammar + types actually consumed across packages (GovernanceConfig, GovernanceManifest, GovernanceFieldValue, EffectiveGovernanceMs, parsePreviewGovernanceField, governanceIssueMessage); move resolveEffectiveGovernanceMs, resolvePreviewExpiry, parseGatewayDurationMs, parseGatewayCap beside their only callers (server preview governance / server config). Splitting "some of the policy is shared, some isn't" by function is the worst of both.

4. Two parallel violation→message mappings, and connectionBudgetDetail hides the +1 it disclaims

apps/server/src/preview/governance.ts:8-12,49-59 vs apps/server/src/sweep/reconcile.ts:290-311.
The module doc states "[callers] project it from connectionProjection with an explicit count, so there is no hidden +1 convention", but connectionBudgetDetail hardcodes ... active previews + 1 x perPreview — admission's candidate semantics baked into a nominally shared formatter. Meanwhile logOverCap re-implements the same three-branch mapping over GovernanceViolation with different wording.
Remedy: make evaluateGovernance/GovernanceViolation the single owner of presentation: one violationMessage(violation): string switch used by both callers, with admission adding its 429 prefix. Delete the second switch. If the texts must differ, put the difference in the violation at construction time, not in two if-chains.

5. kind / issue.code is parse machinery no production code reads

packages/preview-env/src/governance.ts:55-63,77-116; callers apps/server/src/http/deploy.ts:489-514, apps/cli/src/yaml.ts:411-425.
parsePreviewGovernanceField(raw, kind) exists only to stamp GovernanceFieldIssue.code; grep shows the only reader is governance.test.ts:33, while both real callers independently re-derive the field name and error code ("invalid_ttl" / "invalid_idle_teardown", "preview.ttl" / "preview.idle_teardown"). The parameter and the field duplicate knowledge the callers already have.
Remedy: drop kind and code; return { ok: false; raw: string }. Callers choose path/error, as they already do. GovernanceFieldValue.raw is genuinely used (cli/yaml.ts:422) — keep that one.

6. legacyTtlMs is a config constant threaded as a bare primitive through six signatures

apps/server/src/preview/types.ts:126, snapshot.ts:43,58,98,132, http/introspection.ts:56, http/deploy.ts:545,681, plus two inline conversions of the same value at http/routes.ts:69 and sweep/reconcile.ts:180 (ttlHours * 60 * 60 * 1000).
ProvisionInput now carries a number that exists solely so one response can print expires_at. That is a wire-presentation concern leaking into the bring-up domain input.
Remedy: compute it once in loadConfig() (e.g. legacyTtlMs on Config) and pass it via the presentation/deps object that already flows to routes; keep ProvisionInput free of it. At minimum, share one hoursToMs helper instead of two identical inline expressions.

Residual risks

  • Governance bounds are mutable per deploy: bring-up.ts:191-192 overwrites ttlMs/idleMs on every successful deploy, and resolvePreviewExpiry measures both from the same lastActivityAt base, so a redeploy silently redefines an existing preview's deadline. Probe: deploy with preview.ttl=30m, redeploy with 7d, diff expires_at from GET /v1/preview.
  • Expiry can race a pull that has not taken the lock: lifecycle.ts pulls images in pullImagesOutsideLock before withPreviewLock, so a sweep's tryWithPreviewLock sees count 0 and tombstones the row mid-pull. Probe: force a slow registry pull, run the sweep, expect preview_row_missing/404 rather than a resurrected live row.
  • Legacy fallback silently never fires for non-ISO created_at: snapshot.ts:48 / reconcile.ts depend on parseUnambiguousUtcMs; existing fixtures use "2026-09-02 12:00:00". Probe: SELECT count(*) FROM previews WHERE last_activity_at IS NULL AND ttl_ms IS NULL and confirm each created_at parses, else those rows are immortal.
  • Budget counts dead rows: admission.ts:38-48 counts every status != 'removed' row, including failed/removing, so a stuck failed row permanently consumes budget. Probe: leave one failed row and deploy under a tight budget; expect spurious preview_connection_budget_exceeded.
  • Cap check is documented best-effort: two concurrent new deploys can both pass at one remaining slot (admission.ts:26-30). Probe: fire concurrent deploys at maxPreviews - 1 and count live rows.

Unverifiable from here

  • Whether the suite passes or even typechecks end-to-end: bun is not installed in this environment, the worktree was being rewritten concurrently, and a manual tsc run fails on the truncated test in Finding 2 — so the worktree's semantic correctness (including the new runtime.mail.test.ts/config.test.ts fixtures) could not be confirmed. The committed HEAD state is likewise unverifiable because it does not compile.

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 10) — REQUESTCHANGES · 47c0b4d0

  • Findings
    1. Explicit off and "unbounded by default" are both false: null conflates "no bound" with "never governed"
    1. legacyTtlMs still has two owners at the route layer
    1. Test fixture duplication: one policy shape copied ~19 times
    1. Admission re-switches on violation.kind to pick the error code
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

Scope note: The brief expected HEAD 47c0b4d; actual HEAD is 634d9d7 (address review feedback on #265). The worktree also carries six uncommitted files that finish the round-9 fixes (legacyTtlMs moved onto LifecycleDeps, violationMessage moved above evaluateGovernance, formatting). I reviewed the effective worktree state (git diff origin/main), which is the newest coherent content. It typechecks: I ran tsc -p apps/server/tsconfig.build.json --noEmit, tsc -p packages/preview-env/..., and tsc -p apps/cli/... — all exit 0. The round-8/9 blockers (truncated config.ts, < in app.ts, truncated test, split policy boundary, duplicated violation switch) are resolved. bun is absent so I could not execute the suite.

Findings

1. Explicit off and "unbounded by default" are both false: null conflates "no bound" with "never governed"

apps/server/src/preview/governance.ts:86-91 (resolvePreviewExpiry legacy branch), comment at :58-65; apps/server/src/config.ts:576-590; docs/previews.md:57; docs/operator-deploy.md:591.

A row that explicitly sets ttl: off / idle_teardown: off writes ttlMs = null, idleMs = null in closeRunning (bring-up.ts:191-192). resolvePreviewExpiry then cannot tell that row apart from one that never received governance columns, so it falls through to createdAtMs + legacyTtlMs. With the shipped default SPROUT_TTL_HOURS=72, every preview deployed with governance off still expires 72h after creation. That directly contradicts the function's own comment ("applies only when … rows that never received governance columns"), governanceUnboundedWarning's "previews live until PR close", and both docs ("off at the effective level disables", "unbounded apart from PR-close teardown"). The existing tests hide it: governance.test.ts:204-212 passes legacyTtlMs: null for the off case, and http/preview-lifecycle.test.ts:171-176 asserts the legacy bound applies with governance off — i.e. the suite encodes the opposite of the docs.

Remedy (model fix, not a wording fix): give "was this row governed?" its own signal so off is distinguishable from absent. The cheapest is to gate the legacy fallback on the same write that records governance — lastActivityAt === null is set exactly when the bounds are written, today only in closeRunning — or add an explicit governed_at column. Then off truly yields no deadline, and governanceUnboundedWarning/docs become true. Whichever is chosen, update governance.test.ts:204 to exercise lastActivityAt set + both bounds null + a non-null legacyTtlMs, since that is the production shape.

2. legacyTtlMs still has two owners at the route layer

apps/server/src/http/deploy.ts:709-712,730; apps/server/src/http/routes.ts:66-73,131.

The uncommitted move put legacyTtlMs on LifecycleDeps (types.ts:65), but getPreview still takes it as a separate parameter, and routes.ts re-spreads it into deployDeps (:71) only to re-alias it (:73). So the value reaches the same handler through two paths; a future change to how the bound is sourced can update one and silently not the other. This is the leftover half of the round-9 Finding 6.
Remedy: have getPreview read deps.legacyTtlMs and drop the parameter; then delete the duplicate legacyTtlMs: in deployDeps and the const legacyTtlMs = deployDeps.legacyTtlMs alias (routes.ts:71,73). Keep the parameter only for listPreviews, which receives a bare db.

3. Test fixture duplication: one policy shape copied ~19 times

apps/server/src/sweep/live-ports.test.ts:14 (local offGovernance), :95 etc.; apps/server/src/preview/governance.test.ts:18; apps/server/src/preview/admission.test.ts:9; apps/server/src/http/preview-governance.test.ts:36-43; apps/server/src/config.test.ts:390,429,486; apps/server/src/preview/runtime.mail.test.ts:242,271.

previewTtlMs: null appears 19 times and legacyTtlMs: 72 * 3600_000 13 times across 11 files; every one of the six GovernanceConfig fields (and legacyTtlMs on Config) has to be added to each literal whenever the contract grows. The diff's broad test churn is this mechanical fan-out, not behavior coverage. live-ports.test.ts already invented a local offGovernance, which shows the helper is needed but not shared.
Remedy: export one offGovernance / governance(overrides: Partial<GovernanceConfig>) fixture next to createTestApp (or in preview/governance.test-helpers.ts) and one baseConfig(overrides) factory, then delete the literals. That removes roughly 30 lines and the next schema/policy field becomes a one-file change.

4. Admission re-switches on violation.kind to pick the error code

apps/server/src/preview/admission.ts:50-66.

evaluateGovernance already owns violation identity and violationMessage owns wording, but the 429 error code is re-derived by an if (kind === "connection-budget") in the caller. Adding a kind means editing two places.
Remedy: carry the wire code on the violation (e.g. code: "preview_limit_reached" | "preview_connection_budget_exceeded") and let admission emit { status: 429, error: violation.code, detail: violationMessage(violation) } — one branch disappears, and the mapping lives with the type that defines it.

Residual risks

  • Gateway TTL changes never reach already-deployed previews. The gateway bound is resolved at admission and baked into the row; the sweep consults only the row's stored ttl_ms/idle_ms (reconcile.ts:282-288), never ports.governance.previewTtlMs. Probe: set SPROUT_PREVIEW_TTL=2h on a running gateway and confirm a preview deployed before the change keeps its old expires_at.
  • The 202 accept body advertises the legacy deadline, not the resolved one. deploy.ts:701-705 presents the claimed row, which has no governance columns yet, so a fresh deploy with gateway TTL 7d returns expires_at = created + 72h until the settled read corrects it. Probe: compare expires_at in the 202 body with GET /v1/preview after settle.
  • Budget counts dead rows. admission.ts:37-48 counts every non-removed row, including failed/removing, so one stuck failed row consumes budget forever. Probe: leave one failed row and deploy under a tight SPROUT_MAX_PREVIEWS; expect a spurious 429.
  • Try-lock expiry can starve a continuously redeployed preview. tryRemovePreview returns "stale" whenever the lock is held, and dropSettled silently skips it. Probe: alert when a preview is past expires_at for more than two sweep intervals.
  • A writer that touches last_activity_at outside closeRunning blocks all sweeps for that row, because removePreviewUnlocked compares last_activity_at by exact string. Probe: log skips where the planned and current lastActivityAt differ.

Unverifiable from here

  • Whether bun test passes and the Docker/Postgres/forge integration paths behave: bun is not installed in this environment, so I could only confirm the three packages typecheck under tsc --noEmit.

@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 11) — REQUESTCHANGES · 47c0b4d0

  • Findings
    1. legacyTtlMs is speculatively number | null in every consumer, but its only production source is always a number
    1. The list edge re-derives expires_at / last_activity_at / expiry_reason instead of reusing the snapshot it just computed
    1. evaluateGovernance duplicates the per-repo violation construction across the pending/settled branches (and carries a redundant budget guard)
    1. The governance policy is smuggled into Config by intersection and then threaded as two parallel fields
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

Note: HEAD is d733427 ("address review feedback on #265"), not the briefed 47c0b4d. I reviewed the current HEAD as instructed.

The governance decomposition itself is good: a shared GovernanceManifest/GovernanceConfig contract in preview-env, one evaluator shared by admission and sweep, one keyed-lock primitive backing both blocking and non-blocking variants, and resolvePreviewExpiry as the single derivation for the read surface and sweep. No file crosses 1000 lines. The findings below are about residual incidental complexity that the current shape preserves when a smaller model is available.

Findings

1. legacyTtlMs is speculatively number | null in every consumer, but its only production source is always a number

apps/server/src/preview/governance.ts:67-96, apps/server/src/preview/types.ts:65, apps/server/src/http/routes.ts:46, apps/server/src/sweep/reconcile.ts:56, apps/server/src/preview/snapshot.ts:44,59,99,133, apps/server/src/sweep/live-ports.ts:28.

Config.legacyTtlMs is number (config.ts:152), derived from a defaulted positive int (SPROUT_TTL_HOURS, never off). Every production call site threads a number. That means the legacyTtlMs !== null branch in resolvePreviewExpiry (governance.ts:89) is unreachable in production, and the null propagates into eight signatures plus the null arms of planGovernanceExpiry/expiresAtForRow. The only consumer of the null case is governance.test.ts, i.e. the code is shaped to satisfy a test rather than a real boundary.

Remedy: pick one. Either make the legacy bound genuinely optional (let SPROUT_TTL_HOURS=off parse to null, so the null branch means something), or type legacyTtlMs: number through LifecycleDeps/RouteDeps/SweepPorts and delete the null arm from resolvePreviewExpiry. The current middle ground is exactly the "unnecessary optionality obscuring the real invariant" the review bar calls out.

2. The list edge re-derives expires_at / last_activity_at / expiry_reason instead of reusing the snapshot it just computed

apps/server/src/preview/snapshot.ts:144-146.

presentListedPreview calls presentPreviewSnapshot at line 135 (which already sets all three fields via previewSnapshotFromRow), then throws those away and recomputes all three from row in the returned literal. This directly violates the "one derivation" invariant the new comment at snapshot.ts:32-38 claims, and it is the kind of copy-paste drift that will diverge the moment one path changes (e.g. tombstone handling).

Remedy: use the snapshot values — last_activity_at: snap.last_activity_at, expires_at: snap.expires_at, expiry_reason: snap.expiry_reason — and drop the expiresAtForRow call from the list presenter. The "no spreads" note only forbids leaking preview_url/last_error, not reusing explicit fields.

3. evaluateGovernance duplicates the per-repo violation construction across the pending/settled branches (and carries a redundant budget guard)

apps/server/src/preview/governance.ts:204-260.

The if (pending !== null) { ... } else { for (const [repo, count] of status.byRepo) ... } pair emits the identical violation shape twice with the same incoming adjustment; only the candidate set differs. This is avoidable branching in the one policy function both admission and the sweep depend on. The same function also guards perPreview !== null && ceiling !== null at line 244 before calling connectionProjection, which already returns null for exactly that condition (lines 133-135) — a second copy of one invariant.

Remedy: build the candidate list once and run one loop:

const candidates: [string, number][] =
  pending !== null
    ? [[pending.repo, status.byRepo.get(pending.repo) ?? 0]]
    : [...status.byRepo];
for (const [repo, count] of candidates) {
  if (count + incoming > perRepo) { /* push per-repo violation */ }
}

With incoming = 0 for the sweep this reproduces count > cap; with incoming = 1 for the candidate it reproduces count + 1 > cap, both reporting the existing count. And collapse the budget block to const budget = connectionProjection(...); if (budget?.over) { ... }.

4. The governance policy is smuggled into Config by intersection and then threaded as two parallel fields

apps/server/src/config.ts:161 (} & GovernanceConfig), apps/server/src/http/app.ts:30-31, apps/server/src/http/routes.ts:43-46, apps/server/src/sweep/live-ports.ts:28-29.

Config being structurally a GovernanceConfig makes governance: deps.config at app.ts:30 compile by accident, and it is why every Config literal in tests now hand-writes six extra fields (telemetry/payload.test.ts, preview/runtime.mail.test.ts, config.test.ts). Separately, legacyTtlMs travels as a second scalar next to governance through RouteDeps → LifecycleDeps, RouteDeps → SweepPorts → LiveSweepDeps, even though it is part of the same expiry policy.

Remedy: give Config an explicit nested governance: GovernanceConfig field and make it the single policy object the dep slices carry (app.ts then passes deps.config.governance, no second legacyTtlMs unless it is folded into that object). Tests collapse to governance: governanceConfig(), and the read/sweep/admission layers stop each declaring their own view of the same policy. LiveSweepDeps.governance: SweepPorts["governance"] (live-ports.ts:29) should then just be GovernanceConfig, removing the backward indexed-access coupling to reconcile.ts.

Residual risks

  1. Admission is best-effort and can overshoot the cap (documented at admission.ts:25-29): two concurrent new-target deploys can both pass when one slot remains. Probe: fire two /v1/deploy requests at maxPreviews - 1 and assert the live row count afterwards, or log a warning when a post-insert count exceeds the cap.
  2. Sweep vs redeploy handoff window: acceptAsyncDeploy releases the preview lock before runAsyncDeploy re-acquires it via provisionPreview, and writeProvisioningIntent does not refresh lastActivityAt, so a plan built before the redeploy still matches the new staleness check if tryRemovePreview lands in that gap. Probe: a warning when tryRemovePreview returns true for a row whose status is provisioning, plus a race test that starts a deploy during a sweep pass.
  3. Legacy-bound expectation shift: a row that completed a governed deploy with bounds off is unbounded even though SPROUT_TTL_HOURS=72 is set, which is a behavior change from the old creation-age sweep. Probe: for a governed running row, check expires_at is null and survives past 72h in GET /v1/previews.
  4. Timestamp fallback masquerades as "no deadline": unparseable lastActivityAt/createdAt silently fall back (snapshot.ts:48-49, live-ports.ts:71-86). Probe: alert when a row has ttl_ms set but expires_at null, and watch the existing sweep preview invalid createdAt log line.
  5. Connection projection assumes the app honours the injected pool cap (docs/operator-deploy.md:608-614). Probe: compare projected previews × perPreview against pg_stat_activity and alert on SQLSTATE 53300.

Unverifiable from here

End-to-end behavior against real Postgres/Docker/Traefik and the concurrency races in admission/sweep could not be exercised — bun is not installed in this checkout, so the new tests could not be run and only static reading was possible.

Require closeRunning input so the close bring-up path refreshes
governance instead of wiping it; derive sweep expiry from activity
plus stored durations and drop the stored-deadline branch; extract
preview/admission as the single deploy admission gate over one row
load; share connectionProjection/governanceStatus between deploy
and sweep; remove the dead ProvisionInput.governance field; dedupe
removePreview/tryRemovePreview and type the try-lock honestly;
unify on one required-field GovernanceConfig from preview-env.
Round-3 findings plus the remaining round-2 cleanups:

- Single connection-budget owner: drop connections from
  GovernanceStatus; admission and sweep project through
  connectionProjection. Refresh deploys are exempt from caps and
  budget alike, fixing the off-by-one 429 detail.
- Delete the stored expires_at column: last_activity_at plus
  ttl_ms/idle_ms is the single source, derived at the read surface
  (snapshot expiresAtForRow) and in planGovernanceExpiry via
  computeExpiresAtMs. Squash the two same-feature migrations.
- Tombstone policy is explicit: PreviewExpiryReason types the removal
  cause; destroyPreviewRow always writes the same tombstone shape and
  keeps the seed watermark for writeProvisioningIntent to reset.
- One EffectiveGovernanceMs in preview-env replaces DeployAdmission
  and the redeclared shapes; ProvisionInput.governanceMs is required.
- GovernanceConfig is required end to end; Config composes it instead
  of re-declaring the fields; single canonical import path.
Round-4 findings: thread last-activity watermark into the removal
contract so a refreshed preview is a stale plan; fold expiry base,
min-deadline and tie-break into preview-env resolvePreviewExpiry used
by both the read surface and the sweep planner; make
connectionProjection pure previews * perPreview with the +1 at the
admission call site; delete dead SweepReason; clear expiry_reason on
re-provision intent and accept; move the governed-ttl HTTP test into a
focused preview-governance suite and assert full response shapes.
Restore config.ts loader truncated by the rebase; fix app.ts syntax.
Move server-only governance resolution out of @sprout/preview-env
(parseGateway*, resolveEffectiveGovernanceMs, resolvePreviewExpiry)
into apps/server preview/governance; preview-env keeps wire grammar
plus the shared duration parser. Single violationMessage presenter
for admission 429s and sweep logs; budget counts counted previews.
Drop unused parse kind/issue codes. Compute legacyTtlMs once in
loadConfig and thread it through route/sweep deps; make sweep
governance fields required.
Complete the round-9 boundary move: violationMessage as the single
violation presenter (pure-data violations, unified budget wording),
parseDurationMs shared from preview-env, legacyTtlMs computed once in
loadConfig and carried on LifecycleDeps instead of ProvisionInput.
Round-10 findings:
- legacy creation-age bound now applies only to rows that never
  completed a governed deploy (lastActivityMs null), so explicit
  off (activity recorded with null bounds) is truly unbounded on
  both the read surface and the sweep; docs updated to match
- getPreview reads legacyTtlMs from LifecycleDeps instead of a
  second parameter; routes no longer re-spreads/re-aliases it
- shared governanceConfig/offGovernanceConfig fixture replaces the
  copied six-field GovernanceConfig literals in governance tests
- 429 wire code carried on GovernanceViolation; admission emits
  violation.code with no re-switch on kind
Nest governance policy on Config instead of the intersection, so passing
the whole Config never silently satisfies a GovernanceConfig (app and
sweep pass config.governance explicitly; LiveSweepDeps names
GovernanceConfig instead of indexing SweepPorts).

Type legacyTtlMs as number through LifecycleDeps, RouteDeps, SweepPorts
and the snapshot/sweep derivations: the only production source is
SPROUT_TTL_HOURS, always a number. The null arm leaves
resolvePreviewExpiry with the governed/never-governed gate only.

presentListedPreview reuses the snapshot it just computed instead of
re-deriving expiry fields, keeping the one-derivation invariant.

evaluateGovernance runs one candidate loop for pending/settled per-repo
checks and drops the redundant budget null check.

Share testConfig(overrides) for full Config literals alongside the
existing governanceConfig() fixtures.
@simpros
simpros force-pushed the gh-241-preview-governance-ttl-idle-tear branch from 47c0b4d to c2a599e Compare October 2, 2026 16:18
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 12) — REQUESTCHANGES · c2a599e5

  • Findings
    1. config.ts:479-486 — the diff re-inlines the canonical splitListEntries helper
    1. config.ts:7-11, 159, 600 — config now depends on the preview domain, and carries the same TTL twice
    1. packages/preview-env/src/governance.ts:54 + apps/server/src/preview/deploy.ts:508-514 — GovernanceManifest.raw is dead on the server path
    1. apps/server/src/sweep/reconcile.ts:277-301 / apps/server/src/sweep/live-ports.ts:47-58 — the sweep carries two sources of truth for governance
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

The feature is well-organized for its size (no file crosses 1k lines, no ad-hoc branching bolted into unrelated flows, and the expiry derivation really is shared by read and sweep). The blockers below are small, cheap to fix, and mostly artifacts of the "address review feedback" passes rather than the design itself.

Findings

1. config.ts:479-486 — the diff re-inlines the canonical splitListEntries helper

parseExtraGitlabHosts was changed from for (const entry of splitListEntries(raw)) to a hand-rolled raw.trim() + trimmed.split(",") + per-entry trim/skip that is byte-for-byte the body of splitListEntries (config.ts:254-263). It is behavior-identical (that helper already drops empties and handles the empty string), so this is pure duplication of an existing canonical utility — almost certainly the "Restore config.ts loader truncated by the rebase" hunk in a5e0a83 that resolved a conflict by pasting the helper back inline.
Remedy: delete lines 480-485 and restore for (const entry of splitListEntries(raw)) {. One-line revert; no behavior change.

2. config.ts:7-11, 159, 600 — config now depends on the preview domain, and carries the same TTL twice

Two problems in the same boundary:

  • The gateway env parsers parseGatewayDurationMs / parseGatewayCap / hoursToMs live in preview/governance.ts (governance.ts:8-39), and config.ts — the canonical env loader ("fail fast at config load", per AGENTS.md) — now imports them from the domain module. That inverts the dependency: the foundation layer reaches into preview policy. parseGatewayCap also re-derives the positive-integer validation that parsePositiveInt (config.ts:178-189) already owns.
  • Config now stores both ttlHours (line 159) and legacyTtlMs (line 600) for one env var. ttlHours has no production reader left except configSummary (line 738), so the two representations can silently drift.
    Remedy: move parseGatewayDurationMs / parseGatewayCap / hoursToMs into config.ts (leave the wire grammar parseDurationMs in @sprout/preview-env), sharing the positive-int validation, and keep exactly one TTL field. preview/governance.ts should receive already-parsed values and own only policy (expiry math, cap/budget evaluation).

3. packages/preview-env/src/governance.ts:54 + apps/server/src/preview/deploy.ts:508-514 — GovernanceManifest.raw is dead on the server path

GovernanceManifest stores { raw, ms } per field, but the only server-side reader, resolveEffectiveGovernanceMs (preview/governance.ts:44-55), reads .ms exclusively; grep finds no production read of .raw anywhere (only tests). raw is a CLI-forwarding concern that leaked into the server contract, forcing admission/resolution to thread a shape with an unused arm.
Remedy: give the server manifest the parsed bound only ({ ttlMs?: number | null; idleMs?: number | null }), constructed in resolveGovernanceRequest from parsePreviewGovernanceField(...).value?.ms. Drops a level of nesting and makes "off vs inherited" explicit as a nullable number.

4. apps/server/src/sweep/reconcile.ts:277-301 / apps/server/src/sweep/live-ports.ts:47-58 — the sweep carries two sources of truth for governance

Expiry uses the row's stored ttlMs/idleMs (reconcile.ts:282-288), while ports.governance is passed only into logOverCap, which re-filters and re-counts every preview (reconcile.ts:297) immediately after the expiry loop already walked the same list. Nothing states that stored row bounds intentionally win over live config, so a reader can't tell whether ports.governance driving expiry is a bug or a decision. Separately, removeControlPlane(deps, deletion, useTryLock = false) (live-ports.ts:47) is a boolean-mode parameter on an existing helper.
Remedy: fold the over-cap status into the existing expiry loop (one governanceStatus pass) and add one comment on planGovernanceExpiry stating that the effective bound is snapshotted per row at deploy time and live gateway config intentionally does not retroactively re-bound running previews — or make the reverse true. Give the lock choice two named call sites instead of the default-false flag.

Residual risks

  1. Cap overshoot under concurrency — admission.ts:26-31 admits the check is best-effort; two simultaneous new deploys can pass with one slot left. Probe: fire N concurrent POST /v1/deploy for new PRs at the cap and assert SELECT count(*) FROM previews WHERE status != 'removed' never exceeds the cap.
  2. Budget over-counts none/sqlite previews — connectionProjection multiplies all live previews by previewMaxDbConnections (governance.ts:124-132), but previews with db.provider: none|sqlite hold no Postgres connections. Probe: deploy a db.provider: none preview at the projected ceiling and check whether it is rejected with preview_connection_budget_exceeded.
  3. Stored bounds mean a config change needs a redeploy — enabling SPROUT_PREVIEW_TTL after previews exist won't bound them until each redeploys (stored ttl_ms wins). Probe: set the env, restart, and inspect expires_at on GET /v1/previews for an already-running preview.
  4. lastActivityAt === null gate sends never-completed rows to the legacy bound — a deploy that fails before closeRunning keeps null activity/bounds, so createdAt + SPROUT_TTL_HOURS (not the new TTL) governs it. Probe: force a bring-up failure, advance past SPROUT_TTL_HOURS, and verify the sweep reason is sweep:ttl-expired rather than the new idle/TTL bound.
  5. 202 deploy body can show a stale expires_at on refresh — patchAccept (lifecycle.ts:220-239) does not touch lastActivityAt/ttlMs, so the immediate response for a redeploy derives from the prior generation until the async deploy settles. Probe: redeploy a running preview and compare expires_at in the 202 body against the settled GET /v1/preview.

Unverifiable from here

No bun binary in this checkout, so bun test/bun run typecheck could not be executed; compile-correctness, the concurrency claims above (in-process lock timing vs. SQLite writes), and real Postgres connection accounting against max_connections are all unverified.

Round-12 leftovers: gateway env parsers move into config.ts (foundation
no longer imports the preview domain; caps share parsePositiveInt),
parseExtraGitlabHosts reuses splitListEntries, GovernanceManifest drops
the server-dead raw arm to parsed ttlMs/idleMs, sweep names its lock
choice with two functions and documents snapshot-at-deploy bounds,
evaluateGovernance collapses the budget guard. Parser tests move to
config.test.ts with loadConfig governance coverage.
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 13) — REQUESTCHANGES · 6cb10399

    1. The connection-budget invariant is modelled as two independent nullables, so a half-configured budget silently enforces nothing (blocking)
    1. Two call sites parse the same row instants, and SweepPreview carries both the raw and parsed twins
    1. The new CLI YAML keys ship with no CLI-level tests
    1. Dead wire field
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: REQUEST CHANGES

1. The connection-budget invariant is modelled as two independent nullables, so a half-configured budget silently enforces nothing (blocking)

packages/preview-env/src/governance.ts:37-40, apps/server/src/preview/governance.ts:88-96,193-211, apps/server/src/config.ts:666-673.

GovernanceConfig carries previewMaxDbConnections and postgresMaxConnections as separate number | nulls. connectionProjection returns null unless both are set, and loadConfig parses each env independently with no cross-check, so SPROUT_PREVIEW_MAX_DB_CONNECTIONS=12 (or SPROUT_POSTGRES_MAX_CONNECTIONS=100 alone) boots clean and enforces nothing. That is a silent fallback papering over the real invariant — "the budget is a (perPreview, ceiling) pair or absent" — and it is why evaluateGovernance must re-narrow both fields at lines 202-208 after connectionProjection already proved them non-null (the re-narrow is a symptom, not a fix). Remedy: model it as connectionBudget: { perPreview: number; ceiling: number } | null, parsed together at the config boundary (throw at load when exactly one env is present, per the repo's fail-fast rule), and have connectionProjection take that object. The null guard, the double-null branch, and the redundant narrowing all disappear, and "configured but incomplete" becomes unrepresentable.

2. Two call sites parse the same row instants, and SweepPreview carries both the raw and parsed twins

apps/server/src/preview/snapshot.ts:47-53, apps/server/src/sweep/live-ports.ts:90-108, apps/server/src/sweep/reconcile.ts:15-27.

resolvePreviewExpiry is the right shared core, but both fingers of the policy duplicate the parse-and-shape call: expiresAtForRow parses lastActivityAt/createdAt, and listPreviews parses them again into lastActivityMs/createdAtMs, leaving SweepPreview carrying two representations of both instants. Remedy: push the parseUnambiguousUtcMs calls into resolvePreviewExpiry (or add one resolveRowExpiry(row, legacyTtlMs) factory in governance.ts) so the mapping exists once; keep createdAtMs only if the invalid-createdAt log still needs it. While there, expiresAtForRow (snapshot.ts:39) is exported but has no caller outside its own module — drop the export.

3. The new CLI YAML keys ship with no CLI-level tests

apps/cli/src/yaml.ts:411-419,572-577, apps/cli/src/commands/deploy-core.ts:328-331.

preview.labels and preview.volumes each have parse and forward tests in yaml.test.ts; preview.ttl/preview.idle_teardown have none, even though the CLI is the only route by which a repo can set them. The off/duration normalization, the malformed-input error path (governanceIssueMessage at preview.ttl/preview.idle_teardown), and the body.ttl/body.idle_teardown forwarding are all uncovered. Remedy: mirror the volumes tests (valid, off, malformed, absent) plus one buildDeployRequest assertion that both fields forward.

4. Dead wire field

apps/cli/src/commands/deploy-outcome.ts:11.

last_activity_at?: string | null is added to DeploySnapshotFields but is never read anywhere; only expires_at is consumed. Remedy: drop it, or surface it in printSettled if it was intended to be shown.

Residual risks

  • Half-configured connection budget silently enforces nothing — probe: boot with only SPROUT_PREVIEW_MAX_DB_CONNECTIONS=12, deploy past the expected ceiling, and confirm no 429 / no budget log appears.
  • The expiry base depends on lastActivityAt being written by every successful bring-up path; a path that reaches running without closeRunning would let the legacy SPROUT_TTL_HOURS bound expire a live preview. Probe: for each bring-up plan (full_replace/sync_close/close), assert last_activity_at is non-null on a running GET /v1/previews.
  • tryWithPreviewLock is in-process only, so sweep can skip an expired preview mid-deploy and only retry on the next cron tick (default 30m). Probe: count TTL/idle drops that return removed === false and compare against how stale last_activity_at was.
  • Admission and the try-lock are per-process; a multi-replica gateway would let caps overshoot and expire a preview mid-deploy. Probe: confirm single-replica deployment, or add a DB-level guard before scaling out.
  • expires_at is computed from parsed ms and trusts the stored instant; a clock-skewed / future last_activity_at defers expiry indefinitely. Probe: log a warning when last_activity_at > now at deploy completion.

Unverifiable from here

Real per-preview Postgres connection counts (~12) and migration behaviour on a live Postgres, the multi-replica topology, and a live forge to confirm the - Expires: note renders; I ran drizzle-kit generate only in a scratch copy against the real schema (it reports no schema changes — the new migration's snapshot is a non-linear sibling of #268's loud_mimic, and generate correctly merges both prevIds).

Model the connection budget as one pair object with fail-fast boot
on a half-configured pair; share one row-expiry derivation between
the read surface and the sweep; add CLI governance tests
@simpros

simpros commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

🧊 Thermo-nuclear review (round 14) — APPROVE · eea6d3c7

  • Findings
    1. The upgrade contract is silently weakened and docs/operator-deploy.md says the opposite
    1. Config still carries the same TTL twice (re-raised from round 12)
    1. PreviewExpiryReason names an eviction, and its optionality leaks a ?? null
    1. Server-only gateway policy types are exported from the shared wire-grammar package
  • Residual risks
  • Unverifiable from here
Full report (click to expand)

VERDICT: APPROVE

The governance work is well-decomposed for its size: the wire grammar lives in preview-env, gateway env parsing in config.ts, one pure expiry derivation (resolvePreviewExpiry → resolveRowExpiry) is shared by the read surface and the sweep, and one evaluator (evaluateGovernance) is shared by admission and the sweep's over-cap log. No file crosses 1000 lines, no feature checks were bolted into unrelated flows, and the previous rounds' blockers (paired connection budget, single row-expiry mapper, CLI yaml tests, dead last_activity_at wire field) are visibly resolved. The findings below are non-blocking; the first is the one I would actually fix.

Findings

1. The upgrade contract is silently weakened and docs/operator-deploy.md says the opposite

apps/server/src/preview/bring-up.ts:190-193, apps/server/src/preview/governance.ts:53-58, docs/operator-deploy.md:593-599.

closeRunning now stamps lastActivityAt on every successful bring-up while writing the effective ttlMs/idleMs. With governance off (the gateway default), those bounds are both null, and resolvePreviewExpiry only falls back to the legacy creation-age bound when lastActivityMs === null (governance.ts:53). Net effect: any preview that succeeds a deploy after this upgrade is no longer collected by SPROUT_TTL_HOURS, even though operators never opted into anything. The doc claims "an existing deployment upgrades with no new required env and identical behaviour apart from that warning" — that is not what the code does. This is the highest-value thing to correct, because it is an operator-facing resource-leak on upgrade.

Concrete remedy: either state the change plainly — "redeployed previews are no longer reaped by SPROUT_TTL_HOURS; set SPROUT_PREVIEW_TTL (or preview.ttl) to preserve a bound" — or, if preserving the old bound is the intent, seed governance.previewTtlMs from legacyTtlMs when no governance env is set, which also retires most of the special-cased legacy fallback. Pick one and make the doc and code agree.

2. Config still carries the same TTL twice (re-raised from round 12)

apps/server/src/config.ts:154, :157, :648-649, :780.

ttlHours and legacyTtlMs are both on Config for the single SPROUT_TTL_HOURS value. ttlHours has no production reader left except configSummary (config.ts:780); every behavioral consumer uses legacyTtlMs. Two representations of one env var can drift (the test fixture at apps/server/src/config.fixtures.ts:12-13 already hand-writes both). Remedy: keep legacyTtlMs only and derive the summary label (legacyTtlMs / 3600_000), updating the two config.ttlHours assertions in config.test.ts:174,586.

3. PreviewExpiryReason names an eviction, and its optionality leaks a ?? null

apps/server/src/preview/types.ts:133-140, :150; apps/server/src/preview/lifecycle.ts:446,495.

PreviewExpiryReason includes "sweep:pr-not-open", which is not an expiry, and the field on the snapshot is expiry_reason, so PR-close evictions are reported through an expiry-named contract. RemovePreviewInput.expiryReason?: … is optional, which forces the expiryReason ?? null default inside destroyPreviewRow. Remedy: rename to PreviewRemovalReason (or split "sweep:pr-not-open" out into an eviction variant) and make the input field explicit as reason: PreviewRemovalReason | null so every caller states intent rather than relying on a default.

4. Server-only gateway policy types are exported from the shared wire-grammar package

packages/preview-env/src/governance.ts:27-53, packages/preview-env/src/index.ts:114-121.

GovernanceConfig, EffectiveGovernanceMs, and ConnectionBudget describe gateway policy, not preview wire grammar; only apps/server consumes them (the CLI imports just parsePreviewGovernanceField and governanceIssueMessage). Hosting them in @sprout/preview-env widens the shared package for a single consumer and invites the inverse dependency the earlier rounds already had to unwind. Remedy: keep the parse grammar, the issue message, and GovernanceManifest in preview-env; move the three policy types into apps/server (e.g. config.ts / preview/governance.ts) where all their readers live.

Residual risks

  • Default-off upgrades stop reaping deployed previews (see finding 1). Probe: on a gateway with only SPROUT_TTL_HOURS=72, redeploy a running preview and confirm expires_at on GET /v1/previews is null and the row survives past 72h.
  • Cap admission is best-effort under concurrency (documented at admission.ts:25-30). Probe: fire N concurrent new-PR deploys at maxPreviews - 1 and assert SELECT count(*) FROM previews WHERE status != 'removed' never exceeds the cap.
  • Expiry skips a locked preview and waits for the next cron tick (tryRemovePreview, lifecycle.ts:641-651). Probe: log/count drops that return removed === false and compare against the target's last_activity_at age.
  • Stored row bounds mean enabling governance does not retroactively bound running previews until each redeploys. Probe: set SPROUT_PREVIEW_TTL=7d, restart, and compare expires_at before/after a redeploy of an already-running preview.
  • Unparseable lastActivityAt/createdAt silently yields "no deadline" (resolveRowExpiry → parseUnambiguousUtcMs returning null). Probe: alert when a row has ttl_ms/idle_ms set but expires_at is null, alongside the existing sweep preview invalid createdAt log.

Unverifiable from here

No bun binary in this checkout, so bun test / bun run typecheck could not be run (compile-correctness and the concurrency claims are static-reading only), and real Postgres/Docker/Traefik behaviour, actual per-preview connection counts, and the forge-note - Expires: rendering were not exercised.

@simpros
simpros merged commit 07f5313 into main Oct 2, 2026
2 checks passed
@simpros
simpros deleted the gh-241-preview-governance-ttl-idle-tear branch October 2, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant