Two defects in internal/log, fixed in one pass because both live in
the handler construction path.
USER-VISIBLE CHANGE: --verbose and --debug output moves to stderr
All log output now goes to stderr. That includes everything from --verbose and --debug, not just warnings and errors.
vaultik snapshot list --verbose > out.txt used to capture the
diagnostics along with the listing. It no longer does — they stay on the
terminal. Capturing both now needs > out.txt 2> log.txt, or > out.txt 2>&1 to interleave them. Anyone with a script or a cron
wrapper that redirects only stdout and expects the log in the file will
see the change.
This is the intended contract rather than a side effect: stdout carries
the output that was asked for, stderr carries diagnostics. README.md
gains a "stdout and stderr" section stating it, and the --verbose / --debug flag entries point at it.
Initialize built both handlers over os.Stdout. Every --json
subcommand writes its document to that same stream, and WARN/ERROR
are never suppressed by any flag, so a log record could land inside a
JSON document and break the parse.
Reproduced against main with the trigger from the report — a config
file at mode 0644, which fires the insecure-permissions WARN in internal/config:
$ vaultik -q --config /tmp/demo/config.yml snapshot list --json
{"time":"...","level":"WARN","msg":"Config file has insecure permissions (contains S3 credentials)",...}
[]
$ ... | python3 -c 'import json,sys; json.load(sys.stdin)'
json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 279)
Same config, same command, this branch:
$ vaultik -q --config /tmp/demo/config.yml snapshot list --json
[]
with the WARN on stderr, and json.load accepting stdout.
The TTY/JSON format choice moved to os.Stderr along with the sink. It
has to follow the stream the records land on: testing stdout would
colorize records on a redirected stderr whenever stdout happened to be a
terminal, and emit JSON at a terminal in the reverse case.
--quiet and --cron semantics are unchanged. Level selection is not
touched, ui.Writer's quiet handling is not touched, and the run above
used -q — the warning still fired, which is the documented behaviour
(see issue #84): UI.SetQuiet(true) suppresses Begin/Complete/Info/Notice/Detail/
Progress/Banner and never Warning/Error.
The snapshot_list.go workaround is gone
warnWhileListing was hand-rolling structured-log formatting
(fmt.Fprintf(&line, " %v=%v", ...) and a kvPairSize constant) purely
to reach a writer that was not stdout, and the jsonOutput parameter
threaded through collectRemoteSnapshots and describeRemoteOnlySnapshots existed only to pick between the two
writers. Both are deleted; those warnings go through log.Warn in every
mode. reportJSONListingLimits likewise moved from fmt.Fprintf(v.Stderr, ...) to log.Warn with structured fields.
warnRemoteListingFailed keeps its jsonOutput branch, for a reason
that is still true after this change and is now what the comment says:
table mode wants the prose v.UI.Warningf line, and v.UI writes to
stdout, so --json mode goes through the logger instead.
The listingWarning collect-then-emit machinery stays, with a
rewritten rationale. Its original justification — the chosen writer is
not safe for concurrent use — is obsolete, since slog handlers are.
What remains is ordering: emitting from the manifest-fetch workers would
order warnings by network timing, while collecting them and emitting in
key order after group.Wait() makes two runs over the same damaged
store produce the same diagnostics in the same order. No comment
anywhere still refers to this as pending work.
Both returned the receiver and dropped their argument while their doc
comments claimed otherwise. Because the handler is selected by TTY-ness,
attributes vanished on a terminal and were correct in CI — it failed
precisely when someone was debugging interactively.
WithAttrs retains the attributes and emits them on every subsequent
record.
WithGroup is implemented rather than deferred: this format is one
line with nowhere to nest, so a group becomes a dotted key prefix
(WithGroup("db").With("rows", 3) renders db.rows=3). Record-level slog.KindGroup values flatten the same way. Empty attrs are dropped
and an empty-key group is inlined, per the slog.Handler contract.
Both return a new handler. The receiver is never written to, and
the clone copies its slices rather than reslicing them, so two
concurrent derivations cannot append over each other's attribute. The
mutex became a *sync.Mutex so handlers sharing a stream keep sharing
one lock — a value mutex would have given every derived handler its
own and stopped serializing writes.
The // Simplified for now comments are gone, and the doc comments
now describe what the code does.
The tests fail against the unfixed handler
Verified by observation, not assumption: internal/log/tty_handler.go
was reverted to its main version with the new tests in place, and make test reported
The handler was then restored and the suite went green.
TestWithAttributesReachTTYOutput goes through the exported internal/log.With, which is the reachable API the issue names. It is an
in-package test because that is the only way to point the package logger
at a buffer; //nolint:testpackage carries the reason.
TestTTYHandlerMatchesJSONHandlerAttributes feeds both handlers the same
derivation chain and the same record and compares the emitted attribute
sets, flattening JSONHandler's nesting to the same dotted keys. That is
the drift guard the issue asks for.
Existing tests
Four internal/vaultik tests asserted these warnings on the injected v.Stderr buffer. They now capture the process's real stderr through a
new captureProcessStderr helper, since that is where the warnings go;
the guarantees asserted are unchanged. captureProcessStdout keeps
rebuilding the logger on purpose — that is what would catch a logger
regressing back to stdout — and its doc comment no longer cites issue #82 as the reason
no injectable sink exists.
Found while verifying, filed not fixed
Issue #106: the startup
banner is written to stdout by internal/cli/entry.go before cobra
parses anything, and bannerSuppressedInArgs recognizes only --quiet/-q/--cron, not --json. So every --json document is
still preceded by two banner lines and a blank line unless -q is
passed. That is a different writer on a different path from the logger,
so it is filed rather than fixed here — but it means stdout is not fully
clean until that one lands too. The reproductions above use -q for
that reason.
Verification
make check: green. Tests, lint, and fmt-check all pass; the only
linter output is the gomodguard deprecation already tracked as issue #90.
script/cibuild: exit 0, 140s wall. The three check layers executed
rather than replaying from cache (make fmt-check 2.4s, make lint
40.4s, make test 58.5s under the fresh CHECK_EPOCH); the 10 CACHED layers are dependency and module layers. 14 ok lines, zero (cached) markers.
End-to-end with a real 0644 config and a file:// destination, before
and after, as shown above.
Closes [issue #82](https://git.eeqj.de/sneak/vaultik/issues/82).
Closes [issue #97](https://git.eeqj.de/sneak/vaultik/issues/97).
Two defects in `internal/log`, fixed in one pass because both live in
the handler construction path.
## USER-VISIBLE CHANGE: `--verbose` and `--debug` output moves to stderr
All log output now goes to stderr. That includes everything from
`--verbose` and `--debug`, not just warnings and errors.
`vaultik snapshot list --verbose > out.txt` used to capture the
diagnostics along with the listing. It no longer does — they stay on the
terminal. Capturing both now needs `> out.txt 2> log.txt`, or
`> out.txt 2>&1` to interleave them. Anyone with a script or a cron
wrapper that redirects only stdout and expects the log in the file will
see the change.
This is the intended contract rather than a side effect: stdout carries
the output that was asked for, stderr carries diagnostics. `README.md`
gains a "stdout and stderr" section stating it, and the `--verbose` /
`--debug` flag entries point at it.
## #82: the logger wrote to stdout
`Initialize` built both handlers over `os.Stdout`. Every `--json`
subcommand writes its document to that same stream, and `WARN`/`ERROR`
are never suppressed by any flag, so a log record could land inside a
JSON document and break the parse.
Reproduced against `main` with the trigger from the report — a config
file at mode 0644, which fires the insecure-permissions `WARN` in
`internal/config`:
```
$ vaultik -q --config /tmp/demo/config.yml snapshot list --json
{"time":"...","level":"WARN","msg":"Config file has insecure permissions (contains S3 credentials)",...}
[]
$ ... | python3 -c 'import json,sys; json.load(sys.stdin)'
json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 279)
```
Same config, same command, this branch:
```
$ vaultik -q --config /tmp/demo/config.yml snapshot list --json
[]
```
with the `WARN` on stderr, and `json.load` accepting stdout.
The TTY/JSON format choice moved to `os.Stderr` along with the sink. It
has to follow the stream the records land on: testing stdout would
colorize records on a redirected stderr whenever stdout happened to be a
terminal, and emit JSON at a terminal in the reverse case.
`--quiet` and `--cron` semantics are unchanged. Level selection is not
touched, `ui.Writer`'s quiet handling is not touched, and the run above
used `-q` — the warning still fired, which is the documented behaviour
(see [issue #84](https://git.eeqj.de/sneak/vaultik/issues/84)):
`UI.SetQuiet(true)` suppresses Begin/Complete/Info/Notice/Detail/
Progress/Banner and never Warning/Error.
### The `snapshot_list.go` workaround is gone
`warnWhileListing` was hand-rolling structured-log formatting
(`fmt.Fprintf(&line, " %v=%v", ...)` and a `kvPairSize` constant) purely
to reach a writer that was not stdout, and the `jsonOutput` parameter
threaded through `collectRemoteSnapshots` and
`describeRemoteOnlySnapshots` existed only to pick between the two
writers. Both are deleted; those warnings go through `log.Warn` in every
mode. `reportJSONListingLimits` likewise moved from
`fmt.Fprintf(v.Stderr, ...)` to `log.Warn` with structured fields.
`warnRemoteListingFailed` keeps its `jsonOutput` branch, for a reason
that is still true after this change and is now what the comment says:
table mode wants the prose `v.UI.Warningf` line, and `v.UI` writes to
stdout, so `--json` mode goes through the logger instead.
The `listingWarning` collect-then-emit machinery **stays**, with a
rewritten rationale. Its original justification — the chosen writer is
not safe for concurrent use — is obsolete, since `slog` handlers are.
What remains is ordering: emitting from the manifest-fetch workers would
order warnings by network timing, while collecting them and emitting in
key order after `group.Wait()` makes two runs over the same damaged
store produce the same diagnostics in the same order. No comment
anywhere still refers to this as pending work.
## #97: `TTYHandler` discarded `WithAttrs` and `WithGroup`
Both returned the receiver and dropped their argument while their doc
comments claimed otherwise. Because the handler is selected by TTY-ness,
attributes vanished on a terminal and were correct in CI — it failed
precisely when someone was debugging interactively.
- `WithAttrs` retains the attributes and emits them on every subsequent
record.
- `WithGroup` is implemented rather than deferred: this format is one
line with nowhere to nest, so a group becomes a dotted key prefix
(`WithGroup("db").With("rows", 3)` renders `db.rows=3`). Record-level
`slog.KindGroup` values flatten the same way. Empty attrs are dropped
and an empty-key group is inlined, per the `slog.Handler` contract.
- Both return a **new** handler. The receiver is never written to, and
the clone copies its slices rather than reslicing them, so two
concurrent derivations cannot append over each other's attribute. The
mutex became a `*sync.Mutex` so handlers sharing a stream keep sharing
one lock — a value mutex would have given every derived handler its
own and stopped serializing writes.
- The `// Simplified for now` comments are gone, and the doc comments
now describe what the code does.
### The tests fail against the unfixed handler
Verified by observation, not assumption: `internal/log/tty_handler.go`
was reverted to its `main` version with the new tests in place, and
`make test` reported
```
--- FAIL: TestWithAttributesReachTTYOutput
--- FAIL: TestTTYHandlerWithAttrsEmitsAttributes
--- FAIL: TestTTYHandlerWithAttrsPersistsAcrossRecords
--- FAIL: TestTTYHandlerWithGroupQualifiesKeys
--- FAIL: TestTTYHandlerWithAttrsDoesNotMutateReceiver
--- FAIL: TestTTYHandlerConcurrentDerivation
--- FAIL: TestTTYHandlerEmptyGroupAndAttrsAreNoOps
--- FAIL: TestTTYHandlerMatchesJSONHandlerAttributes
--- FAIL: .../handler_attributes
--- FAIL: .../handler_attributes_accumulate
--- FAIL: .../group_qualifies_later_attributes
--- FAIL: .../nested_groups
--- FAIL: .../attributes_before_and_after_a_group
--- FAIL: .../inline_group_value_on_the_record
FAIL sneak.berlin/go/vaultik/internal/log
```
The handler was then restored and the suite went green.
`TestWithAttributesReachTTYOutput` goes through the exported
`internal/log.With`, which is the reachable API the issue names. It is an
in-package test because that is the only way to point the package logger
at a buffer; `//nolint:testpackage` carries the reason.
`TestTTYHandlerMatchesJSONHandlerAttributes` feeds both handlers the same
derivation chain and the same record and compares the emitted attribute
sets, flattening `JSONHandler`'s nesting to the same dotted keys. That is
the drift guard the issue asks for.
## Existing tests
Four `internal/vaultik` tests asserted these warnings on the injected
`v.Stderr` buffer. They now capture the process's real stderr through a
new `captureProcessStderr` helper, since that is where the warnings go;
the guarantees asserted are unchanged. `captureProcessStdout` keeps
rebuilding the logger on purpose — that is what would catch a logger
regressing back to stdout — and its doc comment no longer cites
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82) as the reason
no injectable sink exists.
## Found while verifying, filed not fixed
[Issue #106](https://git.eeqj.de/sneak/vaultik/issues/106): the startup
banner is written to stdout by `internal/cli/entry.go` before cobra
parses anything, and `bannerSuppressedInArgs` recognizes only
`--quiet`/`-q`/`--cron`, not `--json`. So every `--json` document is
still preceded by two banner lines and a blank line unless `-q` is
passed. That is a different writer on a different path from the logger,
so it is filed rather than fixed here — but it means stdout is not fully
clean until that one lands too. The reproductions above use `-q` for
that reason.
## Verification
- `make check`: green. Tests, lint, and fmt-check all pass; the only
linter output is the `gomodguard` deprecation already tracked as
[issue #90](https://git.eeqj.de/sneak/vaultik/issues/90).
- `script/cibuild`: exit 0, 140s wall. The three check layers executed
rather than replaying from cache (`make fmt-check` 2.4s, `make lint`
40.4s, `make test` 58.5s under the fresh `CHECK_EPOCH`); the 10
`CACHED` layers are dependency and module layers. 14 `ok` lines, zero
`(cached)` markers.
- End-to-end with a real 0644 config and a `file://` destination, before
and after, as shown above.
- `.golangci.yml` unchanged: sha256
`021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`.
- No tags created; `git tag -l` is empty.
Two defects in internal/log, fixed together because both live in the
handler construction path.
Logger on stdout (#82). Initialize built both handlers over os.Stdout.
Every --json subcommand writes its document to that same stream, and
WARN/ERROR are never suppressed by any flag, so a log record could land
inside a JSON document and break the parse. This was not theoretical: a
config file with group- or world-readable permissions triggers a WARN
during startup, which was enough to make `snapshot list --json | jq`
fail. Diagnostics now go to stderr, and the TTY/JSON format choice
follows stderr's TTY-ness rather than stdout's -- testing the wrong
stream would colorize records on a redirected stderr whenever stdout
happened to be a terminal, and emit JSON to a terminal in the reverse
case.
This is user-visible: --verbose and --debug output moves to stderr as
well, so `vaultik snapshot list -v > out.txt` no longer captures the
diagnostics. README.md documents the split under a new "stdout and
stderr" section. --quiet and --cron semantics are untouched: level
selection is unchanged, and warnings and errors are still emitted in
both modes.
It also retires the local workaround in internal/vaultik/snapshot_list.go.
warnWhileListing had been hand-rolling structured-log formatting to reach
a writer that was not stdout, and the jsonOutput parameter threaded
through the remote-listing helpers existed only to choose between the two
writers; both are gone, and those warnings go through log.Warn in every
mode. The collect-then-emit machinery around listingWarning stays, on
its remaining merit rather than its original one: slog handlers are safe
for concurrent use, but emitting from the manifest-fetch workers would
order warnings by network timing, where collecting and emitting in key
order after group.Wait makes two runs over the same damaged store
produce the same diagnostics in the same order.
TTYHandler dropped attributes (#97). WithAttrs and WithGroup discarded
their arguments and returned the receiver, while their doc comments
claimed the opposite. Because the handler is chosen by TTY-ness, this
failed only on a terminal and worked correctly in CI -- so it broke
exactly when someone was debugging interactively. Both now return a new
handler: the receiver is never written to, since slog permits a handler
to be shared and derived from concurrently, and the derived handler
copies its slices rather than reslicing so two concurrent derivations
cannot overwrite each other's attributes. The mutex became a pointer so
handlers sharing a stream keep sharing one lock. Attributes persist
across every subsequent record, and grouping is implemented as dotted
key prefixes, which is the only honest rendering for a format with
nowhere to nest.
Tests: an attribute attached through the exported log.With reaches
TTYHandler output; attributes persist across records; groups qualify
keys; deriving does not leak between siblings or back to the parent;
sixteen goroutines derive from and write through one handler under
-race; and the TTY and JSON handlers are fed identical derivation chains
and compared attribute set by attribute set, which is the test that
would have caught the original defect and the one that stops the two
paths drifting again. All of these fail against the unfixed handler.
The existing snapshot-list tests that asserted these warnings on an
injected writer now capture the process's real stderr, which is where
they go; the assertions are otherwise unchanged.
#97 handler correctness.WithAttrs/WithGroup never write to
the receiver; clone() copies rather than reslices attrs/groups,
so concurrent derivation from one parent cannot interleave appends.
The *sync.Mutex is load-bearing — TestTTYHandlerConcurrentDerivation
has 16 goroutines writing through derived handlers into one bytes.Buffer, which is only safe because the pointer is shared. The slog.Handler contract corners are right: WithGroup("") and WithAttrs(nil) return the receiver, empty Attr dropped, empty
group dropped, empty-key group inlined, KindGroup record values
flattened. Nested groups, group-then-attrs, and attrs straddling a
group all match slog.JSONHandler under the dotted-key flattening.
Resolving LogValuer at WithAttrs time rather than Handle time is
a deviation from the naive reading but matches what stdlib's own
handlers do via preformattedAttrs — correct, noted because it
surprises.
Revert-and-run reproduced, not taken on trust.tty_handler.go
reverted to main in a scratch worktree with the new tests in place:
8 top-level FAILs and 6 of 7 TestTTYHandlerMatchesJSONHandlerAttributes subtests fail
(record_attributes_only passes, as it must — it exercises no
derivation). Every other package stayed green. Matches the PR body
exactly.
#82 stream change. No writer in internal/log targets stdout any
more. Keying the TTY/JSON choice on os.Stderr is correct: a
redirected stderr now gets jsonl even when stdout is a terminal, which
is the property that matters (see nit 1 on the doc text).
Workaround removal loses nothing.Initialize sets LevelWarn
under --quiet/--cron, so the log.Warn calls in reportJSONListingLimits still fire in the exact -q --json mode the
reproduction uses — the truncation and unreadable-manifest counts are
not regressed from PR #83's finding 2, and
the tests now assert the counts as "unreadable":1 / "omitted":1 / "limit":1000 rather than only the prose, which is stronger than
before. warnRemoteListingFailed's --json branch is still justified
(v.UI writes to stdout). The listingWarning collect-then-emit
rationale holds: worker-order emission would vary with fetch timing,
and the rewritten comment says exactly that and explicitly retires the
obsolete concurrency-safety reason.
--quiet/--cron semantics unchanged; level selection and ui.Writer untouched.
No claim of a fully clean --json stdout — the PR body, the TODO
entry and the issue comment all name issue #106 as the
remaining stdout contamination.
Nothing weakened..golangci.yml sha256 matches 021cc83f…46bcb; Dockerfile, script/*, REPO_POLICIES.md, .gitea/, Makefile byte-identical to main. No test function
deleted; 10 assertions removed, 16 added.
CI green on b7aee81 (check / check, success in 2m34s).
Mergeable against main, no conflicts.
script/cibuild: exit 0, 314s wall, fresh CHECK_EPOCH; the
check layers executed rather than replayed — make fmt-check 5.4s, make lint 102.1s (0 issues.), make test 132.1s — 14 ok lines,
zero (cached) markers; the 14 CACHED layers are dependency/module
layers. The only lint output is the gomodguard deprecation already
tracked as issue #90. -race clean.
No Claude/Anthropic references or attribution trailers anywhere in the
diff or commit. No tags on origin. Naming, no-stutter, inclusive
terminology, make fmt clean.
Nits (non-blocking)
AGENTS.md policy 9 now contradicts the code. Lines 86-87 still
read "If stdout is not a terminal, output the structured logs in
jsonl format." After this change the format follows stderr, so with vaultik snapshot list --json | jq at a terminal, stdout is not a
terminal and the logs are colorized rather than jsonl — a literal
deviation from the written rule. internal/log/log.go argues the
reinterpretation in a code comment, and I agree the policy's
operative content ("when the log destination is unwatched, make it
machine-readable") is faithfully preserved; keying on stdout after
moving the sink would put ANSI escapes on a redirected stderr, which
is plainly worse. Judgement call, stated plainly: I did not treat
this as an iron-rule failure, because the rule said "stdout" only
because that is where logs used to go. But AGENTS.md is what the
next agent will read, and it now says something false — it should get
the one-line amendment to "the log stream". Owner's call whether that
lands here or separately.
Vaultik.Stderr now has zero production writers. After warnWhileListing and the fmt.Fprintf(v.Stderr, …) calls went
away, the field is only assigned in constructors and tests; env.stderr in snapshot_list_test.go likewise has no readers left. vaultik.go documents the field as deliberately kept to "complete
the standard triple", so this is a choice rather than an oversight —
flagging only so it is a known one.
TODO.md line ~328 (a dated historical entry from earlier work)
still says "still a local workaround pending issue #82". Read as an
immutable history record it is fine, and the new entry supersedes it;
noted only because the PR body claims nothing anywhere still cites issue #82 as pending —
that is true of code comments, not of TODO.md history.
bytesAttrKey special-casing does not survive grouping. An
int64 attribute named bytes under WithGroup("x") has key x.bytes by the time writeAttr compares, so it renders as a raw
number rather than a human byte count. Unreachable today — nothing
in the tree calls WithGroup — and arguably the more predictable
behaviour. Mentioned for the record.
The PR body closes issue #97 as Closes [issue #97](…); Gitea's auto-close parser may not match a
keyword followed by a markdown link, and the landing commit title
carries only (closes #82). Worth confirming issue #97 actually
closes on merge rather than assuming.
## Review: PASS
Independent review of
[PR #107](https://git.eeqj.de/sneak/vaultik/pulls/107) against
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82) and
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97).
### Blocking findings
None.
### Verified
- **#97 handler correctness.** `WithAttrs`/`WithGroup` never write to
the receiver; `clone()` copies rather than reslices `attrs`/`groups`,
so concurrent derivation from one parent cannot interleave appends.
The `*sync.Mutex` is load-bearing — `TestTTYHandlerConcurrentDerivation`
has 16 goroutines writing through derived handlers into one
`bytes.Buffer`, which is only safe because the pointer is shared. The
`slog.Handler` contract corners are right: `WithGroup("")` and
`WithAttrs(nil)` return the receiver, empty `Attr` dropped, empty
group dropped, empty-key group inlined, `KindGroup` record values
flattened. Nested groups, group-then-attrs, and attrs straddling a
group all match `slog.JSONHandler` under the dotted-key flattening.
Resolving `LogValuer` at `WithAttrs` time rather than `Handle` time is
a deviation from the naive reading but matches what stdlib's own
handlers do via `preformattedAttrs` — correct, noted because it
surprises.
- **Revert-and-run reproduced, not taken on trust.** `tty_handler.go`
reverted to `main` in a scratch worktree with the new tests in place:
8 top-level `FAIL`s and 6 of 7
`TestTTYHandlerMatchesJSONHandlerAttributes` subtests fail
(`record_attributes_only` passes, as it must — it exercises no
derivation). Every other package stayed green. Matches the PR body
exactly.
- **#82 stream change.** No writer in `internal/log` targets stdout any
more. Keying the TTY/JSON choice on `os.Stderr` is correct: a
redirected stderr now gets jsonl even when stdout is a terminal, which
is the property that matters (see nit 1 on the doc text).
- **Workaround removal loses nothing.** `Initialize` sets `LevelWarn`
under `--quiet`/`--cron`, so the `log.Warn` calls in
`reportJSONListingLimits` still fire in the exact `-q --json` mode the
reproduction uses — the truncation and unreadable-manifest counts are
not regressed from
[PR #83](https://git.eeqj.de/sneak/vaultik/pulls/83)'s finding 2, and
the tests now assert the counts as `"unreadable":1` / `"omitted":1` /
`"limit":1000` rather than only the prose, which is stronger than
before. `warnRemoteListingFailed`'s `--json` branch is still justified
(`v.UI` writes to stdout). The `listingWarning` collect-then-emit
rationale holds: worker-order emission would vary with fetch timing,
and the rewritten comment says exactly that and explicitly retires the
obsolete concurrency-safety reason.
- **`--quiet`/`--cron` semantics unchanged**; level selection and
`ui.Writer` untouched.
- **No claim of a fully clean `--json` stdout** — the PR body, the TODO
entry and the issue comment all name
[issue #106](https://git.eeqj.de/sneak/vaultik/issues/106) as the
remaining stdout contamination.
- **Nothing weakened.** `.golangci.yml` sha256 matches
`021cc83f…46bcb`; `Dockerfile`, `script/*`, `REPO_POLICIES.md`,
`.gitea/`, `Makefile` byte-identical to `main`. No test function
deleted; 10 assertions removed, 16 added.
- **CI green** on `b7aee81` (`check / check`, success in 2m34s).
Mergeable against `main`, no conflicts.
- **`script/cibuild`**: exit 0, 314s wall, fresh `CHECK_EPOCH`; the
check layers executed rather than replayed — `make fmt-check` 5.4s,
`make lint` 102.1s (`0 issues.`), `make test` 132.1s — 14 `ok` lines,
zero `(cached)` markers; the 14 `CACHED` layers are dependency/module
layers. The only lint output is the `gomodguard` deprecation already
tracked as [issue #90](https://git.eeqj.de/sneak/vaultik/issues/90).
`-race` clean.
- No Claude/Anthropic references or attribution trailers anywhere in the
diff or commit. No tags on origin. Naming, no-stutter, inclusive
terminology, `make fmt` clean.
### Nits (non-blocking)
1. **`AGENTS.md` policy 9 now contradicts the code.** Lines 86-87 still
read "If stdout is not a terminal, output the structured logs in
jsonl format." After this change the format follows stderr, so with
`vaultik snapshot list --json | jq` at a terminal, stdout is not a
terminal and the logs are colorized rather than jsonl — a literal
deviation from the written rule. `internal/log/log.go` argues the
reinterpretation in a code comment, and I agree the policy's
operative content ("when the log destination is unwatched, make it
machine-readable") is faithfully preserved; keying on stdout after
moving the sink would put ANSI escapes on a redirected stderr, which
is plainly worse. **Judgement call, stated plainly: I did not treat
this as an iron-rule failure**, because the rule said "stdout" only
because that is where logs used to go. But `AGENTS.md` is what the
next agent will read, and it now says something false — it should get
the one-line amendment to "the log stream". Owner's call whether that
lands here or separately.
2. **`Vaultik.Stderr` now has zero production writers.** After
`warnWhileListing` and the `fmt.Fprintf(v.Stderr, …)` calls went
away, the field is only assigned in constructors and tests;
`env.stderr` in `snapshot_list_test.go` likewise has no readers left.
`vaultik.go` documents the field as deliberately kept to "complete
the standard triple", so this is a choice rather than an oversight —
flagging only so it is a known one.
3. **`TODO.md` line ~328** (a dated historical entry from earlier work)
still says "still a local workaround pending issue #82". Read as an
immutable history record it is fine, and the new entry supersedes it;
noted only because the PR body claims nothing anywhere still cites
[issue #82](https://git.eeqj.de/sneak/vaultik/issues/82) as pending —
that is true of code comments, not of `TODO.md` history.
4. **`bytesAttrKey` special-casing does not survive grouping.** An
int64 attribute named `bytes` under `WithGroup("x")` has key
`x.bytes` by the time `writeAttr` compares, so it renders as a raw
number rather than a human byte count. Unreachable today — nothing
in the tree calls `WithGroup` — and arguably the more predictable
behaviour. Mentioned for the record.
5. The PR body closes
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97) as
`Closes [issue #97](…)`; Gitea's auto-close parser may not match a
keyword followed by a markdown link, and the landing commit title
carries only `(closes #82)`. Worth confirming
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97) actually
closes on merge rather than assuming.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes issue #82.
Closes issue #97.
Two defects in
internal/log, fixed in one pass because both live inthe handler construction path.
USER-VISIBLE CHANGE:
--verboseand--debugoutput moves to stderrAll log output now goes to stderr. That includes everything from
--verboseand--debug, not just warnings and errors.vaultik snapshot list --verbose > out.txtused to capture thediagnostics along with the listing. It no longer does — they stay on the
terminal. Capturing both now needs
> out.txt 2> log.txt, or> out.txt 2>&1to interleave them. Anyone with a script or a cronwrapper that redirects only stdout and expects the log in the file will
see the change.
This is the intended contract rather than a side effect: stdout carries
the output that was asked for, stderr carries diagnostics.
README.mdgains a "stdout and stderr" section stating it, and the
--verbose/--debugflag entries point at it.#82: the logger wrote to stdout
Initializebuilt both handlers overos.Stdout. Every--jsonsubcommand writes its document to that same stream, and
WARN/ERRORare never suppressed by any flag, so a log record could land inside a
JSON document and break the parse.
Reproduced against
mainwith the trigger from the report — a configfile at mode 0644, which fires the insecure-permissions
WARNininternal/config:Same config, same command, this branch:
with the
WARNon stderr, andjson.loadaccepting stdout.The TTY/JSON format choice moved to
os.Stderralong with the sink. Ithas to follow the stream the records land on: testing stdout would
colorize records on a redirected stderr whenever stdout happened to be a
terminal, and emit JSON at a terminal in the reverse case.
--quietand--cronsemantics are unchanged. Level selection is nottouched,
ui.Writer's quiet handling is not touched, and the run aboveused
-q— the warning still fired, which is the documented behaviour(see issue #84):
UI.SetQuiet(true)suppresses Begin/Complete/Info/Notice/Detail/Progress/Banner and never Warning/Error.
The
snapshot_list.goworkaround is gonewarnWhileListingwas hand-rolling structured-log formatting(
fmt.Fprintf(&line, " %v=%v", ...)and akvPairSizeconstant) purelyto reach a writer that was not stdout, and the
jsonOutputparameterthreaded through
collectRemoteSnapshotsanddescribeRemoteOnlySnapshotsexisted only to pick between the twowriters. Both are deleted; those warnings go through
log.Warnin everymode.
reportJSONListingLimitslikewise moved fromfmt.Fprintf(v.Stderr, ...)tolog.Warnwith structured fields.warnRemoteListingFailedkeeps itsjsonOutputbranch, for a reasonthat is still true after this change and is now what the comment says:
table mode wants the prose
v.UI.Warningfline, andv.UIwrites tostdout, so
--jsonmode goes through the logger instead.The
listingWarningcollect-then-emit machinery stays, with arewritten rationale. Its original justification — the chosen writer is
not safe for concurrent use — is obsolete, since
sloghandlers are.What remains is ordering: emitting from the manifest-fetch workers would
order warnings by network timing, while collecting them and emitting in
key order after
group.Wait()makes two runs over the same damagedstore produce the same diagnostics in the same order. No comment
anywhere still refers to this as pending work.
#97:
TTYHandlerdiscardedWithAttrsandWithGroupBoth returned the receiver and dropped their argument while their doc
comments claimed otherwise. Because the handler is selected by TTY-ness,
attributes vanished on a terminal and were correct in CI — it failed
precisely when someone was debugging interactively.
WithAttrsretains the attributes and emits them on every subsequentrecord.
WithGroupis implemented rather than deferred: this format is oneline with nowhere to nest, so a group becomes a dotted key prefix
(
WithGroup("db").With("rows", 3)rendersdb.rows=3). Record-levelslog.KindGroupvalues flatten the same way. Empty attrs are droppedand an empty-key group is inlined, per the
slog.Handlercontract.the clone copies its slices rather than reslicing them, so two
concurrent derivations cannot append over each other's attribute. The
mutex became a
*sync.Mutexso handlers sharing a stream keep sharingone lock — a value mutex would have given every derived handler its
own and stopped serializing writes.
// Simplified for nowcomments are gone, and the doc commentsnow describe what the code does.
The tests fail against the unfixed handler
Verified by observation, not assumption:
internal/log/tty_handler.gowas reverted to its
mainversion with the new tests in place, andmake testreportedThe handler was then restored and the suite went green.
TestWithAttributesReachTTYOutputgoes through the exportedinternal/log.With, which is the reachable API the issue names. It is anin-package test because that is the only way to point the package logger
at a buffer;
//nolint:testpackagecarries the reason.TestTTYHandlerMatchesJSONHandlerAttributesfeeds both handlers the samederivation chain and the same record and compares the emitted attribute
sets, flattening
JSONHandler's nesting to the same dotted keys. That isthe drift guard the issue asks for.
Existing tests
Four
internal/vaultiktests asserted these warnings on the injectedv.Stderrbuffer. They now capture the process's real stderr through anew
captureProcessStderrhelper, since that is where the warnings go;the guarantees asserted are unchanged.
captureProcessStdoutkeepsrebuilding the logger on purpose — that is what would catch a logger
regressing back to stdout — and its doc comment no longer cites
issue #82 as the reason
no injectable sink exists.
Found while verifying, filed not fixed
Issue #106: the startup
banner is written to stdout by
internal/cli/entry.gobefore cobraparses anything, and
bannerSuppressedInArgsrecognizes only--quiet/-q/--cron, not--json. So every--jsondocument isstill preceded by two banner lines and a blank line unless
-qispassed. That is a different writer on a different path from the logger,
so it is filed rather than fixed here — but it means stdout is not fully
clean until that one lands too. The reproductions above use
-qforthat reason.
Verification
make check: green. Tests, lint, and fmt-check all pass; the onlylinter output is the
gomodguarddeprecation already tracked asissue #90.
script/cibuild: exit 0, 140s wall. The three check layers executedrather than replaying from cache (
make fmt-check2.4s,make lint40.4s,
make test58.5s under the freshCHECK_EPOCH); the 10CACHEDlayers are dependency and module layers. 14oklines, zero(cached)markers.file://destination, beforeand after, as shown above.
.golangci.ymlunchanged: sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.git tag -lis empty.Review: PASS
Independent review of
PR #107 against
issue #82 and
issue #97.
Blocking findings
None.
Verified
WithAttrs/WithGroupnever write tothe receiver;
clone()copies rather than reslicesattrs/groups,so concurrent derivation from one parent cannot interleave appends.
The
*sync.Mutexis load-bearing —TestTTYHandlerConcurrentDerivationhas 16 goroutines writing through derived handlers into one
bytes.Buffer, which is only safe because the pointer is shared. Theslog.Handlercontract corners are right:WithGroup("")andWithAttrs(nil)return the receiver, emptyAttrdropped, emptygroup dropped, empty-key group inlined,
KindGrouprecord valuesflattened. Nested groups, group-then-attrs, and attrs straddling a
group all match
slog.JSONHandlerunder the dotted-key flattening.Resolving
LogValueratWithAttrstime rather thanHandletime isa deviation from the naive reading but matches what stdlib's own
handlers do via
preformattedAttrs— correct, noted because itsurprises.
tty_handler.goreverted to
mainin a scratch worktree with the new tests in place:8 top-level
FAILs and 6 of 7TestTTYHandlerMatchesJSONHandlerAttributessubtests fail(
record_attributes_onlypasses, as it must — it exercises noderivation). Every other package stayed green. Matches the PR body
exactly.
internal/logtargets stdout anymore. Keying the TTY/JSON choice on
os.Stderris correct: aredirected stderr now gets jsonl even when stdout is a terminal, which
is the property that matters (see nit 1 on the doc text).
InitializesetsLevelWarnunder
--quiet/--cron, so thelog.Warncalls inreportJSONListingLimitsstill fire in the exact-q --jsonmode thereproduction uses — the truncation and unreadable-manifest counts are
not regressed from
PR #83's finding 2, and
the tests now assert the counts as
"unreadable":1/"omitted":1/"limit":1000rather than only the prose, which is stronger thanbefore.
warnRemoteListingFailed's--jsonbranch is still justified(
v.UIwrites to stdout). ThelistingWarningcollect-then-emitrationale holds: worker-order emission would vary with fetch timing,
and the rewritten comment says exactly that and explicitly retires the
obsolete concurrency-safety reason.
--quiet/--cronsemantics unchanged; level selection andui.Writeruntouched.--jsonstdout — the PR body, the TODOentry and the issue comment all name
issue #106 as the
remaining stdout contamination.
.golangci.ymlsha256 matches021cc83f…46bcb;Dockerfile,script/*,REPO_POLICIES.md,.gitea/,Makefilebyte-identical tomain. No test functiondeleted; 10 assertions removed, 16 added.
b7aee81(check / check, success in 2m34s).Mergeable against
main, no conflicts.script/cibuild: exit 0, 314s wall, freshCHECK_EPOCH; thecheck layers executed rather than replayed —
make fmt-check5.4s,make lint102.1s (0 issues.),make test132.1s — 14oklines,zero
(cached)markers; the 14CACHEDlayers are dependency/modulelayers. The only lint output is the
gomodguarddeprecation alreadytracked as issue #90.
-raceclean.diff or commit. No tags on origin. Naming, no-stutter, inclusive
terminology,
make fmtclean.Nits (non-blocking)
AGENTS.mdpolicy 9 now contradicts the code. Lines 86-87 stillread "If stdout is not a terminal, output the structured logs in
jsonl format." After this change the format follows stderr, so with
vaultik snapshot list --json | jqat a terminal, stdout is not aterminal and the logs are colorized rather than jsonl — a literal
deviation from the written rule.
internal/log/log.goargues thereinterpretation in a code comment, and I agree the policy's
operative content ("when the log destination is unwatched, make it
machine-readable") is faithfully preserved; keying on stdout after
moving the sink would put ANSI escapes on a redirected stderr, which
is plainly worse. Judgement call, stated plainly: I did not treat
this as an iron-rule failure, because the rule said "stdout" only
because that is where logs used to go. But
AGENTS.mdis what thenext agent will read, and it now says something false — it should get
the one-line amendment to "the log stream". Owner's call whether that
lands here or separately.
Vaultik.Stderrnow has zero production writers. AfterwarnWhileListingand thefmt.Fprintf(v.Stderr, …)calls wentaway, the field is only assigned in constructors and tests;
env.stderrinsnapshot_list_test.golikewise has no readers left.vaultik.godocuments the field as deliberately kept to "completethe standard triple", so this is a choice rather than an oversight —
flagging only so it is a known one.
TODO.mdline ~328 (a dated historical entry from earlier work)still says "still a local workaround pending issue #82". Read as an
immutable history record it is fine, and the new entry supersedes it;
noted only because the PR body claims nothing anywhere still cites
issue #82 as pending —
that is true of code comments, not of
TODO.mdhistory.bytesAttrKeyspecial-casing does not survive grouping. Anint64 attribute named
bytesunderWithGroup("x")has keyx.bytesby the timewriteAttrcompares, so it renders as a rawnumber rather than a human byte count. Unreachable today — nothing
in the tree calls
WithGroup— and arguably the more predictablebehaviour. Mentioned for the record.
issue #97 as
Closes [issue #97](…); Gitea's auto-close parser may not match akeyword followed by a markdown link, and the landing commit title
carries only
(closes #82). Worth confirmingissue #97 actually
closes on merge rather than assuming.