Every lint run now happens inside its own container, invoked through script/lint, and linting is a build step rather than a container command: a successful build of the new root Dockerfile.lint IS a clean lint. That shape also works where the docker daemon is remote and bind mounts are impossible. Its FROM line -- golangci/golangci-lint:v2.12.2, pinned by digest -- is now the only pin of the linter version in this repo. A container per run has its own lint cache and its own golangci-lint lock, both discarded with it, so neither cross-worktree contamination nor lock contention exists any more. The machinery that defended against them is therefore gone: the per-worktree cache directories, the lock-retry loop, and script/lint-audit, which existed to catch findings replayed from a cache that no longer exists. So is the host lint path in its entirety -- the native escape hatch, its version detection, and VAULTIK_LINT_IN_CONTAINER in both script/lint and the Dockerfile. Nothing lints on the host, at any version. A cached build lints nothing, so the CHECK_EPOCH mechanism the product Dockerfile already used is what makes a green mean something: ARG CHECK_EPOCH with no default, placed below the module layers so dependency caching survives, a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard so a build that withholds the arg fails instead of replaying, and the value expanded into the lint command itself. script/lint computes `epoch="$(date +%s%N)$$"` as a bare assignment on its own line, because inline in the argument a failing substitution does not abort under `set -eu` and yields a constant empty epoch -- which is exactly the false green being prevented. The product Dockerfile loses its lint stage rather than gaining a second linter pin. That stage ran `make lint`, which is now `docker build`: docker-in-docker inside a BuildKit step with no daemon. Calling golangci-lint directly there instead would have meant two independently bumpable digests for one tool. `make fmt-check` moves beside `make test` in the builder stage, and script/cibuild now builds Dockerfile.lint and then Dockerfile, each with its own fresh epoch, failing on either. Consequence, stated in comments rather than left to be discovered: script/docker builds the product image only and no longer lints; script/check and script/cibuild are the gates. `golangci-lint config verify` runs as its own epoch-keyed layer, above the lint. It is not belt-and-braces: `golangci-lint run` rejects a config it cannot PARSE but silently IGNORES an unknown top-level KEY. Renaming .golangci.yml's `linters:` to `linterz:` -- one character -- discards `default: all`, the disable list and every threshold, leaves only the small default linter set running, and exits 0 reporting `0 issues.` on a tree the real config fails with an lll finding, in a run whose lint layer demonstrably executed. That is a set-but- ineffective config falling back to defaults instead of failing loudly, sitting in the gate's own configuration. `config verify` catches it and does so with the network genuinely off at this pin: under `docker run --network none` against the pinned digest it exits 0 on this repo's config and exits 3 on the `linterz:` variant. It is keyed on CHECK_EPOCH like the lint itself, because a cached validation validates nothing. script/lint-fix is kept, reimplemented as a bind-mounted docker run against the image parsed out of Dockerfile.lint -- a build step cannot write fixes back to the worktree -- and its header states outright that it is a developer convenience, never a gate, and needs a local daemon. cmd/vaultik/lintdocker_test.go parses both Dockerfiles and both scripts and fails if any part of the mechanism is dropped: the digest pin, the defaultless ARG below `go mod download`, the emptiness guard, the expansion of the epoch into each check command, the bare per-invocation epoch assignment in both scripts, cibuild building both files, the config verification running before the lint, and -- structurally, not by searching for one retired variable name -- that no script invokes golangci-lint except through docker. Every one of those losses is silent: the build still exits 0 and nothing is checked, which is why they are asserted rather than trusted. The scanner behind the last of those has its own test, because a structural check that goes blind passes on every tree, including a broken one. script/lint takes no arguments now, and says so instead of dropping them: a build step has no command line to pass linter flags to.
34 KiB
Workflow
- branch (from
main) - do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (
TODO.mdchanges in the same commit as the work) - merge to
mainif the branch is not protected, otherwise open a PR - push
Status
pre-1.0
Next Step
Define the remaining scope for the first tagged release under the 1.0.0
milestone, then cut that tag. The mechanism to cut it now exists and is
exercised; what is left is the scope decision, which is the owner's.
This step deliberately names one version number: it previously said
"cut v0.1.0" while the Makefile baked in 1.0.0-rc.1 and the issue
milestone said 1.0.0, and three different answers to "what is the next
release" is exactly the contradiction
issue #65 was filed over.
Completed Steps
-
2026-08-10: Moved every lint run into its own container, as a build step (issue #113). New root
Dockerfile.lint, built byscript/lint, runsgolangci-lint run --config .golangci.yml ./...as aRUNinstruction in the digest-pinnedgolangci/golangci-lintimage: a successful build of that file is a clean lint, and it works even where the daemon is remote and bind mounts are impossible. ThatFROMline is now the only pin of the linter version in the repo.This supersedes the per-worktree cache isolation landed for issue #99. Isolation fixed cross-worktree contamination but not lock contention — two concurrent runs with entirely separate cache directories still collided. A container per run has its own cache and its own lock, so the whole class is gone, and with it the per-worktree cache machinery, the lock-retry loop, and
script/lint-audit, which existed to catch replayed findings from a cache that no longer exists. The host lint path went too: no escape hatch, noVAULTIK_LINT_IN_CONTAINER, no version detection. Nothing lints on the host at any version.A cached build lints nothing, so the same
CHECK_EPOCHmechanism the productDockerfilealready used is what makes the green mean something:ARG CHECK_EPOCHwith no default below the module layers, aRUN [ -n "$CHECK_EPOCH" ] || exit 1guard, the value expanded into each check command, and a fresh$(date +%s%N)$$per invocation computed as a bare assignment.cmd/vaultik/lintdocker_test.goparses both Dockerfiles and both scripts and fails if any part of that is dropped, because every way of losing it is silent. Its host-lint assertion is structural — no script runsgolangci-lintexcept throughdocker— rather than a search for the one retired variable name, which nothing could ever reintroduce.The product
Dockerfilelost its lint stage rather than gaining a second linter pin:make lintis nowdocker build, so the stage would have been docker-in-docker with no daemon, and callinggolangci-lintdirectly there would have restored the two-pins drift of issue #78.make fmt-checkmoved besidemake testin the builder stage, andscript/cibuildnow buildsDockerfile.lintand thenDockerfile, each with its own fresh epoch. Consequence, stated rather than left to be found:script/dockerbuilds the product image only and no longer lints; the gates arescript/checkandscript/cibuild.golangci-lint config verifyruns as its own epoch-keyed layer, above the lint.golangci-lint runrejects a config it cannot parse but silently ignores an unknown top-level key: renaminglinters:tolinterz:discardeddefault: alland every threshold and still exited 0 on a tree the real config fails.config verifycatches that, and it does so with the network off at this pin — checked underdocker run --network none, not assumed. An earlier revision omitted it on the claim that it fetches its schema over live HTTPS; that claim was false at v2.12.2.script/lint-fixis kept, reimplemented as a bind-mounteddocker runagainst the image parsed out ofDockerfile.lint— it cannot be a build step, because fixes have to land in the worktree — and marked in its header as a developer convenience that no gate reads. -
2026-08-09: Finished the
--jsonstdout contract and gavemake builda rule (issue #108, issue #110). Two unrelated defects of the same shape — a command reporting something it did not do — landed together because both are small.CleanupLocalSnapshotswrote three prose lines to stdout with no--jsonawareness, covering every branch of the function, so no input avoided them andvaultik prune --json | jqfailed even after issue #106 removed the banner.-qnever helped either:printlnStdoutandstdoutfwrite straight toVaultik.Stdoutand never consultVaultik.UI, which is whatSetQuietaffects. The issue offered three fixes and asked for a decision. Taken: thread*PruneOptionsinto the function and gate each write on!opts.JSON, matchingPruneBlobs(its sibling phase, which already takes the same struct),RemoveSnapshotandremote info, so the package has one pattern rather than two. Rejected: moving the lines tolog.Info, because the logger's default level isslog.LevelWarn, so that would not relocate them to stderr — it would delete them from a plainvaultik prune, and the removal of rows from the local index is not something to narrate only under--verbose. Also rejected: putting the stale-record count intoPruneBlobsResult, whose every field is blob-scoped and which is produced by the later phase; a prune document covering both phases is a reasonable thing to want, but it is a schema design question and not a stream-hygiene fix. The narration is duplicated aslog.Inforecords, whichPruneBlobsalready does alongside its own prints, so the events survive on stderr for anyone running--verbose.make buildprinted "Nothing to be done for 'build'" and exited 0 without producing a binary:buildwas listed in.PHONYwith nobuild:rule anywhere, and declaring a name phony is exactly what converts make's "No rule to make target" error into a silent success. Fixed withbuild: vaultik, keepingvaultik:as the file rule. The audit the issue asked for covers all 19.PHONYnames;buildwas the only one without a rule, andvaultikis correctly absent from.PHONY, being a real file target.Tests, each verified to fail with the fix reverted rather than assumed to:
CleanupLocalSnapshotsleaves stdout untouched under--jsonin all three branches (stale records, none, empty index) and still emits every line without it, so the guard cannot be satisfied by deleting the output;prune --jsonrun end to end throughEntry, cobra and fx over the process's real stdout descriptor against afile://store, asserting exactly one JSON document, in both the stale and non-stale branches; and a parse of theMakefileasserting every.PHONYname has a rule and thatbuildreaches the rule that produces the binary, which keeps the audit true for names added later. That last one is a parse rather than an invocation ofmake, sincemake testis what runs it and shelling back intomake buildwould nest a build inside the test run. The property a parse cannot establish — that the recipe still fails when the build fails — was verified by hand against a deliberately broken tree:make buildexits 2 and produces nothing.cmd/vaultikgains its first test file, somake testnow reports 16 packagesokwhere it reported 15. -
2026-08-09: Stopped the startup banner from contaminating
--jsondocuments (issue #106).Entrywrites the banner to stdout before cobra parses anything, and the flag scan that suppresses it knew--quiet,-qand--cronbut not--json, so every--jsondocument arrived behind two lines of prose and a blank line, andvaultik snapshot list --json | jqfailed. With the logger already on stderr from issue #82, this was the last writer that could put something on stdout that the caller did not ask for. The design question the issue raised — extend the raw-argv scan, or move the banner after parsing — is answered in favour of the scan: the banner is printed first deliberately, so that it still appears when cobra rejects the arguments and on--help, and after parsing there is no single place that covers those paths. The stated cost of the scan, that--jsonis a subcommand flag matched anywhere in the vector, is a cost--cronalready carries — it exists only onsnapshot create— so this adds an instance of an accepted imprecision rather than a new kind, and the two error directions are not symmetric: a false positive loses a decorative banner, a false negative corrupts a document. Regression tests at the CLI layer, whereinternal/vaultik's existing guard cannot reach: one runsEntryitself over the process's real stdout descriptor, through cobra and fx to the document, made hermetic byfile://storage; a second covers the argument vectors of all five--jsoncommands; a third asserts the banner is still printed without a suppressing flag, so the first cannot be satisfied by deleting the banner. Also correctedAGENTS.mdpolicy 9, which still keyed the structured-log format on stdout's TTY-ness after #82 moved that decision to stderr — a rules file that misdescribes the code misleads exactly the readers who trust it most. Two smaller findings from the same review:bytesAttrKey's human-readable byte formatting silently stopped applying under an open group, because the key reaching the comparison is group-qualified (transfer.bytes), now matched on its final segment and tested both ways; andlistEnv.stderrinsnapshot_list_test.go, assigned but never read since those tests began capturing the process's stderr, is removed.Vaultik.Stderris kept — nothing writes to it today, which its comment now says outright. -
2026-08-09: Moved the logger to stderr and fixed
TTYHandler's discarded attributes (issue #82, issue #97). Two defects ininternal/log, fixed together because both live in the handler construction path. The first: both handlers were built overos.Stdout, andWARN/ERRORare never suppressed, so a config file with group- or world-readable permissions was enough to put a log record inside a--jsondocument and breakjq. Diagnostics now go to stderr, and the TTY/JSON format choice follows stderr rather than stdout — testing the wrong stream would colorize records on a redirected stderr whenever stdout happened to be a terminal. This is user-visible:--verboseand--debugoutput moves to stderr too, which is documented inREADME.mdunder "stdout and stderr". It also let the local workaround ininternal/vaultik/snapshot_list.gogo:warnWhileListinghad been hand-rolling structured-log formatting to reach a non-stdout writer, and thejsonOutputparameter threaded through the remote-listing helpers existed only to choose between the two writers. The collect-then-emit machinery aroundlistingWarningstays, but on its remaining merit — warnings emitted in key order aftergroup.Wait()are deterministic run to run, where emitting from the fetch workers would order them by network timing. The second defect:TTYHandler.WithAttrsandWithGroupdiscarded their arguments and returned the receiver while their doc comments claimed otherwise, solog.Withattributes vanished on a terminal and appeared correctly in CI — failing precisely when someone is debugging interactively. Both now return a new handler (the receiver is never written to, sinceslogpermits concurrent derivation), attributes persist across records, and grouping is implemented as dotted key prefixes, which is the only honest rendering for a format with nowhere to nest. New tests cover both, including one that feeds the same derivation chain to the TTY and JSON handlers and compares the attribute sets, so the two paths cannot drift apart again. Found and filed while verifying: the startup banner is written to stdout and--jsondoes not suppress it (issue #106), which is a separate writer on a separate path and the remaining source of stdout contamination. -
2026-08-09: Made the tagged-release path actually work on Gitea (issue #65). Three independent blockers, one of which was the whole release:
.goreleaser.yamlhad nogitea_urls:block, so goreleaser defaulted to the GitHub API and agoreleaser releasefrom this repo would have failed or published where nobody is looking. It now points athttps://git.eeqj.de/api/v1. The version is the second: it was a hardcodedVERSION := 1.0.0-rc.1in theMakefile, so every local build claimed to be a release candidate that had never been tagged and did not exist, whilegit tag -lwas empty andinternal/globalsdefaulted todev. Version now comes from git via the newscript/version— the exact tag with a leadingvstripped (so amakebuild and a goreleaser build of one commit report the same string, and it matches the archive names), otherwisedev-<12-char sha>, with-dirtyappended in either case when tracked files are modified. Untracked files are deliberately not counted, matchinggit describe --dirty. The same honesty was owed by the snapshot path:snapshot.version_templatewas{{ incpatch .Version }}-next, which manufactures a release number from the last tag and, with no tags at all, from goreleaser's fabricatedv0.0.0; it now emits the samedev-<sha>. The one non-obvious consequence is thatinternal/cli/version.gogated its "this is a development build" notice on the version being exactlydev, so the moment untagged builds began carrying a commit sha that notice would have gone silent and an unreleased binary would have read as a release — the gate is nowglobals.IsDevVersion, which is a predicate over a string rather than a comparison against a global precisely so it can be tested, and it is tested at the boundary (1.0.0-devis a release,dev-<sha>is not). Release automation is the third blocker: a tag-triggered.gitea/workflows/release.ymlruns the build in CI rather than from a laptop, withfetch-depth: 0because a shallow checkout has no tags and would silently mislabel the release, and with theRELEASE_TOKENrepository secret passed asGITEA_TOKEN(documented inREADME.md; the runner's automatic token is not used because it is not guaranteed to carry release write scope).script/releaseunsets anyGITHUB_TOKEN/GITLAB_TOKENit finds, since goreleaser chooses its forge from whichever token variable is set and refuses to run when it sees more than one — a runner-provided token must not get to decide where these artifacts are published.make releaseandmake release-snapshot, the last two Makefile targets that were not shims, now callscript/releaseandscript/release-snapshot, which resolve goreleaser exactly the wayscript/lintresolves the linter: aPATHbinary is used only at the pinned version, never as a silent fallback.script/bootstrapinstalls it, from a sha256-verified GitHub release archive perREPO_POLICIES.md, via a separatescript/install-goreleaser— separate becausescript/bootstraphard-fails without a usable Docker daemon by design, and the release runner needs goreleaser without needing Docker. Verified by running the thing rather than reading it:make release-snapshotproduced four archives andchecksums.txt, and the linux/amd64 binary fromdist/reportsdev-<sha>with the development-build notice. Tag handling was exercised in a throwaway repository rather than by tagging this one; no tag was created here, since that is the owner's call. Signing, SBOM, reproducible builds, completions and a man page are out of scope by the issue. -
2026-08-09: Isolated the lint cache per worktree and context-gated the native lint path (issues #99, #80). One defect seen twice:
script/lintdecided whether it could skip the pinned image by asking what version was onPATHrather than where it was running, and cache isolation is part of that same question. The cache was one directory per repo, shared by every worktree on the host, so two checkouts with identical Go file contents collided and golangci-lint replayed the stored analysis — paths and all. The loud direction of that failure (a clean tree failed by a dirty sibling) is the harmless one; the silent direction, a dirty tree passed by a clean sibling, is a sixth way for a gate here to report a green it did not earn. The cache is now keyed on a digest of the worktree path, and every run is audited by the newscript/lint-audit, which rejects output citing any file that is not in the tree being linted — a backstop that runs on clean output too, because that is the case nobody investigates. Caches record the worktree they belong to and are collected when it disappears, so throwaway worktrees do not accumulate them; the whole tree lives underXDG_CACHE_HOMEand is disposable. Theparallel golangci-lint is runningrefusal is now a bounded retry rather than a verdict: it is not a lint result, and exiting non-zero on it is indistinguishable to a caller from real findings (#88 showed a private cache does not remove that contention). The native path now requiresVAULTIK_LINT_IN_CONTAINER=1, set only by theDockerfilelint stage, in addition to matching the pin, so a developer's locally installed 2.12.2 no longer bypasses the digest pin;/.dockerenvwas rejected as the signal becausedockerdcreates it fordocker runand it is not reliably present during a BuildKitdocker build, which is the case the exception exists for. Version detection usesgolangci-lint version --shortwith the old banner scrape kept only as a fallback.script/bootstrapno longer printsbootstrap completeon a machine that cannot run the gate: a missing docker, or one whose daemon is unreachable, is a hard failure naming exactly what breaks. Verification was by reproduction rather than inspection — two concurrent lints from two worktrees of differing cleanliness, a real run made to report an outside path, a matching linter shimmed ontoPATH, and aPATHwith docker removed — and is recorded on the pull request. -
2026-08-09: Closed the fifth false-green mechanism (issues #93, #69).
script/testomitted-count=1, so Go's test result cache could satisfy the gate outright: a second back-to-backmake testprinted the full set of 14oklines, every one marked(cached), having executed no test at all. Sinceok <pkg> (cached)is anokline, the "14oklines means the suite ran" signal this repo leans on was forgeable, one level below the Docker layer cache that #85 addressed. Fixed with-count=1unconditionally rather than only in the container, because the pre-commit hook runs the same script and a gate honest only in CI is dishonest where people rely on it most;test-coveragegot the same flag, andscript/checkinherits it by callingscript/test. In the same area,make test-integrationwas deleted rather than made real: no file in the repo carried a build tag, so-tags=integrationselected nothing and the target was an exact duplicate ofmake test. Tagging a subset was rejected because the entire suite runs in well under a minute, and a scheme whose failure mode is "some tests silently stopped running" is a poor trade for those seconds in a repo with this particular history. The-timeoutwas raised from 30s after measuring rather than after assuming: the standing claim that cold-cache compilation is charged against-timeoutis false, disproved by a containerised run that spent 46s compiling and still reported per-package durations within noise of a warm host run.-timeoutreaches the test binary as-test.timeoutand its clock starts insidetesting.M.Run, after the build. The real exposure was margin, not compilation. The 120s landed on is a deliberate, documented divergence fromREPO_POLICIES.md:192, which mandates 30s, and from that file's canonical recipe at:212-214; the divergence is recorded inscript/test's comment becauseREPO_POLICIES.mdis org-canonical and not editable here, and issue #101 proposes amending the policy text upstream. Numbers and the full verification are recorded once, on the pull request, and are deliberately not restated here. -
2026-08-09: Triaged all fifteen stale remote branches (issue #71) and deleted fourteen of them; the full per-branch disposition with evidence is recorded on that issue. Method mattered more than the outcome here: a three-dot
git diff main...branchdiffs from the merge base, so it replays everything that landed onmainafter the branch diverged and makes any old branch look like it holds unlanded work. That artifact is what madegolangci-v2.12.2appear to carry 126 files of unpushed changes when its tree was byte-identical tomain's. Every containment claim here therefore rests on two-dot tip diffs, tree-hash equality,git cherry, andgit branch -r --merged. Nine branches were plain ancestors ofmainwith zerogit cherry+commits.golangci-v2.12.2had landed squashed ascc58583, whose tree hash equals the branch tip's exactly; note the hash recorded in the issue had gone stale becausemainadvanced, so the check had to be redone rather than repeated.fix/sync-snapshot-cleanupwas redundant, its one line already onmaininsyncWithRemote.feature/restore-progress-barwas superseded byprintRestoreProgressand the disk-backed blob cache, and had become actively regressive — it would have deletedinternal/blobgen/compress_test.go, the #28 regression test that landed separately. The two branches this issue was filed for both turned out to be closed questions thatmainhad already moved past by a recorded decision, so neither was landed and no regression test was owed:ctimeno longer exists anywhere in the codebase after1c72a37removed the column, theFile.CTimefield and every use (#54/#55), and change detection compares size, mtime, mode, uid and gid only, exactly asARCHITECTURE.mddocuments — so the silently-skipped-file data-loss risk that made this a 1.0 item does not exist. The SQL allow-list branch would have revertedbfd7334, which replaced that very allow-list with regex sanitisation on review feedback, and would have brokengetTableCount("snapshots")because its allow-list omits that table.feature/daemon-modeis untouched and deferred to #94 pending an owner decision, so it is the one branch besidesmainstill on the remote. The staleTODO.mdentry named in the issue needed no fix:e496aa3had already removed it. No product code changed. -
2026-08-09: Adopted the remaining upstream
CHECK_EPOCHhardening (issue #91), closing the gap #85 knowingly left open. Four changes, all four decided as adopt upstream insneak/prompts#26. (1) Each check stage now asserts[ -n "$CHECK_EPOCH" ] || exit 1before running anything, so a build that supplies no--build-argfails instead of lying. This is the item that mattered: an unsetARGis an empty string and an empty string is a stable cache key, so the second and every later baredocker build .on an unchanged tree replayed all three check layers and still exited 0 — anddocker build .is the commandREPO_POLICIES.mdnames verbatim as a thing that must be green, so the documented command was precisely the one that lied. Failed steps are never cached, which is what makes the guard fire on every invocation rather than once. (2) The epoch is now expanded into each check command rather than left as a bare declaration, so the cache miss no longer depends on BuildKit's unreferenced-ARGhandling staying as it is, and the value appears in the build log. (3)script/cibuildusesepoch="$(date +%s%N)$$", unique per invocation rather than per second;%Nalone is insufficient because busybox drops it silently and exits 0, and$$is what makes the guarantee hold regardless. The bare-assignment form is kept deliberately — inlined in an argument, a failing substitution does not abort underset -euand would yield an empty constant epoch, restoring the exact false green being fixed. (4)script/dockerpasses the same fresh arg, so the two entrypoints cannot disagree about whether the tree is green; local builds are almost always warm, which made it the likelier fooling in practice. TheARGplacement from #85 is unchanged, belowapk add,COPY go.mod go.sumandgo mod download, so dependency layers still cache and the build is not cold. Verified by negative control rather than inspection — a baredocker build .run twice back to back, plus back-to-back pairs of both scripts and a host-sidemake check; the measurements are recorded once, in the PR verification comment, rather than restated here..golangci.yml, the lint-stageFROMline and its digest,script/lint,REPO_POLICIES.mdand.gitea/workflows/check.ymlare all untouched. -
2026-08-09: Stopped
script/cibuildfrom reporting a green it did not earn (issue #85). A baredocker build .let Docker serve the check layers from the layer cache whenever the tree had not changed: the checks never executed and the build still exited 0. The fix is anARG CHECK_EPOCHdeclared immediately above the checkRUNs in both the lint stage and the builder stage (ARGscope is per-stage, so each declares its own), withscript/cibuildassigningepoch="$(date +%s)"and passing--build-arg CHECK_EPOCH="$epoch". The assignment is separate on purpose: underset -eua command substitution that fails inside an argument does not abort the script, which would leave an empty constantCHECK_EPOCHand restore the very false green being fixed. Placement is the rest of the point — theARGsits below theapk add,COPY go.mod go.sum, andgo mod downloadlayers, so only the checks are invalidated and the dependency layers still cache. The guarantee is conditional on a fresh value rather than absolute: a baredocker build .gets an emptyCHECK_EPOCHand can still serve the check layers from cache, whichREADME.mdand theDockerfilenow say plainly, with issue #91 tracking the upstream hardening (expandedARGform, unset guard, per-invocation epoch,script/docker) that would close it. Verified by re-running the reproduction plus the withheld---build-argcounterfactual; the measurements are recorded once, in the PR #89 verification comment, rather than restated here..golangci.yml, the lint-stageFROMline and its digest,script/lint, and.gitea/workflows/check.ymlare all untouched. -
2026-08-09: Corrected the
Vaultik.UIdoc comment (issue #84). It claimed the cli layer replaces the writer with a discarding one in--cronmode; the actual mechanism isUI.SetQuiet(true)insetupGlobals, which drops Begin/Complete/Info/Notice/Detail/ Progress/Banner but still emits Warning and Error. The--cronline inREADME.mdsaid "Silent unless error", which understated what survives, and now names warnings too. The other--croncomments (internal/log/log.go,internal/cli/snapshot.go,internal/vaultik/snapshot.go) were audited and already accurate. Comments and docs only, no behavior change. -
2026-08-09: Made
snapshot listlist the destination store without the private key (issue #64). The listing is now the union of the local index and a single streamed listing of themetadata/prefix, with noage_secret_keygate — the manifest is unencrypted, so a host holding only the public key can enumerate its own backups and a host that lost its local index can still see them. A remote-only snapshot's hostname and name are deliberately not recovered (they are not recoverable without the private key, and making them so would undo the privacy property tracked in issue #81); such rows are labelled by an abbreviation of their remote key and carry the real timestamp and compressed size from the manifest, with<remote only>in the two columns that require the local index. Local-only snapshots are reported as drift, and the hint now namesvaultik prune, which exists, instead ofvaultik snapshot cleanup, which does not.reportRemoteDriftcollapsed into the merged view. Every remote manifest read in the codebase now goes throughdownloadManifestByKey, so issue #81 has one call site to change. Review rework: snapshot timestamps now normalize to UTC inscanSnapshotRows, the one place they enter the domain, so the merged TIMESTAMP column cannot show local time for a locally tracked row and UTC for a remote-only row on a non-UTC host;GetIncompleteByHostnamewas folded onto that same scanner.--jsonnow reports the unreadable-manifest count and the 1000-row truncation on stderr instead of returning a silently short document (the document's shape is unchanged). The two per-snapshotlog.Warncalls on the listing path now route through the same JSON-aware writer as the existing workaround, so one corrupt manifest can no longer put a log line on stdout ahead of the document and break| jq— still a local workaround pending issue #82. Verified withscript/cibuildand with an uncachedmake check(0 issues., no cached test packages), plus end to end against afile://destination with no secret key present. -
2026-08-09: Closed the gap between
make lintand CI (issue #78).script/lintnow runs the digest-pinnedgolangci-lintimage taken from theDockerfilelint stage, which is the single source of truth for the linter version; the duplicate pin in theMakefiledepstarget and the unpinnedgolangci-lintinstall inscript/bootstrapare gone. Agolangci-lintonPATHis used only when its version is exactly the pinned one (which is how the lint stage runs it inside the container); anything else goes through Docker, and a missing or unreachable Docker daemon is a hard error rather than a silent fallback. Only the lint leg ofmake checkbecame equivalent toscript/cibuild; its tests andgofmtstill run on the host against the host toolchain, asREADME.mdstates. An earlier version of this entry claimedmake checkwas "as trustworthy asscript/cibuild" outright, which overstated it; corrected under issue #80. -
2026-08-09: Finished the lint remediation under the canonical
.golangci.yml(issue #61, which also unblocks issue #59). The remaining findings were fixed behavior-preservingly:wsl_v5whitespace,sqlclosecheck, andprealloc. Thesqlclosechecksites now closesql.Rowsin a deferred closure instead of via theCloseRowshelper, which the linter could not see through. Only therevivepackage-name findings remain suppressed, with per-site//nolintdirectives; the package-rename question behind them is tracked in issue #76. Verified withscript/cibuild, which exits 0 — that is the only trustworthy gate, becausescript/lintruns whatevergolangci-linthappens to be onPATHrather than the pinned v2.12.2 that CI and theDockerfileuse, somake checkcan report green on findings CI still fails. That tooling gap is tracked in issue #78. -
2026-08-09: The earlier next step "reconcile the uncommitted
ARCHITECTURE.mdedits onmain" needed no work: the working tree is clean andARCHITECTURE.mdis committed onmain. -
2026-08-07: Updated golangci-lint to v2.12.2 everywhere it is pinned (
Dockerfilelint stage,Makefiledeps target), replaced.golangci.ymlwith the canonical config (v2 schema,default: all), and remediated the bulk of the lint findings it surfaced (issue #61): behavior-preserving fixes across every package, 2,990 findings down to 80.make testandmake fmt-checkwere green at that point butmake lintwas still red; the commit message claimingmake checkwas green was wrong. -
2026-08-07: Added the standard
.golangci.ymland.editorconfig(issue #59); lint findings under the new config are tracked in issue #61.script/bootstrapnow installs sqlite3 (needed by tests). -
2026-07-07 Adopted scripts-to-rule-them-all:
script/entrypoints, Makefile shims, README Entrypoints section -
2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound the local index to its backup destination URL.
-
2026-06-28: snapshot rm now removes metadata only and prints the prune command; restore skips chown when running as non-root.
-
2026-06-26: Snapshot IDs hashed at the storage boundary; snapshot list made resilient to bad remote entries.
-
2026-06-24: Collapsed snapshot prune into vaultik prune; restore streams blobs to disk and restores files in blob-locality order; cron output fixes.
-
2026-06-17: Restore overhaul: ReadAt chunk reads from cached blobs, reference-counted blob sweeper, integration tests; new internal/ui output layer, banner, and progress lines.
-
2025-12-18: Added ARCHITECTURE.md and godoc coverage for exported API.
-
2025-07-26: End-to-end integration tests; manifest format refactor; renamed backup to snapshot; afero filesystem abstraction.
-
2025-07-20: Initial design and implementation: cobra + fx CLI skeleton, SQLite index database, UUID blob storage with streaming chunking.
Future Steps
None queued; the release-scoping item is now the Next Step.