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 resetrejected —pull --forceis 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 earlier | v0.17 | |
|---|---|---|
vsync pull <env> when local vault has uncommitted edits | silently overwrites | refuses; 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-synced | silently overwrites remote | refuses; 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 file | did not exist | new — ${XDG_CONFIG_HOME}/vsync/<repo>/env_<env>.ledger.json |
vsync status output | per-env table without sync-state | adds 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.jsonMode 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
{
"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 }
}
}| Field | Type | Required | Notes |
|---|---|---|---|
v | integer | yes | Envelope version. v0.17 emits 1. Readers reject any other value (fail-loud per decision I). |
generation | integer | yes | The remote bundle's generation as of the last successful pull or push. Drives the lost-update guard in §5. |
last_sync_at | string (ISO-8601 UTC) | yes | Timestamp of the last successful pull or push. Drives the rename notice + display in vsync status. |
last_sync_op | string | yes | Either "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?" |
files | object | yes | Map 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
| When | What happens to the ledger |
|---|---|
vsync init <env> | No ledger written. (Vault is empty until first pull/push.) |
vsync pull <env> succeeds | Walk freshly-written vault tree; write ledger with current mtime+size for every file; generation = remote's gen; last_sync_op = "pull". |
vsync push <env> succeeds | Walk vault tree (same content that was just encrypted); write ledger; generation = new gen returned by the server; last_sync_op = "push". |
vsync pull <env> --backup | Backup dir copied first (see §7), then identical to a regular successful pull — ledger rewritten from new state. |
vsync pull <env> --force | Vault deleted, fresh pull, identical ledger write to regular pull. |
| Ledger missing on a pull | Treated as "untracked" — proceed with the pull (no clobber refusal possible, nothing to compare to). Print a one-time warning. |
| Ledger missing on a push | Same — print warning, proceed. After this push, ledger exists for future ops. |
| Ledger malformed | LedgerMalformedError (§9). Operator must delete or fix. |
Ledger v ≠ 1 | LedgerMalformedError. (Future v=2 readers handle both; v0.17 doesn't.) |
2.4 Why mtime+size, not SHA-256
| Approach | Speed (1000 files) | Reliability | Verdict |
|---|---|---|---|
| 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 rewrite | Ship this |
| Per-file SHA-256 | ~10ms/MB (must read every byte) | ✅ always correct | Overkill for the speed cost |
The mtime+size approach misses two adversarial cases:
touch -m -t YYYYMMDDHHMMreset 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.- 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
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
| Command | Behaviour on clean vault | Behaviour on dirty vault |
|---|---|---|
vsync pull <env> | Pull as before; write/refresh ledger | Refuse with LocalDirtyError (§9). Print the modified / added / deleted file list. Suggest --backup and --force. |
vsync pull <env> --backup | Backup the (clean) vault to <backup-path>/, then pull; write ledger | Backup the (dirty) vault, delete the original, pull fresh, write ledger |
vsync pull <env> --force | Delete vault, pull fresh, write ledger | Delete 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
// 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
| Command | Local ledger gen vs remote gen | Behaviour |
|---|---|---|
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 ledger | Print one-time migration notice; push proceeds; ledger written. |
vsync push <env> --force | any | Push 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 ledgerdirty (<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 genahead (gen N)— ledger gen > remote gen (impossible in practice; shows after a push if remote HEAD lagged — informational)behind (gen N)— remote gen > ledger genunknown— network error or manifest missing—— not checked (default; no--check-remotepassed)
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 syncUseful 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:
{
"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):
- Compute backup target path.
mkdir -pthe parent. - Recursive copy (not move) from
infra/vault/<env>/→ backup target. Copy preserves mode bits; mtimes preserved. Symlinks abort withSymlinkInVaultError(see §8 —--backupdoesn't get to ignore the symlink rule). - Delete the local
infra/vault/<env>/directory. - Proceed with the pull as normal — fetch, decrypt, write fresh vault, write ledger.
- 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.
8. Symlinks in the vault
The vault folder is plain data. Symlinks inside infra/vault/<env>/ are not supported:
- Dirty check: symlink encountered during
walk()→ abort the operation withSymlinkInVaultError. - 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 -rwould 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:
| Class | When |
|---|---|
LocalDirtyError | vsync 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. |
RemoteAheadError | vsync push <env> refuses because remote gen > ledger.gen. Carries (localGen, remoteGen). |
LedgerMalformedError | Ledger file exists but is unparseable JSON / wrong v / missing required field / files contains a normalised-path violation. |
SymlinkInVaultError | Dirty 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
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:
- Read ledger (may be null).
- Run
checkDirty(vaultDir, ledger). - If
dirtyand not--forceand not--backup→ throwLocalDirtyError. - If
--backup→ recursive copy to backup path, thenrm -rf vaultDir. - If
--force→rm -rf vaultDir. - Proceed with existing pull (fetch + decrypt + write).
- Walk the freshly-written vault, write the new ledger.
10.4 bin/push.ts — refuse-on-remote-ahead logic
Before the existing push flow:
- Read ledger (may be null).
- If ledger present and not
--force: HEAD on<prefix>manifest, parsegenerationfrom response. - If
remoteGen > ledger.generation→ throwRemoteAheadError. - Proceed with existing push (encrypt + upload + audit-append).
- 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--helptext 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 →
nullreturn. - Malformed JSON →
LedgerMalformedError. v ≠ 1→LedgerMalformedError.- Missing
generation/files/last_sync_at→LedgerMalformedError. - 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
--backupand clean vault → backup written, pull proceeds. - Pull with
--backupand dirty vault → backup contains the dirty state, pull proceeds, vault clean. - Pull with
--forceand dirty vault → vault discarded, pull proceeds. --backupand--forcetogether → 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
--forceand 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: cleanwhen vault matches ledger.local: dirty (N)when vault diverges; N matches len(modified) + len(added) + len(deleted).local: untrackedwhen vault exists but no ledger.local: emptywhen vault dir doesn't exist.--check-remotepopulatesremotefield; non-flag leaves itunchecked.--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):
| # | Scenario | Asserts |
|---|---|---|
| 1 | Push v1, locally edit .env.dev, pull without flag | Exit 1; .env.dev unchanged; ledger unchanged |
| 2 | Push v1, locally edit, pull --backup | Backup dir at correct path contains the edited file; new vault contains v1 |
| 3 | Push v1, locally edit, pull --force | No backup; new vault contains v1; edits lost |
| 4 | Push v1, locally add a new asset (stripe.pem), pull | Exit 1; stripe.pem survives in vault |
| 5 | Push v1, push v2 from a second process, push v3 from first → RemoteAheadError | First's third push refused; second's v2 still on bucket |
| 6 | Same as 5, but first uses --force | First'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-contentflag if mtime-only ever proves insufficient. Not in v0.17. - A
vsync reset <env>verb.pull --forceis the reset. One less subcommand to document. - A
vsync backups list / removeverb. Operators uselsandrm -rfon 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
RemoteAheadErrorto 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 usewrites a symlink at./.env→infra/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
untrackedgrace 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/RemoteAheadErrorcontinue 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-tokendoesn'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-remoteflag (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.