secrets/docs/superpowers/plans/2026-06-08-egb-713-version-skew-nudge.md
2026-06-08 11:56:47 -07:00

14 KiB

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 <a> <b> 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:

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):

# 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:
@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):

# 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:
@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):

# 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:
  if [ -d "$SECRETS_DIR/.git" ]; then
    return
  fi

to:

  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:
@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:

  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: VERSION0.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.