Skip to content

Detect configuration drift by fingerprinting the declaration at apply time #39

Description

@jamessawle

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:

  1. On create, compute a fingerprint of the declared configuration and persist it alongside the sandbox.
  2. On a later up against an existing sandbox, recompute the fingerprint from the current declaration and compare.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions