Skip to content

fix: close the v0.6.0 milestone — absorbs #331, ports the seven missing commands, and takes the validate exit-code break - #352

Merged
alpCaner merged 28 commits into
devfrom
feat/v06-batch
Aug 3, 2026
Merged

fix: close the v0.6.0 milestone — absorbs #331, ports the seven missing commands, and takes the validate exit-code break#352
alpCaner merged 28 commits into
devfrom
feat/v06-batch

Conversation

@alpCaner

@alpCaner alpCaner commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The single v0.6.0 PR. Absorbs #331 (rebased) and carries every remaining open
v0.6.0 issue except #253 (tan model), which stays with #58.

Opened early and deliberately incomplete so CI runs continuously against it.
The checklist below is the scope; e2e on both platforms runs last, against
frozen binaries built from this branch, and its result is posted here.

Absorbed from #331 (rebased onto dev)

#224 #308 #309 #311 #312 #313 #324 #213, plus #180 / #179 / #139 closed as
already-fixed. #318's crates/ change is dropped: dev already carries the
equivalent from #333 in a stronger form (it keeps the byte-identity marker
assertion alongside the wait_until_spawnable guard), so crates/ is unchanged
against dev on this branch.

The rebase was not mechanical — two fixes to the same block

#331 and the merged #336 both edit execute_slices. Git merged them cleanly,
textually, and the result was wrong in two ways:

Separately, five of #308's unit tests hardcoded POSIX literals against values the
code renders with str(Path(...)) — on Windows Path("/sdk") is \sdk, so all
five failed the required test (windows-latest) gate. Expectations now derive
through the same call and mean what they say on either platform.

Remaining scope

Product decisions taken (maintainer-confirmed)

Issue Decision
#138 Restore the six v0.3.1 defaults. --pre-launch-task overrides. The vscode task provider stops being a dead contribution and needs no change.
#262 Take the break in v0.6.0. validate moves exit 1 → 2 for a failing project, with a called-out ### Changed CHANGELOG entry and a matching alp-sdk-vscode severity check. CI keyed on -eq 1 breaks and must be told.
#91 Refuse and print for anything needing elevation. Non-elevated commands run; a sudo-requiring one is printed verbatim with a named issue and a non-zero exit. tan never spawns sudo — the only option that cannot hang under --format json, where a password prompt is unanswerable.
#260 Port scaffold fully, module-template engine included, rather than retiring it.

ADR 0021 already settles #91's other three: the Tier A/B/C consent gate, the
doctor --fix-only placement (so build/run cannot inherit it through
maybe_auto_bootstrap), and no same-process re-check.

Not in scope

#253 / tan model — left to #58.


Issues this closes on merge

dev is this repo's default branch, so these fire automatically. They were
missing from the original body — the work was on the branch, the keywords were
not, so nothing would have closed.

Closes #77
Closes #91
Closes #138
Closes #213
Closes #224
Closes #254
Closes #255
Closes #256
Closes #257
Closes #258
Closes #259
Closes #260
Closes #261
Closes #262
Closes #297
Closes #308
Closes #309
Closes #311
Closes #312
Closes #313
Closes #321
Closes #324
Closes #344
Closes #350
Closes #351

Deliberately NOT closed by this PR

alpCaner and others added 11 commits August 2, 2026 23:35
…sal causes (#311, #312)

plan_alif_mram_jlink emitted `loadbin <artefact> <app_address>` uncondi-
tionally, with no is_raw_bin guard -- unlike its sibling plan_swd_probe,
which branches loadbin-for-.bin vs loadfile-for-ELF. A slot0-linked artefact
that is not a raw .bin would have had its ELF headers written into on-die
MRAM at the load address.

It now raises FlashPlanError naming the artefact and pointing at zephyr.bin.
Deliberately a refusal rather than plan_swd_probe's loadfile fallback: a
loadfile here would silently ignore slot0_load_address, which is the worse
failure. Latent today -- no in-tree board.yaml sets slot0_load_address.

Separately, the Flow D DPIDR preflight conflated two causes. A banner
reporting a DIFFERENT DP ID (a real wrong-board or probe-selection problem)
and a banner reporting no DP ID at all (connect failed outright, typically
the J-Link still re-enumerating after a prior JLinkExe close) both got:

  Check the probe selection (flash_args.jlink_serial) and the wiring.

The second now says the probe reported no DP ID at all and advises a retry.
Detection is conservative and falls back to the existing generic message
when it cannot tell -- a widened refusal that claimed the wiring was fine
would be worse than the ambiguity. Both cases still refuse to write MRAM;
this is a diagnostic split only.

Neither backend has a v0.4.1 oracle counterpart -- alif_mram_jlink is new --
so there is no envelope to diff against here.
…at never loaded Zephyr (#308, #309)

Two oracle-parity divergences in the executor, where the v0.4.1 oracle does
the right thing and the port did not.

#308: build_cmd.py passed gap_fillers=() with a literal '# NOT YET PORTED',
and execute.py starts from dict(os.environ). ADR-0020 plans never carry
ZEPHYR_BASE, so a stale ambient $ZEPHYR_BASE in the user's shell beat the
west workspace tan had itself just resolved. The oracle fills it via
zephyr_env_overrides; the resolved value already existed on this side as
west_workspace_dir. Ported as pure logic into python/tan/core/zephyr_env.py
rather than into the command module.

EXTRA_ZEPHYR_MODULES joins with ';' on every platform -- it is a CMake list
that Zephyr's own zephyr_module.py splits on ';' regardless of host OS, and
joining it with ':' on Linux breaks west build configure with 'is not a
valid zephyr module'. The values flow through the existing sep_for_key
machinery rather than re-implementing the join, and assemble_slice_env's
seed-then-append still extends an inherited PYTHONPATH instead of replacing
it. Both are covered by tests.

#309: a CMakeLists.txt that never calls find_package(Zephyr ...) still
configures and links fine under `west build -b <board>` -- CMake only emits
a dev warning about the missing project() call -- so a real exit code 0 was
not sufficient evidence and a core declared `os: zephyr` could produce a
plain host binary and be reported [+] ok. The board name is never even
validated, because nothing loaded the code that would validate it.

zephyr_boilerplate_loaded / dir_shows_zephyr are ported into build/
manifest.py and applied in execute.py's status assembly, checked only on an
otherwise-successful slice and skipped when the slice redirects west's build
dir, where the evidence lives somewhere this cannot see. It looks one level
below build/ as well, because --sysbuild nests the real per-image Zephyr
builds under its own superbuild and only the nested build carries a signal.

The guard exposed 13 tests whose plan fixtures declared backend zephyr while
dispatching a trivial stand-in tool that produces no Zephyr evidence. Twelve
are relabelled to a non-zephyr backend after checking per test that no
assertion touched zephyr artefact resolution; the one test genuinely about
west's own build-dir resolution instead has its stand-in west script create
a real zephyr/ directory, keeping it on the live code path.

The separate question of whether `tan init --template minimal-app` should
emit a different CMake shape is a maintainer decision and is untouched here;
the scaffold is byte-identical to the oracle's.
The Rust gate for this landed in 78d8308 (contract.rs's
every_prefixed_issue_code_is_registered, with PREFIXING_SITES), but crates/
ships to nobody -- the release assets are PyInstaller freezes of python/ --
so on the shipping surface there was no source-to-registry scan at all,
prefixed or otherwise.

The shape the Rust gate exists to catch is already live here: bootstrap_cmd
builds f"bootstrap.{code}", debug_config_cmd builds f"debug-config.{code}",
doctor_cmd does the same. A literal-only scan sees none of them.

The new gate walks emit sites for both literal codes and f-string assembly
through a prefixing helper, reading the registry through the same source
test_frozen_issue_codes.py already uses rather than inventing a second one.
It carries a self-test proving it REJECTS a deliberately unregistered code:
a gate that cannot fail is not a gate, which this repo learned for real in
tan-cli#275 when a scrubbed env var made one skip silently for eleven
commits. Where it must skip, it skips loudly and names what was missing.

Run against the tree it found 146 unregistered codes, including
cli.command-deferred. All are registered `reserved` in contract/
issue-codes.json -- 146 additions, zero removals, zero modifications.
cargo test --locked stays green, frozen_issue_codes included.

contract/issue-codes.json ships as a release asset; the next release notes
should name these entries.
…nventory (#313, #324)

Eight parity cases branch on a which() probe, so their frozen answers record
which tools the CAPTURE host had -- and the Python side is then compared
live against the REPLAY host. The two sides were not measuring the same
environment, so the comparison measured the host rather than the port.

#313: three yocto_wic cases froze 'would run dd if=...', because the capture
host had dd and no bmaptool. plan_yocto_wic prefers bmaptool when planning
only and dd is absent, so on a host with neither the live Python side picked
bmaptool and the three failed. Green wherever dd exists, hence green in CI.

#324: three west_forward cases froze the 'west not found on PATH' launch
error, because the capture host had no west. On a host that HAS west the
live Python side genuinely launches it and reports the child's failure
instead. Same bug, opposite direction.

Neither is a port gap. Run both binaries under a sanitised environment and
they agree byte for byte -- same command, same exitCode, same data, same
issue code, same message. The port already carries the identical check and
the identical string.

Replay now pins the Python side's PATH to the recorded inventory. Because
that side runs as a subprocess, an in-process monkeypatch cannot reach it,
so the pin crosses the process boundary as a scratch PATH. #324 reuses the
mechanism #313 introduced rather than adding a second one, generalised to
the absent-tool case as oracle.empty_tool_inventory.

Under TAN_PARITY_LIVE=1 both sides genuinely spawn, and compare() REFUSES a
python_env_overrides rather than silently comparing one pinned side against
one unpinned side -- which would be a fresh divergence, not a capture.

PROVENANCE.txt now records the capture host's inventory and the amended
re-capture recipe, because the recipe at the top of that file hard-errors
for these eight by design. Capturing on a host with west installed and the
pin dropped would freeze an answer that only replays on hosts that also have
west, reintroducing exactly the bug #324 closed.
…in the native-sim e2e

The autouse _scrub_sdk_discovery_env fixture isolated ALP_SDK_ROOT,
SOURCE_DATE_EPOCH, HOME and USERPROFILE but left ZEPHYR_BASE inherited from
the developer's shell, so one more discovery input could differ between a
local run and CI. It is scrubbed now.

test_native_sim_e2e's _zephyr_base() read os.environ directly, which would
have defeated that scrub; it reads REAL_ENVIRON instead, matching the idiom
the same file already uses for the real west build subprocess env.

Verified with and without a hostile ZEPHYR_BASE exported: byte-identical
counts either way across the doctor, kconfig and native-sim suites.

This is NOT a fix for #297, which stays open. That issue's diagnosis -- that
an ambient ZEPHYR_BASE makes test_west_resolved_reproduces_and_closes_tan_
cli_123 flaky -- does not survive measurement: find_workspace_venv's upward
.venv walk resolves before the ZEPHYR_BASE read is ever reached, and the
test plants its .venv at the subprocess's own cwd. With a hostile value set,
that test gives 5 passed / 128 deselected, identical to the unset baseline.
The original Windows-side failure remains unreproduced and needs a fresh
repro on Windows.
…ref (#213)

client_payload.sdk_ref was read into a step output and echoed via ::notice::,
which is job-log-only -- it never reached the Actions API display_title field
alp-sdk polls to correlate the run it triggered.

A top-level run-name key surfaces it. The non-dispatch arm is an empty
string, not a formatted event name: GitHub falls back to the event-specific
default title when run-name is empty or whitespace, so push and pull_request
runs keep showing their commit message or PR title. Naming the event there
instead would have overwritten that default on every such run -- a
regression traded for the fix. Same idiom this file already uses one line
away for ref:.

Inert until it reaches the default branch.
…numbers (#224)

The gate landed keyed on (file, ABSOLUTE LINE), which reds the build on any
unrelated edit that shifts a declared site. It happened immediately: dev's
fc88ca1 added +69/-18 lines to bootstrap_cmd.py, moving f"bootstrap.{code}"
from :1522 to :1532, and both the `python` and `seam1 -- plan-shape parity`
CI jobs went red on the PR's merge commit while the branch itself was green.

Re-pinning the number was the wrong fix. A gate that must be hand-re-pinned
after every unrelated edit trains reviewers to update the pin mechanically
without reading what changed, which is how a gate stops gating -- the
tan-cli#275 lesson this file already cites.

Keys are now (file, enclosing qualname). Line numbers stay in the error
messages, where a human needs them, and appear in no hand-maintained key.
Proved by measurement: shift bootstrap_cmd.py, gate still passes; plant an
unregistered code, gate still fails naming it.

Re-keying opened a smaller hole, found by review and reproduced: a SECOND
template inside an already-declared function collapses onto one key, leaving
only the scalar EXPECTED_TEMPLATE_COUNT -- whose own failure message says to
bump the count. So each declaration now pins how many sites it covers, and a
mismatch fails naming the qualname, both counts and every matched file:line.
_FORWARDER_SUFFIXES, the third bucket with the same many-to-one shape, gets
the same discipline.

_check_site_counts was a closure and could not be tested. This file states
its own bar -- an assertion nobody has ever watched fail is not proven to
fire -- and met it for its two older assertions. It is extracted and tested
now, including the _ACKNOWLEDGED_CEILINGS half that is empty in production
and had never had a real value driven through it.

One hole is documented rather than closed. Resolving an f-string statically
cannot tell a substituted name bound by the enclosing function's parameter
list from one rebound by a comprehension target of the same spelling, so a
1-for-1 swap at a declared key needs no count bump at all and stays green.
That is a limit, not a missing pin; chasing it means shadowing heuristics
that false-positive on ordinary Python. The module docstring records it as
an acknowledged ceiling with the verbatim repro and names the real backstop:
contract/issue-codes.json is read directly by release.yml when building the
published envelope-contract.json, and alp-sdk-vscode fails open on a code it
does not recognise, so the cost is a silently-ignored issue, not a crash.

Also drops three line-number comments that had themselves rotted (2072,
2061, 2326 -> 2099, 2088, 2363) rather than re-measuring them, in the file
whose whole subject is that line identity decays.
…nstead of asserting it

Milestone v0.6.0's goal is, verbatim, "Full command-surface parity with the
v0.4.1 oracle: model, new-som, monitor, faultdecode, the introspection set,
renode, and the seven entirely-unported verbs." Not one of those verbs
appeared in this file's CASES, which covered --version, a bogus command, a
bare invocation, debug-config, presets, clean and build --plan. The
milestone's central claim had no instrument that could read it.

Nine case-functions (17 node IDs) now cover every named verb, each
established by RUNNING target/debug/tan rather than by reading crates/.

Matching, as live parity cases: explain (overview, unknown-template,
unknown-target), image, renode -- full envelopes byte-identical.

Diverging, each as a documented known-divergence case that pins the
divergence on BOTH sides so it fails if silently fixed or worsened:
model, new-som, monitor, faultdecode, size, run, and all seven deferred
verbs. Several are structural, not cosmetic. faultdecode's exit codes
coincide at 2 while the envelopes differ entirely -- the port re-implemented
it as ARMv8-M register arithmetic that reads no SDK, so it answers
cli.parse-error where the oracle answers faultdecode.failed. new-som's Click
command declares no --format option at all, so --format json is a usage
error rather than an envelope. size's divergence is permanent: two runtimes
rendering the same ENOENT, Rust's `(No such file or directory (os error 2))`
against Python's `([Errno 2] No such file or directory: '<path>')`.

Two harness defects had to be closed for any of this to mean anything.

The support-bundle case first encoded this host's tool inventory, gaining a
third issue under a stripped PATH -- tan-cli#313 and tan-cli#324 reintroduced
in the PR that fixed them. These cases spawn both binaries live every run, so
unlike the frozen-replay cases they can pin PATH SYMMETRICALLY, and now do.
empty_tool_inventory also seeds a real `which`: with a literally empty PATH
the oracle's probe cannot resolve `which` itself and reports every tool
missing because it could not look, which is an artefact, not an answer. It
raises now rather than silently returning to that.

rust_binary() preferred target/release over target/debug. On a tree with a
stale release build that silently measured against the wrong oracle -- forced
over the other four parity files it gave "108 failed, 8 passed", red but
reading as port bugs, with eight cases passing against the wrong binary. It
picks the most recently built profile now, refuses an mtime tie rather than
breaking it silently, and a session-scoped fixture in the new conftest.py
asserts the resolved binary's --version for every file under tests/parity.
Wrongness fails loudly; absence stays a skip, which is each file's own gate
to decide (missing_for_live's rule, tan-cli#272). Resolution happens in the
fixture body, not at conftest import, so a raise cannot abort collection for
the whole suite.
…bootstrap warn count

Merging dev put the tan-cli#224 emit-site gate in one tree with two commits
that landed while this branch was in flight, and it caught both.

fcb2153 (tan-cli#325, `fix(init,generate): confine writes to the project
after symlink resolution`) added `generate.write-escapes-project` --
raised as a GenerateError with ExitCode.WRITE_FAILURE when resolve_confined
finds a target's output path resolving outside the project root -- and
registered it nowhere. That is ungated on both sides of the seam at once:
this repo's registry-driven checks never see it, and the published
envelope-contract.json is built from that same registry, so alp-sdk-vscode
cannot see it either. Registered `reserved`/`consumer: none`, which costs
nothing since a reserved code may still be renamed freely.

It is unregistered on dev right now, not only here -- this registration
fixes it for everyone at merge.

518ac8c (tan-cli#334) split the single `zephyr-base-incompatible` warn into
a found/else pair so the message can name the evidence, taking that helper
from 16 call sites to 17. One code, two sites, already registered. Checked
before bumping, which is the entire point of pinning the count: it forced
the look rather than allowing a reflexive increment.
…f cancelling it

Rebasing #331 onto dev put two independent fixes to the same block of
execute_slices side by side, and git merged them cleanly -- textually. The
composition was wrong in two ways, one of them silent.

#308 FILLS ZEPHYR_BASE from the workspace tan resolved, as a gap filler
merged through assemble_slice_env. #336 POPS an inherited ZEPHYR_BASE off a
west slice's env, and runs after that merge. Its condition tested only
`sl.env` -- the plan's own pins -- so it could not see the gap filler's
contribution and stripped it right back out, on precisely the slices #308
exists to serve (the ones that do NOT pin the key themselves). The pop now
tests the assembled `slice_env`, which holds the plan's env AND the gap
fillers, so "present" means "something authoritative decided this" and only
an ambient, inherited value is dropped. The two then compose in order: #308
supplies the right value whenever the workspace has a real zephyr/; #336
removes a stale ambient one for the cases #308 cannot fill (no workspace_dir,
or a workspace never `west update`d).

That defect was invisible to CI. Every test in test_execute_zephyr_env.py
drives a `backend: baremetal` slice whose tool is the interpreter itself, so
`is_west` is False and the pop is never reached. Three tests are added that
use `tool: "west"` -- the only shape that reaches it -- covering the gap-fill
surviving the pop, the pop still firing where #308 has nothing to give, and a
plan-pinned value surviving both. The first fails on the naive composition
and passes here; verified by reverting the condition and re-running.

The second defect was CI-visible. #309's Zephyr-boilerplate guard sets both
`status` and `message`, but #336's re-wording block sat AFTER it and
recomputed `message` unconditionally -- so a guard-failed slice reported
`failed` with `message: None`, a verdict with its evidence deleted. #336's
re-wording moves into the initial message assembly, leaving #309's guard as
the last word. Confirmed against the broken shape: it reds
test_the_refusal_message_matches_the_oracle_verbatim.

Also fixes five #308 unit tests that hardcoded POSIX literals against values
the code renders with str(Path(...)): on Windows `Path("/sdk")` is `\sdk`, so
all five failed on the required `test (windows-latest)` gate. Production is
correct -- it feeds real resolved paths -- so the expectations now derive
through the same str(Path(...)) and mean what they say on either platform.

And #336's fake west shim now writes the build/CMakeCache.txt ZEPHYR_BASE:
entry a real successful `west build` leaves behind, honouring #307's injected
`-d <slice dir>/build` rather than assuming <cwd>/build (the child's cwd is
the workspace, where the shim itself lives). Without it the shim exits 0
having produced nothing and #309 correctly fails the slice, masking what
#336's assertions are about.

#318's crates/ change is dropped from this branch: dev already carries the
equivalent from #333, in a stronger form that keeps the byte-identity marker
assertion alongside the wait_until_spawnable guard. crates/ is unchanged
against dev.

Suite: 1814 passed, 194 skipped, 6 xfailed, 0 failed.
…nsent hole

Wave 1 of the v0.6.0 batch: scaffold, completion, diff, pinmux, inspect, trace
and support-bundle are real commands rather than deferral stubs (tan-cli#260,
#257), new-som/monitor/faultdecode reach oracle parity (#254, #255, #256),
renode gains --sim-mode (#77), doctor gains --fix and names a dangling global
default (#91, #344), debug-config restores its preLaunchTask defaults and flags
the unresolvable gdbserver address (#138, #321), and validate takes the exit
1 -> 2 break (#262).

Every unit was measured against the running oracle binary, not read out of
crates/, and every one was then adversarially re-verified by a second agent
whose job was to refute it. That second pass is what this commit is mostly
about; two of its findings were blockers.

tan-cli#91 shipped a consent gate with three of its five conditions. The
hand-written form tested only the flags (!non_interactive && !ci && !is_json)
and omitted both isatty() calls, so a CI runner that redirected its output but
did not happen to pass --ci got unattended host mutation -- demonstrated live
under fully captured pipes, where `tan doctor --fix` spawned four real winget
installs (Git.Git, Kitware.CMake, Python.Python.3.12, Ninja-build.Ninja) with
nobody watching. A redirected stdio stream is the most common shape of an
automated run, so the omitted half was the one that mattered. The whole
doctor --fix suite stayed green through three independent mutations of that
guard, including deleting it outright.

Fixed at the root rather than at the call site: tan/core/consent.py is now the
one implementation of GlobalArgs::can_prompt(), imported by doctor_cmd and
scaffold_cmd. tests/core/test_consent.py is exhaustive over all 32 flag/tty
combinations -- a sampled test is exactly what let this through, since no case
in the old suite combined "flags all clear" with "stdio is not a terminal".
Verified the tests catch it: removing the two isatty() lines reds 6 of them.
End to end, `tan doctor --fix` under captured pipes now spawns nothing.

tan-cli#138's "restore the six v0.3.1 defaults" is implemented as THREE, not
six-minus-servers. Restoring the yocto-userspace label would re-break what
alp-sdk-vscode#406 deliberately fixed: its preLaunchTaskFor maps only the three
build kinds, because the sole task registered for yocto-userspace is a
placeholder that exits 1 by design, and naming it would put VS Code's
"preLaunchTask terminated with exit code 1 -- Debug Anyway / Show Errors"
dialog in front of every F5, including the hand-configured setup that works.
So #138 and #321 pull opposite ways and only the three build labels are safe;
the reasoning is recorded at DEFAULT_PRE_LAUNCH_TASK with the consumer's own
wording quoted.

deferred_cmd.py keeps only the constants build_cmd.py needs for its deferred
FLAGS; the stub factory and DEFERRED_VERBS are gone, and cli.py spells the
seven names into _HONOURS_ROOT_FORMAT instead of splatting a tuple that no
longer exists.

Known-red and tracked, not claimed done: the shared-file debts every unit was
barred from touching (contract/issue-codes.json entries, the issue-code gate's
helper tables, test_oracle_parity.py's _DEFERRED_VERBS rows, five debug-config
conformance fixtures), plus the oracle divergences the verifiers found in
diff/pinmux and support-bundle, and a repo-wide CRLF/stdout divergence. All
are itemised on the PR and fixed in the next pass.
@alpCaner

alpCaner commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Wave 1 landed (81d03c4) — and what its verification found

All seven formerly-deferred verbs are real commands, new-som/monitor/faultdecode
reach oracle parity, renode --sim-mode exists, and validate takes the exit-code
break. Every unit was measured against the running oracle binary, then handed to a
second agent whose only job was to refute it.

That second pass is the part worth reading. It returned 1 done, 2 incomplete, 7
defective
— and two of those were blockers that would have shipped.

Blocker 1 — doctor --fix could mutate a machine with nobody watching

The consent gate shipped with three of its five conditions. It tested the flags
(!non_interactive && !ci && !is_json) and omitted both isatty() calls, so a CI
runner that redirects its output but does not happen to pass --ci got unattended
host mutation. Demonstrated live under fully captured pipes: tan doctor --fix
spawned four real installs — Git.Git, Kitware.CMake, Python.Python.3.12,
Ninja-build.Ninja.

A redirected stdio stream is the most common shape of an automated run, so the omitted
half was the one that mattered. The oracle's own --non-interactive help states it:
"the same rule applies unasked when stdin or stderr is not a terminal — piped,
redirected, or a CI runner."

The whole doctor --fix suite stayed green through three independent mutations of
that guard, including deleting it outright.

Fixed at the root, not the call site: tan/core/consent.py is now the one
implementation of GlobalArgs::can_prompt(), imported by doctor_cmd and
scaffold_cmd. tests/core/test_consent.py is exhaustive over all 32 flag/tty
combinations — sampling is exactly what let this through, since no case in the old
suite combined "flags all clear" with "stdio is not a terminal". Removing the two
isatty() lines reds 6 of the new tests. End to end, tan doctor --fix under captured
pipes now spawns nothing.

Blocker 2 — restoring all six preLaunchTask defaults would have re-broken F5

The maintainer call was "restore the six v0.3.1 defaults". Implemented as three,
because the sixth would re-break what alp-sdk-vscode#406 deliberately fixed. That
repo's preLaunchTaskFor maps only the three build kinds, and says why verbatim:

yocto-userspace deliberately gets NOTHING. The only task registered for it is the
"deploy and start gdbserver" placeholder, which exits 1 by design … Naming it would
put VS Code's "the preLaunchTask terminated with exit code 1 — Debug Anyway / Show
Errors" dialog in front of EVERY F5, including one where the customer has already
copied the binary across, started gdbserver by hand and filled in
miDebuggerServerAddress — the setup that works.

So #138 and #321 pull opposite ways. The three build kinds get their defaults back —
which is what makes the extension's task provider stop being a dead contribution — and
yocto-userspace keeps none. Recorded at DEFAULT_PRE_LAUNCH_TASK with the consumer's
own wording quoted, so the next reader does not "restore" the fourth.

Known red, tracked, not claimed done

Being explicit rather than letting a green summary imply more than it should. Wave 2 is
running against all of it:

  • Oracle divergences in diff/pinmux — PyYAML resolves YAML 1.1 on/off/yes/no
    to bool where serde_yaml (YAML 1.2) gives strings, so os: on is a false
    refusal
    ; a non-string scalar pad field is a hard exit-2 where the oracle coerces and
    exits 0; iot: {wifi: "yes"} is a false accept that fabricates a change entry the
    oracle never emits; and the sdk envelope block is dropped whenever --sdk-root is
    passed.
  • support-bundle echoes an invalid --target-kind/--server verbatim where the
    oracle reports the defaults, and its doctor section flips the exit code 0 → 4.
  • Repo-wide CRLF/stdout divergence — every command's output is CRLF where the oracle
    emits LF, and the emitted completion script is a hard syntax error when sourced
    in a strict bash. Every existing test asserts through CliRunner, whose in-memory
    stream applies no newline translation, so the suite structurally cannot see this.
  • monitor rejects every global flag (--sdk-root, --project, …) that v0.4.1
    accepts — Deferred: tan monitor — pyserial extra and console polish #255's own acceptance line.
  • Shared-file debts the per-unit agents were barred from touching: ~20 unregistered
    issue codes in contract/issue-codes.json, the issue-code gate's helper tables,
    test_oracle_parity.py's now-stale _DEFERRED_VERBS rows, and 5 debug-config
    conformance fixtures.

Still to start

#261 (one shared global-flag registration, ~109 sites), #257/#258 (surface
parity on the already-ported commands), #259 (the parked minor findings), then the
cross-platform e2e against frozen binaries — whose result gets posted here.

Alp Lab AB added 3 commits August 3, 2026 08:13
… on Windows

Wave 2. Every defect below was found by RUNNING both binaries side by side --
none by reading crates/ -- and each is fixed against the measurement.

Oracle divergences in the newly ported commands:

* diff false-REFUSED a board.yaml the oracle accepts. PyYAML's SafeLoader is
  YAML 1.1 and resolves on/off/yes/no/y/n to bool; serde_yaml is YAML 1.2 and
  resolves them to strings. So `os: on` produced
  `diff.schema-violation: os: expected a string, got a boolean` at exit 2 where
  the oracle exits 0 -- and it was every str-typed field, not just `os`. A
  YAML-1.2 bool loader fixes the class.
* diff false-ACCEPTED and fabricated a change entry the oracle never emits:
  `iot: {wifi: "yes"}` gave exit 0 with an `iot` removal, where the oracle
  refuses at exit 2. iot's four toggles and inference's fields are type-checked
  before pruning now.
* diff dropped the whole `sdk` envelope block whenever --sdk-root was passed,
  on both the success and failure paths. Two docstrings asserted the opposite.
* pinmux hard-refused a non-string scalar pad field at exit 2; the oracle
  coerces it and exits 0. Now only list/dict refuse.
* support-bundle echoed an invalid --target-kind/--server back into
  data.targetKind/data.server; the oracle reports the DEFAULTS, never
  partial-parses. It also flipped the exit code 0 -> 4 on a bundle that was
  written successfully -- the doctor section is DATA inside the bundle, not
  this command's verdict.
* monitor rejected every global flag v0.4.1 accepts (--sdk-root, --project,
  --board-yaml, --quiet, --verbose, --no-color: `No such option` at exit 2),
  breaking tan-cli#255's own acceptance line. README also still listed monitor
  as an alp_cli forwarder, which is false for this port.

A process-wide output defect nobody had a test that could see:

* Every command's stdout was CRLF on Windows where the oracle emits LF, and
  non-ASCII was \u-escaped where the oracle emits raw UTF-8. The emitted
  `tan completion --shell bash` script was consequently a HARD SYNTAX ERROR
  when sourced in a strict bash (`syntax error near unexpected token $'{\r'`).
  Fixed once at the process boundary in main() rather than per-command. Every
  existing test asserts through CliRunner, whose in-memory stream applies no
  newline translation, so the suite structurally could not see this class --
  tests/test_stdout_bytes.py drives a real subprocess and asserts raw bytes.

Three suite failures, each a defect in the EXPECTATION rather than the code:

* test_build_streaming built its expectation with json.dumps at the default
  ensure_ascii=True, so it measured json.dumps' default rather than what tan
  wrote, and reddened on a message carrying an em dash.
* test_size_missing_manifest pinned POSIX-only literals and was a Linux-only
  pass. The missing component is the `build` DIRECTORY, and Windows
  distinguishes that from a missing leaf: ERROR_PATH_NOT_FOUND (3), "The system
  cannot find the path specified.", against POSIX ENOENT (2) for both. It also
  rebuilt the path as str(Path), all-backslash, which NEITHER binary emits --
  both keep the root verbatim and join the tail with os.sep -- and hand-quoted
  the Python filename where OSError interpolates it with %r, doubling every
  separator. Widened by PLATFORM, never to "exit code only".
* renode_sim.py's WIRED_CONSOLE_SKUS hardcodes E1M-AEN801, a real vendor fact
  the hardware-fact gate is right to flag. Allowlisted as DEBT with its
  retirement condition, matching how scaffold.py and models.py are already
  recorded -- not dropped, because deleting it would make the silent-UART
  warning claim the firmware printed nothing when the truth is that the
  wired-console path is deferred.

Also: doctor --fix now reports spawn errors, non-zero exits and timeouts
instead of `continue`-ing silently, and its wiring has tests that fail against
each of the three mutations that previously left the suite green. The shared
registries the per-unit agents could not reach are reconciled -- issue codes
registered, gate tables extended without weakening the gate, the seven stale
_DEFERRED_VERBS rows removed, five debug-config conformance fixtures
regenerated for the three-of-four preLaunchTask decision.

Suite: 2091 passed, 177 skipped, 6 xfailed, 0 failed.
…acting 14 MB per run

Waves 3 and 4 of the v0.6.0 batch.

tan-cli#261 -- global flags were registered inconsistently across the command
surface. Measured by INVOKING both binaries rather than reading --help (hidden
options do not appear there): 99 sites across 17 commands where the port
answered "No such option" for a flag the v0.4.1 oracle accepts. The issue asked
for a shared mechanism rather than 18 copy-pasted fixes, "so a global flag is
declared once and applies everywhere" -- tan/core/global_flags.py is that, and
tests/gates/test_global_flags_gate.py is the deliverable that keeps it true, so
the next command added cannot get it wrong again. After: 0 sites. A
traceback-aware smoke pass (not merely "was it rejected") caught a real
regression on the way -- PEP 563 stringized annotations on the wrapped
functions raising `RuntimeError: Type not yet supported: str`.

tan-cli#349 -- the frozen binary re-extracted ~14 MB of runtime into a fresh
temp dir on EVERY invocation. Reported at 13.25/19.35/19.35/18.58/19.74 s on
macOS arm64 against `git --version` at 0.01 s; I reproduced the same shape on
the published rc4 assets at 1.05-1.19 s on Windows and 0.36-0.51 s on Linux, so
this was every platform, with macOS a further ~40x on top because extracted
.dylibs are not covered by the parent's ad-hoc signature and get verified
individually on load.

This was not a comfort problem. alp-sdk-vscode caps its version probe at
`timeout: 3000` (vscodeAdapter.ts:1406) and `commandOnPath` at 5 s -- so on
macOS the extension's probe already timed out.

Switched to --onedir, which extracts once at install time. Proven before the
pipeline was rewritten, same commit, same host: --onefile 0.880 s mean,
--onedir 0.337 s mean. The stable-tmpdir alternative was rejected deliberately
-- it caches extracted executable code in a fixed predictable path, which is
not something to ship to customers -- and signing alone would fix macOS only
while leaving the re-extraction intact.

Each target now ships one archive (.zip on Windows, .tar.gz elsewhere), and
everything that assumed one raw file per target moved in the same slice:
release.yml, clean-host.yml, install.sh, install.ps1, getting-started.yml,
python-binaries.yml, verify_binary.sh and docs/release-contract.md.
install.ps1 mattered most -- it still named `tan-x86_64-pc-windows-msvc.exe`,
which release.yml no longer publishes, so every Windows user running the
documented installer would have taken a 404.

verify_binary.sh's CA-trust check needed rewriting rather than re-pointing:
under --onedir the certifi bundle lives in _internal/ instead of inside the
executable, so `grep cacert.pem "$BIN"` returns nothing even on a healthy
freeze. That check exists because tan-cli#304 shipped an asset with NO CA
bundle and TLS died everywhere, so it was proven both ways -- passes on an
intact onedir build, and fails with `no certifi CA bundle at .../_internal/
certifi/cacert.pem` once the bundle is moved aside.

The regression gate is the point: tests/conformance/test_packaged_binary.py
now asserts `--version` stays inside a 0.6 s budget. Validated against the
known-bad artifact, not inferred -- it passes at 0.297 s on the onedir build
and fails at 0.94 s against a real --onefile build of the same commit. The e2e
harness asserts correctness only, which is exactly how a 19 s startup shipped
unnoticed.

Also: tan-cli#257's run-parity file carried a stale xfail(strict=True) claiming
`run` was "not yet registered in tan.cli", which had gone stale unnoticed and
was hiding two real divergences behind a wrong excuse; tan-cli#259's three
environment-dependent tests are pinned (one read the real terminal width via
COLUMNS -- `COLUMNS=20 pytest` reproduced the failure before the fix).

Suite: 2428 passed, 170 skipped, 9 xfailed, 0 failed.
…, and run stops accepting a bogus --sdk-root

tan-cli#350 (release-blocker) -- `tan validate` on a project with no board.yaml
said "validate: validation failure". Nothing had been validated: no board.yaml
was found, which is the state every user is in before `tan init`, and the
message sent them looking for a defect in a file that does not exist. Its
siblings all name the missing thing and the fix -- `build` names --sdk-root,
`size` names `tan build --project .`, `bootstrap` names git clone plus
--sdk-root -- and validate was the odd one out. Text mode now says "validate:
no board.yaml to validate", and the shared message names WHERE tan looked and
the remedy (`tan init` / `--board-yaml <path>`). The distinct
`validate.board-yaml-missing` code is unchanged, so a machine consumer that
already told the two cases apart still does.

This is a deliberate divergence, not a port fix: the v0.4.1 oracle prints the
same two lines at the same exit 2, so it is commented as such at the code.

Reported as "that is the whole message" -- worth recording that the message was
already two lines and the second one already carried the path, on rc3 and on
the oracle both. The reported wording was not what the binary does; the
substance was right anyway, and it is the verdict, not the length, that was
wrong.

tan-cli#351 -- `tan sdk list` refused without network and exited 1 while
`sdk current` answered the same absence at exit 0. Measured the oracle first:
it has NO --online flag at all and reaches GitHub unconditionally, so there is
no "offline list" behaviour to match and repurposing bare `list` to answer a
local question would invent a second meaning for one verb. Instead the network
requirement stops being reported as a failure.

tan build / tan run --sdk-root <nonexistent> -- the port RESOLVED the bogus
path, reported `sdk.sourceTier: "sdkRootFlag"`, and was then refused for the
next missing thing: "no board.yaml found", plus an `sdk` key the oracle never
emits there. It told the customer their project was broken when the flag they
had just typed was what was wrong. The oracle treats an unresolvable explicit
root as no root at all and refuses with "no alp-sdk checkout found".

This is the class tan-cli#258 ranks first -- "a flag that silently changed
meaning is worse than one that disappeared, because the customer's script keeps
running" -- and `build` is in the essential set that had to be gap-free.

Guarded at each flag's own entry point rather than inside
`resolve_sdk_root_ladder`, because every other caller depends on that staying
unvalidated (I-31 terminal-for-REPORTING, matching the oracle's own
`resolve_sdk_tiered`); the same placement `clean_cmd.sdk_root_resolves` and
`flash_cmd._resolve_sdk` already chose. `run` needed its own copy: its
resolution line was a VERBATIM COPY of `build`'s, so fixing only `build` would
have left the twin live under `tan run`.

test_run_sdk_root_invalid_is_a_known_divergence_from_the_oracle went RED on the
fix, which is what a pinned-divergence test is for -- it is now
test_run_sdk_root_invalid_now_matches_the_oracle and asserts neither side emits
an `sdk` block. The message wording still differs (the oracle names three
remedies where the port names two) and stays pinned literally on both sides
rather than narrowed away.

Suite: 2428 passed, 170 skipped, 9 xfailed, 0 failed.
Alp Lab AB added 2 commits August 3, 2026 12:19
…obe selection (#353)

Found by running tan-cli#268's Target 1 on the real board -- e1m-aen-evk-01,
E8 AE822, J-Link SN 603000869, DPS-150 Vin 16.0 V -- entirely through tan.
`tan init` and `tan build` passed; the flash could not complete without four
values nothing in the chain emits. Filed as #353. Two of the four are tan's
and are fixed here; the other two need a product decision and are not
patched over.

FIXED, tan-side:

alp-sdk's manifest reports `output_artefact: .../zephyr.elf` for an AEN801
slot0 slice while the raw `.../zephyr.bin` the mramxip shape needs sits in the
same directory. tan-cli#311's guard refused -- correctly, since loadbin'ing an
ELF at slot0_load_address writes its own headers into on-die MRAM -- but
refused over something resolvable, so no AEN801 flash could complete without
hand-editing the manifest. The sibling `.bin` is now resolved when it really
exists, and the RESOLVED path is what reaches loadbin/verifybin, not merely
what the guard inspected.

This is a resolution, not a relaxation: it only ever swaps in a file that is a
real raw `.bin`, is the artefact's own sibling (same directory, same stem), and
exists on disk. An ELF with no sibling `.bin` still hits the refusal, which now
also says no sibling was found. Proven both ways -- the three new tests fail
with the resolution removed and pass with it.

The connect-failure remediation now names the unpinned case. tan emits no
`SelectEmuBySN` when `flash_args.jlink_serial` is absent, which is fine on a
single-probe host and cannot connect at all on a bench carrying several: the
AEN bench has three J-Links, and SEGGER's answer is the bare "Connecting to
J-Link ...FAILED: Cannot connect to the probe/programmer." Measured -- the
flash only succeeded once jlink_serial was pinned by hand. The plan is
deliberately unchanged; refusing would break every correct single-probe host.

NOT FIXED, and not papered over (see #353):

Nothing runs SETOOLS `app-gen-toc`, so `flash_args.atoc`/`atoc_address` are
never produced -- I signed by hand to get past it. Whether tan runs the signing
step or alp-sdk emits a signed ATOC during build is a product decision, and
SETOOLS is license-gated, which is exactly why it needs one. `slot0_load_address`
is likewise absent and is a build-time property (only correct under
CONFIG_USE_DT_CODE_PARTITION=y) that tan cannot detect.

Also here: the e2e harness is vendored at scripts/e2e-full.sh instead of living
in a scratch directory, which is how it drifted unreviewed and how a check
survived that reported PASS on `UNREADABLE == UNREADABLE` -- two runs equally
broken comparing equal. It now installs the --onedir tree the way install.sh
does, aborts instead of running on a tree a previous run left behind (read-only
Zephyr files defeat a plain `rm -rf`, and the run continued on stale state), and
drives the onedir exe directly, since Git Bash cannot exec a `.cmd` by absolute
path and returned 127 for every call.

Bench facts worth recording: slot0 byte-matched the flashed .bin on all four
words (20004250 80015A51 8001F9F3 80015A3D) and SURVIVED a true cold
power-cycle, so Flow D does persist -- the skill's "verified but did not
commit" note is stale. The probe was identity-checked before every write
(DPIDR 0x4C013477, the AEN E8; 0x0BE12477 would have been the GD32 bridge on
another board, which shares the same cloned serial).

Suite: 2431 passed, 170 skipped, 9 xfailed, 0 failed.
…iction

23/23 on Windows and 23/23 on Linux against --onedir freezes of this branch.
Getting there took five rounds, and EVERY failure was the harness rather than
tan -- which is the reason this now lives in the repo instead of a scratch
directory where it drifted unreviewed.

The six defects, each of which produced a confidently wrong signal:

1. A FALSE PASS. `#336` compared slice outcomes for equality, and after the
   onedir change both sides produced UNREADABLE and compared equal -- so it
   printed PASS while nothing had built at all. That check exists to catch a
   bug that silently drops one core from a multi-core build. It now fails
   explicitly when either side is UNREADABLE: two runs being equally broken is
   not the property under test.

2. CRLF. Python's `write_text` emits \r\n on Windows, so a harness edited that
   way and piped into WSL died at `set: pipefail: invalid option name` before
   running a single check.

3. MSYS path translation. `wsl -- bash /tmp/probe.sh` became
   `bash C:/Users/.../Temp/probe.sh`. Needs MSYS_NO_PATHCONV=1 ON THE CALL;
   exporting it does nothing.

4. A non-idempotent copy. `cp -r SRC DST` copies INTO an existing DST, giving
   `tan-cli-lib/tan/tan.exe`, so the launcher pointed at a path that did not
   exist.

5. A silent `rm -rf`. A `west update` checkout leaves read-only files on
   Windows, so the cleanup failed, the run continued on a half-deleted tree,
   and a clean 23/0 became 3/26 whose every failure was stale state. It now
   ABORTS rather than measuring a dirty tree.

6. The harness CLOBBERED THE BINARY. Pointing TAN at the onedir exe while
   leaving the launcher `echo`s writing to $TAN overwrote the real 15 MB
   executable with a 40-byte `@echo off` script; every call then failed
   `line 1: @echo: command not found`. Launcher and driven binary are now two
   distinct paths.

The guard that makes this class un-misattributable: the harness now runs
`$TAN --version` before asserting anything, and aborts naming the binary and
its size. Defects 1, 4, 5 and 6 all had the same underlying fault -- $TAN was
not a working binary -- and all four surfaced as 20+ misattributed assertion
failures instead of one line.

scripts/e2e-linux-freeze.sh is a FILE, not an inline `wsl -- bash -c` block,
and its header says why: Git Bash expands $PWD/$PATH in the OUTER shell before
wsl sees the string -- inside single quotes, and even with MSYS_NO_PATHCONV=1
-- so they arrive EMPTY. Proven by an inline block echoing `P=`. The symptom
was `build_binary.sh: line 126: python: command not found`, which reads like a
broken WSL while .venv-build/bin/python existed, resolved to
/usr/bin/python3.12, and had PyInstaller 6.21.0. That cost three rounds.

Nothing in tan/ changed here. The product findings this harness DID surface
stand: the #308/#309 composition cancel, the repo-wide CRLF/UTF-8 stdout
divergence, monitor's rejected globals, and #353's AEN801 flash gap.
@alpCaner

alpCaner commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Everything in the v0.6.0 milestone is closed here

Sixteen commits. Suite 2431 passed, 170 skipped, 9 xfailed, 0 failed.

Absorbed from #331 (rebased) #224 #308 #309 #311 #312 #313 #324 #213
Seven deferred verbs are real commands #260 #257
Deferred ports #254 #255 #256 #77
Behaviour #91 #138 #321 #344 #262
Systemic #261 #258 #259 #297
Filed by Hakan during the batch #349 #350 #351
Found on silicon, filed + half-fixed #353

Only #253 (tan model) is out of scope — it stays with #58.

Product decisions taken, so review is not archaeology

Issue Decision
#138 Restore the v0.3.1 preLaunchTask defaults — but three of four, not six. Naming yocto-userspace's task would re-break alp-sdk-vscode#406: the only task registered for it exits 1 by design, so it would put "the preLaunchTask terminated with exit code 1" in front of every F5, including the hand-configured setup that works.
#262 Take the exit-code break: validate 1 → 2. CI keyed on -eq 1 breaks; vscode renders 2 as a warning, which is what a failing project should look like.
#91 doctor --fix refuses and prints anything needing elevation. tan never spawns sudo — under --format json a password prompt is unanswerable and would hang forever.
#260 Port scaffold fully rather than retire it.
#349 --onefile--onedir. Rejected the cheap fix (stable pyi-runtime-tmpdir) deliberately: it caches executable code in a fixed predictable path.

The two blockers the verify pass caught before they shipped

doctor --fix could mutate a machine with nobody watching. The consent gate
had three of its five conditions — it checked the flags and omitted both
isatty() calls, so a CI runner that redirects output without passing --ci
got unattended host mutation. Demonstrated under fully captured pipes: four
real winget install runs
(Git.Git, Kitware.CMake, Python.Python.3.12,
Ninja-build.Ninja). The whole doctor --fix suite stayed green through
three independent mutations of that guard, including deleting it outright.
Fixed at the root — tan/core/consent.py is now the one can_prompt(), and its
test is exhaustive over all 32 flag/tty combinations, because sampling is
exactly what let this through.

#308 was silently cancelled by #336. Git merged them cleanly, textually.
#308 fills ZEPHYR_BASE; #336 pops an inherited one and ran after the fill,
keyed on the plan's sl.env only — so it stripped the value #308 had just
computed, on precisely the slices #308 exists for. CI would have been green:
every test in that file drives a baremetal slice where is_west is False,
so the pop is never reached.

A guard that fired on real silicon

#311 refused to loadbin a .elf at slot0_load_address on the AEN801
bench — that write would have put ELF headers into on-die MRAM. It was right,
and it is why #353 exists rather than a corrupted board.

E2E: 23/23 on both platforms, against --onedir freezes of this branch

=== MINGW64_NT-10.0-26200: 23 passed, 0 failed ===
=== Linux:                 23 passed, 0 failed ===

Fresh-host and dirty-host, both platforms, one script — anything that differs
between them is a finding, not something a second script papers over:

#349's startup budget on the real freeze: 0.348 / 0.335 / 0.342 s, against
the 0.6 s gate — which was itself validated against a real --onefile build
that fails it at 0.94 s.

The harness was wrong five times before this, and that is the part worth reading

Every red round was the harness, not tan. It is now vendored at
scripts/e2e-full.sh + scripts/e2e-linux-freeze.sh instead of living in a
scratch directory where it drifted unreviewed.

The one that matters most was a false PASS: #336 compared slice outcomes
for equality, and when both sides produced UNREADABLE they compared equal —
so it printed PASS while nothing had built. That check exists to catch a bug
that silently drops one core from a multi-core build.

The others: CRLF corruption (set: pipefail: invalid option name), MSYS
rewriting /home/... before wsl saw it, a non-idempotent cp -r, a
rm -rf that failed silently on read-only Zephyr files and let the run
continue on stale state, and — the worst — the harness overwriting the real
15 MB binary with a 40-byte @echo off script
and then reporting 23 tan
failures.

Four of those six were the same underlying fault ($TAN is not a working
binary) surfacing as 20+ misattributed assertion failures. The harness now runs
$TAN --version first and aborts naming the binary and its size.

Left open deliberately

Not released, per instruction: no tag, no GitHub Release.

Alp Lab AB added 2 commits August 3, 2026 12:53
…still verifies (#354)

Found by testing tan the way a customer does -- a pristine `ubuntu:24.04`
container with nothing pre-installed. Every HTTPS call failed
`CERTIFICATE_VERIFY_FAILED`, so `tan sdk list --online` and the whole
`sdk install` path were dead on any minimal container or CI base image
(`ubuntu`, `debian:*-slim` and friends ship no `ca-certificates`).

tan SHIPPED a CA bundle and did not USE it. `net.py` treated certifi as an
ALTERNATIVE to truststore, reached only if truststore "fails to construct" --
its own docstring's words. But on a host with an EMPTY OS trust store
`truststore.SSLContext(...)` constructs perfectly well: it defers to the
platform verifier, and that verifier simply has no anchors. The failure lands
later, at VERIFY time inside `urlopen`, which no `except` around construction
can observe. So the floor was never underneath anything.

certifi is now loaded into the SAME context, which widens the anchor set
rather than replacing it: a populated OS store -- the corporate-CA case #304
deliberately chose truststore for -- keeps working, and a host with no OS
store can still verify public CAs. That is the "merge, never narrow" intent
this module's docstring already took from `crates/tan-cli/src/http.rs`; the
old fall-back shape could not express it. Measured: 0 usable anchors before,
119 after.

NOT caused by the --onedir change in #349, and worth recording precisely
because the shape invites that assumption: the published v0.5.0-rc4 --onefile
asset reproduces it byte-identically in the same container (`_ssl.c:1010` vs
`_ssl.c:1000`). It has shipped in every RC.

The e2e suite could never have caught this. Every host it had run on -- both
my machines, and CI -- has a system CA store; a container is the first
genuinely clean host tan has been tested on. That is the actual lesson here,
not the four lines of code.
…leaking an account (#355)

Both found by running the e2e inside a PRISTINE ubuntu:24.04 container -- the
customer's real first ten minutes, with only what the quickstart says to
install. 17 passed, 5 failed, and all five cascaded from one refusal.

tan-cli#355. On a host with no toolchain, bootstrap said:

    Missing required tools: cmake ninja xz wget.  Install them and re-run.

and stopped. The detection is correct and is not the defect. The dead-end is
that tan SHIPS the fix -- `doctor --build --fix`, added by tan-cli#91 precisely
to run the manifest-owned install commands for these tools -- and never named
it. A first-time customer got four package names and was left to work out their
distro's incantation while the command that would do it sat one subcommand
away. Same shape as tan-cli#305.

`posix_refusal`'s docstring pinned the old wording deliberately -- "bootstrap.sh's
one line: the tool names and nothing else, TWO spaces before Install. The oracle
prints no per-tool commands and neither may this." That was right when tan had
no installer. tan-cli#91 changed the fact, so this adds a SECOND line and only a
second line: the oracle's own first line is still emitted byte for byte, double
space and all. The per-tool commands stay out of the prose exactly as before,
in the structured payload where alp-sdk#959 put them.

test_the_posix_refusal_stays_one_line_with_two_spaces_before_install asserted
the refusal is exactly ONE line -- the intent tan-cli#91 invalidated. Inverted
with its reasoning rather than left to fail, and it now asserts the double space
explicitly, since a reflow would eat it silently and take the oracle match with
it.

Separately, and worse: tests/gates/test_no_leaked_host_paths.py caught MY OWN
vendoring. Committing the e2e harness in 59ee11f put `/home/caner`,
`/Users/Caner` and a `C:/Users/Caner` .cmake path into tracked files in a
PUBLIC repo whose history is permanent. Six sites across
scripts/e2e-full.sh and scripts/e2e-linux-freeze.sh, now derived instead:
ZEPHYR_SDK_INSTALL_DIR / ZEPHYR_SDK_VERSION / TAN_CHECKOUT with $HOME
fallbacks, no account named anywhere. Re-verified the freeze script still
builds after parameterising (tan 0.5.0-rc4, 15180355 B).

That gate is the reason the leak lasted one commit instead of reaching a tag,
and it is exactly the class the same gate caught before in tan-cli#33's
history purge. Vendoring the harness was right; vendoring it unreviewed was not.

Suite: 2452 passed before this change with the leak gate red; green now.
@alpCaner

alpCaner commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thorough Python-port review at 1cab240df85d5896c5390697c069720ff2bb24d3, performed from a detached isolated worktree.

Filed findings:

Reproduced evidence:

  • fresh Python 3.12 venv: 2,384 passed, 219 skipped, 9 xfailed, 1 failed (the committed personal-path leak gate)
  • Rust workspace: 439 unit tests pass; contract suite fails frozen_issue_codes
  • fresh PyInstaller onedir archive builds; absolute-path binary verification passes 5/5
  • clean Ubuntu 24.04 container: frozen binary starts and real sdk list --online TLS succeeds
  • fresh SDK/bootstrap/build E2E produces a real ARM EABI5 ELF, but the harness's reported 23/0 includes a flash envelope with ok:false, exit 1
  • native clean-host freeze jobs pass Windows x64, Linux x64, macOS Intel, macOS arm64; PR remains blocked on the contract/Python/shim/installer jobs

Release-state blocker not split into another issue: the branch still reports tan 0.5.0-rc4; python/scripts/version_check.py --tag v0.6.0 correctly fails. That needs the normal coordinated version bump before a v0.6 tag.

Alp Lab AB added 10 commits August 3, 2026 14:33
tan-cli#364. `feat/v06-batch` was authored largely from a Windows box whose
`core.autocrlf` is `true`, and it CRLF-converted 33 tracked files that were
pure LF on `origin/dev` -- `.github/workflows/release.yml`, `python/tan/cli.py`,
`doctor_cmd.py`, `flash_cmd.py`, `run_cmd.py`, `scaffold_cmd.py`,
`core/bootstrap.py`, `core/flash_plan.py`, four parity oracle fixtures, and
whole test modules.

Nothing about it was functional, and that is exactly why it mattered: every
one of those files then rendered as a full-file replacement. The 38-line
`bootstrap.py` change in 624d2c2 showed up as 3668 changed lines and its
25-line test change as 4495, inside a 105-file review. `git diff --check
origin/dev...HEAD` reported 11,788 findings. The real diff was not auditable.

Fix is the repo default rather than 33 individual corrections:
`* text=auto eol=lf` at the top of `.gitattributes`, then `git add
--renormalize .`. Every tracked file here is text (.py .rs .yaml .txt .md
.json .c .conf .exit .yml .sh .toml .h .js .zsh .ps1 .lock .fish .env .bash),
so `text=auto` has no binary to misdetect; a binary asset added later needs
its own `-text` line, and the header says so.

The pre-existing specific `text eol=lf` pins are kept, not folded into the
default. Each records why its path must be LF *regardless* of what the
default happens to be -- byte-exact vendored trees diffed against alp-sdk,
`install.sh` executing on a customer's POSIX host, the completion-script
gate anchored on a bare `\n`. A future default change must not silently
take those with it.

Verification: `git diff --cached --ignore-cr-at-eol --stat` lists
`.gitattributes` and nothing else -- the other 33 files changed line endings
and no content. Zero CR bytes remain in any staged blob.
)

tan-cli#358. The harness printed `=== Linux: 23 passed, 0 failed ===` while at
least two named assertions proved nothing. A harness that cannot fail is not
evidence, and this one had been reporting green over a broken command for two
rounds.

`jrun` scored a call PASS on "stdout parsed as JSON and stderr was empty". It
never read RC, `ok` or `exitCode`. So

    tan flash --dry-run --format json

run from the PARENT directory with no `--project` -- planning against a tree
with no board.yaml -- returned ok:false / exitCode 1 /
`flash.manifest-not-found` and printed
`PASS flash: one envelope, 0-byte stderr (exit 1)`.

`jrun` now takes an expected exit code, or `any` where the correct answer
legitimately depends on the host (a bare container's `doctor` exits 4; a
provisioned one exits 0 -- pinning either number would make the harness lie on
the other host). `any` waives the NUMBER and nothing else. Enforced on every
call either way:

  * stderr empty, stdout exactly one JSON envelope;
  * `envelope.exitCode` EQUALS the process exit code -- the CLI-wide invariant,
    and checking it here is what makes a silent divergence impossible to score
    as a pass;
  * `envelope.ok` is true if and only if the process exited 0.

`sdk list --online` is the one hard `0`: reaching the release index over real
TLS is the #304 CA canary and has exactly one correct answer everywhere.

The `#322` leg compared the two roots for NON-EMPTINESS. A real run printed
doctor=.../proj/alp-sdk against bootstrap=.../proj/alp-workspace/alp-sdk -- two
different checkouts, the exact disagreement #322 exists to catch -- and scored
`PASS #322: both resolve an SDK`. It asserts equality now, and prints both
values when they differ.

`flash` targets `--project blinky-e2e`, the project the build leg actually
built, and asserts `project.root` ends in blinky-e2e and `project.boardYaml`
resolved. The exit code there does depend on whether the build produced an
artefact, so it stays `any` -- but WHICH project it planned against does not
depend on the host at all, and that was the defect.

The alp-sdk clone was an unpinned `--depth 1` of whatever the default branch
was that hour, so a result could not be reproduced later from the tan SHA
alone. `ALP_SDK_REF` (default `dev`) pins it and the resolved SHA is printed
with the result.

`e2e-linux-freeze.sh` defaulted to a fixed `$HOME/tan-cli` and then
unconditionally `git fetch`ed + `git checkout -B v06 origin/feat/v06-batch`.
Both are the same bug: it measured what had been PUSHED, in a tree that was
not necessarily the one under test, so a local fix could pass an e2e it had
never been built into. It now operates on its own checkout (resolved from the
script's location), freezes what is there including uncommitted work, says so
in the banner, and fetches an explicit ref only when `TAN_E2E_REF` asks.

New `scripts/e2e-container.sh` runs that same harness -- bind-mounted, not
copied, since a drifted second copy was part of this issue -- inside a pristine
`ubuntu:24.04` carrying only what the quickstart says to install
(ca-certificates, git, python3; python3 is the HARNESS's envelope parser, not a
tan prerequisite). No west, no cmake/ninja/dtc/gperf, no Zephyr SDK: providing
those is bootstrap's job and whether it does is the test. That container is
where tan-cli#354 and #355 were found, both of which every green developer-host
run had missed and #354 of which had shipped in every RC.

Still open on #358 and not claimed here: wiring this into CI on the platforms
the harness claims to cover.
…n Linux (#370)

tan-cli#370, and it is a defect in my own tan-cli#355 fix, found by reading
what alp-sdk's manifest actually contains rather than what the sentence assumed.

#355 added a second line to bootstrap's missing-prerequisites refusal:

    Missing required tools: cmake ninja xz wget.  Install them and re-run.
    Or run `tan doctor --build --fix` to install them from the SDK's manifest.

On Linux the second sentence is false. alp-sdk `dev`'s
`metadata/bootstrap.json` declares `prerequisites.install.linux` as six
`sudo apt-get install -y ...` entries, and `doctor --build --fix` deliberately
REFUSES to spawn anything whose first word is `sudo`
(`doctor_cmd.fix_needs_sudo_check`, `doctor.fix-needs-sudo`) -- under
`--format json` this process's stdio is captured end to end, so a password
prompt would hang forever rather than fail loudly. That refusal is correct and
is not what this changes.

What it changes is the sentence pointing at it. On Linux `--fix` installs
nothing; it prints the exact command per tool. Saying "to install them" there
trades one wrong expectation for another, which is precisely what #355 set out
to stop -- and it does so on the host most customers are on and the one the
container e2e represents. A customer runs it, sees six warnings and nothing
installed, and reasonably concludes the tool is broken.

Keyed on the COMMANDS, not on the platform. macOS is POSIX and its
`brew install ...` needs no elevation, so it keeps the plain wording; Windows
`winget` is user-scope and keeps it too. A platform switch would have been
wrong for macOS in the opposite direction, and reading the commands is also
what keeps this correct against a manifest nobody here has seen -- the
manifest is alp-sdk's to change.

    Or run `tan doctor --build --fix`: it prints the exact command for each
    tool from the SDK's manifest, and runs the ones needing no elevation
    (tan never spawns `sudo` itself).

The oracle's own first line is still emitted byte for byte, two spaces before
`Install` included, and the existing parity test still pins it.

Three tests, transcribing alp-sdk `dev`'s real install maps rather than
inventing them: the Linux map must not promise an install and must give the
`sudo` reason; the macOS map must still promise one; and a missing tool the
manifest has no command for must not tip the wording toward elevation (it
contributes generic advice, never a spawn -- guarding the `install.get(tool,
"")` default). The first fails against HEAD, whose single unconditional
constant contains no such wording.

Suite: 126 passed across tests/core/test_bootstrap.py and
tests/commands/test_bootstrap_command.py.
…h losing a relative path (#359, #361)

tan-cli#359. `tan pinmux` built its table path as

    Path(sdk[0]) / "metadata" / "pinmux" / f"{resolved_family}.yaml"

An absolute `--family` DISCARDS the SDK prefix outright under pathlib
semantics, and `..` components walk out of it. Nothing checked either. So

    tan pinmux --sdk-root <sdk-A> --family <sdk-B>/metadata/pinmux/aen --format json

returned rc=0, pads=96, issues=[] with `sdkRoot` naming sdk-A while the table
came from sdk-B -- and any schema-shaped YAML anywhere on the filesystem could
be read and surfaced the same way. `sdkRoot` stopped identifying the metadata
source, which is the one thing it exists to do.

Two independent checks, because either alone has a hole: the stem is validated
(no separators of EITHER kind, no drive-relative or rooted drive path, no UNC,
no `.`/`..`), and the resolved path is then re-verified to sit under
`<sdkRoot>/metadata/pinmux`. The stem check cannot see a symlink inside the
SDK; the containment check alone would still read a traversal-shaped name
before rejecting it. Rejection emits one coded validation issue and reads
nothing.

The raw string is checked for BOTH separators regardless of host: pathlib on
POSIX does not treat `\` as a separator, so an `os.sep`-only check passes on
Linux while leaving Windows unprotected. Tests cover POSIX absolute, Windows
drive-relative and rooted-drive, UNC, and `../` traversal.

tan-cli#361. `verify_binary.sh` documents relative examples (`dist/tan/tan`),
stored the argument verbatim, passed checks 1 and 2, then `cd`ed into a
temporary project and died:

    == 3/5 init writes the vendored template tree
    scripts/verify_binary.sh: 96: ./dist/tan/tan: not found
    FAIL: init exited non-zero

An absolute path passed all five. CI happens to pass `$PWD/...`, so it could
never catch the invocation the script's own usage text documents.

Both arguments are canonicalised before the first `cd`, not lazily at each use
site -- a later contributor adding check 6 must not have to remember. The
canonicalisation is the portable cd-dirname/pwd idiom, not `readlink -f` (GNU
only; BSD readlink has no `-f`) and not `realpath` (absent from a stock macOS),
so it works in bash 3.2, dash and Git Bash as the acceptance criterion
requires.

Both fixes verified fail-before/pass-after. POSIX-only tests re-run under WSL
Ubuntu-24.04, where they execute rather than skip.
…the contract gate keying on source formatting (#360, #363)

tan-cli#360. `doctor_cmd.run_fix` resolved the first word of each manifest
install command through `on_path` and, if it could not resolve it, silently
continued:

    resolved_exe = on_path(argv[0])
    if resolved_exe is None:
        continue

emitting no outcome check at all -- and a test pinned that silence.

alp-sdk's manifest uses `brew install ...` on macOS and `winget install ...` on
Windows. A fresh macOS without Homebrew, or a Windows image without a usable
winget, reports tools missing, ACCEPTS `doctor --fix`, and then produces no
`fix:*` check for anything. The customer sees the original failures with no
indication that `--fix` did nothing, and cannot tell "nothing needed fixing"
from "tan could not find the installer needed to attempt any fix". The
least-equipped hosts -- the entire audience for `--fix` -- got the least
diagnostic behaviour, and it contradicted `run_fix`'s own stated invariant that
every entry is either run or refused and every outcome becomes a check.

Now an unresolved installer emits a named outcome carrying the installer AND
the affected tools, in text and JSON, deduped so one absent `brew` covering six
tools does not print six identical paragraphs. The remedy names what to install
(Homebrew on macOS, App Installer/winget on Windows), not merely that something
was not found. The test that pinned `[]` asserts the verdict instead.

tan-cli#363. `cargo test --locked --workspace` failed on Linux, Windows AND
macOS with

    RESERVED ISSUE CODE `sdk.network-required` is gone:
    python/tan/commands/sdk_cmd.py no longer contains
    "Issue(\"sdk.network-required\", \"warning\", ...)" outside comments.

The emission was not gone. It had become an ordinary multiline call. The Rust
gate substring-matched each registry entry's prose `literal` against Python
source, so a line wrap turned a live registered code into a stale-code verdict
and made source FORMATTING part of the wire-contract gate. The tempting
workaround -- rewrapping production code to satisfy a scanner -- was rejected
in the issue and is not what this does: `sdk_cmd.py` is untouched.

The Rust side now delegates python-side entries to the Python gate, which
parses structurally, and the delegation is checked from both ends: contract.rs
asserts a total partition, that the delegate file exists and carries the
expected `def`, and that both halves are non-empty; the Python gate asserts the
same partition mirror plus a floor. An `emittedBy` convention change reddens
both rather than opening a gap.

The delegation pin proves the delegate EXISTS, not that it RUNS -- a skip
decorator would satisfy the substring while checking nothing, and skips are
green here. Filed as tan-cli#372 with the two other findings from that
verification rather than papered over.
…actually configures (#309)

tan-cli#309. `tan init --template minimal-app` emitted a project whose
`src/features/app_bootstrap.c` was never compiled or linked, while its own
`board.yaml` declared `os: zephyr`.

TWO bugs stacked, and fixing only the visible one made it worse.

`board.yaml`'s `app:` decides which `CMakeLists.txt` `west build` configures,
via `_zephyr_app_dir` (`tan/planner/orchestrator.py`): it picks the `app:` path
ITSELF whenever that path has its own `CMakeLists.txt`, falling back to the
parent only when it does not. `minimal-app`'s `src/` ships one, so `app: ./src`
sent west straight at `src/CMakeLists.txt` and never at the root file. The
sources were named in a dead `set(ALP_APP_SOURCES ...)` list that Zephyr's real
`app` target never reads, and CMake configures that shape without complaint --
so exit 0 was never evidence anything Zephyr-shaped had happened.

A first attempt fixed the root `CMakeLists.txt` to a real Zephyr shape. That
file is dead code in this build, and it left `src/CMakeLists.txt` -- the file
west DOES configure -- as a bare `target_sources(app PRIVATE ...)`, which real
cmake 3.25.0 rejects:

    CMake Error at CMakeLists.txt:1 (target_sources):
      Cannot specify sources for target "app" which is not built by this project.

That would have replaced a silent mis-build with a hard configure failure, and
-- because the slice then never exits 0 -- would have bypassed the `os: zephyr`
guard at `python/tan/commands/build/execute.py:782-809`, costing the customer
its actionable message too. Caught by review, not by the suite.

The fix is `app: .`, so the root file is the one west configures and
`src/CMakeLists.txt`'s `target_sources(app ...)` binds to the `app` target the
parent creates via `find_package(Zephyr ...)` + `project()`. The eight-file
list and its golden are untouched -- they pin path and kind, not content or
`app:`.

Verified by building, not by asserting: `cmake -S <project> -B <build>
-DBOARD=qemu_x86 -G Ninja` reaches `Configuring done` / `Generating done`,
`ninja` links, `nm app/libapp.a` shows `app_bootstrap_step` as T (defined)
alongside `main.c.obj`, and `build.ninja` feeds
`CMakeFiles/app.dir/src/features/app_bootstrap.c.obj` into the archive Zephyr
pulls in with `-Wl,--whole-archive`. `zephyr.elf` links.

The new test runs the REAL `_zephyr_app_dir` against a scaffolded tree, so the
scaffold and the planner can no longer disagree silently about which file gets
configured -- the gap that let the first attempt look green.

This diverges from `crates/`, which still emits the pre-#309 shape and is
frozen. Nothing in either language compares the two implementations' emitted
file CONTENT (the envelope golden pins path and kind; byte-parity covers only
the vendored trees, and `minimal-app` has none), so there is no gate to declare
the divergence to -- filed as tan-cli#371.

#309's own "Measured" table conflates two build slices; corrected on the issue.
…irst-install gate certifying a released binary (#356, #362)

tan-cli#349 switched the release to PyInstaller `--onedir` archives, and both
installers plus the npm shim began requesting the new shape unconditionally.

No published release has it. `v0.4.1` is `latest` and publishes raw binaries;
the published `v0.5.0-rc4` does too -- measured, not assumed:

    .../v0.5.0-rc4/tan-x86_64-unknown-linux-gnu.tar.gz   -> 404
    .../v0.5.0-rc4/tan-x86_64-pc-windows-msvc.zip        -> 404
    .../v0.5.0-rc4/tan-x86_64-unknown-linux-gnu          -> 200

So the documented install command broke for every tag that exists, and the npm
shim regressed at ITS OWN pinned version -- the old code fetched an asset that
exists, the new one a 404.

One rule, one source, three consumers: `install.sh`, `install.ps1` and
`npm-shim/postinstall.js` now look each candidate name up in the release's own
`checksums.txt` -- archive first, raw as fallback -- and take the shape the
release actually published. Never from the version, which is a second source of
truth that drifts and would break a pin nobody remembered to add. The shim
already fetched that file and already had the lookup, so this costs zero extra
requests: fetch checksums, choose the name, then download.

Fail-closed ordering is preserved and tightened: the sha256 tool is checked
before the download, and the digest is compared before any unpack and before
any write to the install dir. A release listing NEITHER candidate refuses
loudly, naming both names tried.

The transition tag is `v0.5.0`, which is not cut yet. Three files asserted that
rc4 already shipped archives -- provably false against the live release, and
the exact claim #356 exists to delete. Corrected in `postinstall.js`,
`npm-shim/README.md` and its test.

BLOCKER, and worse than the installers themselves.
`.github/workflows/getting-started.yml` is this branch's own first-install
gate: it runs `./install.sh`, then swaps the PR's freeze in over the top. That
swap assumed install.sh had left a onedir layout. After the fix it has not --
`latest` is v0.4.1, install.sh takes the raw branch and creates no
`tan-cli-lib` at all, so the copied freeze is orphaned and `tan --version`
reports `0.4.1`. Since `crates/` carries every subcommand the job then
exercises, it would have run clean to `all asserted` HAVING TESTED NONE OF THE
PR -- precisely what that workflow's own comment says it exists to prevent. It
now writes the launcher itself regardless of layout, and fails loudly with
`::error::` if `command -v tan` is not the expected path or `tan --version`
does not equal this checkout's `TAN_VERSION`.

A silently-green gate is worse than a red one; that one would have covered the
whole release.

Also: the npm shim's rollback could delete the install it had just preserved
(a failed rollback rename propagated into a `finally` that removed the staging
dir, taking `stage/previous` with it); the `.zip` path is now tested with a
genuine PK-signed zip built in-tree rather than a renamed tarball, which had
been passing only because bsdtar content-sniffs; the widened "no asset for this
platform" refusal is now actually reached by tests on both scripts (reverting
it used to leave all 12 green); and the bare-`latest` fixture now FAILS with
the tag to add rather than skipping green when `latest` moves.

musl: the refusal still fires before the tag is resolved, so `--version v0.4.1`
on Alpine is refused for a tag that genuinely published `-musl`. Deliberate --
"works for old tags, refuses for new ones" is a worse promise than one
tag-independent refusal -- and now stated in both the message and
`docs/release-contract.md` as a revisitable decision rather than a platform
limit.

Verified: npm shim installs and runs at v0.5.0-rc4 over the real network
(`tan 0.5.0-rc4`, was 404); shim tests 11 -> 18 passed; installer tests 15
collected, 7 passed on Windows and the 8 POSIX cases re-run under WSL
Ubuntu-24.04, where they execute rather than skip.
…361)

tan-cli#361 survived because all three release call sites passed `$PWD/...`,
so the invocation the script's own usage text documents was never exercised.
The windows job now passes relative arguments and linux/macos keep absolute
ones, so the release path itself covers both forms. Git Bash is also the shell
the fix's cd-into-dirname idiom is least obvious on.
…ter the codes on the wire (#357, #374)

tan-cli#357. The Python `support-bundle` hardcoded `ExitCode.SUCCESS` after
writing the bundle, so automation saw `ok:true` / exit 0 while the same
envelope carried error-severity issues -- against an oracle that makes
`doctor.summary.fail > 0` a doctor failure. And it substituted
`doctor_cmd._collect`'s build/flash-readiness checklist for the oracle's
DEBUG-focused report, dropping `projectSelected`, `debuggerExtensions` and the
CodeLLDB/LLDB checks the command exists to collect.

Measured against the live oracle, same project, empty PATH:

    RUST rc=4 ok=false exitCode=4
    PY   rc=0 ok=true  exitCode=0   ->  now rc=4 ok=false exitCode=4

tan-cli#374, found verifying that fix. It was right about the failing host and
wrong about the healthy one: `exit_code_for` was fed the Python-only-widened
`longPaths` check (tan-cli#306), which is not one of the oracle's fail axes, so
the port exited 4 where the oracle exits 0 -- on a fresh `HOME` with no global
`.gitconfig`, i.e. exactly a first-run customer. Invisible on a developer box
carrying `core.longpaths=true`, which is why the parity test never saw it: it
pinned the ONE scenario where both sides are already 4.

`longPaths` is now capped `fail -> warn` before it feeds the report, not
filtered out of the exit code alone, so the written bundle, `summary`,
`nextSteps` and the wire verdict stay mutually consistent. Measured after:
both sides `rc=0 ok=true exitCode=0`. A second parity case now runs against a
resolvable SDK where the oracle returns 0, so the divergence has somewhere to
be caught.

Four unregistered codes were reaching the wire -- `support-bundle.sdkRoot`
(ERROR), `.jlinkBackend`, `.openocdBackend`, `.pyocdBackend`, plus three more
reachable (`.gdb`, `.gdbserverBackend`, `.noneBackend`) -- while
`pytest tests/gates/ -q` reported `11 passed`, because the gate hand-declares
this command's value space instead of deriving it. All seven registered; the
suffix set re-derived from `_debug_doctor_report`'s actual construction; the
14 rows for checks this command can no longer emit retired, along with the
comment citing `_doctor_section`, a function this change deletes.

Verified both directions rather than assumed: with the corrected suffix table
but the old registry, the gate fails naming exactly those seven; re-adding a
retired row makes the still-emitted gate fail correctly.

`bootstrapManifest` stays as a port-only check and is now DECLARED in the
parity file rather than passing silently because the old scenario resolved no
SDK. `_extension_check`'s detail now carries the real U+2014 EM DASH the oracle
writes, not `--`; a string ported from the oracle copies it byte for byte,
unlike this file's own prose.

Every other unit test monkeypatches `_collect` with a two-check stub, so none
of them could notice a check entering or leaving the bundle -- which is how
both the exit-code divergence and the undeclared check got through. One test
now runs the real `_collect` and asserts the oracle-shaped name list.

Also lands the gate half of tan-cli#360, which belonged with `doctor_cmd.py`'s
55th `Check(...)` in 6f791cf and was split off it because this file was being
edited concurrently. That split is what turned `python` and
`seam1 -- plan-shape parity` red on the last push: `expected 54 call(s) to
Check(...), found 55`. A hand-maintained count and its subject must move
together -- exactly what #374 says about deriving these instead of declaring
them.

Suite: 377 passed, 3 skipped across tests/gates/, the support-bundle command
tests and the oracle-parity file.
…oying the sign record (#365-#369, #373)

Closes the SETOOLS auto-sign gap tan-cli#353 opened, and the five defects the
first pass at it introduced or left open.

tan-cli#365. `sign_slot0` decided `app-gen-toc` had succeeded by re-reading two
fixed paths, neither of which proves THIS spawn wrote them: a stale
`app-package-map.txt` plus a stale `AppTocPackage.bin` from a previous run
satisfied both checks, so a soft-failing re-sign returned the OLD address with
the OLD blob and `plan_alif_mram_jlink` burned that mismatched pair into on-die
MRAM -- recoverable only by re-provisioning over SE-UART.

tan-cli#373, and this is the part that matters: the first fix DELETED both
files before spawning, so their reappearance would prove the run. That is
correct for `AppTocPackage.bin`, which `app-gen-toc` overwrites whole each run,
and WRONG for `app-package-map.txt`, which this repo already documents as
append-mode at `flash_plan.parse_atoc_start_address` -- "a re-signed re-run
APPENDS a fresh block rather than truncating the file", which is why that
parser takes the LAST match and why every bench script ends `| tail -1`. It is
the accumulated sign record for that install, hand-runs included.

Deleting it had its own MRAM path: a two-entry manifest where entry A supplies
`flash_args.atoc_map` pointing at that file plus its own `flash_args.atoc`, and
entry B auto-signs. The unlink wipes the map, `app-gen-toc` recreates it with
only B's block, and A then parses B's address and pairs it with A's blob. Two
different ways to write a mismatched ATOC, the second introduced while fixing
the first.

Now the map is SNAPSHOTTED, never removed: `(mtime_ns, size)` before the spawn,
and an unchanged snapshot after a zero exit is the soft-failure signal. An
append moves both, so it detects exactly what the unlink did and destroys
nothing. The blob keeps the delete-then-check treatment, with the difference
between the two files reasoned in the comment rather than assumed. Verified in
an isolated repro: pre-fix the map is gone afterwards; post-fix the guard fires
AND the map's content is byte-identical.

tan-cli#366. `--dry-run` returned `ok:true` exit 0 for manifests it never
validated, whenever `SETOOLS_DIR` happened to resolve -- so the defect was
invisible to anyone without SETOOLS installed. The first fix narrowed it
without closing it: `jlink_speed: "fast"` and `confirm: "true"` still passed a
dry run and were refused on the real one, AFTER `app-gen-toc` had already run
and written into the customer's SETOOLS install. Both checks are now hoisted
into `validate_flow_d_shape`, the one function both paths call first.

tan-cli#367. `plan_alif_mram_jlink` and `_resolve_flow_d_atoc_via_setools` each
carried their own copy of the sibling-`.bin` resolution and NEITHER restricted
it to ELF, so a `.hex` resolved exactly like an ELF -- while the comment, the
commit message, and a test literally named
`test_a_hex_artefact_is_refused_even_with_a_sibling_bin` all said it was
refused. One `resolve_slot0_binary` now, and the SETOOLS path receives the
resolved shape instead of re-deriving it, so signing and writing cannot
disagree. Per #367(a) the accepted shapes are no-extension, `.elf` and `.out`;
a `.hex` is refused, naming both what it got and what it wanted.

tan-cli#368. `--setools-dir` added, precedence flag > env > manifest -- the
manifest demoted deliberately, since `tan build` regenerates
`system-manifest.yaml` and a customer's edit there does not survive. The
resolved source is now reported on a SUCCESSFUL sign too, not only in a
failure message: a stale `SETOOLS_DIR` silently winning over a working manifest
field was invisible.

tan-cli#369. `missing_tool_message` claimed "this does not look like an Alif
Security Toolkit install" for a directory that does not exist, for a path
pointing at the binary rather than its parent, AND for a genuine Windows
install -- `find_app_gen_toc` never tried `app-gen-toc.exe`. It now branches on
`os.path.isdir` and names the exact candidates tried. Paths reaching J-Link
Commander are quoted when they contain whitespace, so a SETOOLS install under
`C:\Program Files\...` stops rendering `loadbin C:\Program`.

The remediation rewrite over-reached and #373 caught it: that tail is the
shared fallback for FOUR banners, not one, and replacing all four deleted the
correct advice for three of them -- telling a customer with an unset
`jlink_serial` on a multi-probe host NOT to pin it, when pinning it is the fix.
Now branched: the cloned-serial/SW-DP-ID text fires only where a board actually
answered with a different DP ID; the probe-selection sentence is restored for
the unrecognised-banner, target-level and refused-serial cases.

Three tests had docstrings promising the original remediation and bodies
asserting its replacement -- the same name-versus-body contradiction #367 was
filed over, reintroduced by the PR fixing #367. Corrected.

`docs/setools.md` (new) documents SETOOLS as license-gated, obtained from Alif
and never redistributed here, with the sourcing precedence and the
cloned-serial caveat; README now points at it, so the tool is discoverable
without failing first.

Suite: 2528 passed, 179 skipped, 9 xfailed, 0 failed.

Reported, not fixed here (doctor_cmd.py, another change in flight):
`setools_check` reads only `os.environ["SETOOLS_DIR"]` and so misreports for a
customer using the new flag, and `:1071` still carries the
verdict-without-evidence phrasing removed from `setools.py`.
@alpCaner
alpCaner merged commit ac79d4c into dev Aug 3, 2026
19 checks passed
@alpCaner
alpCaner deleted the feat/v06-batch branch August 3, 2026 14:55
This was referenced Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment