Skip to content

release-train: staging -> main - #557

Merged
tracebloc-release-train[bot] merged 15 commits into
mainfrom
release-train/to-main
Aug 23, 2026
Merged

release-train: staging -> main#557
tracebloc-release-train[bot] merged 15 commits into
mainfrom
release-train/to-main

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Automated promotion by the release train (RFC-0008 D14). Head is the train-managed release-train/to-main branch (a mirror of staging), so it never collides with a human PR. Merged only when the fr-gate is green.


Note

High Risk
Touches auth/env matching, bearer-token telemetry POSTs, and helm apply paths for resource ceilings. A mismatch can send events to the wrong backend or skip confirmation on resource writes.

Overview
Release-train promotion (0.10.9 → 0.10.10) that turns command-outcome telemetry into a real, fail-closed delivery path and tightens how the CLI answers “which backend?” and “who set this ceiling?”.

Telemetry now POSTs OTLP/HTTP JSON to /telemetry/v1/records/ with a ~1s budget, per-env disk spool (drop-oldest, 0600), and drain on the next run. Label, spool path, and destination share one resolved env. The CLI also drains installer fallback spools, but only records whose deployment.environment matches this session. After delete wipes ~/.tracebloc, exit-path writes must not recreate it.

Session env is normalized (trim + lower-case) in sessionEnv only. auth status, --check, cluster doctor, and telemetry all use that value so a stored "Dev" no longer disagrees with --env dev. A module-wide test forbids new ambient CurrentEnv/CLIENT_ENV reads.

resources set stamps RESOURCE_PROVENANCE=user even when the ceiling is unchanged (installer/unknown is stale). Bookkeeping applies (provenance + phantom GPU) skip the confirm gate so scripts stay exit 0; a real size change still requires --yes off a TTY. Verbose resources shows who set the ceiling.

CI: make fmt-check scopes formatters to tracked *.go files; a hermetic fmt-selftest guards fail-closed script behavior. New weekly/PR envelope-contract drift job diffs the vendored JSON against pinned client-runtime. Stale-issue sweep becomes a thin reusable caller. E2E path filter includes telemetry*.go. Installer job drops apt-get and shells format.sh.

Reviewed by Cursor Bugbot for commit d289cd8. Bugbot is set up for automated code reviews on this repo. Configure here.

shujaatTracebloc and others added 15 commits August 20, 2026 12:57
…eep Decision A (backend#2220) (#538)

* refactor(resources): read the envelope constants from the contract, keep Decision A (backend#2220)

Third and last reader in the P0 consolidation. client-runtime#358 made the
training-envelope arithmetic single-definition and client#766 made both
installers readers of it; this does the same for set.go.

Deleted: the four literals (1-CPU/3-GiB overhead, 1-core/2-GiB floors). They now
come from internal/resources/envelope_contract.json, vendored from
client-runtime, whose arithmetic is node_sizing.envelope_from_allocatable.

cli#143 Decision A is AMENDED IN SCOPE, NOT DEVIATED FROM. The number the user
sets is still the per-run ceiling written to RESOURCE_* verbatim; DeriveTraining
is still the identity on it; Overhead() is still a fit-check safety margin that
is never subtracted. Every exported signature is unchanged, and the existing
suite passes untouched. What went away is the duplicate *definition* of four
numbers, which was never part of that decision. Amendment recorded on cli#143.

Worth naming what the duplication actually cost, because it was not only
tidiness: LargestReadyNode/nodeLarger ranks candidates (cpu, memory) while the
bash installer ranked them (memory, cpu). On a cluster of 8c/16Gi + 4c/32Gi
`resources set` and the installer anchored on DIFFERENT nodes and gave different
answers about one machine. Nobody chose that; it fell out of two independent
implementations. The contract records one order, and this repo's was the one
kept -- it is what the user is shown when the wizard clamps their prompt.

Go needs no generator, unlike the installers: go:embed takes the contract
verbatim at compile time, so the vendored artifact is byte-identical to upstream
and the cross-repo gate is a plain diff. The constants became small funcs rather
than package vars for the same reason Overhead() is a func -- a var would be
mutable from anywhere, and the point is that one place decides.

mustContract panics on a malformed contract, MustCompile-style: the file is
embedded at COMPILE time, so it can only be invalid via a hand-edit or a botched
re-vendor -- a broken build, not a runtime condition. A default here would be a
fifth policy. TestContractIsValid keeps the panic out of a release.

The drift gate FAILS CLOSED when it cannot read upstream, per cli#536 -- a check
that never executed must not report as passing. client-runtime is private, so it
mints a least-privilege App token (named repositories, contents:read) instead of
GITHUB_TOKEN. It also re-derives upstream's vectors from upstream's own generator:
a vendored contract can match upstream byte-for-byte while UPSTREAM's goldens have
gone stale against upstream's code, and mirroring that faithfully is still wrong.

Verified: go build + go vet clean, go test ./... all packages ok, gofmt and
goimports clean, go.mod/go.sum untouched. Mutation-tested: overhead 3GiB -> 4GiB
in the vendored contract reddens 5 tests here, two of them PRE-EXISTING
(TestOverhead_IsOneCoreThreeGiB, TestMaxRunCoresAndGiB) -- so the existing suite
already anchored these numbers independently, which is the ticket's DoD holding
in a consumer repo.

Refs: RFC-BACKEND-664 P0, client-runtime#358, client#766, cli#143, cli#536

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(resources): drop the unreachable ContractVersion export (deadcode gate)

The Lint job's deadcode check flagged it: exported, but unreachable from
./cmd/tracebloc. I had added it "for doctor output" and then never wired it into
a command, which is exactly the shape that gate exists to catch — an export with
no caller reads as API surface and gets maintained like it.

The tests are in-package, so they use mustContract().ContractVersion directly and
lose nothing. Surfacing the contract version in `doctor` may well be worth doing
(a binary and an edge disagreeing about the contract is a real field question),
but it belongs in the PR that touches the read path, not as a stub here.

Not touched: the pre-existing stale allowlist entry for
internal/doctor/doctor.go Status.String. It is reported on clean develop too and
is a warning, not a failure — deadcode-check.sh still exits 0. Pruning it is
someone's cleanup, not this PR's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: shujaat hasan <shujaathasan@shujaats-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`Installer (shell)` is a REQUIRED status check on develop, and its first action was
an `apt-get` with no retry and no time bound of its own. A slow package mirror
therefore consumed the whole 10-minute job budget before any shell was parsed, and
blocked every PR in the repo while doing it.

MEASURED, not theorised. cli#533 is a workflow-only diff that cannot touch installer
behaviour, and it failed FOUR consecutive times:

  job 96126585157   Installer (shell)   failure   10m16s
    15:34  Set up job
    15:34  Run actions/checkout
    15:34  shellcheck + dash parse    <- 10 minutes here, then killed
    15:44  Post Run actions/checkout

Nothing after the `apt-get` line ever ran. And the annotation read `The job has
exceeded the maximum execution time of 10m0s` on a job called `Installer (shell)`,
so whoever sees it reasonably concludes the installer is hanging. Nothing points at
apt.

NOT REPO-WIDE, which is worth stating because the ticket first implied it: #530 and
#526 pass the same check. It reproduced on one head, four times.

THE FIX REMOVES THE DEPENDENCY RATHER THAN HARDENING IT. Both tools are already on
`ubuntu-latest`:

  * shellcheck is preinstalled -- tracebloc/.github's own `quality / shellcheck`
    job, a REQUIRED check in 16 repos, calls `shellcheck --version` with no install
    at all. The org has depended on that fleet-wide for as long as that job existed.
  * dash IS Ubuntu's /bin/sh, an essential package.

A retry-with-timeout around apt would have been the smaller diff and the worse fix:
a step that installs nothing cannot stall on a mirror, and no wrapper can say that.

`shellcheck --version | head -2` is kept as the first line, matching what the org's
own shellcheck job does -- so the version in use is in the log, and an absent binary
fails on line one with an obvious message instead of somewhere further down.

THIS PR'S OWN RUN IS THE PROOF, and that is deliberate: if either tool were missing
the step fails loudly here, before merge. Better than any claim in the comment.

Verified locally too: shellcheck --shell=sh --severity=error scripts/install.sh
clean, dash -n scripts/install.sh clean.

Closes #534.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…0) (#539)

* feat(resources): stamp `resources set` as a human choice (backend#2220)

Companion to client#768, and the half that makes the marker mean anything.

client#768 taught the installer to write env.RESOURCE_PROVENANCE
(installer | user | unknown). Without this side, an edge the installer marked
`installer` and the operator then re-sized by hand would KEEP saying
`installer` -- a deliberate human choice wearing the one label that invites a
future ladder to overwrite it. That is strictly worse than no marker at all,
so the two PRs are only correct together.

BuildEnvSpec now writes RESOURCE_PROVENANCE=user unconditionally. Unconditional
for the same reason NoGPUEnvValue is: the apply runs
`helm upgrade --reset-then-reuse-values`, which re-applies the release's stored
values on top of chart defaults, so an omitted key is silently RE-INHERITED
rather than cleared. There is no branch on which omitting it would be safe.
`tracebloc resources set` is by definition the human choice -- there is no
variant of running it that isn't.

Read side: Training.Provenance, normalised through NormalizeProvenance, which
maps anything unrecognised -- empty, junk, a value this binary predates, wrong
case -- to `unknown`, never to a guess. Returning "" would have made callers
branch on empty and invent their own default, which is a second policy and
exactly what this ticket removes. Callers MUST treat `unknown` as a human
choice: it means we do not know, and guessing `installer` risks overruling an
operator.

Surfaced in `resources --verbose` as "set by", not in the default view: it
answers a support question ("did someone set this, or did we?"), not one an
operator needs on every run, and a line of bookkeeping that never changes the
numbers should not grow the default output. `unknown` renders with its
explanation rather than the bare word, which invites the wrong conclusion -- it
does not mean something is broken.

The env map already flows into helm.UpgradeParams.Env, so no new plumbing. The
added apply-boundary test starts from an installer-marked edge specifically,
because the unit test proves the map is right while this proves the map arrives
-- and a marker that never lands looks identical to one that did, with the
consequence surfacing much later.

zz-all-strings.golden regenerated: one line, "set by", reviewed.

Verified: go build + go vet clean, go test ./... all packages green, gofmt and
goimports clean, deadcode exit 0. 6 new tests in internal/resources (including a
set-then-read round trip) + 1 apply-boundary test in internal/cli.

Refs: RFC-BACKEND-664 P0, client#768, client-runtime#358, cli#538

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(resources set): a same-size set must still stamp `user` (Bugbot + review, #539)

Bugbot's High, confirmed by @saadqbal in review, and the fix was indeed sitting
three lines below the bug.

BuildEnvSpec stamps RESOURCE_PROVENANCE=user unconditionally WITHIN ITSELF, but
the caller could exit before ever reaching it: `sameCeiling` returned "nothing to
change" whenever the desired ceiling matched the current one. So an
installer-sized edge whose operator ran `resources set --max`, or passed flags
restating the current ceiling, kept `installer` -- a deliberate human choice
wearing the one label that invites a future ladder to overwrite it. That is
verbatim the state BuildEnvSpec's own comment calls the most dangerous the marker
can be in, and my PR body claimed this PR and client#768 "are only correct
together" while this hole made that untrue: not wrong, just narrower.

Fixed with the phantom-GPU branch as the template -- same condition, same
reason, already written and already reviewed. An unchanged ceiling now falls
through to persist when the stored marker is not already `user`. Both conditions
can hold at once, so both report their reason rather than one masking the other.

`unknown` counts as stale deliberately. A pre-marker edge whose operator restates
the ceiling has just made that size explicit, so recording it is the honest
answer; the cost is one extra apply per edge, exactly once, because the second
run sees `user` and is a clean no-op again.

Three existing no-op tests now carry RESOURCE_PROVENANCE=user in their fixtures
(TestSet_NoOpSkipsApply, TestSet_NoOpEvenWhenCurrentNoLongerFits,
TestWizard_LeaveAsIs). Their invariant is unchanged and still asserted -- a clean
no-op makes no helm call -- it just needs the marker present to BE the clean
case. The uncovered case became TestSet_SameCeilingStampsProvenance, a table over
installer / pre-marker / junk, asserting the apply happens, that we do not claim
nothing changed while correcting the marker, and that the reason is stated.

zz-all-strings.golden regenerated: one line, reviewed.

Also rebased onto develop now that cli#538 has merged -- `git rebase --onto
origin/develop 05517ed` to drop the squashed parent's commits rather than
replay them into conflicts. GitHub had already retargeted the base to develop
when #538 landed, so the stacked-PR banner in the description was stale; removed.

Verified: go build + go vet clean, go test ./... all packages green, gofmt and
goimports clean, deadcode exit 0.

Refs: backend#2220, client#768

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(release): bump VERSION to 0.10.10

v0.10.9 is already released and this PR changes published files under
internal/*, so the release train needs a version above every released tag —
version-bump-gate fails otherwise (backend#1561).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: shujaat hasan <shujaathasan@shujaats-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#2217) (#542)

* feat(telemetry): the CLI validated events and delivered none (backend#2217)

`pendingSink()` returned nil, so every command outcome was validated and
dropped. The ingest endpoint exists now (backend#1905) and speaks OTLP/HTTP JSON
(backend#2213), so this is the delivery half.

Option (c), as decided on the ticket: one inline POST with a ~1s budget for the
whole step, spool to disk on any failure, drain at most 20 pending on the next
invocation. No background daemon, no goroutine outliving the process, no retry
loop -- the next command IS the retry. The deciding argument against
fire-and-forget is that in a short-lived CLI async delivery is a lie: a goroutine
outliving main() is killed at exit, so the honest options are "always block" or
"always drop on failure", and dropping on failure discards exactly the
partition-time events worth having.

Three things worth a reviewer's attention:

1. `anyValue` switches on reflect.Kind, not on concrete type. The emitter's
   `checkAttrValue` deliberately admits named scalar types (`type Reason string`,
   time.Duration via telemetry.Duration) by kind; a `switch v := value.(type)`
   at the seam would match the dynamic type, miss those, and silently drop the
   values the layer above went out of its way to accept.

2. A 4xx other than 401/403/408/429 DISCARDS the batch. The endpoint answers 400
   for a wholly unparseable batch; re-spooling that would wedge the spool
   forever, re-sent by every future command and pushing good records out at the
   cap. 401/403 retry because they are a credential state, not a payload
   verdict -- the next login makes them deliverable.

3. The spool keeps drop-OLDEST, which is not a contradiction of D7's amended
   drop-newest row. That row says drop-newest because `exporterhelper` sheds at
   the entrance and offers nothing else -- a platform constraint on the edge
   Collector. This spool is our own code and can do what D7 originally wanted,
   so it does, matching the installer's `tail -n` trim.

`deliver` takes a resolved URL rather than an env. Found by its own test: with
`api.BaseURL(env)` computed inside, the test posted to PRODUCTION. Passing the
URL also means the record's label and its destination cannot disagree.

No `timeUnixNano`. Per the #2213 decision no client-side timing is sent, so the
receiver stamps arrival and "how late" is knowingly invisible; adding an event
clock is a contract change, not a drive-by here.

Verified: `make check` green (vet, full test suite, fmt, file-budget, style,
tool-pins) and `make lint` clean (errcheck, ineffassign, misspell, staticcheck).
Nine mutations run against the nine new assertions -- collapse the resourceLogs
entries, encode int64 as a number, swap reflect for a type switch, invert the
trim, retry a 400, send the legacy `Token` keyword, widen the spool to 0644,
drop on retry, drop when unauthenticated -- all nine killed their test, none
survived, none merely broke the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(telemetry): the spool round-trip recoded integers as doubles (#542 review)

@saqlainsyed007 and Bugbot, both right, and it defeated this PR's own wire-mapping
guarantee on the path the design exists for.

`readSpool` used `json.Unmarshal` into `map[string]any`, so every JSON number came
back as float64 and `anyValue` routed it to `doubleValue`. The in-memory attempt
sent `exit_code` as a canonical `intValue` string; every DRAINED retry -- the
partition-time events the whole spool exists to preserve -- sent the same field as
`doubleValue`. Same event, two encodings, wrong one on the path that matters.

Reproduced before fixing rather than argued from the code:
  in-memory : "tracebloc.cli.exit_code":{"intValue":"2"}
  round-trip: "tracebloc.cli.exit_code":{"doubleValue":2}

Fix is the one suggested: decode with `dec.UseNumber()` so numbers arrive as
`json.Number`, and handle that in `anyValue` -- `intValue` when integral,
`doubleValue` otherwise. It also keeps the spool file human-readable, which was a
stated goal.

THE ORDERING IN `anyValue` IS LOAD-BEARING, and this is the part worth reviewing:
`json.Number` is a NAMED STRING TYPE, so the existing kind switch would have
matched `reflect.String` and emitted `stringValue` -- turning an exit code into a
string on the drained path, a different wrong answer from the float64 one. The
json.Number branch therefore sits before the switch, not inside it.

Why the existing test stayed green, since that is the reviewable lesson:
`TestIntegersAreEncodedAsStrings` operates on the in-memory shape and never goes
through writeSpool/readSpool. So it could not see this, which is the vacuity the
house bar rejects. Two tests now cover the drained path:

* `TestSpoolRoundTripPreservesIntegerEncoding` asserts the two encodings AGREE,
  not merely that the drained one looks right -- a test checking only the drained
  payload would be satisfied by both paths being wrong together. It also asserts
  the in-memory reference explicitly, so a regression there cannot make the
  comparison pass by both sides breaking.
* `TestSpoolRoundTripKeepsRealsAsDoubles`, because a fix for integers that
  truncated every float would pass the first test.

One note on the first draft of that test: it asserted "no doubleValue anywhere in
the drained payload" and failed on CORRECT output, because the fixture carries
`sampling_rate: 0.5` which must stay a double. Narrowed to per-attribute
assertions -- an over-broad assertion reported as a code bug is its own defect.

Verified: `make check` green (vet, full suite, fmt, file-budget, style,
tool-pins), `make lint` clean. 13 mutations now, all killed: the original nine
plus dropping `UseNumber` (the reported defect), letting json.Number fall through
to the kind switch, routing integral numbers to doubleValue, and truncating reals
to integers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(telemetry): the spool leaked records across environments (#542 review)

Bugbot, and it reopened one level up exactly the mismatch `deliver` takes a
resolved URL to prevent.

A single host-wide `pending.jsonl` meant records queued while signed in to one
backend were drained by the NEXT invocation against another -- POSTed to its
endpoint, with its token, still carrying the first one's
`deployment.environment`. `tracebloc login --env dev` after a failed prod command
was enough. Reproduced before fixing: a `deployment.environment=prod` record
arrived at a dev endpoint with a dev token.

The spool is now per environment, `pending-<env>.jsonl`, and `deliver` takes the
resolved spool PATH rather than deriving one -- so label, spool and destination
are three views of a single resolution in `pendingSink` instead of three chances
to disagree. That is the third reason on that function's doc comment, and the
second and third each bit once.

The consequence is stated in the code rather than hidden: records for an
environment the operator never uses again are never delivered. That is the right
trade -- they are capped, there is usually no token for that environment anyway,
and delivering them to the WRONG backend is not a better outcome than not
delivering them.

`spoolEnvSlug` strips anything outside [a-z0-9-] and lowercases. Unreachable with
the closed dev/stg/prod set, and present because a path segment built from a
string is a traversal waiting for that set to open; an unexpected value becomes
one bucket rather than being dropped, since an undeliverable record is still
evidence.

The leak test asserts BOTH halves -- nothing prod-labelled reaches the dev
endpoint, AND the prod spool still holds its record afterwards -- because a fix
that simply discarded the other environment's queue would satisfy the first
assertion alone.

Verified: `make check` green, `make lint` clean, 16 mutations all killed (the
original nine, the four from the int-encoding fix, plus reverting to a host-wide
spool, and the slug ceasing to strip separators or normalise case).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d#2217) (#545)

Completes backend#2217. The CLI half landed in #542; this is the installer half,
built as option (b) rather than (a).

WHY NOT (a), THE ROUTE THE TICKET ASSUMED. #2217 says "convert at the seam and
POST", which presumes the installer can authenticate. It cannot: the ingest
endpoint needs `Token`/`Bearer` under `IsAuthenticatedEdge`, and the installer
holds `TRACEBLOC_CLIENT_ID`/`TRACEBLOC_CLIENT_PASSWORD` -- a provisioning pair
with no exchange for a token -- and never reads the CLI's config (zero hits across
`scripts/`). Having it read `~/.tracebloc/config.json` would also deliver NOTHING
for the failures that matter most: `validate_config` and `early_data_dir_guard`
run before provisioning, so no token exists on disk when those events are written,
and those are exactly what the installer's `$TMPDIR` fallback was built to
preserve.

So the CLI carries them. It already owns the token and, since #542, the spool,
drain loop and OTLP mapping -- this adds one more file to read and leaves the
installer with no credential handling at all.

THE UNPREDICTABLE FALLBACK PATH TURNED OUT NOT TO NEED AN INDEX FILE. I had
expected to need one; `_telemetry_fallback_spool` uses
`mktemp .../tracebloc-telemetry-XXXXXX`, so the NAME is unpredictable but the
PATTERN is fixed. A glob over $TMPDIR / $HOME / /tmp finds them, the installer
needs no change, and there is no shared state to keep in step.

EVERY RECORD IS FILTERED BY ITS OWN ENVIRONMENT, and this is the part to review.
Our spool is partitioned by env in the FILENAME; the installer's is not, and its
records carry whatever CLIENT_ENV that run used. Forwarding blind would post a
prod-labelled install failure to whichever backend this invocation points at --
the same leak #542's second finding was about. A record ships only when its
`deployment.environment` matches this run's; the rest stay for a later invocation
against that env. A record with NO environment is never forwarded (the contract
omits rather than empties, so absent means unresolvable) but is also never
dropped: it is still evidence.

Their files are touched only on a path that CONSUMED them, and never on failure
or when unauthenticated. A file we took nothing from is not rewritten at all.

Verified: `make check` green, `make lint` clean, 16 mutations across the three
suites all killed. One of the new nine earned its keep: it proved
`TestInstallerRecordsSkipsAFileWithNothingForUs` VACUOUS -- it compared file
bytes, and a rewrite of unchanged records produces identical bytes, so it passed
under the mutation it existed to catch. It now asserts the real contract (the file
never enters `remainder`) and the mutation kills it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#544)

* chore(shell): drop the pipe into early-closing readers (backend#2264)

Seven pipelines fed a reader that closes before EOF (`| grep -q`, `| head -1`).
Under `set -euo pipefail` that shape can return 141: the reader closes, the
producer takes SIGPIPE, pipefail surfaces the signal as the pipeline status.

MEASURED, none of the seven is reachable. Every producer here is a bash
builtin `printf` emitting a single short token ("$min", "$max", "$line",
"$BACKEND_REF"), and a single-line producer cannot SIGPIPE a `grep -q`
anyway -- grep must read to end-of-line before it can report the match, so
-q drains the input regardless. This is a shape conversion so the shared
gate can be armed with the repo already green, NOT a bug fix.

  scripts/coverage-floor.sh        2 sites
  scripts/file-budget.sh           1 site
  scripts/sync-backend-fixtures.sh 2 sites
  scripts/sync-schema.sh           2 sites

Verified behaviourally, both directions, on the converted conditions:

  ref validators   REJECT (exit 2) ../../etc/passwd, a..b, -badstart,
                   'x;rm -rf /', 'a b'
                   ACCEPT main, abc123/def-1.2_3, a 40-char SHA
  malformed guards catch 'pkg' (no colon), 'pkg:abc', 'pkg:12x', 'pkg:-5'
                   for BUDGETS and FLOORS alike; a well-formed entry still
                   reports "ok: internal/cli/data.go 248 <= 500"
  pct extraction   still reads a real number: "ok: ./internal/cli/ 85.0% >= 1%"

Each fixture was asserted to actually apply before its run, so an inert
mutation cannot be mistaken for coverage.

`make file-budget` and `make check-style` pass; shellcheck -S warning clean
across scripts/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: restore the pinned fixtures my own verification overwrote (backend#2264)

Self-inflicted, and worth naming precisely. To check that the converted ref
validators still ACCEPT valid refs, I ran

    DATA_INGESTORS_REF=main  bash scripts/sync-schema.sh
    BACKEND_REF=develop      bash scripts/sync-backend-fixtures.sh

These scripts are not predicates. On a valid ref they do the sync -- they
downloaded from unpinned refs and rewrote four tracked files, and `git add -A`
swept them into the conversion commit:

    internal/schema/ingest.v1.json                     (+79 lines from main)
    internal/api/testdata/edge_device_adopt.json       ("status": 0 -> 2)
    internal/api/testdata/edge_device_create.json
    internal/api/testdata/edge_device_patch_cluster_id.json

That is a silent supply-chain change to PINNED content, which is exactly what
`.data-ingestors-ref` and `.backend-ref` exist to prevent, and it is what
reddened Schema drift, Backend fixtures drift, Test and Integration (kind).
The drift checks did their job.

Restored all four from origin/develop. The PR is back to the four script
changes it claims to be.

The lesson is the harness, not the scripts: a verification step that INVOKES a
mutating command is not a read-only check, and `git add -A` after running one
cannot tell the difference. The rejection cases (exit 2 before any network
call) were safe; the acceptance cases were not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… (cli#546) (#547)

Bugbot MEDIUM on the develop->staging promotion mirror (#540), against #539
(backend#2220).

#539 made any non-`user` RESOURCE_PROVENANCE stale, so an unchanged ceiling
stopped returning early and instead fell through to the apply in order to
re-stamp the marker as `user`. Correct intent, wrong landing site: the
fall-through lands in the confirmation gate, and off a terminal that gate does
not ask -- it returns exit 1. So

  tracebloc resources set --cores 4 --memory 16   # the CURRENT ceiling

went from the exit-0 no-op the command's own --help documents ("0  applied (or
nothing to change)"), and that docs/cli-navigation.md draws as an edge going
straight to exit 0 bypassing CONF, to a hard failure.

Nearly every installed edge still reads `installer` or `unknown`, so the blast
radius was the installed base rather than an edge case, and the callers that
restate a size are scripts -- the bootstrap, the end-to-end journey -- none of
which pass --yes for what the docs promise is a no-op.

The fix is one clause on the gate, not a revert: the gate guards the CEILING,
and an unchanged ceiling has nothing to ask about ("Let each training run use
up to 4 CPU / 16 GiB?" when the answer is already that is a question with one
honest answer). The re-stamp #539 added still happens. The phantom-GPU
fall-through (#241) had the same shape and is fixed by the same clause: both
are bookkeeping writes, already announced by their own Infof lines, not budget
changes an operator needs to sanction.

Tests: TestSet_SameCeilingNeedsNoYes. Every same-ceiling case already in the
file passed `yes: true` -- the flag under dispute -- which is why nothing was
red. The new cases assert exit 0 AND that the apply still happens, because
either alone is satisfiable by the wrong fix; they read RESOURCE_PROVENANCE=user
off the values file helm was actually handed (the existing assertions go through
--dry-run, which skips the very gate at issue); they cover the phantom-GPU
sibling and a declining prompter on a terminal; and one sub-case bounds the fix
by proving a real CHANGE off a terminal still exits 1 and mutates nothing.
Five of the six sub-cases fail without the one-clause change.

docs/cli-navigation.md: the `no change` node now says it may still re-stamp
provenance or clear a phantom GPU, and never asks to confirm -- true of #539's
behaviour too, which shipped without updating the map.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… ~/.tracebloc (backend#2314) (#548)

* fix(delete): stop the exit-path telemetry write re-creating the wiped ~/.tracebloc (backend#2314)

`tracebloc delete` printed "✔ Removed local tracebloc data and config." and then
put the directory back before the process exited, so the offboard's central
promise was not kept.

main.go emits the command-outcome event AFTER the command tree returns, and the
telemetry spool lives at <config.Dir()>/telemetry/pending-<env>.jsonl — inside
the tree the offboard just removed. Two separate defects combined:

  * writeSpool called MkdirAll BEFORE its len(events) == 0 early return, so it
    created the directory even when it had nothing to write and was about to
    delete the spool file. This is why the tree came back on the DELIVERED path
    too, not just offline.

  * Nothing told the exit-path write that this invocation had deliberately
    removed local state, so on the undelivered path it wrote a real event file
    back into the wiped tree. That is the path the offboard always takes: the
    wipe takes the token with it, so deliver() finds no credential and spools.

removeHostDataDir now returns the directory it removed and the offboard records
it, so writeSpool drops any write that lands inside it. The recorded value is
the PATH, not a boolean: a bare "telemetry is off" flag silences writes the
offboard never touched, and is permanently sticky inside a test binary — three
unrelated spool tests failed exactly that way while this was being written.

Delivery over the network is untouched: an online offboard still reports its
outcome. Only the on-disk fallback is suppressed, and a dropped telemetry record
is the cheaper loss against silently undoing a wipe the user asked for.

Regression coverage in telemetry_transport_test.go, verified load-bearing by
reverting each half independently. This is the only failing assertion in the
`Offboard teardown (k3d)` e2e, red on develop since c246912.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): the offboard suite depends on internal/cli/telemetry*.go too (backend#2314)

The paths probe gates `Offboard teardown (k3d)` on the black-box run's
dependency surface, and the telemetry transport was missing from it. That is the
same gap the filter's own comment records for internal/ui after #367: the
command-outcome event is emitted from main.go AFTER the offboard returns, and its
spool lives inside the ~/.tracebloc the offboard just deleted, so a telemetry
change re-created the wiped tree and broke the suite's config-dir assertion
without touching delete.go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…cli#549) (#550)

* fix(fmt): the formatter gates walked the working tree, not the repo (cli#549)

`make fmt-check` ran `gofmt -s -l .` and `goimports -l .`, and `.` is the whole
working TREE. Any untracked directory holding Go files — a nested git worktree,
a vendored copy, a build sandbox — was reported as drift while every tracked
file in the repo was correctly formatted:

    ==> goimports (import grouping) needed on:
        <untracked-scratch-dir>/internal/cli/data.go
        ==> run `make fmt` to fix

CI never saw it, because a fresh checkout has no untracked Go files. So this was
a local-only FALSE failure in `make check`, the documented pre-push tier — and
the remedy it printed was the same bug in write mode: `make fmt` (`gofmt -s -w .`)
rewrote files the repo does not track, i.e. someone else's working copy.

Both invocations, check and write, now take the file list from
`git ls-files '*.go'` — exactly the set a PR can contain.

Notably this was the ONLY gate affected: Go's `./...` skips dot-prefixed
directories, so `vet`, `test` and `deadcode` never saw `.claude/` at all. `gofmt
.` does not skip them, which is why fmt-check alone cried wolf.

scripts/format.sh, rather than more backslash-continued shell in the Makefile,
because the edge cases want testing and comments:

  * xargs over a NUL stream from a printf builtin, not a bare argv expansion.
    Measured: 9000 tracked files is 2.0 MB of argv against a 1 MB ARG_MAX —
    the naive form dies with "argument list too long", the batched form
    reports all 9000.
  * tracked-but-DELETED index entries are filtered out. `git ls-files` reports
    the index, so a mid-edit deletion would otherwise hard-error the gate.
  * FAILS CLOSED (exit 2) outside a git work tree, and on an empty file list.
    The empty case is not cosmetic: bare `gofmt -l` with no path arguments
    reads STDIN, so an unguarded empty list checks nothing and exits 0 — the
    inert-verification class of backend#1729.
  * stderr is deliberately not captured; `go run`'s download progress would
    otherwise appear as phantom drift filenames on a cold cache.

build.yml's Lint job calls `make fmt-check` instead of keeping its own inline
copy, so the file set has one definition and cannot drift from local. That also
drops the restated `goimports@v0.48.0` pin, and GOIMPORTS_VERSION joins
check-tool-pins.sh's TOOLS so it stays dropped on the next bump.

Verified on this branch, with a `.claude/worktrees/` directory present holding
deliberately misformatted Go:

  * `make check` — green (was red on the same tree before this change; the old
    `gofmt -s -l .` flags the scratch file, shown in the ticket)
  * tracked drift still caught, both gates: a non-simplified slice expression
    in internal/slug/slug.go fails gofmt -s, a mis-grouped import fails
    goimports
  * `make fmt` leaves the scratch file byte-identical (sha unchanged)
  * fail-closed paths exercised: no work tree, empty list, bad usage — all 2
  * a deleted tracked file passes at 218/219 rather than erroring
  * shellcheck (default severity, not just -error) clean; actionlint clean

Refs cli#549. Found while working on cli#548, kept out of it to keep that PR
scoped to the offboard telemetry bug (backend#2314).

* fix(fmt): check mode swallowed a formatter failure and called it clean (cli#549)

Bugbot High on scripts/format.sh:90 and @LukasWodka on #550, both correct, and
the defect was the same class this PR exists to fix — in the PR's own new code.

`run_formatter` ended with `exit 2` on a non-zero formatter. Write mode called it
directly, so that killed the script. Check mode captured it:

    drift="$(run_formatter "gofmt -s" gofmt -s -l)"

A function that exits inside a command substitution ends only the SUBSHELL. With
`set -uo pipefail` and no `-e`, the parent read an empty `drift`, found no
drift, left `fail` at 0, printed

    ==> fmt-check: 219 tracked Go file(s) clean

and exited 0. So a cold `go run` cache, a network blip, a bad GOIMPORTS_VERSION
pin or any non-zero gofmt produced a FALSE GREEN in `make fmt-check` and in CI's
Lint step, with the true `FAILED (exit N)` line going to stderr where nothing
acted on it. Reproduced standalone before changing anything:

    f() { echo boom >&2; exit 2; }
    out="$(f)"; echo "PARENT STILL RUNNING; out=[${out}]"
    -> boom / PARENT STILL RUNNING; out=[] / parent exit=0

`run_formatter` now writes the formatter's stdout to a temp file and RETURNS the
status; callers are `run_formatter ... || exit 2` in the parent shell. It never
exits and is never wrapped in `$( )`.

A comment cannot hold that shut, so scripts/tests/format-verify.sh does: eight
properties, formatters stubbed on PATH and via $GO, hermetic, no network, ~6 s.
It asserts the failure propagates in BOTH modes, that the untracked file never
reaches a formatter while the tracked one does, that tracked drift still exits
1, and the three fail-closed paths.

Proof it is not inert — the harness run against a copy of format.sh with the old
subshell shape restored:

    FAIL: a failing formatter exited 0 (check mode swallowed it — the #550 defect)
    format-verify: 1 FAILED, 7 passed

and against the fix: `format-verify: 8 properties hold`.

Wired into `make check`, `make ci`, and build.yml's Installer (shell) job — that
job, not Lint, because stubbed formatters need no Go toolchain. `make check` is
10.6 s warm with it, against the 60 s budget.

Fixing the harness cost one round of the same trap: `rc="$(run_case ...)"` lost
the CASE_OUT the function had set. Call sites are direct now, with a comment
naming why, since it is the identical mechanism.

Re-verified after the refactor: `make check` green with a `.claude/worktrees/`
scratch dir present holding misformatted Go (the old `gofmt -s -l .` still flags
it), tracked gofmt drift still exits 1, `make fmt` leaves the scratch file
byte-identical, shellcheck clean on both scripts at default severity, actionlint
clean.

Refs cli#549.

* test(fmt): pin each propagation path, not "at least one" (cli#549)

@LukasWodka on #550, and he mutation-proved it rather than asserting it:
swallowing ONLY the gofmt check-mode call site left format-verify green.

The `explode` stub failed BOTH formatters, so whichever call site still had
`|| exit 2` carried the script to a non-zero exit and the case read that as
success. What the suite actually asserted was "at least one propagation path
works" — or-coverage under a name that promises per-formatter coverage. A
regression breaking only gofmt, or only goimports, shipped green through it.

Which is the scenario the file exists to prevent, because ONE call-site shape
being wrong is exactly how the original bug arrived.

The two formatters now get independent stubs (`write_stub`, mode per tool), and
two cases fail exactly one of them, so only that call site can produce the
non-zero exit. Mutation-proved in both directions:

    swallow only the gofmt call site
      -> FAIL: a failing gofmt alone exited 0 — that call site swallows failures
    swallow only the goimports call site
      -> FAIL: a failing goimports alone exited 0 — that call site swallows failures
    unmutated
      -> format-verify: 10 properties hold

Before this commit the first of those was `10 properties hold`, exit 0.

`make check` stays green and inside budget; shellcheck clean at default severity.

Refs cli#549.
…eader (backend#2320) (#551)

* fix(cli): resolve the backend env once per invocation, not once per reader (backend#2320)

The env/base-URL resolution family, not the one site #540 named. Each of the
last recuts produced one more finding about a site that answered "which
backend?" differently from its neighbour (cli#528 review, #542 review, now
#540) — the failure mode is additive, so only the SECOND site is ever a bug.

- telemetry: recordCommandOutcome took the resolved env as a parameter instead
  of calling telemetryEnv(signedInEnv()) a second time. The label and the sink
  (spool path + POST destination) now derive from one value, which is what the
  comment above RecordCommandOutcome already claimed. This is #540's finding.

- sessionEnv is now the single config -> session-env resolution point, and it
  normalises (trim + lower-case) like api.ResolveEnv. Returning cfg.CurrentEnv
  verbatim made it the one env-resolving function whose output was not
  normalised: invisible where the value only reaches api.BaseURL (which
  lower-cases again), load-bearing where it is COMPARED, or where one consumer
  trims and another does not — api.BaseURL does not trim, so " dev " fell
  through to PROD.

- `cluster doctor` built its API client from cfg.CurrentEnv raw and
  `auth status --check` compared it raw against an already-normalised target.
  Both go through sessionEnv now: a doctor probing prod with a dev token
  reports "session expired" for a session that is fine, and the installer,
  whose contract is --check's exit code, re-ran login against a working
  session.

- internal/doctor.backendHost derives its host from api.BaseURL instead of
  restating the same three hosts in a second switch. Behaviour-identical
  (BaseURL already lower-cases); it removes the copy that drifts.

- A guard test pins the closed set of sanctioned resolution sites, so the next
  one fails a check instead of a review.

NOT changed: api.BaseURL's unknown/empty -> prod fail-open. It is shared with
the installer's _backend_url and contradicted by client-runtime's
controller.py, so it is a three-component decision tracked on backend#2171.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(telemetry): reflow the BaseURL-mirroring note left ragged by the previous edit

Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): `auth status` must report the env the client dials, not the stored string (backend#2320)

The audit's last site: `auth status` printed cfg.CurrentEnv as its "backend"
field while runAuthCheck — the machine-facing answer to the same question, 40
lines below in the same file — compares the resolved one. A status command that
disagrees with the client is worse than no status command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): keep the doctor env test off the network (backend#2320)

My own bug, and worth the comment it now carries. Past the session probe,
`cluster doctor` loads the real kubeconfig and calls the real doctor.Run, whose
checkBackendEgress probes backendHost("") — a live GET to
https://api.tracebloc.io/. On a developer machine with a real k3d cluster the
test therefore made a production request; the run time (~16s vs 0.01s stubbed)
is the tell. Stubbing loadClusterFn returns right after the session probe, which
is all this test needs: the env is decided before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): make the resolution guard actually guard (backend#2320)

Four review findings, all the same class — a check that verifies the FORM of a
thing rather than the property the form exists to guarantee. Which is the class
this PR is about, so the guard having it was the worst possible place for it.

- Scan Go as Go. The hand-rolled comment stripper was fail-open on string
  literals: the `//` inside an `https://…` literal started a "comment" that ate
  the rest of the line, needle included, and a `/*` inside a literal like
  `"/*.json"` swallowed every needle below it to the next `*/` or EOF. Seven
  non-test files in internal/cli already carry an https:// literal, so this was
  one future line away. go/scanner with mode 0 drops comments and knows literals,
  so neither evasion exists. Literals are kept in the output — a needle inside a
  string is then a loud false positive, which is the cheap direction.

- Walk the module root, not the test's own package dir. The guard covered 1 of
  the 17 packages under internal/, i.e. it was blind exactly where the next site
  is most likely to land: a new package written by someone who never reads
  internal/cli. Keys are now repo-relative, and internal/api, internal/config and
  internal/doctor join the allowlist with the reasons the PR body already gave.

- An inert allowlist entry now FAILS. Checking only that a sanctioned file exists
  let my own change turn the telemetry.go entry into a licence: it matched no
  needle any more, so it checked nothing while silently pre-approving the next raw
  read in the very file whose double resolution this PR removes. The entry is gone
  and the staleness assertion stops the next one going inert unnoticed.

- signedInEnv's docstring was false: it can no longer return "". Says so now,
  including that `if signedInEnv() == ""` cannot fire — and telemetryEnv's empty
  arm is marked production-unreachable-but-test-reachable rather than left to be
  traced.

Also corrects an overclaim I made in the first draft of goCodeTokens' comment:
go/scanner is lexical, so the error covers unterminated literals and comments —
the faults that would desynchronise boundary tracking — not `func f( {`, which
scans clean. Stated precisely rather than left flattering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): one needle set, one matcher, one allowlist — each checked both ways (backend#2320)

Adopts Lukas's unified shape for the guard, which is better than what I pushed in
f33a3d1 in two concrete ways:

- `matchesAnyNeedle` is now THE matcher, called from the detection sweep AND the
  allowlist audit. Two copies of "does this file resolve an env?" is the same
  shape as the two copies of "which env?" this PR removes, and it would let
  detection and allowlisting drift apart exactly where nobody looks.

- The allowlist audit is per ENTRY, not per suite, so staleness, the empty-reason
  check and the needles-went-stale backstop all fall out of one loop and the
  failure names the entry to delete. Renaming a needle now reports all five
  entries by name instead of a global counter hitting zero.

Kept a narrow anchor the per-entry loop genuinely cannot see: an EMPTY allowlist
makes that loop vacuous, so a needle rename plus an empty allowlist would pass in
silence. It asserts both counts are non-zero.

Eight reproductions, all red, all restored green — the control, both string-literal
evasions, the sibling-package site, a re-inerted sanctioned entry, an empty reason,
a needle rename, and a lexical fault elsewhere in the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bumps the golang-x group with 1 update: [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/text` from 0.40.0 to 0.41.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](golang/text@v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…nder (cli#552) (#553)

Bugbot's High on the #540 promotion. Two keys for one concept, and the failure
was silent by construction.

`telemetryToken` took the telemetry LABEL and looked the profile up by it.
Profiles are keyed on the RAW `cfg.CurrentEnv`; the label has been through
`telemetryEnv`, which lower-cases and trims (via `sessionEnv`) and remaps
anything unrecognised onto prod. Whenever those disagreed the lookup did not
miss loudly — `Profile()` CREATED an empty profile and returned no token, so
delivery took the no-token spool path forever while `authedClient`, reading
`cfg.Current()` on the raw key, kept working. The CLI looked signed in and
healthy; outcomes simply never arrived.

THE PARAMETER WAS THE DEFECT, so it is gone rather than corrected.
`telemetryToken()` now reads the current session's token, which is the same
thing `authedClient` reads and the same key `SignedIn()` tests. Passing a label
in is no longer possible, so the two cannot disagree again — as opposed to
agreeing today and drifting the next time `telemetryEnv` gains a case.

`config.CurrentToken()` is the reader, and it READS WITHOUT CREATING.
`Profile()` storing an absent profile is correct for the write paths it exists
for — sign-in mutates the returned pointer, then Saves — and a trap as a lookup:
the miss is recorded, so the second call finds a profile and looks like a hit.
That is the ticket's separate observation and it is fixed here, not deferred.

THE TOKEN STILL GOES WHERE THE SESSION ALREADY TALKS. `api.BaseURL` routes an
unrecognised env to prod exactly as `telemetryEnv` does, so the destination the
label picks is the one this session's client is already using. That BaseURL does
that at all is a real defect — shared with the installer's `_backend_url`,
contradicted by client-runtime's controller.py, tracked across three components
on backend#2171 — and `telemetryEnv`'s own note says this code must match that
behaviour until it changes rather than diverge from it. This fix does not
diverge from it.

TESTS. There were NONE for `telemetryToken`, which is why this reached a
promotion. Added at both levels:

* `internal/config` — the reader is keyed on the raw `CurrentEnv` and agrees
  with `SignedIn()`; it does not create on a miss, with the contrast against
  `Profile()` asserted in the same test so a refactor cannot route one through
  the other and still pass; and it is empty on every genuinely-tokenless shape.
* `internal/cli` — a table of raw keys that each differ from their resolved
  label (unrecognised → prod, upper-case, surrounding whitespace, a v1
  verbatim `Dev`), plus `prod` as the control that worked before and must still.
  Each case first asserts the label really does diverge, so the table still
  means something if `telemetryEnv`'s mapping changes.

And the composed behaviour the ticket asks for: a signed-in session whose label
was remapped POSTs rather than spools. Real `telemetryToken` over a real on-disk
config, real `deliver`, only the URL substituted — `api.BaseURL` has no test
seam and would otherwise have sent the test at production.

Mutation-proven:
  the two-key lookup restored        2 failed (incl. the POST-vs-spool test)
  CurrentToken routed via Profile()  1 failed (the creates-on-miss test)
No survivors; green on restore. gofmt, go vet and the full suite clean.

Closes #552

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…/stale (#543)

* ci(1979): call the board-aware stale sweep instead of copying actions/stale

* ci(1979): drop the 16-way actions/stale copy this caller replaces
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit d289cd8. Configure here.

@tracebloc-release-train tracebloc-release-train Bot added gate-nudge Toggled by the release train to (re-)fire the fr-gate and removed gate-nudge Toggled by the release train to (re-)fire the fr-gate labels Aug 23, 2026
@tracebloc-release-train
tracebloc-release-train Bot merged commit ced4e2d into main Aug 23, 2026
58 of 59 checks passed
@tracebloc-release-train
tracebloc-release-train Bot deleted the release-train/to-main branch August 23, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants