Skip to content

vsync v0.17.0 — Spec

Status: design · target package @muthuishere/vsync · breaking — pull and push grow refuse-on-divergence defaults. Wire format unchanged.

One theme: vsync pull must stop clobbering local edits, and vsync push must stop clobbering remote pushes. Today both verbs are last-write-wins: pull silently overwrites whatever the operator was editing in infra/vault/<env>/; push silently overwrites whatever a teammate just uploaded. v0.17 adds a machine-local per-file mtime ledger that snapshots the vault folder after every pull/push, and makes both verbs refuse to proceed when local or remote has diverged from the ledger. vsync status surfaces the divergence so operators see it before they lose work.

For prior context, see v0.2-secret-lib.md (config layout — ledger lives next to it), v0.4-audit-log.md (generation counter that drives the remote-staleness check), and v0.13-profiles-init-status.md (status command — v0.17 extends its output).

Decisions locked from the design huddle (referenced where applied):

  • S1 — Per-file {mtime_ms, size} ledger. No hashing. Speed over bulletproofness; corner cases (touch -m, identical-byte rewrite) recoverable via --force.
  • S2 — Pull refuses on dirty by default. Escape hatches: --backup (snapshot then pull) and --force (delete then pull).
  • S3 — Push refuses if remote generation has advanced since the ledger's recorded generation. Escape hatch: --force (overwrite teammate's work; dangerous).
  • S4 — Backups live under ${XDG_CONFIG_HOME:-~/.config}/vsync/backups/<repo>/<env>.backup-<ts>/. Per-repo namespaced; timestamped; operator cleans up.
  • S5 — Symlinks in the vault folder error at push (vault is plain data). One-sentence policy.
  • S6 — No new verb (vsync reset rejected — pull --force is the reset). Flags only.
  • I — pre-1.0; no compat shims; fail-loud on unknowns (inherited from v0.13).

1. Diff from current behavior

v0.16 and earlierv0.17
vsync pull <env> when local vault has uncommitted editssilently overwritesrefuses; lists modified/added/deleted files; suggests --backup / --force
vsync pull <env> --force(not a flag)deletes vault, pulls fresh, rewrites ledger
vsync pull <env> --backup(not a flag)moves vault to ${XDG_CONFIG_HOME}/vsync/backups/<repo>/<env>.backup-<ts>/, pulls fresh, rewrites ledger
vsync push <env> when remote generation > last-syncedsilently overwrites remoterefuses; names current vs remote generation; suggests --force or pull first
vsync push <env> --force(existing — already overwrites)unchanged (still the "I know what I'm doing" override)
Local ledger filedid not existnew${XDG_CONFIG_HOME}/vsync/<repo>/env_<env>.ledger.json
vsync status outputper-env table without sync-stateadds local column (clean / dirty (<N>) / untracked); adds remote column when --check-remote is passed (up-to-date / behind (gen <N>) / unknown)
Symlinks in infra/vault/<env>/followed silently (or read as their target)error at push — vault is plain data

Migration: the first pull after upgrading writes the ledger from the freshly-pulled state. Operators with locally-edited vaults but no ledger get a one-time grace period — v0.17 detects "vault exists but no ledger" as untracked (not dirty), prints a one-time warning, and proceeds. After that first pull/push, every subsequent operation is ledger-checked. (See §13 for the exact migration message.)


2. The ledger — format, location, lifecycle

2.1 Location

${XDG_CONFIG_HOME:-~/.config}/vsync/<repo>/env_<env>.ledger.json

Mode 0600. Per-(repo, env), per-machine. Never committed; never shared between teammates (each operator has their own pull/push history).

Same directory as the existing per-(repo, env) config file (env_<env>), so operators looking at ls ~/.config/vsync/<repo>/ see both side by side.

2.2 Schema

json
{
  "v": 1,
  "generation": 12,
  "last_sync_at": "2026-05-25T14:23:00Z",
  "last_sync_op": "pull",
  "files": {
    ".env.dev":       { "mtime_ms": 1716647823100, "size": 1234 },
    "gcp-sa.json":    { "mtime_ms": 1716647823100, "size": 5678 },
    "tls/server.crt": { "mtime_ms": 1716647823100, "size": 2048 }
  }
}
FieldTypeRequiredNotes
vintegeryesEnvelope version. v0.17 emits 1. Readers reject any other value (fail-loud per decision I).
generationintegeryesThe remote bundle's generation as of the last successful pull or push. Drives the lost-update guard in §5.
last_sync_atstring (ISO-8601 UTC)yesTimestamp of the last successful pull or push. Drives the rename notice + display in vsync status.
last_sync_opstringyesEither "pull" or "push". Disambiguates the source of last_sync_at; useful when diagnosing "why does status say I'm behind by 1 gen — did I push without pulling first?"
filesobjectyesMap of vault-relative path (POSIX separators) → {mtime_ms, size}. May be empty {} for a freshly-init'd env that's never pulled.

files key grammar: POSIX path relative to infra/vault/<env>/, no leading slash, no .., no Windows backslashes. Writers normalise; readers reject mismatches as malformed.

2.3 Lifecycle

WhenWhat happens to the ledger
vsync init <env>No ledger written. (Vault is empty until first pull/push.)
vsync pull <env> succeedsWalk freshly-written vault tree; write ledger with current mtime+size for every file; generation = remote's gen; last_sync_op = "pull".
vsync push <env> succeedsWalk vault tree (same content that was just encrypted); write ledger; generation = new gen returned by the server; last_sync_op = "push".
vsync pull <env> --backupBackup dir copied first (see §7), then identical to a regular successful pull — ledger rewritten from new state.
vsync pull <env> --forceVault deleted, fresh pull, identical ledger write to regular pull.
Ledger missing on a pullTreated as "untracked" — proceed with the pull (no clobber refusal possible, nothing to compare to). Print a one-time warning.
Ledger missing on a pushSame — print warning, proceed. After this push, ledger exists for future ops.
Ledger malformedLedgerMalformedError (§9). Operator must delete or fix.
Ledger v ≠ 1LedgerMalformedError. (Future v=2 readers handle both; v0.17 doesn't.)

2.4 Why mtime+size, not SHA-256

ApproachSpeed (1000 files)ReliabilityVerdict
Folder-level mtime only<1ms❌ misses in-place edits (FS doesn't bump dir mtime on file content change)Wrong
Per-file mtime+size~5ms (stat per file)✅ for editors, copies, deletes, additions; ❌ under touch -m-reset or identical-byte rewriteShip this
Per-file SHA-256~10ms/MB (must read every byte)✅ always correctOverkill for the speed cost

The mtime+size approach misses two adversarial cases:

  1. touch -m -t YYYYMMDDHHMM reset after editing — restores the old mtime, so the ledger thinks nothing changed. Caught only by content hashing. The operator who did this can recover via --force.
  2. Identical-byte rewrite — vim saves a file that ends up byte-identical to the previous version but with a new mtime. Ledger reports "dirty". Push uploads a byte-identical bundle. No data loss; just one needless push.

Neither case loses data. The far more common cases — vim edit, cp overwrite, rm, new file drop — are all caught.

A future --check-content flag could add the SHA pass on demand if operators report needing it. Not in v0.17.


3. Dirty detection algorithm

ts
type DirtyDiff =
  | { kind: "clean" }
  | { kind: "untracked" }   // no ledger; treat as clean for migration
  | { kind: "dirty"; modified: string[]; added: string[]; deleted: string[] };

function checkDirty(vaultDir: string, ledger: Ledger | null): DirtyDiff {
  if (!ledger) return { kind: "untracked" };
  if (!existsSync(vaultDir)) {
    // vault dir deleted entirely — every ledger file is "deleted"
    return { kind: "dirty", modified: [], added: [], deleted: Object.keys(ledger.files) };
  }

  const seen = new Set<string>();
  const modified: string[] = [];
  const added: string[] = [];

  for (const entry of walk(vaultDir)) {            // recursive, regular files only
    const rel = relative(vaultDir, entry.path);    // POSIX-normalised
    seen.add(rel);
    const expected = ledger.files[rel];
    if (!expected) {
      added.push(rel);
    } else if (
      entry.stat.mtimeMs !== expected.mtime_ms ||
      entry.stat.size !== expected.size
    ) {
      modified.push(rel);
    }
  }

  const deleted: string[] = [];
  for (const rel of Object.keys(ledger.files)) {
    if (!seen.has(rel)) deleted.push(rel);
  }

  if (modified.length === 0 && added.length === 0 && deleted.length === 0) {
    return { kind: "clean" };
  }
  return { kind: "dirty", modified, added, deleted };
}

Cost: O(n) where n = vault file count. One stat() per file, no reads. For a 50-file vault, the whole check is <2ms.

Symlinks encountered during walk() raise immediately — see §8.


4. Pull behaviour

4.1 Flag matrix

CommandBehaviour on clean vaultBehaviour on dirty vault
vsync pull <env>Pull as before; write/refresh ledgerRefuse with LocalDirtyError (§9). Print the modified / added / deleted file list. Suggest --backup and --force.
vsync pull <env> --backupBackup the (clean) vault to <backup-path>/, then pull; write ledgerBackup the (dirty) vault, delete the original, pull fresh, write ledger
vsync pull <env> --forceDelete vault, pull fresh, write ledgerDelete vault (discarding edits), pull fresh, write ledger

--backup and --force are mutually exclusive — passing both is a parse error (mutually exclusive flags: --backup, --force).

4.2 Error message — local dirty

✗ Local vault has unsynced changes — refusing to overwrite.

  env:       dev
  vault:     /Users/muthu/work/acme-web/infra/vault/dev
  last sync: pull at 2026-05-25 14:23 UTC (gen 12)

  modified (1):
    .env.dev

  added (1):
    stripe-key.pem

  deleted (1):
    tls/server.key

To proceed:

  vsync push dev               # if these edits ARE the intended state
  vsync pull dev --backup      # snapshot current vault to ~/.config/vsync/backups/acme-web/, then pull
  vsync pull dev --force       # discard local edits and pull (DANGEROUS — backup is not made)

Exit 1. List truncates at 20 files per category with ... and N more suffix.

4.3 Ledger missing — one-time grace

ℹ no ledger for dev — first sync since v0.17 upgrade. Proceeding without dirty check.
   After this pull, vsync will track changes to prevent silent overwrites.

Printed once per env. Pull proceeds normally; ledger is written from the pulled state.


5. Push behaviour

5.1 The lost-update guard

ts
// Before encrypt + upload:
const localGen   = ledger?.generation ?? null;
const remoteGen  = await fetchRemoteGeneration(env);  // HEAD on <prefix>manifest

if (localGen !== null && remoteGen > localGen) {
  throw new RemoteAheadError(env, localGen, remoteGen);
}

When the ledger doesn't exist (first push after init, or post-migration), the guard is skipped — there's nothing to compare against. The push succeeds and writes the ledger; subsequent pushes have the guard active.

5.2 Flag matrix

CommandLocal ledger gen vs remote genBehaviour
vsync push <env>local ≥ remote (you're up to date)Push as before; write/refresh ledger
vsync push <env>local < remote (teammate pushed since your last sync)Refuse with RemoteAheadError (§9). Suggest pull first or --force.
vsync push <env>no ledgerPrint one-time migration notice; push proceeds; ledger written.
vsync push <env> --forceanyPush regardless of remote state. Overwrites teammate's work if any. Ledger written from the new state.

5.3 Error message — remote ahead

✗ Remote has new changes since your last sync — refusing to push.

  env:              dev
  your last sync:   pull at 2026-05-25 14:23 UTC (gen 12)
  remote currently: gen 15

  Someone (you or a teammate) pushed 3 generations since your last pull.

To proceed:

  vsync pull dev               # fetch their changes (will flag dirty if you also edited)
  vsync push dev --force       # overwrite their work (DANGEROUS — they lose data)

Exit 1.

The "Someone (you or a teammate)" wording is intentional — vsync doesn't know which IAM user pushed without scanning the audit log. If audit-log lookup is cheap (one extra HEAD + GET), v0.18 can sharpen this to "last pushed by <iam-user-id> at <time>". Not in v0.17.


6. vsync status extensions

bin/status.ts and src/status.ts gain two columns: local (always populated, offline) and remote (populated only when --check-remote is passed).

6.1 Text report

Repo:     acme-web
Source:   file (.vsync at /Users/muthu/work/acme-web/.vsync)
Toplevel: /Users/muthu/work/acme-web
CWD:      /Users/muthu/work/acme-web
Origin:   git@github.com:acme/web.git

Envs:
  env    profile           prefix          gen   last sync       local         remote
  ───    ─────────         ──────          ───   ─────────       ─────         ──────
  dev    hetzner-personal  acme-web/dev/   12    2h ago (pull)   dirty (3)     behind (gen 15)
  prod   hetzner-personal  acme-web/prod/  45    1d ago (push)   clean         up-to-date

Profiles:
  hetzner-personal  hel1.your-objectstorage.com  personal-secrets

Notices:
  ℹ dev — remote is ahead by 3 generations. `vsync pull dev` to fetch.
  ⚠ dev — local has 3 unsynced changes. `vsync status --files dev` to list them.

(invoke `vsync status --check-remote` to populate the remote column)

local values:

  • clean — vault exists, every file matches the ledger
  • dirty (<N>) — vault exists, <N> files differ (sum of modified + added + deleted)
  • untracked — vault exists but no ledger (first sync since upgrade, or operator deleted the ledger)
  • empty — vault folder doesn't exist (post-init, pre-pull)
  • unknown — vault folder unreadable (permissions)

remote values (only populated with --check-remote):

  • up-to-date — remote gen == ledger gen
  • ahead (gen N) — ledger gen > remote gen (impossible in practice; shows after a push if remote HEAD lagged — informational)
  • behind (gen N) — remote gen > ledger gen
  • unknown — network error or manifest missing
  • — not checked (default; no --check-remote passed)

6.2 status --files <env> — drill-down

A new flag prints the full file-level breakdown for one env:

$ vsync status --files dev

dev — last sync: pull at 2026-05-25 14:23 UTC (gen 12)

  modified (1):
    .env.dev                  size 1234 → 1247 bytes
                              mtime 14:23 → 15:08

  added (1):
    stripe-key.pem            size 1675 bytes
                              created 15:11

  deleted (1):
    tls/server.key            was 1675 bytes at last sync

Useful for triage before vsync pull --backup or vsync push.

6.3 JSON output

StatusReport.envs[].local and StatusReport.envs[].remote are new fields. Each is a discriminated union mirroring the text values:

json
{
  "env": "dev",
  "local": {
    "state": "dirty",
    "modified": [".env.dev"],
    "added": ["stripe-key.pem"],
    "deleted": ["tls/server.key"],
    "ledger_generation": 12,
    "ledger_last_sync_at": "2026-05-25T14:23:00Z",
    "ledger_last_sync_op": "pull"
  },
  "remote": {
    "state": "behind",
    "remote_generation": 15,
    "checked_at": "2026-05-25T16:30:00Z"
  }
}

When --check-remote is not passed, remote is { "state": "unchecked" }.


7. Backup mechanism

7.1 Path layout

${XDG_CONFIG_HOME:-~/.config}/vsync/backups/<repo>/<env>.backup-<iso-utc>/

Concrete example: /Users/muthu/.config/vsync/backups/acme-web/dev.backup-2026-05-25T14-23-00Z/

ISO-8601 with : replaced by - (filesystem-safe). UTC always. Per-repo namespacing prevents two repos with an env called dev from colliding.

7.2 Backup operation

When vsync pull <env> --backup runs and the vault is dirty (or even when it's clean — --backup always backs up, never short-circuits):

  1. Compute backup target path. mkdir -p the parent.
  2. Recursive copy (not move) from infra/vault/<env>/ → backup target. Copy preserves mode bits; mtimes preserved. Symlinks abort with SymlinkInVaultError (see §8 — --backup doesn't get to ignore the symlink rule).
  3. Delete the local infra/vault/<env>/ directory.
  4. Proceed with the pull as normal — fetch, decrypt, write fresh vault, write ledger.
  5. Print:
    ✔ pulled dev (gen 15)
      backup: ~/.config/vsync/backups/acme-web/dev.backup-2026-05-25T14-23-00Z (3 files preserved)

If any step 1-3 fails, abort before the pull — leave the original vault in place. If step 4 fails (network, decrypt error), the backup is still on disk and the operator can recover by cp -r <backup>/* infra/vault/<env>/.

7.3 Cleanup

Operator-managed. Backups accumulate as siblings:

~/.config/vsync/backups/acme-web/
├── dev.backup-2026-05-25T10-15-00Z/
├── dev.backup-2026-05-25T14-23-00Z/
└── prod.backup-2026-05-25T11-30-00Z/

vsync status adds a soft notice when a repo has more than 5 backup dirs:

ℹ 7 backup dirs in ~/.config/vsync/backups/acme-web — consider cleaning up old ones.

No automatic deletion. Backups are operator data; vsync doesn't garbage-collect.

A future vsync backups list / remove verb is plausible but not in v0.17 — ls and rm -rf suffice.


The vault folder is plain data. Symlinks inside infra/vault/<env>/ are not supported:

  • Dirty check: symlink encountered during walk() → abort the operation with SymlinkInVaultError.
  • Pull: decrypt never produces symlinks (RQE1 bundles are flat byte streams). The bundle format has no symlink representation.
  • Push: symlink encountered → SymlinkInVaultError, refuses to push. Operator must either replace the symlink with the file's actual content (cp -L target infra/vault/<env>/name) or move the symlink out of the vault tree.
  • --backup: symlinks abort the backup too — cp -r would follow them and the resulting backup would be wrong. Same error.

Error message:

✗ Symlink found in vault — not supported.

  symlink:  infra/vault/dev/gcp-sa.json
  target:   ../../../../keys/gcp-sa-real.json

The vault folder stores plain data. Symlinks would point outside the
encrypted bundle and silently leak the target's path on every sync.

Either:
  - copy the target's content in place:
      cp -L infra/vault/dev/gcp-sa.json /tmp/x && rm infra/vault/dev/gcp-sa.json && mv /tmp/x infra/vault/dev/gcp-sa.json
  - or move the symlink out of the vault tree.

Exit 1.


9. Error class taxonomy

Four new typed errors, all extending the existing top-level CLI error base:

ClassWhen
LocalDirtyErrorvsync pull <env> refuses because the vault has modified/added/deleted files vs. the ledger. Carries the diff payload so the CLI can format the message.
RemoteAheadErrorvsync push <env> refuses because remote gen > ledger.gen. Carries (localGen, remoteGen).
LedgerMalformedErrorLedger file exists but is unparseable JSON / wrong v / missing required field / files contains a normalised-path violation.
SymlinkInVaultErrorDirty check, pull, push, or backup encountered a symlink under infra/vault/<env>/. Carries the symlink path and its target.

None are part of the runtime-lib's error taxonomy (the lib reads VSYNC_CONFIG + VSYNC_PASSPHRASE and never touches the filesystem). The five errors from v0.16 plus these four = nine new typed errors total since v0.13.


10. Implementation slices

10.1 src/ledger.ts — new module

ts
export function ledgerPath(repo: string, env: string): string;
export function readLedger(repo: string, env: string): Ledger | null;     // null if absent
export function writeLedger(repo: string, env: string, l: Ledger): void;  // atomic write via tmp + rename
export function checkDirty(vaultDir: string, l: Ledger | null): DirtyDiff;

Atomic write: write to env_<env>.ledger.json.tmp, then rename() to final path. Avoids partially-written ledgers if the process is killed mid-write.

10.2 src/vaultwalk.ts — new module

Recursive walker that yields { path, stat } for every regular file under a root. Symlinks raise SymlinkInVaultError (caller decides whether to catch). Directories are recursed; hidden files (dotfiles) are included.

10.3 bin/pull.ts — refuse-on-dirty logic

Before the existing pull flow:

  1. Read ledger (may be null).
  2. Run checkDirty(vaultDir, ledger).
  3. If dirty and not --force and not --backup → throw LocalDirtyError.
  4. If --backup → recursive copy to backup path, then rm -rf vaultDir.
  5. If --forcerm -rf vaultDir.
  6. Proceed with existing pull (fetch + decrypt + write).
  7. Walk the freshly-written vault, write the new ledger.

10.4 bin/push.ts — refuse-on-remote-ahead logic

Before the existing push flow:

  1. Read ledger (may be null).
  2. If ledger present and not --force: HEAD on <prefix>manifest, parse generation from response.
  3. If remoteGen > ledger.generation → throw RemoteAheadError.
  4. Proceed with existing push (encrypt + upload + audit-append).
  5. Walk vault, write new ledger with the gen returned by the server.

10.5 bin/status.ts + src/status.ts — new columns

Compute local for every env on every status invocation (cheap — one tree walk). Compute remote only when --check-remote is passed (one HEAD per env). Both feed into the existing text + JSON renderers.

Add --files <env> flag for the file-level drill-down (§6.2). Add --check-remote flag (v0.13 reserved it; v0.17 implements it).

10.6 Existing modules touched

  • bin/pull.ts, bin/push.ts, bin/status.ts — new logic above.
  • src/errors.ts — four new error classes.
  • src/help.ts — updated --help text for pull/push/status.
  • No changes to src/crypto.ts, src/manifest.ts, src/sharefile.ts, src/s3.ts, src/audit.ts, or any runtime-lib code.

11. Tests

11.1 Unit tests

New test/ledger.test.ts (~10 tests):

  • Round-trip read/write.
  • Atomic write under simulated mid-write crash (kill between tmp-write and rename — ledger never half-written).
  • Missing file → null return.
  • Malformed JSON → LedgerMalformedError.
  • v ≠ 1LedgerMalformedError.
  • Missing generation / files / last_sync_atLedgerMalformedError.
  • Normalised path violation (.., leading /, backslash) → LedgerMalformedError.
  • Empty files: {} → valid (post-init, pre-pull case).
  • Ledger path computation under custom XDG_CONFIG_HOME.
  • Ledger mode is 0600.

New test/vaultwalk.test.ts (~6 tests):

  • Walks recursively, includes nested dirs.
  • Includes dotfiles.
  • Excludes the infra/vault/<env>/ parent itself (walks contents only).
  • Yields stable order (sorted by path) for snapshot tests.
  • Symlink encountered → SymlinkInVaultError.
  • Non-existent root → empty iterator (no throw).

test/pull.test.ts extensions (~8 tests):

  • Pull with no ledger → succeeds, writes ledger from new state, prints migration notice.
  • Pull with clean vault → succeeds, ledger refreshed.
  • Pull with dirty vault, no flag → LocalDirtyError, vault untouched, ledger untouched.
  • Pull with --backup and clean vault → backup written, pull proceeds.
  • Pull with --backup and dirty vault → backup contains the dirty state, pull proceeds, vault clean.
  • Pull with --force and dirty vault → vault discarded, pull proceeds.
  • --backup and --force together → parse error (mutually exclusive).
  • Backup target path is correctly computed under custom XDG_CONFIG_HOME.

test/push.test.ts extensions (~5 tests):

  • Push with no ledger → succeeds, ledger written, migration notice.
  • Push with ledger and remote gen == ledger gen → succeeds.
  • Push with ledger and remote gen > ledger gen → RemoteAheadError, no upload.
  • Push with --force and remote gen > ledger gen → succeeds, ledger updated to new gen.
  • Concurrent-process race (two pushes from same machine without intervening pull) — second push gets RemoteAheadError.

test/status.test.ts extensions (~7 tests):

  • local: clean when vault matches ledger.
  • local: dirty (N) when vault diverges; N matches len(modified) + len(added) + len(deleted).
  • local: untracked when vault exists but no ledger.
  • local: empty when vault dir doesn't exist.
  • --check-remote populates remote field; non-flag leaves it unchecked.
  • --files <env> prints the detailed file-level breakdown.
  • Notice emitted when backup dir count > 5.

test/bin-pull.test.ts / bin-push.test.ts / bin-status.test.ts — help-text tests:

  • Existing snapshot tests pick up the new flag mentions (--backup, --force, --check-remote, --files).

11.2 Integration tests (extend the v0.16 harness)

Reuse the workspace-based harness from v0.16 §11.A. Adds one new integration file:

New test/integration/pull-safety.test.ts (~6 scenarios):

#ScenarioAsserts
1Push v1, locally edit .env.dev, pull without flagExit 1; .env.dev unchanged; ledger unchanged
2Push v1, locally edit, pull --backupBackup dir at correct path contains the edited file; new vault contains v1
3Push v1, locally edit, pull --forceNo backup; new vault contains v1; edits lost
4Push v1, locally add a new asset (stripe.pem), pullExit 1; stripe.pem survives in vault
5Push v1, push v2 from a second process, push v3 from first → RemoteAheadErrorFirst's third push refused; second's v2 still on bucket
6Same as 5, but first uses --forceFirst's v3 overwrites v2

Total: ~36 net new unit tests + 6 integration scenarios. v0.16 lands around ~293; v0.17 lands around ~335.


12. Non-goals

  • Content hashing. Hash-based dirty detection is reserved for a possible future --check-content flag if mtime-only ever proves insufficient. Not in v0.17.
  • A vsync reset <env> verb. pull --force is the reset. One less subcommand to document.
  • A vsync backups list / remove verb. Operators use ls and rm -rf on the backup tree.
  • Auto-deletion of old backups. Backups are operator data; vsync doesn't garbage-collect them.
  • Symlink follow / inline. Symlinks in the vault are a configuration error, not a use case to support.
  • Sharpening RemoteAheadError to name the teammate. Possible follow-up (audit log lookup), not in v0.17.
  • Cross-machine ledger sync. The ledger is per-machine. Two machines pulling the same env will have separate ledgers — that's correct (each tracks its own sync history).
  • Dirty detection for vsync use. vsync use writes a symlink at ./.envinfra/vault/<env>/.env.dev. It doesn't modify the vault content. No ledger interaction.
  • Lockfiles. No vsync lock / vsync unlock. The mtime ledger + remote-ahead guard is the entire safety mechanism.

13. Compatibility & migration

  • Wire format (RQE1, RQEM0001, SLS1, share files, audit log): unchanged.

  • 0.16.x ↔ 0.17.0 bundles are mutually readable.

  • First-time migration: any (repo, env) without a ledger gets the one-time untracked grace on the next pull or push, prints a one-line notice, and writes the ledger from the new state. No flag changes; existing scripts keep working.

  • Operators with locally-edited vaults at the time of upgrade: their first pull sees untracked, proceeds (no clobber refusal possible — ledger doesn't exist yet), but does overwrite their edits. There is no way for v0.17 to detect divergence without a ledger. Migration warning text:

    ⚠ no ledger for dev — first sync since upgrade to v0.17.
    
    If you have local edits in infra/vault/dev/ that are NOT yet pushed,
    this pull will overwrite them.
    
    To preserve current state:
      Ctrl-C now, then either:
        vsync push dev              # if your edits are the intended state
        cp -r infra/vault/dev /tmp  # snapshot before pulling
    
    Continuing in 5 seconds...

    Five-second countdown is the safety net for the migration window. Once the ledger is written, all subsequent runs have the full safety guarantee.

  • Operators on v0.16 who never had LocalDirtyError / RemoteAheadError continue to function identically until the ledger exists.


14. Relationship to other specs

  • v0.2 (secret-lib.md) — keychain + config split. Ledger is a sibling of the config file in the same per-(repo, env) directory.
  • v0.4 (audit-log.md) — generation counter and ETag-conditional manifest are what makes the remote-ahead guard possible. v0.17 reuses them as-is; no protocol changes.
  • v0.10 (runtime-token-cli.md) — runtime-token doesn't touch the vault folder; ledger doesn't interact.
  • v0.12 / v0.15 (runtime libraries) — runtime libs are unaffected. They read bootstrap inputs and call S3; they don't have a vault folder or a ledger.
  • v0.13 (profiles-init-status.md) — status command extended (§6). --check-remote flag (reserved in v0.13) is now implemented here.
  • v0.16 (repo-identity-git-only.md) — independent. v0.17 can ship together with v0.16 in a single 0.17 release, or v0.16 can ship first as 0.17 and pull-safety as 0.18. Both options work — pick at release time based on PR readiness.

Released under the MIT License.