# EGB-713: Version-skew nudge Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / subagent-driven-development. Steps use `- [ ]`. **Goal:** Warn (non-fatally) when the active store was last written by a newer `secrets` version than the running client, so a behind user is told to update — the loud counterpart to EGB-712's quiet forcing function. **Architecture:** Stamp the store with the highest writer `VERSION` seen (`$SECRETS_DIR/.secrets-writer-version`, committed, monotonic) on every store-committing write. On any store command, compare that stamp to the client's own `VERSION` (read from `$SCRIPT_DIR/VERSION`); if the stamp is newer, print a one-time stderr nudge. Legacy stores with no stamp are silent. **Tech Stack:** bash 3.2 (`secrets`); bats. Spec/idea: ticket **EGB-713**. ## Background (verified against `main`, post-EGB-712) - `SCRIPT_DIR` already defined at `secrets:21` (`$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)`). The repo-root `VERSION` file lives next to the script. - Store-committing write sites (each does `git -C "$SECRETS_DIR" add -A`): `commit_and_push_secrets()` `secrets:1265` (push), rekey `secrets:1822`, migrate copy-forward `secrets:2215`, finalize no-v1 `secrets:2305`, finalize drop `secrets:2340`. Plus `cmd_init` (born store) and `cmd_rm`. - `ensure_store_protections()` (`secrets:1144`) is shared with pull (read) — do NOT stamp there. - `check_initialized()` (`secrets:54`) early-returns when `$SECRETS_DIR/.git` exists and is called by push/pull/list/rm/rekey/verify/migrate — the natural warning hook. - `cmd_which()` (`secrets:1945`) prints `format: v$(_store_format)` — add the writer-version line here. - `.gitignore` only ignores `key.txt`, so `.secrets-writer-version` commits normally. - VERSION currently `0.7.0.0` → bump to `0.7.1.0`. - Baseline: `bats test/` = 246 passing. New tests land in a new file `test/version.bats`. --- ## Task 1: Version helpers + comparator (TDD) **Files:** `secrets`, `test/version.bats` (new). - [ ] Step 1: Create `test/version.bats` testing the comparator via a tiny harness that sources the script's functions is awkward (the script runs main). Instead test through observable behavior in later tasks; for the comparator, add a hidden debug subcommand is overkill. Use this approach: test `_version_gt` indirectly by exporting it is not possible. So test the comparator by adding the helpers and a **`secrets __vercmp `** internal is overkill too. Decision: test the comparator's *effect* in Task 3 (warning) and Task 2 (stamp monotonicity), which exercise it end-to-end. For Task 1, write the helpers and verify with a one-off `bash -c` sourcing guard. Add to `test/version.bats`: ```bash load test_helper # Exercises the comparator through a bash subshell that defines the same logic # the script uses, guarding the numeric (not lexical) ordering contract. @test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically" { run bash -c ' _version_gt() { local a="$1" b="$2" i ai bi; local -a af bf IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" for i in 0 1 2 3; do ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi done; return 1 } _version_gt 0.10.0.0 0.7.0.0 && echo "10gt7" _version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10" _version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt" ' [ "$status" -eq 0 ] [[ "$output" == *"10gt7"* ]] || false [[ "$output" == *"7not_gt_10"* ]] || false [[ "$output" == *"equal_not_gt"* ]] || false } ``` - [ ] Step 2: Run `bats test/version.bats` → PASS (pins the contract the script must match). - [ ] Step 3: Add the helpers to `secrets` (near `_store_format`, after `SCRIPT_DIR`/version constants — place after the `MANIFEST_VERSION=2` area or near `_store_format`): ```bash # The running client's own version, read from the VERSION file shipped beside # the script. Empty/"0.0.0.0" if absent (e.g. an odd install) — treated as # "unknown/oldest" so a missing VERSION never triggers a spurious nudge. _client_version() { local v="" [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]') printf '%s' "${v:-0.0.0.0}" } # Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2. # Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0. _version_gt() { local a="$1" b="$2" i ai bi; local -a af bf IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" for i in 0 1 2 3; do ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi done return 1 } WRITER_VERSION_FILE_NAME=".secrets-writer-version" # Highest client version recorded as having written to the store (empty if the # store predates this feature — "minus the initial builds", silent by design). _store_writer_version() { local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" [ -f "$f" ] && head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]' } ``` - [ ] Step 4: `bash -n secrets` parses; `bats test/` still 246 + 1 (the comparator test) = 247. - [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: version helpers + numeric comparator (EGB-713)"` --- ## Task 2: Stamp the writer-version on write (TDD) **Files:** `secrets`, `test/version.bats`. - [ ] Step 1: Add tests: ```bash @test "push stamps the store writer-version with the client version" { init_with_remote create_project_dir wvstamp "$SECRETS_BIN" push wvstamp >/dev/null 2>&1 [ -f "$SECRETS_DIR/.secrets-writer-version" ] run cat "$SECRETS_DIR/.secrets-writer-version" [ "$output" = "$(cat "$(dirname "$SECRETS_BIN")/VERSION")" ] } @test "writer-version stamp is monotonic (a push never lowers a higher stamp)" { init_with_remote create_project_dir wvmono printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version" "$SECRETS_BIN" push wvmono >/dev/null 2>&1 run cat "$SECRETS_DIR/.secrets-writer-version" [ "$output" = "9.9.9.9" ] # not lowered to the client's version } @test "writer-version stamp is committed, not gitignored" { init_with_remote create_project_dir wvcommit "$SECRETS_BIN" push wvcommit >/dev/null 2>&1 run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version" [ "$status" -eq 0 ] } ``` - [ ] Step 2: Run `bats test/version.bats -f "stamp"` → FAIL (no stamping yet). - [ ] Step 3: Add the stamp helper (after `_store_writer_version`): ```bash # Raise the store's recorded writer-version to the client's version (monotonic; # never lowers it). Called right before each store-committing `git add -A` so # the stamp rides the same commit. Read paths (pull) never call this. _stamp_writer_version() { local cur cli cur=$(_store_writer_version) cli=$(_client_version) if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" fi } ``` - [ ] Step 4: Call `_stamp_writer_version` immediately before each store-committing `git -C "$SECRETS_DIR" add -A`: - `secrets:1265` (in `commit_and_push_secrets`, before `git add -A`) - `secrets:1822` (rekey) - `secrets:2215` (migrate copy-forward) - `secrets:2305` (finalize, no-v1 path) - `secrets:2340` (finalize, drop path) Also in `cmd_init`, after the store repo is created and before its first commit (so a born store records its version), and in `cmd_rm` before its commit. Each insertion is the single line ` _stamp_writer_version` at the matching indentation directly above the `git ... add -A` (or before the `git ... commit` where there's no add -A, e.g. rm/init — there, stamp then ensure it's staged via the existing add/commit). - [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 250 (247 + 3). - [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: stamp store writer-version on write, monotonic (EGB-713)"` --- ## Task 3: Skew warning on command (TDD) **Files:** `secrets`, `test/version.bats`. - [ ] Step 1: Add tests: ```bash @test "a store written by a newer version warns on a command (non-fatal)" { init_with_remote create_project_dir skewwarn "$SECRETS_BIN" push skewwarn >/dev/null 2>&1 printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" run "$SECRETS_BIN" list [ "$status" -eq 0 ] # non-fatal [[ "$output" == *"newer"* || "$output" == *"update"* ]] || false } @test "a store at the same/older version is silent" { init_with_remote create_project_dir noskew "$SECRETS_BIN" push noskew >/dev/null 2>&1 # stamp == client version run "$SECRETS_BIN" list [ "$status" -eq 0 ] [[ "$output" != *"update your secrets"* ]] || false } @test "a store with no writer-version marker is silent (legacy store)" { init_with_remote create_project_dir legacynostamp "$SECRETS_BIN" push legacynostamp >/dev/null 2>&1 rm -f "$SECRETS_DIR/.secrets-writer-version" run "$SECRETS_BIN" list [ "$status" -eq 0 ] [[ "$output" != *"update your secrets"* ]] || false } ``` - [ ] Step 2: Run `bats test/version.bats -f "skew\|silent\|legacy"` → the "newer" test FAILS (no warning yet). - [ ] Step 3: Add the skew check (after `_stamp_writer_version`): ```bash # Warn ONCE per invocation if the store was last written by a newer client than # us. Non-fatal (read/write paths keep their exit codes). Silent when the store # carries no writer-version (legacy) or is same/older than us. _VERSION_SKEW_WARNED=0 _check_store_version_skew() { [ "$_VERSION_SKEW_WARNED" = 1 ] && return 0 local sv cv sv=$(_store_writer_version) [ -n "$sv" ] || return 0 cv=$(_client_version) if _version_gt "$sv" "$cv"; then _VERSION_SKEW_WARNED=1 echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2 echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2 fi return 0 } ``` - [ ] Step 4: Hook it into `check_initialized` — change `secrets:55-57`: ```bash if [ -d "$SECRETS_DIR/.git" ]; then return fi ``` to: ```bash if [ -d "$SECRETS_DIR/.git" ]; then _check_store_version_skew return fi ``` - [ ] Step 5: `bats test/version.bats` → all pass. `bats test/` → 253. - [ ] Step 6: Commit: `git add secrets test/version.bats && git commit -m "feat: warn on store version skew (once per invocation, EGB-713)"` --- ## Task 4: `secrets which` surfaces the writer-version (TDD) **Files:** `secrets`, `test/version.bats`. - [ ] Step 1: Add test: ```bash @test "which prints the store writer-version and a behind note" { init_with_remote create_project_dir whichwv "$SECRETS_BIN" push whichwv >/dev/null 2>&1 printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" run "$SECRETS_BIN" which [ "$status" -eq 0 ] [[ "$output" == *"written-by: v99.0.0.0"* ]] || false [[ "$output" == *"behind"* || "$output" == *"update"* ]] || false } ``` - [ ] Step 2: Run → FAIL (which doesn't print written-by). - [ ] Step 3: In `cmd_which`, after the `echo "format: v$(_store_format)"` line (`secrets:1951`), add: ```bash local _wv; _wv=$(_store_writer_version) if [ -n "$_wv" ]; then local _cv; _cv=$(_client_version) if _version_gt "$_wv" "$_cv"; then echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)" else echo "written-by: v$_wv" fi fi ``` Note: `cmd_which` calls `resolve_store` but may not call `check_initialized`, so this also avoids double-printing the skew NOTE; the `which` line is the dedicated surface. - [ ] Step 4: `bats test/version.bats` → pass. `bats test/` → 254. - [ ] Step 5: Commit: `git add secrets test/version.bats && git commit -m "feat: secrets which shows store writer-version + behind note (EGB-713)"` --- ## Task 5: Docs + version bump **Files:** `secrets` (cmd_help unchanged unless adding a note), `CLAUDE.md`, `README.md`, `VERSION`, `CHANGELOG.md`. - [ ] Step 1: `CLAUDE.md` — add to the "Store format" bullet a sentence on the writer-version: a committed `.secrets-writer-version` records the highest client `VERSION` that has written (monotonic, stamped on store-committing writes); commands warn once (stderr, non-fatal) when the store's stamp exceeds the running client, and `secrets which` shows `written-by: vN`. Legacy stores (no marker) are silent. (EGB-713.) - [ ] Step 2: `README.md` — under the upgrading section, note that an out-of-date `secrets` prints a one-line "update" nudge when it touches a store newer than itself. - [ ] Step 3: `VERSION` → `0.7.1.0`. - [ ] Step 4: `CHANGELOG.md` — new `## [0.7.1.0] - 2026-06-08` with an Added entry for the version-skew nudge + `secrets which` writer-version line. - [ ] Step 5: `bats test/` → all green (254). `./secrets which` against a scratch store renders (covered by tests). - [ ] Step 6: Commit: `git add secrets CLAUDE.md README.md VERSION CHANGELOG.md && git commit -m "docs: version-skew nudge + writer-version; bump 0.7.1.0 (EGB-713)"` --- ## Self-review vs ticket AC - "Newer stamp → nudge; same/older → silent" → Task 3. - "No marker → silent (legacy)" → Task 3 + `_store_writer_version` empty. - "Monotonic, committed" → Task 2. - "Numeric comparator (0.7.0.0 < 0.10.0.0)" → Task 1. - "Non-fatal, never changes read/pull exit codes" → Task 3 (`_check_store_version_skew` always `return 0`). - "`which` surfaces it" → Task 4. "Warn once per invocation" → `_VERSION_SKEW_WARNED` guard. - bash 3.2: `read -a`, `local -a`, `10#`, parameter strips — all 3.2-safe.