Plan: Devstack Staleness Guard

On this page

Status

Step Description Status

1

Create xtask/src/devstack_guard.rs with hashing, markers, staleness detection, and auto-refresh

Done (2026-04-09) — 350+ lines: RebuildType, VolumeAction, SeedAction enums, StalenessReport, check_staleness(), write_markers(), auto_refresh()

2

Wire write_markers() into dev start, dev reload, dev restart; add dev refresh and staleness to dev status

Done (2026-04-09) — Action::Refresh in dev.rs, auto_refresh() wired, status shows staleness summary

3

Wire check_staleness() and auto_refresh() into test.rs and e2e.rs with --no-refresh flag

Done (2026-04-09) — auto-refresh before integration tests

4

Add /.devstack/ to .gitignore

Done (2026-04-09) — .devstack/ directory exists and is ignored

5

Update documentation (local-dev.md, CLAUDE.md, CHANGELOG.adoc, developer-guide.adoc)

Done (2026-04-09) — cargo xtask dev refresh documented in CLAUDE.md

Issues: #301
Branch: feature/devstack-staleness-guard

Context

Running cargo xtask test or cargo xtask e2e after modifying Rust code tests against stale container binaries that have not been rebuilt. This causes false test failures and wastes significant debugging time.

Today, the only options are manual dev reload (preserves volumes, always rebuilds) or dev restart (wipes everything, always rebuilds with --no-cache). Both do a full rebuild regardless of what actually changed. There is no mechanism to detect whether containers are current, whether volumes need wiping, or whether seed data needs refreshing.

The goal is to make xtask automatically detect what changed since the last successful build and perform the minimum action needed — skip Docker entirely when nothing changed, do a cached rebuild when only source changed, use --no-cache only when Cargo.toml/Cargo.lock/Dockerfile changed, wipe volumes only when existing migrations were modified or deleted, and re-seed only when seed source changed or volumes were wiped.

Scope

In scope:

  • SHA-256 marker files in .devstack/ tracking source, deps, Dockerfile, compose, infra, rulesets, static assets, migrations, and seed source

  • Mtime fast path to skip hashing when nothing has been modified (filesystem stat only)

  • Migration add-vs-modify detection via manifest file (distinguishes additive migrations from modified/deleted ones)

  • auto_refresh() function that executes the minimum Docker action based on three independent dimensions

  • cargo xtask dev refresh subcommand for manual auto-detection without running tests

  • Staleness summary in cargo xtask dev status

  • --no-refresh flag on test and e2e commands

  • Documentation updates across local-dev.md, CLAUDE.md, CHANGELOG.adoc, developer-guide.adoc

Out of scope:

  • Selective per-service rebuild (single Dockerfile builds all binaries; per-service granularity requires Dockerfile restructuring)

  • Docker BuildKit cargo cache mounts (separate optimization, orthogonal to staleness detection)

  • Changes to CI pipeline (CI builds fresh every time; --no-refresh flag available if needed)

Design

Staleness detection uses three independent dimensions whose results combine into the minimum action.

Dimension 1: Rebuild Type

What changed Type Docker action

Nothing image-related

None

Skip Docker entirely

.rs files in services/, crates/, tools/

Cached

docker compose up -d --build

Rulesets, jurisdiction.toml, BFF static assets

Cached

docker compose up -d --build

docker-compose.yml, devstack/ configs

Cached

down + up -d --build

New migration files (additive only)

Cached

up -d --build

Modified/deleted existing migration

Cached

Same (volume wipe handled separately)

Cargo.toml, Cargo.lock, Dockerfile

NoCache

build --no-cache + up -d

Dimension 2: Volume State

What changed Action Data impact

Nothing migration-related

Keep

Volumes untouched

New migration files (additive)

Keep

sqlx::migrate!() applies new ones on startup

Modified/deleted existing migration

Wipe

down -v — checksums won’t match

Dimension 3: Seed State

What changed Action

Nothing seed-related

Skip

tools/canopy-seed/src/ changed

Reseed

rulesets/ changed (seed reads jurisdiction.toml)

Reseed

Volumes were just wiped

Reseed (data is gone)

Combined Action Sequence

1. If volume_wipe  → docker compose down -v
2. If rebuild=NoCache → docker compose build --no-cache → up -d
   If rebuild=Cached  → docker compose up -d --build
   If rebuild=None && volume_wipe → docker compose up -d  (restart for migrations)
3. Wait for health
4. Write updated markers
5. If reseed → cargo xtask seed

Key: a modified migration with unchanged Cargo.toml/Dockerfile only does down -v + up -d --build (cached rebuild), NOT --no-cache.

Marker Files

Stored in .devstack/ at workspace root (added to .gitignore).

File Contents Tracks

source.sha256

SHA-256 hex

.rs files in services/, crates/, tools/

deps.sha256

SHA-256 hex

Cargo.toml + Cargo.lock

dockerfile.sha256

SHA-256 hex

Dockerfile

compose.sha256

SHA-256 hex

docker-compose.yml

infra.sha256

SHA-256 hex

devstack/ directory contents

rulesets.sha256

SHA-256 hex

rulesets/ + jurisdiction.toml

static.sha256

SHA-256 hex

BFF static assets

migrations.sha256

SHA-256 hex

All /migrations/.sql content

migrations.manifest

Sorted paths

Migration file inventory (add vs modify detection)

seed.sha256

SHA-256 hex

tools/canopy-seed/src/ + rulesets/ (seed data generation reads jurisdiction.toml)

profile

String

Which deployment profile was started

shared-db

Boolean string

Whether --shared-db was used

Profile and Flag Mismatch Detection

The profile and shared-db markers are not just informational — they are part of the staleness check. If the current invocation’s profile or shared-db flag differs from the stored marker, the guard must treat this as a compose-level change (equivalent to docker-compose.yml change): down the old profile, up the new one. This prevents running tests against containers started with --profile snap-only when the test expects --profile full.

dev refresh reads profile and shared-db from existing markers and reuses them — no explicit flags required. Only dev start requires explicit flags. If markers don’t exist and no flags are provided, dev refresh errors with: "no devstack markers found — run cargo xtask dev start first."

Hash computation: walk matching files recursively, sort by relative path (normalized to forward slashes), feed relative_path\0content into a single SHA-256 digest. Performance note: on a warm filesystem cache this completes in <1s for the full workspace. On cold cache, consider using git ls-files -s tree hashes as an optimization in a future pass — git already has content hashes indexed. For now, direct file reads are simpler and correct.

Mtime fast path: before computing hashes, check filesystem mtimes against marker files. If any tracked file is newer than the marker, that dimension is definitely stale (skip hashing, go straight to rebuild). If no tracked file is newer, still hash — mtime is unreliable as a freshness proof. Git operations (pull, checkout, rebase) can set all file mtimes to the checkout time, making unchanged files appear newer or equal. NTFS mtime resolution is fine (100ns), but git’s behavior means "same or older mtime" does NOT guarantee "unchanged content." The mtime check is a one-directional fast path: it can prove staleness early, but it cannot prove freshness.

Migration Modification Detection

Compare current migration file list against migrations.manifest. If the current list is a strict superset of stored (all stored paths present with unchanged content, just new files added), it is additive — volumes Keep. If any stored path is missing or its content differs, volumes Wipe.

Steps

Step 1: Create devstack_guard.rs

Files: xtask/src/devstack_guard.rs

Create the core staleness detection module with:

  • RebuildType enum (None, Cached, NoCache)

  • VolumeAction enum (Keep, Wipe)

  • SeedAction enum (Skip, Reseed)

  • StalenessReport struct combining all three dimensions plus human-readable reasons

  • check_staleness() → Result<StalenessReport> — reads markers, computes current hashes, compares

  • write_markers() → Result<()> — writes all marker files after successful dev start/reload/restart

  • auto_refresh(project) → Result<StalenessReport> — reads profile/shared-db from stored markers, checks staleness, executes minimum action

  • markers_exist() → bool — checks whether .devstack/ markers are present (devstack has been started at least once)

  • Internal helpers: hash_files(), marker_dir(), read_marker(), write_marker(), check_migration_modifications(), mtime_fast_path()

Reuse docker::workspace_root() from xtask/src/docker.rs for path resolution. Use sha2::Sha256 (already a workspace dependency) for hashing.

Step 2: Wire into dev.rs

Files: xtask/src/cmd/dev.rs, xtask/src/main.rs

  • Add mod devstack_guard; to main.rs

  • Call devstack_guard::write_markers() after do_start() succeeds in Start, Reload, and Restart arms

  • Clear .devstack/ markers before Restart starts (so stale markers don’t persist if start fails)

  • Add Refresh variant to Action enum (no flags — reads profile and shared-db from stored markers) — calls auto_refresh() without running tests

  • Error if markers don’t exist: "no devstack markers found — run cargo xtask dev start first"

  • In Status arm, append check_staleness() summary after docker compose ps output

Step 3: Wire into test.rs and e2e.rs

Files: xtask/src/cmd/test.rs, xtask/src/cmd/e2e.rs

  • Add --no-refresh flag (bool) to both Args structs

  • In test.rs: before integration tests run (not for --unit), call auto_refresh() unless --no-refresh

  • In e2e.rs: call auto_refresh() at top of run() unless --no-refresh; subsumes existing "ensure devstack is running" logic

Step 4: Update .gitignore

Files: .gitignore

Add /.devstack/ entry after the existing /.keys/ line.

Step 5: Documentation updates

Files: .claude/docs/local-dev.md, .claude/CLAUDE.md, CHANGELOG.adoc, docs/modules/ROOT/pages/developer-guide.adoc

  • local-dev.md: add dev refresh to Standard Commands; add staleness guard explanation; update Testing section; document --no-refresh flag

  • CLAUDE.md: update Build & Test section with dev refresh; mention staleness guard in Conventions

  • CHANGELOG.adoc: entry under == Unreleased=== Added

  • developer-guide.adoc: add dev refresh to Common Tasks; update Running tests section

Files Touched

File Change

xtask/src/devstack_guard.rs

Create — core staleness detection, hashing, markers, auto-refresh (~280 lines)

xtask/src/main.rs

Add mod devstack_guard; declaration

xtask/src/cmd/dev.rs

Add Refresh subcommand; call write_markers() after start/reload/restart; staleness in Status

xtask/src/cmd/test.rs

Add --no-refresh flag; call auto_refresh() before integration tests

xtask/src/cmd/e2e.rs

Add --no-refresh flag; call auto_refresh() + seed check

.gitignore

Add /.devstack/

.claude/docs/local-dev.md

Document dev refresh, staleness guard, --no-refresh flag

.claude/CLAUDE.md

Update Build & Test and Conventions sections

CHANGELOG.adoc

Add entry under Unreleased

docs/modules/ROOT/pages/developer-guide.adoc

Add dev refresh, update test documentation

Verification

  1. cargo nextest run --workspace --lib — unit tests pass

  2. cargo xtask dev start --shared-db — markers written to .devstack/

  3. cargo xtask dev status — shows staleness report (all up to date)

  4. Edit a .rs file, run cargo xtask dev status — shows STALE for source

  5. cargo xtask dev refresh — reads profile/shared-db from markers, performs cached rebuild, markers updated

  6. cargo xtask test --unit — no staleness check, tests pass

  7. cargo xtask test --no-refresh — skips staleness check

  8. Modify existing migration, run cargo xtask dev status — shows volume wipe needed + cached rebuild (NOT --no-cache)

  9. cargo xtask e2e — auto-refreshes and re-seeds if needed

  10. cargo xtask dev refresh (without prior dev start) — errors with "no devstack markers found"

  11. git pull then cargo xtask dev status — still detects staleness even though mtimes may be equal (hash-based, not mtime-only)

  12. Change jurisdiction.toml, run cargo xtask dev status — shows both rulesets stale AND seed stale

  13. Start with --profile snap-only, then cargo xtask dev status --profile full — detects profile mismatch

Documentation Updates

  • .claude/docs/local-dev.md — dev refresh, staleness guard, --no-refresh

  • .claude/CLAUDE.md — Build & Test, Conventions

  • CHANGELOG.adoc — entry under == Unreleased

  • docs/modules/ROOT/pages/developer-guide.adoc — dev refresh, test auto-refresh

Edit this page · default