Problem
sbxflow up/down only ever look at the lifecycle state of a sandbox (absent/stopped/running, via Inspect in internal/application/lifecycle/up.go) to decide whether to create, enter, or recreate it. Nothing records what was applied when the sandbox was created, so a user can edit sbxflow.yaml and then re-enter a sandbox that was built from an older revision of it, with no signal that the two have diverged.
doctor (internal/application/doctor/) doesn't help here — it only checks global/environment health (sbx version, sbx diagnose, network policy, kit source allow-listing) and never reads a specific sandbox's declaration.
There is no persisted record of the last-applied configuration anywhere: no lockfile, hash, or manifest.
Proposal
Now that the config file is mandated as the source of truth and Docker Sandboxes is the only execution substrate, drift detection does not need to introspect the running sandbox. It reduces to a fingerprint comparison:
- On create, compute a fingerprint of the declared configuration and persist it alongside the sandbox.
- On a later
up against an existing sandbox, recompute the fingerprint from the current declaration and compare.
- Differ → the sandbox was built from a configuration that no longer matches what's declared.
This deliberately narrows the scope from the original framing. It detects "the declared configuration changed since this sandbox was applied". It does not detect out-of-band mutation of a running sandbox (someone attaching kits via sbx directly), and it does not detect a mutable upstream kit ref whose contents changed under a pinned name. Given the config file is now the mandated source of truth, that trade is intentional — but it is a narrowing, recorded here so it isn't mistaken for the original goal being met.
In exchange it removes this issue's original blocker entirely: no need to survey what sbx's introspection surface exposes, and no new inspect call on the sbx client (internal/adapters/outbound/sbx/client.go).
Open question: what to fingerprint
Not decided yet. Three candidates:
- Raw
sbxflow.yaml bytes. Simplest possible thing, and unambiguously answers "the source of truth changed". Costs: false positives on comment / formatting / key-order churn, and blind to changes that reach the sandbox from outside that one file — extends/linked config, a local kit's resolved path, a remote kit ref that resolves differently.
- Canonicalised configuration. Parse and re-serialize post-
extends, then hash. Kills formatting noise without coupling the fingerprint to execution-layer types.
- Resolved
Plan (internal/application/lifecycle/plan.go). Exactly the set of inputs handed to Docker, so no false positives and no blind spots. Costs: coupled to Plan's shape — needs a stable serialization and a schema version, or every struct change reads as drift for every existing sandbox.
Whichever is chosen, the fingerprint payload should carry a schema version so a future change to the hashing scheme can be distinguished from real drift.
Store the digest, or the whole snapshot?
Storing the full serialized snapshot (not just the digest) costs roughly the same and is the difference between:
⚠ configuration has changed since this sandbox was created
and a field-level report:
~ agent: applied "claude", declared "codex"
+ kit declared, not applied: mise/python@1.2.0
Suggest: digest for the cheap equality check, snapshot for the message.
Where to persist it
Preferred: inside the sandbox, written at create time via the existing ExecuteCommand plumbing that already runs the initialize hooks in UpRunner.create. Its lifetime is then tied to the sandbox — removing the sandbox via sbx directly cannot leave stale state behind, destroy.go/removal.go need no cleanup, and it survives the repository moving on disk.
Needs verifying first: whether sbx exec works against a stopped sandbox, since up must read the fingerprint before entering. If it doesn't, fall back to a repo-local .sbxflow/state.json keyed by sandbox name, and accept the stale-state edge case when a sandbox is removed outside sbxflow.
Sketch
- Fingerprint computation in a new
internal/application/drift package (or extending internal/application/lifecycle).
- Write on create in
UpRunner.create; read and compare on the existing-sandbox path in UpRunner.Run.
- Optional
sbxflow status command (internal/adapters/inbound/cli/status.go, wired into root.go) reporting the comparison on demand, reusing the doctor.Runner/Check/Result/Grade pattern where it fits.
Sandboxes created before this lands have no stored fingerprint; treat "absent" as unknown rather than as drift.
Problem
sbxflow up/downonly ever look at the lifecycle state of a sandbox (absent/stopped/running, viaInspectininternal/application/lifecycle/up.go) to decide whether to create, enter, or recreate it. Nothing records what was applied when the sandbox was created, so a user can editsbxflow.yamland then re-enter a sandbox that was built from an older revision of it, with no signal that the two have diverged.doctor(internal/application/doctor/) doesn't help here — it only checks global/environment health (sbxversion,sbx diagnose, network policy, kit source allow-listing) and never reads a specific sandbox's declaration.There is no persisted record of the last-applied configuration anywhere: no lockfile, hash, or manifest.
Proposal
Now that the config file is mandated as the source of truth and Docker Sandboxes is the only execution substrate, drift detection does not need to introspect the running sandbox. It reduces to a fingerprint comparison:
upagainst an existing sandbox, recompute the fingerprint from the current declaration and compare.This deliberately narrows the scope from the original framing. It detects "the declared configuration changed since this sandbox was applied". It does not detect out-of-band mutation of a running sandbox (someone attaching kits via
sbxdirectly), and it does not detect a mutable upstream kit ref whose contents changed under a pinned name. Given the config file is now the mandated source of truth, that trade is intentional — but it is a narrowing, recorded here so it isn't mistaken for the original goal being met.In exchange it removes this issue's original blocker entirely: no need to survey what
sbx's introspection surface exposes, and no new inspect call on thesbxclient (internal/adapters/outbound/sbx/client.go).Open question: what to fingerprint
Not decided yet. Three candidates:
sbxflow.yamlbytes. Simplest possible thing, and unambiguously answers "the source of truth changed". Costs: false positives on comment / formatting / key-order churn, and blind to changes that reach the sandbox from outside that one file —extends/linked config, a local kit's resolved path, a remote kit ref that resolves differently.extends, then hash. Kills formatting noise without coupling the fingerprint to execution-layer types.Plan(internal/application/lifecycle/plan.go). Exactly the set of inputs handed to Docker, so no false positives and no blind spots. Costs: coupled toPlan's shape — needs a stable serialization and a schema version, or every struct change reads as drift for every existing sandbox.Whichever is chosen, the fingerprint payload should carry a schema version so a future change to the hashing scheme can be distinguished from real drift.
Store the digest, or the whole snapshot?
Storing the full serialized snapshot (not just the digest) costs roughly the same and is the difference between:
and a field-level report:
Suggest: digest for the cheap equality check, snapshot for the message.
Where to persist it
Preferred: inside the sandbox, written at create time via the existing
ExecuteCommandplumbing that already runs theinitializehooks inUpRunner.create. Its lifetime is then tied to the sandbox — removing the sandbox viasbxdirectly cannot leave stale state behind,destroy.go/removal.goneed no cleanup, and it survives the repository moving on disk.Needs verifying first: whether
sbx execworks against a stopped sandbox, sinceupmust read the fingerprint before entering. If it doesn't, fall back to a repo-local.sbxflow/state.jsonkeyed by sandbox name, and accept the stale-state edge case when a sandbox is removed outside sbxflow.Sketch
internal/application/driftpackage (or extendinginternal/application/lifecycle).UpRunner.create; read and compare on the existing-sandbox path inUpRunner.Run.sbxflow statuscommand (internal/adapters/inbound/cli/status.go, wired intoroot.go) reporting the comparison on demand, reusing thedoctor.Runner/Check/Result/Gradepattern where it fits.Sandboxes created before this lands have no stored fingerprint; treat "absent" as unknown rather than as drift.