feat: add OTLP/HTTP JSON encoder for wake records - #34
Merged
Conversation
Project stored records onto an OTLP span export payload behind the `remote` build tag. The encoder is pure: no network, no filesystem, no clock, no model call, and no new module dependency. ADR-0017 required the wire payload to be the spool's own bytes. ADR-0027 spends that property deliberately — OTLP is a span shape and the spool is a record shape, and no single serialisation is both. What replaces it is stricter: the emitted attribute key set is a frozen literal declared in the test file and asserted by equality, so a new key cannot arrive without the assertion failing. Containment would have passed for exactly the change worth catching. Design points worth review: - trace_id reuses record.DeriveEventID rather than re-deriving SHA-256, which keeps one definition of a derived id (ADR-0004) and keeps crypto/sha256 out of the import set the freeze test asserts. - span_id is the event id's prefix, so it is a pure function of the record's own identity and a re-send deduplicates at the receiver. - A nil outcome maps to UNSET, never OK; denied_policy and denied_user also map to UNSET as known non-failures, reusing record.IsFailure rather than restating it (ADR-0005). - An unreported duration renders end == start and omits wake.duration_ms; an explicit zero renders end == start and emits it. The attribute is the only thing separating the two. - Encode fails closed with a counted drop: it re-validates every record on the way out and drops what it cannot represent, returning the count so a caller can report blindness instead of zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0007 requires a hostile-payload corpus per input shape rather than one shared set. The wire is a new output shape with its own failure modes, so it gets its own corpus here — inheriting internal/record's would mean this package is covered by tests it never runs. The corpus adds two entries that matter only on a payload leaving the machine: a prompt-injection string and an API-key-shaped string. The main test states a narrower contract than "hostile input is rejected", because the encoder must not become a second validator — record.Validate is the single gate and a duplicate set of rules here would eventually disagree with it. What the encoder owes instead: - a value the gate refused appears nowhere in the payload, and its record is dropped and counted; - a value the gate accepted appears only as its own field's attribute; - no path-shaped value is ever accepted, in any field. "sk-ant-api03-DEADBEEF" lands in the accepted half deliberately: it is a well-formed bounded Identifier, and a skill genuinely named that should be reported. The record type is the allowlist; the encoder's job is to add nothing to it. assertEveryStringIsAllowlisted is the positive form of that — it walks the decoded payload and requires every string, field names included, to be a frozen key, a package constant, a typed Record field value, a derived id, or a rendered number. A containment test only catches leaks somebody thought to name; this catches the ones nobody did. Verified by mutation: injecting a gen_ai.prompt attribute fails it, the key-set equality test, and the transcript-key test independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Determinism is what makes at-least-once delivery safe: the same records
must always produce the same bytes, so a re-send is a duplicate the
receiver can drop rather than a second, differently-shaped event. It
holds because attributes are built as a slice in fixed source order —
ranging a map anywhere in the encoder would break it intermittently,
which is the worst way for it to break.
The golden fixture is the human-review surface. Assertions state
properties; this is the one place a reviewer reads exactly what leaves
the machine. It is stored indented for that reason, generated with
-update and never hand-written, and it lives in this package's own
testdata/ rather than the repo-root one AGENTS.md reserves for harness
fixtures captured through the redaction tooling.
The batch is chosen for what it discriminates. Reviewed the generated
file directly: no slash and no backslash anywhere, none of the four
transcript keys, and three spans reading
full 20 keys, status OK, duration attribute present
minimal 9 keys, status UNSET, zero-length span
denied 19 keys, status UNSET, wake.outcome "denied_user", no
duration attribute
The last row is the one worth having. It shows denial staying
distinguishable from silence despite both rendering UNSET, and an
unreported duration omitting its attribute rather than reporting zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/config's boundary test forbids any .go file outside that package from containing the literal filename of the local hash-to-label map. The encoder's wake.repo comment named it while explaining that the encoder never reads it — true, but the check is deliberately mechanical rather than semantic, and that is what makes it worth having: the privacy guarantee stays checkable by reading one directory, without anyone auditing call sites in the next ticket. Reworded to say the same thing without the literal. The test was right; the comment was the violation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spanTimes guarded only startNano < 0, but time.Time.UnixNano() is
undefined outside roughly 1678-2262 and wraps rather than saturating.
Far-future dates wrap negative and were caught; far-past dates wrap
POSITIVE and sailed through. A record timestamped 1600-01-01 passes
record.Validate, which imposes no range on Timestamp, and was emitted
as startTimeUnixNano "6770648073709551616" — year 2184 — uncounted.
Range-check Unix() seconds before converting, since Unix() is exact at
every time a time.Time can hold. This restores the contract the comment
already claimed ("dropped rather than wrapped") and the drop-and-count
guarantee in Encode's doc comment: a fabricated timestamp would land in
a receiver store that can never be rebuilt from our side (ADR-0027), so
a reported blind spot beats a definite-looking wrong number (plan §12).
The existing pre-epoch row used time.Unix(-1, 0), which is inside the
representable range and so only ever exercised the sign check. Adds
pre-1678 and post-2262 rows, plus a record.Validate assertion on every
row so a drop cannot pass for the wrong reason.
The frozen import set is unchanged: the bound is a nanosPerSecond
constant rather than time.Second, keeping the package's no-clock claim
literally true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The package doc claims purity for the PACKAGE — "no network, no filesystem, no clock" — but the assertion behind it parsed otlp.go by name. internal/remote holds one non-test file today, so the claim held; the transport lands behind this same remote build tag, and if it arrives here as client.go then net/http enters the package while the test stays green and the doc quietly becomes false. Scan every non-test .go file in the directory and assert the union of their imports. Verified by injecting a probe file importing net/http: the old test passed, the new one fails naming both the file and the leaked capability. Build constraints are intentionally not honoured — a file excluded from this build is still a file in this package for some other build. Also fails on an empty scan, so the assertion cannot pass vacuously if the scan ever stops finding files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moraisjose
marked this pull request as ready for review
August 24, 2026 14:59
2 tasks
moraisjose
added a commit
that referenced
this pull request
Aug 28, 2026
* feat: add OTLP/HTTP JSON encoder for wake records (#34)
* feat(remote): add OTLP/HTTP JSON encoder for wake records
Project stored records onto an OTLP span export payload behind the
`remote` build tag. The encoder is pure: no network, no filesystem, no
clock, no model call, and no new module dependency.
ADR-0017 required the wire payload to be the spool's own bytes. ADR-0027
spends that property deliberately — OTLP is a span shape and the spool is
a record shape, and no single serialisation is both. What replaces it is
stricter: the emitted attribute key set is a frozen literal declared in
the test file and asserted by equality, so a new key cannot arrive
without the assertion failing. Containment would have passed for exactly
the change worth catching.
Design points worth review:
- trace_id reuses record.DeriveEventID rather than re-deriving SHA-256,
which keeps one definition of a derived id (ADR-0004) and keeps
crypto/sha256 out of the import set the freeze test asserts.
- span_id is the event id's prefix, so it is a pure function of the
record's own identity and a re-send deduplicates at the receiver.
- A nil outcome maps to UNSET, never OK; denied_policy and denied_user
also map to UNSET as known non-failures, reusing record.IsFailure
rather than restating it (ADR-0005).
- An unreported duration renders end == start and omits
wake.duration_ms; an explicit zero renders end == start and emits it.
The attribute is the only thing separating the two.
- Encode fails closed with a counted drop: it re-validates every record
on the way out and drops what it cannot represent, returning the count
so a caller can report blindness instead of zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(remote): add the wire's hostile-payload and allowlist gate
ADR-0007 requires a hostile-payload corpus per input shape rather than
one shared set. The wire is a new output shape with its own failure
modes, so it gets its own corpus here — inheriting internal/record's
would mean this package is covered by tests it never runs.
The corpus adds two entries that matter only on a payload leaving the
machine: a prompt-injection string and an API-key-shaped string.
The main test states a narrower contract than "hostile input is
rejected", because the encoder must not become a second validator —
record.Validate is the single gate and a duplicate set of rules here
would eventually disagree with it. What the encoder owes instead:
- a value the gate refused appears nowhere in the payload, and its
record is dropped and counted;
- a value the gate accepted appears only as its own field's attribute;
- no path-shaped value is ever accepted, in any field.
"sk-ant-api03-DEADBEEF" lands in the accepted half deliberately: it is a
well-formed bounded Identifier, and a skill genuinely named that should
be reported. The record type is the allowlist; the encoder's job is to
add nothing to it.
assertEveryStringIsAllowlisted is the positive form of that — it walks
the decoded payload and requires every string, field names included, to
be a frozen key, a package constant, a typed Record field value, a
derived id, or a rendered number. A containment test only catches leaks
somebody thought to name; this catches the ones nobody did. Verified by
mutation: injecting a gen_ai.prompt attribute fails it, the key-set
equality test, and the transcript-key test independently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(remote): pin encoder determinism with a golden payload
Determinism is what makes at-least-once delivery safe: the same records
must always produce the same bytes, so a re-send is a duplicate the
receiver can drop rather than a second, differently-shaped event. It
holds because attributes are built as a slice in fixed source order —
ranging a map anywhere in the encoder would break it intermittently,
which is the worst way for it to break.
The golden fixture is the human-review surface. Assertions state
properties; this is the one place a reviewer reads exactly what leaves
the machine. It is stored indented for that reason, generated with
-update and never hand-written, and it lives in this package's own
testdata/ rather than the repo-root one AGENTS.md reserves for harness
fixtures captured through the redaction tooling.
The batch is chosen for what it discriminates. Reviewed the generated
file directly: no slash and no backslash anywhere, none of the four
transcript keys, and three spans reading
full 20 keys, status OK, duration attribute present
minimal 9 keys, status UNSET, zero-length span
denied 19 keys, status UNSET, wake.outcome "denied_user", no
duration attribute
The last row is the one worth having. It shows denial staying
distinguishable from silence despite both rendering UNSET, and an
unreported duration omitting its attribute rather than reporting zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(remote): stop naming the repo map file in a code comment
internal/config's boundary test forbids any .go file outside that package
from containing the literal filename of the local hash-to-label map. The
encoder's wake.repo comment named it while explaining that the encoder
never reads it — true, but the check is deliberately mechanical rather
than semantic, and that is what makes it worth having: the privacy
guarantee stays checkable by reading one directory, without anyone
auditing call sites in the next ticket.
Reworded to say the same thing without the literal. The test was right;
the comment was the violation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(remote): drop timestamps outside UnixNano's representable range
spanTimes guarded only startNano < 0, but time.Time.UnixNano() is
undefined outside roughly 1678-2262 and wraps rather than saturating.
Far-future dates wrap negative and were caught; far-past dates wrap
POSITIVE and sailed through. A record timestamped 1600-01-01 passes
record.Validate, which imposes no range on Timestamp, and was emitted
as startTimeUnixNano "6770648073709551616" — year 2184 — uncounted.
Range-check Unix() seconds before converting, since Unix() is exact at
every time a time.Time can hold. This restores the contract the comment
already claimed ("dropped rather than wrapped") and the drop-and-count
guarantee in Encode's doc comment: a fabricated timestamp would land in
a receiver store that can never be rebuilt from our side (ADR-0027), so
a reported blind spot beats a definite-looking wrong number (plan §12).
The existing pre-epoch row used time.Unix(-1, 0), which is inside the
representable range and so only ever exercised the sign check. Adds
pre-1678 and post-2262 rows, plus a record.Validate assertion on every
row so a drop cannot pass for the wrong reason.
The frozen import set is unchanged: the bound is a nanosPerSecond
constant rather than time.Second, keeping the package's no-clock claim
literally true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(remote): freeze imports across the package, not one file by name
The package doc claims purity for the PACKAGE — "no network, no
filesystem, no clock" — but the assertion behind it parsed otlp.go by
name. internal/remote holds one non-test file today, so the claim held;
the transport lands behind this same remote build tag, and if it arrives
here as client.go then net/http enters the package while the test stays
green and the doc quietly becomes false.
Scan every non-test .go file in the directory and assert the union of
their imports. Verified by injecting a probe file importing net/http:
the old test passed, the new one fails naming both the file and the
leaked capability. Build constraints are intentionally not honoured — a
file excluded from this build is still a file in this package for some
other build.
Also fails on an empty scan, so the assertion cannot pass vacuously if
the scan ever stops finding files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(config): remote endpoint and credential store (#35)
* test(config): extend the secret-filename boundary to the remote credential store
The boundary walk fails the build when a filename only internal/config may
spell appears in any other package. It covered repo-salt and projects.json;
ADR-0028 adds a third secret, remote-auth.json, and a secret this test cannot
see is a secret whose confinement nothing verifies.
The list moves from a function-local slice to the package-level
confinedFileNames so the tagged build can assert against it, and the new entry
is a literal rather than the remoteAuthFileName constant because that constant
lives in a //go:build remote file while this test is untagged. The tagged
TestRemoteAuthFileNameIsConfined pins the two spellings together so they cannot
drift.
The function is renamed because its old name enumerated two of what are now
three files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(config): scope the default-build key assertions to the untagged build
Three key assertions are exhaustive by design — the registry holds exactly the
seven names in ADR-0014, no name carries a remote. prefix, and exactly two keys
are provisional. All three are statements about what the *default* build
registers, but they were untagged, so they compiled into the -tags remote test
binary too and would fail on the eighth key that build is meant to have.
Move them behind //go:build !remote, verbatim. Nothing is weakened: the tagged
build gets its own exhaustive counterparts rather than these three relaxed to
"at least". The remaining assertions in registry_test.go are about registry
mechanics that hold under either build and stay untagged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(config): register remote.min_interval in the remote-tagged build
ADR-0018 decides that a flush is always a detached child, single-flight by
lockfile, with a minimum interval. That interval is the one sizing knob a
document asks for, so it is the whole remote.* configuration surface: the batch
record count and the byte ceiling have no decision behind them and stay
constants in internal/remote, under keys.go's rule that a key with no decision
behind it is scope creep rather than a convenience.
It lands in its own //go:build remote file. ADR-0012 compiles remote delivery
out rather than configuring it off, so the default build must expose no
remote.* key at all — which is exactly why registry.go keeps an open append
list, and why neither registry.go nor keys.go is touched here. KindDuration is
reused rather than extended, so no new Kind reaches the default build.
The key is Provisional because no document states a value. 15m is a first guess
— long enough that a burst of hook-triggered scans does not become a burst of
flushes, short enough that a day's work is not one delivery — and ADR-0014's
rule for exactly this case is that it ships labelled rather than presented as
considered.
TestListDistinguishesDefaultFromOverridden held a second, closed literal over
the provisional set, which the eighth key broke under the tag. Its own comment
says what it is for — the provisional fact has to reach the list command — so it
now checks that List carries the registry's flag through unchanged. Which keys
are provisional stays pinned exhaustively per build, by the untagged and tagged
registry tests that can each state it truthfully.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(config): add the 0600 remote endpoint and credential store
The third member of the secrets boundary internal/config owns, present only
under //go:build remote. It holds a third-party-issued credential — the first
secret this project stores that did not originate on this machine — which is
why it is a file of its own: the far end can revoke it at any time, so it has a
lifecycle neither repo-salt (never regenerated) nor projects.json (in the
deletable data root) can carry. It is never in config.toml, the file users are
asked to paste into bug reports.
The endpoint, the enabled flag and the credential are one unit with one
lifecycle, so there is one file and one publication rather than three, through
atomicfile: the file is chmodded to 0600 before the rename, so it is never
briefly readable, and a successful return means the bytes are durable.
Fail closed on the way back in. checkSensitiveFile refuses a symlink, a
non-regular file, or any mode looser than 0600 before the file is opened — a
symlink is a redirection that would read, and on the next publish write,
wherever it points. A store that does not parse is an error rather than an
empty one, because an empty store silently stops delivering, and it is never
rewritten from a failed parse: those bytes are the only copy of the credential
there is. An unrecognised version stops the read, since a future format misread
as this one would post a credential to whatever the misread endpoint yielded.
No refusal names the file, the endpoint or the credential: the role is a fixed
literal, every fault is a sentinel carrying no value, decode failures go through
parseFailure, and url.Parse's error is discarded because it embeds the URL. On
top of that RemoteAuth redacts itself — String reports presence, not values — so
a %v somewhere else entirely cannot leak either secret. That is the half nobody
reviews for.
SetRemoteAuth validates before touching disk, so a rejected endpoint leaves no
half-configured state. ADR-0027 makes OTLP/HTTP JSON the only integration
surface, so a non-http destination is a credential posted somewhere no decision
permits; the check is scheme and host only, with no reachability probe and
nothing that reads the network. The zero value is accepted — it is how delivery
is turned off — but enabling without an endpoint is not, because that can only
fail later in a background flush nobody is watching.
WAKE_REMOTE_AUTHORIZATION overrides the stored credential and only the
credential: a credential with no destination is not a state any decision
provides for. It is never written back, so removing it reverts to disk, and it
carries the same public:secret pair the stored field does rather than a
pre-encoded header — the Basic encoding happens at delivery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(config): guard the remote store's directory and validate on read
The credential store adopted two of the three legs of the secrets boundary
repo-salt and projects.json define, and dropped the one carrying ownership.
checkSensitiveFile tests symlink, regular-file and mode, never owner; so in a
group- or world-writable config root another local user could rename their own
0600 file over remote-auth.json and LoadRemoteAuth would accept it — their
credential, posted to their endpoint. Both LoadRemoteAuth and SetRemoteAuth now
call checkStateDir(p.ConfigDir) first, tolerating a directory that does not
exist yet, exactly as OpenRepos does for the other two files.
The endpoint invariant was enforced only on the way in, so a hand-written
version-1 store carrying ftp:// or file:// — or enabled:true with no endpoint —
loaded with err=nil and was returned verbatim, contradicting the field's own doc
comment and ADR-0027's OTLP/HTTP-only surface. The write path is the one this
build controls; the read path is the one worth checking. Both now go through a
single validateRemoteAuth, so the two directions cannot drift into a store that
can be written but not read, and the read path fails closed on the existing
sentinels, which quote nothing.
Tests cover the group-writable config root on both functions (the fourth case
shape from secure_test.go that remote_auth_test.go had not reproduced),
non-http and empty-with-enabled stored endpoints, and the mirror case that a
disabled store with no endpoint still loads. All three refusals are added to the
message-leak table.
Refs: ADR-0027, ADR-0028, plan §3.4
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: batch OTLP delivery with self-healing watermark (#36)
* test(remote): freeze package imports per file, not per package
The delivery loop lands in this package next, and TestEncoderImportsAreFrozen
was written to stop exactly that from arriving unreviewed: it unions every
non-test file's imports and asserts the package holds no `net`, `net/http`,
`os`, `io`, `path/filepath` or `bufio`. Its own comment names the case ("if it
arrives here as client.go, net/http enters the package"). DG-65 is the ticket
that trips it, and the ticket names `internal/remote/deliver.go` verbatim, so
the file location is the requirement and the assertion is what adapts.
The assertion gets stricter rather than looser. One union covered every file,
so any file could carry any allowlisted import; now each file declares its
exact set, and a file with no entry in the map fails outright — which is the
tripwire the original comment was written for, kept and sharpened. The no-I/O
forbidden-set check stays pointed at the encoder's own files via `encoderFiles`,
because that is what the package doc's purity claim is actually about. A stale
map entry naming a file the package no longer contains now fails too, so a
deleted file cannot leave its allowlist lingering.
The package doc is narrowed in the same commit: "this package is pure" becomes
false the moment deliver.go exists, and a doc comment that has to be corrected
later is one nobody corrects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(remote): add the delivery watermark, a store position and a flush time
The cursor is a store position and never a timestamp. ADR-0004 derives every
event id from its source event, so the store appends each event exactly once
and position is monotonic even when timestamps are not; ADR-0018 settled that a
timestamp cursor would be wrong, because re-scanning discovers older events
later — a transcript imported today can carry last week's event — and every one
of them would be skipped permanently. The reasoning is in the type's doc
comment, where the next person to reach for a timestamp will read it.
LastFlush shares the file and is not a cursor: it gates remote.min_interval and
it is what `remote status` will print. The alternative to storing it — taking
the watermark file's mtime as the last-attempt signal — does not advance on a
failed or empty run, so a dead endpoint would be retried on every trigger,
which is what the interval exists to prevent.
readDeliveryState returns no error on purpose. Missing, unparseable and
wrong-version all resolve to "delivered through nothing", because the failure
directions are not symmetric: failing backward costs a re-send, which is free
now that span_id is derived from the deterministic event_id and the receiver
collapses duplicates, while failing forward would skip records and nothing
downstream would ever notice.
Under the data root at 0600, published through atomicfile. Derived state that
is meaningless without the spool it indexes has to die with the spool rather
than outlive it and describe records that are gone (ADR-0014, ADR-0015).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(remote): deliver batched OTLP spans and advance the watermark
Flush is the only place in this codebase that opens an outbound connection, and
it is compiled only under the remote build tag, so the default binary still
links no network client (ADR-0012, ADR-0026).
The order of the first two steps is an acceptance criterion, not an
implementation detail: the credential store is consulted before anything else
happens, so a build that is off returns before a lock file, a state file, or a
request exists. "Sends nothing" is asserted as "constructs nothing".
The watermark advances only on a 2xx and only to that batch's last position,
and the first non-2xx breaks the loop. Continuing past a failed batch and
advancing over a later successful one would open a gap nothing ever closes —
the watermark is a single position and cannot describe a hole. Two tests cover
this, because with only two batches "stops the run" and "skips a batch" are
indistinguishable; the three-batch case is the one that tells them apart, and
it was added after the two-batch case passed against a deliberately broken
implementation.
The state is written on the failure path too. The partial position has to
survive or the next run re-sends batches the receiver already accepted, and the
attempt time has to advance or a dead endpoint is retried on every trigger.
A transport error is never wrapped. http.Client.Do returns a *url.Error that
embeds the URL it failed on, so returning or wrapping it would put the endpoint
into an error message — ADR-0028's "never echo what was read". post returns
valueless sentinels instead, the same reason config's isHTTPEndpoint discards
url.Parse's error, and the response body is drained into io.Discard rather than
read into a message, because a receiver can echo the request back. A test
drives every failure Flush can return and asserts none of them names the
endpoint, either half of the credential, or its base64 form; wrapping Do's
error makes it fail.
The client carries an explicit 10s timeout. Go's default has none, and a hung
endpoint that never returns is not "indistinguishable from an absent one".
Batch ceilings are constants here rather than config keys (ADR-0014): the byte
ceiling is exercised through batchesWithin at a size a real record can reach,
since every Record field is a bounded identifier and none comes near 4 MiB.
Content-Encoding: gzip is sent alongside the three headers the ticket names. A
gzipped body without it is undecodable at the receiver, so it is implied by
"gzipped body" rather than added scope.
internal/store is untouched: Entries(after uint64) already took exactly this
parameter, and a test asserts the spool's bytes are identical across a flush,
so a per-record delivered flag could not be added without failing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(remote): expose the delivery status doctor and remote status render
One struct, several renderers over it, and no formatting decision in this
package (ADR-0011). `remote status` and `doctor` will both print this value, so
a choice taken here would be a choice taken for both.
It carries endpoint presence and never the endpoint. ADR-0018's command surface
listed "endpoint" among what `remote status` reports; ADR-0028 is later and
narrower — never echo what was read — and the narrower constraint governs.
Presence still answers ADR-0012's requirement that doctor state whether an
endpoint is configured, which is the question a bug report actually needs.
Every field is a bool, a count, or a time, which is health.Report's rule for
the same reason: doctor output is what people paste into issues, and the
temptation a later change feels is to add "and here is why" as a string. A test
asserts the field list by equality and the types by kind, and rejects any name
carrying path, root, label, dir, cwd, credential, url or host — internal/config's
own boundary test is untagged and cannot see this type at all.
Describe applies the same self-heal view Flush does. A watermark past head means
the spool was rebuilt under it, so delivered-through reads as 0; reporting the
stale position would claim delivery of records the spool no longer holds, and
the pending subtraction would wrap a uint64 into an enormous number rather than
going negative.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build: gate the //go:build remote surface in make validate and CI
Nothing compiled or ran the tagged build. `make validate` is fmt-check, vet,
lint and test, and CI runs those same four plus test-race — all untagged, and
`go build`, `go vet`, `go test` and golangci-lint every one honour build
constraints. So the OTLP encoder and the credential store have had no gate
since they landed, and the delivery loop this branch adds would have been dead
code the moment it was written: verification would run `make validate`, see
green, and never have built the deliverable.
validate-remote is four lines and validate now depends on it, so the local gate
and the CI gate stay one door in. `remote` is the only build tag in the tree
(ADR-0012), so one extra run covers the whole tagged surface.
The lint step is a second run rather than run.build-tags in .golangci.yml:
internal/config/registry_default_test.go is //go:build !remote, and setting the
tag globally would stop the default-build-only assertions from being linted at
all.
The CI step sits in the test job because it needs the Go matrix's toolchain,
and is gated to stable for the reason test-race is — the floor is what the
matrix proves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: wake remote command surface (status, set, on, off, flush) (#37)
* feat(config): narrow entry points for the remote credential store
`remote set`, `remote on` and `remote off` need three things from the
credential store — the endpoint's host for display, an endpoint plus
credential written as one unit, and the enabled flag toggled — and each
of them is a shape that would be wrong to get by reading the whole
RemoteAuth into internal/cli.
The read half of LoadRemoteAuth becomes storedRemoteAuth, which is what
the file holds before EnvRemoteAuthorization is applied. Every write path
goes through it: a naive LoadRemoteAuth -> mutate -> SetRemoteAuth in
`remote on` would persist the environment's credential to disk, which
ADR-0028 forbids outright and which leaves a store looking entirely
ordinary afterwards.
RemoteEndpointHost reports host and port only — no scheme, path, query or
userinfo — so `remote status` can name a destination without echoing one.
SetRemoteEndpoint preserves the stored on/off state, and SetRemoteEnabled
preserves the endpoint and credential and writes nothing when already in
that state, both for ADR-0018's reason that a command must not quietly
undo the state the user put the machine in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(remote): report what a flush sent and preview what one would send
`remote flush` has to say what it sent, and `remote flush --dry-run` has
to show the exact bytes a flush would post. Neither is something
internal/cli can derive: differencing Describe across the call would be a
second, subtly different answer, and re-rendering the payload would be a
second serialiser (ADR-0001, ADR-0027).
FlushReport is the run with counts and Flush becomes a wrapper over it.
The signature stays because the hook-invoked path is forbidden to report
anything (ADR-0016), so a report-less entry point is semantically right
there rather than merely cheaper. Encode's dropped count stops being
discarded: the watermark still advances past a refused record — it will
be refused on every future run — but a user who ran the flush
deliberately can be told the run was partly blind.
PreviewFlush goes through the same Encode and batches flushLocked uses,
so the preview is the projection rather than a rendering of it. Three
gates are deliberately absent because each guards a request and none is
made: the enabled check, the single-flight lock, and the minimum
interval. Inspecting what would leave before turning delivery on is the
whole reason it exists. It writes nothing — the watermark advances only
on a 2xx, and a preview has none.
The two delivery sentinels are exported so `remote flush` can classify a
failure without re-deriving it. Their messages stay valueless: they are
printed verbatim, and http.Client.Do's own error embeds the endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): remote status, set, on, off and flush
The command surface for remote delivery, in its own file behind
//go:build remote and self-registered from an init(), so registry.go and
root.go are untouched — and so the default build's `wake --help` has no
`remote` line at all. That absence is the point: ADR-0012 compiles
delivery out rather than configuring it off, and a line reporting itself
unsupported would turn "this binary contains no network code" into
something a reader has to verify. remote_absent_test.go asserts it in the
one build where it is observable.
`set` reads the credential from standard input and never reflects it. A
credential in argv lands in shell history and in `ps` output for every
user on the machine, so a second positional argument is refused rather
than accepted with a warning, and the refusal names the rule instead of
the value. `set` on a fresh store leaves delivery off and points at
`wake remote on`: naming a destination is not the same act as starting to
send to it.
`off` keeps the endpoint and says so, and `status` reports it as host and
port only — no scheme, path, query or userinfo — which is what makes
"configured but paused" legible without echoing what was read.
`flush --dry-run` prints one payload per line, one line per batch, and
sends nothing. Encode's output carries no literal newline, so each line
is byte-for-byte the body the corresponding request would carry;
`--dry-run > payload.json` yields exactly what would leave, because
stdout carries the answer and every caveat goes to stderr.
A dead endpoint exits 0 with the sentinel's own text. ADR-0018 requires a
dead endpoint to be indistinguishable from an absent one from the user's
point of view, and a non-zero exit from a command that did everything it
could is a failure report about the far end rather than this machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(remote): report credential presence, and settle what status may show
Status carried two of the three conditions a flush gates on. An endpoint
that was configured and on but had no credential therefore rendered as a
healthy configuration while delivery returned silently at
`auth.Credential == ""` — plan §12's "collects nothing" mistaken for
"collects zero", applied to delivery. CredentialConfigured is that third
condition, as presence and never the value: a credential has no bare-host
analogue, because every byte of it is the secret. It is read through
LoadRemoteAuth, so a machine deliberately keeping no secret on disk and
supplying WAKE_REMOTE_AUTHORIZATION reads as configured rather than broken.
The comments either side of that struct also disagreed with each other.
state.go read ADR-0028's "never echo what was read" as governing outright,
and remote_auth.go's RemoteEndpointHost cited the same ADR for printing the
host — two contradictory adjudications of one live decision, shipped in
adjacent files. ADR-0029 settles it by consumer rather than by seniority:
the pasteable struct carries presence, `remote status` may print the bare
host to the person who typed the command, and everything else stays under
ADR-0028 as written. Both comments now cite it.
SetRemoteEndpoint is unchanged and now pinned: an empty credential is a
destination with no secret on disk, and it clears whatever was stored,
because carrying one service's credential over to a new endpoint would post
it somewhere it was never issued for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): refuse a truncated credential and report what a flush really did
Three ways `remote` reported something that had not happened.
`set` read stdin through io.LimitReader at exactly the ceiling, which stops
with a nil error — so 5 KiB of a file redirected by accident was stored as
its first 4096 bytes, at mode 0600, indistinguishable from a whole
credential. The far end then rejected every batch with nothing pointing
back at the truncation. It now reads one byte past the ceiling and refuses
anything longer, naming the limit and never the value.
`flush` checked two of the three conditions delivery gates on, so a
configured endpoint that was on with no credential fell through into
FlushReport's silent zero return and printed a flush that never happened.
It checks the third now and says so, `status` renders it as a
`credential:` line, and `set` and `on` say it at the moment they leave the
machine in that state — which also opens the route ADR-0028 provides for
and nothing implemented: an endpoint configured with no secret on disk, its
credential supplied by WAKE_REMOTE_AUTHORIZATION. An empty stdin is that
configuration rather than a refusal; a credential in argv is still refused
with the rule rather than with cobra's arity message.
A partial flush discarded its own report: with batch 1 accepted and batch 2
rejected, the user was told the endpoint could not be reached and nothing
about the 500 records that had already left. What was sent is now printed
before why it stopped, and only when something actually was. Classification
no longer asks errors.Is of a joined error either — flushLocked joins the
delivery error with the delivery-state write's, and a join is satisfied by
any member, so a watermark that never persisted was being reported as a
benign far-end problem and exited 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): default-build extension seams for doctor and post-scan flush (#38)
* feat(cli): add the default-build extension seams for doctor and post-scan
Two package-level slices in internal/cli/extensions.go — diagnosisSections
and afterScan — that a //go:build remote file appends to from its own init(),
exactly as a subcommand appends to commands in registry.go. doctor.go and
ingest.go each gain one unconditional call to drain them, so neither file
carries a build-tag conditional and the absence of a delivery path in the
default binary stays something a reader can see rather than verify (ADR-0012).
Both slices are empty in the default build, asserted under //go:build !remote
alongside a byte-for-byte golden of default doctor output captured from the
pre-change binary — a Contains assertion cannot witness "unchanged", since an
extra line passes every one of them.
The post-scan call sits inside the spinner closure after the scan and before
its error is returned, and after activation.Trigger on the hook-invoked path.
A scan that errored partway may still have written records, and ids are derived
from the source event (ADR-0004), so a hook that runs after a partial scan or
twice costs correctness nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): report remote delivery state in doctor under the remote tag
A diagnosis section registered from init() in a //go:build remote file, so
doctor.go itself stays free of any conditional. It renders remote.Describe's
Status and derives nothing of its own — the watermark arithmetic and the
pending subtraction stay in internal/remote rather than gaining a second path
to the same numbers (ADR-0011).
Presence on every line, never a value. doctor output is what people paste into
issues (ADR-0019 §7), so ADR-0029's bare-host carve-out for `wake remote
status` deliberately does not reach here: config.RemoteEndpointHost is not
called, and a test asserts the whole of doctor's stdout carries no path
separator, neither half of the credential, and not the endpoint host, in the
configured-and-on state most able to leak.
A Describe that failed renders "remote delivery: unreadable" and nothing else.
A row of zeros would read as a healthy delivery path that has sent nothing —
the "collects nothing" / "collects zero" conflation doctor exists to prevent
(ADR-0010) — so a test asserts the zeros are absent, not merely that the word
is present.
The presence vocabulary duplicates writeRemoteStatus deliberately: sharing a
helper is the mechanism by which `remote status`'s host would one day reach
doctor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): flush remote delivery in a detached child after any scan
Verified with make validate and make validate-remote after fixing a fork
storm: the tagged test build now defaults flushChild to a no-op via a
package-wide init(), so no test can start a detached copy of the test
binary. Production is unaffected — flushChild is still detach.Start, and
in a real build self is the wake binary, which terminates.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* ci: assert the release artefact ships no remote command (#39)
ADR-0012's promise is about the artefact, not about the config: what a user
installs must contain no delivery code. internal/cli/remote_absent_test.go
already asserts this for the build `go test` produces, but nothing asserted it
for the binary GoReleaser produces — a different build path, its own flags, its
own config file. This adds that assertion to the release-config job, against the
snapshot artefact, on every pull request. release.yml runs this workflow as its
gate, so the same check covers the real release build at tag time.
The check was proven to discriminate in both directions before being written:
`--help` on the untagged build names no remote command, and on a
`-tags remote` build it does, so this catches the regression it exists for
rather than passing vacuously.
The rest of DG-68's criteria landed earlier in PR #36 — `make validate` runs
`validate-remote` (build, vet, lint and test under the tag), CI reports it as
its own step, and `make help` lists it. This closes the one remaining
criterion, artefact-level confirmation.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix: report throttled flush and stop remote set echoing endpoint (#40)
* fix(remote): report a flush the minimum interval held back
A run suppressed by remote.min_interval returned the zero Report and a nil
error, which is byte-for-byte what a run that read the spool and found nothing
returns. `remote flush` therefore printed "sent 0 records in 0 batches." for a
flush that never happened.
Report grows a Suppressed bool, set only by flushLocked's throttle branch. A
field rather than a sentinel error because Flush is the hook-invoked entry
point, where a throttled run is a correct outcome and not a failure (ADR-0016,
ADR-0018) — a sentinel would make the common trigger path return non-nil.
The value is derived from the throttle and never from the credential store, so
it names which local gate held the run and never where the run would have gone
(ADR-0028). The single-flight lock-skip path is untouched and still reports the
zero Report.
TestReportFieldsAreExactly now requires every field to be an int count or a
bool, keeping the rule it was written for: no field that could hold "and here
is why" as a string (ADR-0007).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): say when the minimum interval held a flush back
`remote flush` printed "sent 0 records in 0 batches." for a run
remote.min_interval suppressed, which is the same sentence a flush that ran and
found nothing prints. The suppressed run never read the spool, so it has no
counts to report.
writeFlushReport now branches on Report.Suppressed and prints the throttle
instead of the counts. The message names remote.min_interval because that is
configuration the reader can change, and says nothing about the far end — a
dead endpoint stays indistinguishable from an absent one (ADR-0018), and
nothing read from the credential store is echoed (ADR-0028).
Exit behaviour is unchanged: a suppressed run still returns a nil error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): stop remote set echoing the endpoint host
ADR-0029 carves the bare host out of ADR-0028's never-echo rule for exactly one
command — `remote status`, typed by the person who configured the endpoint.
`remote set` printed the host too, which is outside the carve-out and therefore
falls back under "never echo what was read".
`set` now confirms the write without naming the destination and points at the
one command allowed to answer "where". `remote status` and
config.RemoteEndpointHost are untouched; the host is still rendered there, and
still not from a field on remote.Status.
The two existing assertions that required the host on `set`'s stdout asserted
the behaviour ADR-0029 forbids, so they are updated rather than preserved. A
new test keeps the carve-out from widening again: nothing from the URL — host,
port, userinfo or path — may reach either stream.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* chore: drop the remote build tag from the default binary (#41)
* chore(config): drop the remote build tag from the config surface
remote.min_interval, the remote-auth store and their tests were compiled
out of the default build. They now build unconditionally, so the eight-key
registry, the three provisional keys and every ADR-0028 guarantee are
asserted once instead of once per build.
registry_default_test.go becomes keys_test.go and holds the only copy of
the exhaustive key lists; keys_remote_test.go keeps just what is particular
to the remote key. boundary_test.go takes remoteAuthFileName as a constant
now that it is visible, which removes the literal that
TestRemoteAuthFileNameIsConfined existed to pin — so that test goes too.
Comments citing ADR-0012's compiled-out mechanism are rewritten to the
reason that survives it: the endpoint, the enabled flag and the credential
stay out of config.toml because it is the file people paste into bug
reports, and Paths does not disclose a file no user has created.
Refs: DG-73, ADR-0028, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(remote): drop the remote build tag from the delivery package
The encoder, the transport, the watermark and the state file now compile
into every binary. No logic changes: the frozen attribute-key-set test,
the batching and watermark model, silent failure with retry and
TestStatusFieldsAreExactly are byte-for-byte what they were, and
`go test -list` reports the same names it reported under -tags remote.
Comments that justified something by the tag are rewritten to what still
holds. The allowlist governing the wire as well as the disk is now stated
unconditionally, because it is; the POST is described as the only outbound
connection this process makes, reached only once a user has configured an
endpoint and turned delivery on.
Refs: DG-73, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(cli): ship the remote command in every build
remote.go, doctor_remote.go and flush_remote.go lose their build tag, so
`wake remote`, doctor's delivery section and the post-scan flush spawn are
present in the one binary the project ships. No functional change: all
three already self-registered from init(), and doctor.go and ingest.go are
untouched apart from a comment that had gone stale.
remote_absent_test.go is deleted — it asserted a build state that no longer
exists. extensions_default_test.go becomes extensions_test.go and inverts:
each seam must be registered exactly once (a second registration would
print doctor's remote section twice and spawn two flushes per scan), and
doctor's whole stdout on a fresh install now includes the six remote lines
reporting delivery disabled. That whole-of-stdout comparison is ADR-0030's
claim at the unit level, so it runs under isolateRemote rather than isolate
to keep an exported WAKE_REMOTE_AUTHORIZATION from deciding the result.
Refs: DG-73, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* build: fold the tagged verify run into validate
validate-remote existed because `go build`, `go vet`, golangci-lint and
`go test` all honour build constraints, so the tagged delivery path would
otherwise have been compiled by nothing. There is no tag left, so the four
default steps already see the whole tree and `make validate` is the only
gate again.
No `build` step replaces it: vet and test both compile every package
including cmd/wake, leaving only the link step, which CI's build job
performs for all four platforms. Adding one would make validate write dist/
as a side effect it does not have today.
Refs: DG-73, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* ci: assert a fresh release artefact has delivery disabled
The tagged verify step goes with the target it called. The release check
inverts: it asserted `remote` was absent from --help, which was ADR-0012's
artefact claim; it now asserts ADR-0030's, that a fresh unconfigured
install of the binary GoReleaser produces sends nothing and says so.
The flush is the load-bearing line. Delivery fails silently by design
(ADR-0018), so denying the runner network access would prove nothing — a
blocked send and a send that never happened look identical. Asking the one
code path that can send to send, on a fresh root with
WAKE_REMOTE_AUTHORIZATION cleared, and reading back its refusal is the
strongest assertion here that can actually fail.
Every asserted string was run against a real binary before being written.
Refs: DG-73, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: stop promising a binary with no delivery code
Two published claims went false when the tag went away. README's security
section said the official binaries contain no remote-delivery code and that
the update path is why the binary carries no network code; it now says every
binary ships the capability and it is off until you run `remote set` and
`remote on`, with `remote status` and `doctor` as how you check. The closing
"no network destination is configured by default" needed no change and is
now the load-bearing sentence.
.goreleaser.yaml's comment justified the empty build-tag list by the
compiled-out promise; it is empty now because there are no tags.
AGENTS.md is corrected too but is gitignored, so it carries no diff here:
the build-tag gate row is gone, the verify-gate arrow drops its last hop,
and the review-escalation rule stops being conditional — the allowlist
governs the wire as well as the disk in every build, and its applies-to
list names the six remote paths outright.
No `## Remote Delivery` section is added — that section has never existed
here and writing one is new documentation, not this correction. Filed as a
follow-up instead.
Refs: DG-73, ADR-0030
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: document the wake remote command surface
The Commands table and Privacy section referenced remote delivery but
never showed how to use it - add a Remote Delivery section with the
command reference and a set/on/flush/status walkthrough.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): interactive wizard for wake remote set (#42)
* refactor(config): export EndpointHost as the one printable-host derivation
`remote status` was the only consumer of a bare host, so the derivation could
live unexported beside its single caller. ADR-0031 adds a second — `remote set`'s
interactive confirmation, which shows a destination in flight before anything is
written — and two derivations of "what a URL may show" would drift into two
answers about what a host may carry.
Exporting it is also what makes an empty result usable as a refusal: the scheme
is now checked here as well as in validateRemoteAuth, so a caller can decline a
typed value before it reaches the store rather than confirming a destination the
write path would reject.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): add a terminal seam for remote set's interactive path
A credential typed at a prompt must not be echoed, and Go's standard library
cannot turn terminal echo off. golang.org/x/term is the mechanism ADR-0031
sanctions for that, in preference to shelling out to `stty` or hand-rolling a
per-platform ioctl.
The seam is a two-method interface handed to the command as a parameter rather
than a package-level hook, so a test supplies a fake terminal without mutating
state another test can see. Whether there is a person to prompt is decided by
root.go's existing os.ModeCharDevice check, so both ends of this package answer
that question the same way — and the nil that says "nobody" is an untyped one,
because a typed nil *termPrompter would satisfy `!= nil` and send a piped
invocation into a prompt loop reading from a stream that has already ended.
x/term is pinned to v0.40.0 and x/sys to v0.41.0 rather than to latest: from
x/sys v0.42.0 on, both declare `go 1.25.0`, which rewrites go.mod's deliberately
major-minor `go 1.25` floor to a patch floor on every `go mod tidy`. That floor
is a documented decision CI asserts, so it is not something to invert as a side
effect of picking a dependency version. term.ReadPassword is unchanged across
the range. dependabot.yml does not manage gomod, so the pins hold until a human
moves them, and its comment now says why moving them needs care.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): prompt for the endpoint host and both credential halves
The confirmation shows config.EndpointHost and never the URL. ADR-0031 §1
rejects reading DG-74's "confirm it" as an echo of the full URL, and it is right
to: a path can hold a token, a query is where an API key usually hides, and
userinfo is a credential outright. The value that reaches the store is the whole
URL; the value shown to the person is url.Host.
Declining re-prompts rather than aborting. The confirmation exists to catch a
mistyped host, and re-prompting is the repair — aborting would make the user run
the command again and re-type a secret they had already given. The loop is
bounded by the reader, so a Ctrl-D ends it with nothing written.
A URL that is not an absolute http(s) one is refused here rather than at the
store, so the wizard cannot confirm a destination the write path would reject.
The refusal is a fixed literal that never quotes back what was typed.
The public key is echoed and the secret key is not, because ADR-0028 §Context
names only the secret half as the credential. Two empty answers are a
credential-less configuration rather than a credential of ":", matching what
readCredential already does with empty standard input, and the joined value is
held to the same 4 KiB ceiling a piped one is.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): prompt through remote set when standard input is a terminal
Which branch runs is decided by standard input alone. A terminal gets the
wizard; a pipe, a redirected file and CI get the single whole read this command
has always done, so the scripted path is byte-for-byte what it was — asserted
by pinning its stdout exactly and by checking not one prompt string was written,
because a prompt reaching a pipe would block a CI run forever.
`set` now takes the URL optionally, since a terminal can be asked for one. It
stays mandatory without a terminal: a zero-argument scripted invocation is
refused with a message naming both ways out rather than being left to read from
a stream nobody is typing into. That refusal writes nothing, and neither does a
wizard the user abandons at the confirmation.
The two stale claims this makes in the file's own prose are corrected with it:
the header's "only in `status`" and RunE's "for nothing else" both predate
ADR-0031's revision of the carve-out to two consumers. The confirmation is the
second, and it may show a host only *before* the write, where it can still
change an outcome — the line printed afterwards names no destination on either
path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: describe remote set's interactive path
The command surface gained a second shape, and the table row described only the
first. It now says what each path asks for and, as importantly, what neither
path ever shows: the secret key, the joined credential, or the full URL.
The `<url>` spellings become `[url]` to match: the argument is optional at a
terminal and required without one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): ask the fd, not the file mode, whether a human is there
`remote set` decided "is this a terminal" with the `os.ModeCharDevice` check
root.go applies to stdout. /dev/null is a character device, so
`wake remote set <url> < /dev/null` — what systemd units, cron, nohup and a CI
`run:` step with nothing to pipe supply — entered the wizard and died at the
first prompt with a bare `Error: EOF`, on the exact invocation ADR-0031 §1
promises is untouched.
The two fds ask different questions. On stdout a wrong answer costs a colour
code; on stdin it decides whether the command runs at all. `term.IsTerminal`
performs the ioctl a terminal answers and a redirected file does not, and
x/term is already a direct dependency, so the weaker proxy buys nothing here.
ADR-0031 §1 carries the dated correction.
This closes the /dev/zero path too: it is not a terminal either, so the
wizard is never built over a stream with no newline for `ReadString` to find,
and the scripted read's 4 KiB ceiling refuses it instead.
The test that pinned the character-device check as correct is replaced by one
asserting the distinction it missed, and the regression is covered at the
RunE level through the real osPrompter — a fake terminal there would test
nothing, since the branch is what broke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): wake init --global consent boundary (#43)
* refactor(config): bound root discovery by a directory and a ceiling
DiscoverRootForRegistration took no arguments and always asked os.Getwd,
so the one function allowed to discover a root could only ever discover
the one the process was standing in. A collection boundary needs it for a
directory a scan observed, and needs the upward walk to stop before the
boundary itself — otherwise a repository enclosing the boundary would be
recorded as the root of everything under it.
dir empty still means the working directory, so which directory gets
consented stays a decision internal/config makes and internal/cli only
asks for (ADR-0001). ceiling empty still means unbounded.
Existence is now checked before git runs. The git failure falls back to
the directory itself for ADR-0019 §5's plain-directory case, and for a
directory that has been removed that fallback was an invented root:
consent recorded for a path nothing can be read from, looking successful
and then reporting a complete pass over nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(config): record a machine-wide collection boundary, keyed and verified
The boundary is a top-level field in projects.json, not an entry. A
boundary is consent and never an identity: nothing resolves to it, and
nestedWith must not see it, because enclosing many consented roots is
exactly what it is for — an entry there would make ADR-0019 §5's
nested-root refusal reject the boundary on any machine that has already
run wake init.
It carries its own keyed digest under its own domain. A boundary consents
everything under it, so a root hand-widened to a parent directory is a
consent widening, and without a digest that edit would take effect with
no error and no counter. A distinct domain is what stops a recorded
entry's digest being pasted into global_root over a root that entry
legitimately carries, which would otherwise verify.
Fail closed throughout: a boundary this build cannot verify is absent —
every directory is then outside it and nothing new is consented — and
readTable refuses to carry it back into the file, matching the posture
Register already takes for an entry.
WithinGlobalRoot is a pure string operation over the snapshot. It runs on
the derivation path, once per unmatched working directory a scan sees, so
it may not stat or resolve a symlink (ADR-0019 §1).
RegisterUnderGlobalRoot bounds root discovery with the boundary as a
ceiling, so a repository enclosing the boundary can never become the
recorded root of everything inside it, and refuses the boundary itself:
the common invocation is from the home directory, and registering that
would enclose every repository the boundary later discovers.
projectsVersion stays 1. The bump rationale is about a format that
reinterprets existing bytes; a version-1 file with no global_root reads
as "no boundary", which is the fail-closed reading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(health): count what boundary discovery could not do
Two counters, not one, because the two are different facts. A discovered
directory that is gone is an honest zero — there is nothing left there to
read — and a registration that was refused is collection that was lost:
the sessions were readable, the repository has no identity, and no number
carries them. Only the second joins Diagnose's "collects nothing" arm.
Ints, so neither can ever carry the reason. doctor output is what people
paste into issues, and a counter carries a count and never a line, a path
or a label.
reportVersion 3 -> 4. A version-3 file read as this format would report 0
for two counters nobody measured, and one of them is the only line that
says a repository under the boundary could not be registered. The file is
derived and non-precious (ADR-0014), so refusing it costs one scan's
diagnostics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(activation): register repositories the collection boundary discovers
One walk observes, then registration happens, then a second walk imports
what the new identities made collectable. Registering inside the resolver
would judge two events of one scan against two different tables, which is
what ADR-0019 §1's snapshot rule exists to prevent; waiting for the next
scan would mean the session that revealed the repository is the one whose
events are attributed to nothing.
There is exactly one walk in the common case. With no boundary recorded
WithinGlobalRoot is always false, so the discovery set stays empty and
the second walk is never reached. There is never a third: registration
happens once, after the walk that observed the directories.
The second walk's counters replace the first's, because a walk is a
complete pass over the source and its numbers describe that source.
EventsWritten is the one exception and is summed: it describes work done,
not the source, and taking the second walk's alone would report zero
events for a scan that wrote plenty on the first — doctor would then say
"collects zero" about a scan that collected.
Init's two halves are extracted so InitGlobal shares them exactly: every
refusal decidable from the arguments alone is still raised before
anything is written, on both paths. InitGlobal registers no root of its
own — the boundary encloses roots and is never one.
All three scan entry points route through the sequencer, so a user-asked
scan, init --full and the hook-fired trigger all pick up a repository the
boundary encloses that no scan has yet seen a session in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): wake init --global consents every project under a directory
-g takes an optional path and defaults to the home directory, which is
the invocation the feature exists for. os.UserHomeDir is not root
discovery and starts no process, so this layer still parses and prints.
Plain init keeps cobra.NoArgs exactly rather than both paths sharing a
widened rule: only --global takes a path, and a typo must not be able to
consent a directory nobody named.
The disclosure gains a sentence saying repositories will be registered
under the boundary as sessions run in them, including ones created later
— consent for a directory tree is consent for repositories that do not
exist yet, and ADR-0010 rests on that reaching the user before anything
is written. config.toml leaves the list, because a boundary is recorded
in projects.json and writes no config key: naming a file the command
leaves alone is as wrong as omitting one it writes.
The boundary is the one path any of this output carries. It is the path
the user typed, and no repository path, label or log content joins it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): resolve the default boundary below internal/cli
TestInternalCliResolvesNoHomeDirectory walks every file under
internal/cli and fails on the identifier UserHomeDir: internal/cli only
parses and prints, so nothing under it resolves anything (ADR-0001). The
default boundary is a decision, exactly as which root a plain init
consents is, so it moves to config.DefaultGlobalRoot and internal/cli
calls it by name.
Two comments said "projects.json" in prose, which
TestSecretFilenamesAreNamedOnlyInThisPackage greps for in raw bytes
outside internal/config. Reworded to "the project table".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(cli): doctor reports the collection boundary's state and counters
Two lines, because "no boundary set" and "a boundary is set and nothing
has been discovered yet" are different states and a single count of zero
cannot tell them apart. "refused" is the third: fail-closed is right, and
silent fail-closed is not — a user whose repositories stopped being
registered has to be able to find out the boundary was rejected rather
than never recorded.
The state word arrives through the doctor seam because it needs the
project table, which writeDiagnosis knows nothing about. The two counters
go into writeDiagnosis beside the refused-entry count, where every other
health.Scan counter lives.
A word or a count on every line, never the boundary path: doctor output
is what people paste into issues, and wake init --global is where that
path is printed once, to the person who typed it.
The frozen fresh-install snapshot and the seam count in extensions_test
are updated rather than loosened — the snapshot is still byte-exact.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: document wake init --global
The Use section explains what the boundary consents and, more
importantly, what it does not: each repository under it is registered
under its own identity, so report and serve keep their per-project
breakdown rather than folding a whole tree into one. Forward-only applies
per repository from the moment it is registered, and --full is the way to
ask for the history.
Local State says the boundary lives in the project table and never in
config.toml — there is no configuration key for it, so it cannot be
widened by editing a settings file.
Also names the boundary among the questions internal/config answers,
beside "which repository an observed working directory belongs to", and
renames three shadowed err variables the linter flagged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(config): check the discovered root against the boundary, not just the directory
RegisterUnderGlobalRoot checked the directory a scan observed and then
registered whatever root git handed back. The ceiling was the only thing
standing between an unattended scan and a root the user never consented,
and git documents two ways it does not bound the walk:
GIT_CEILING_DIRECTORIES is a colon-separated list, so a boundary whose
own path contains a colon splits into entries that are ancestors of
nothing, and an entry git cannot resolve is skipped silently. Both make
it answer with the boundary's parent — verified against git 2.50.1 — and
registering that attributes every repository under the parent to one id,
the identity collapse ADR-0019 §5 keeps the root set non-nested to
prevent.
So the root is now checked after discovery, in both spellings that reach
the table: the discovered one, and the canonical one Register records.
The second closes the same hole through a link — a directory under the
boundary whose physical location is outside it would have been recorded
outside it, and consent is about where the repository is rather than how
a transcript spelled the way to it. A root that vanished between
discovery and canonicalisation is the honest zero the vanished starting
directory already gets.
GIT_DIR and GIT_WORK_TREE are dropped from the environment of a bounded
discovery, because git states the ceiling "will not exclude ... a GIT_DIR
set on the command line or in the environment" and the bounded call is
the unattended one: the scan a hook fires inherits the session's
environment. That is a narrowing, not the guarantee; the check above is
the guarantee. Plain init is unbounded and keeps honouring the
environment as it always has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): refuse an empty --global path instead of consenting the home directory
`wake init -g ""` fell through to the default and consented all of
$HOME. The i…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
remote.Encode, the OTLP/HTTP JSON encoder that turns wake records into an OTLP exportpayload. The encoder emits only a frozen attribute allowlist so a record cannot transport
prompt/transcript content by construction (ADR-0007), and it carries no free-text field, no clock,
no filesystem, and no network access — verified by a package-wide frozen-import test rather than
by convention. Fail-closed applies here too: a record that fails
record.Validate(includingunrepresentable timestamps/durations near the
int64nanosecond boundary) is dropped and counted,never emitted as a degraded span. Determinism is structural — no map is ranged over when building
attributes — and pinned with a golden-payload test. This is a pure library encoder behind the
remotebuild tag; transport wiring is out of scope here and lands in a later ticket.Related Issue
DG-63 — https://supermodularai.atlassian.net/browse/DG-63
How to Test
go test -tags remote -count=1 ./internal/remote/...— full suite, including the hostile-payloadand allowlist corpus, the golden-payload pin, and the frozen-import guard.
go vet -tags remote ./internal/remote/...andgo tool -modfile=go.tools.mod golangci-lint run --build-tags remote ./internal/remote/...—both clean (this diff lives behind
//go:build remote, so defaultmake validatedoes notcompile it).
remote.Encode—it is dropped and counted, never emitted as a span.
outside the frozen attribute key set reaches the encoded output.
Screenshots
Checklist