Skip to content

Hardware bring-up program: assistant tiers, auth, capacity, mailslot/restore CLI, FUSE, TapeAlert, CI gates, clean lint baseline - #44

Merged
github-actions[bot] merged 121 commits into
masterfrom
integrate/eight-items
Sep 13, 2026
Merged

github-actions[bot] merged 121 commits into
masterfrom
integrate/eight-items

Conversation

@Amantux

@Amantux Amantux commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Integrates eight workstreams built and verified against the local mhvtl rig (3 drives, real LTFS) and the in-repo i3 emulator:

  1. Pre-existing failures eliminated — both were stale tests (occupied-slot seed drift; placeholder-checksum assertion); suite baseline is now 0 failed.
  2. Bearer-token API auth (OPENBLADE_API_TOKEN[_FILE], off by default) — middleware chokepoint, OpenAPI-derived 401 sweep, X-Forwarded-Prefix classification bypass found by review and eliminated, /aml/proxy gated. i3 compliance 142/142 in both modes.
  3. Capacity policy — one reserve helper (min(64 MiB, 1%)), sharded lanes gain their first capacity check, catalog-hydrated tape state at startup. ENOSPC-on-empty-file reproduced live, then fixed.
  4. Operator surfaces — restore tree/file, archive sharded, mailslot list/import/export (export refuses data-carrying tapes; reviewer-proven barcode/slot mismatch exploit fixed). Live-rig round-trips byte-identical.
  5. FUSE + TapeAlert — real read-only catalog mount (EROFS/EIO semantics, opt-in hydrate), hardware drive-health with T10-spec severity table.
  6. Assistant media tier — ask→confirm→do for load/unload/move/format/archive/restore; typed-barcode confirmation for format, gate enforced in perform() not the prompt; 28/28 mutations killed; live-rig transcript in the wiki.
  7. CI — wiki-reference staleness gate, weekly mhvtl canary workflow, backend-lint now blocking.
  8. Toolchain — ruff pinned exactly, baseline cleared to zero findings, tree fully formatted (AST-verified no-op), SAFETY_003 + jobs.error/AML detail sanitization.

Local gates: unit+integration 1,988 passed / 0 failed; safety 223/0; i3 142/142 both auth modes; live hardware suite 54/0/8; mypy zero added errors; make lint green for the first time.

First real runs to watch here: wiki-reference, blocking backend-lint, mhvtl-weekly (dispatch).

🤖 Generated with Claude Code

https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2

Amantux and others added 30 commits September 11, 2026 04:32
…nstN nodes

docs/hardware-setup.md said /dev/st0 while tests/hardware/README.md already used
/dev/nst0. LTFS on a rewinding node repositions the tape on every close and can
corrupt the index, so every instruction, example and --device flag now names the
no-rewind node, with a short section in docs/hardware-setup.md explaining why.

Parser fixtures and sample tool output (openblade/hardware/{discovery,ltfs,mtx}.py,
tests/unit/*, the nst->st normalizers in tests/hardware/) are left alone: lsscsi
and ltfs genuinely report /dev/stN and falsifying that output would break the
parsers and mislead operators reading real tool output.

Phase 0 item 3 of docs/runbooks/real-i3-bringup-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…d real mode

docker-compose.yml has no devices: passthrough, so OPENBLADE_BACKEND=real cannot
work in a container. This adds a never-auto-loaded example override that maps the
changer sg node and up to three drives (/dev/nst0..2 plus their sg nodes, one
commented block per drive so operators delete what they lack) and wires
OPENBLADE_BACKEND / OPENBLADE_REAL_HARDWARE_ENABLED / OPENBLADE_CHANGER_DEVICE /
OPENBLADE_DRIVE_DEVICES.

Two findings, both verified empirically rather than assumed:

* No cap_add and no privileged: true are needed. A container holding only
  --device /dev/sgN and the default capability set (CapEff 0xa80425fb, no
  CAP_SYS_RAWIO) accepted SG_IO for every opcode these backends use - INQUIRY,
  MODE SENSE, READ ELEMENT STATUS, MOVE MEDIUM, INITIALIZE ELEMENT STATUS,
  READ/WRITE(6), WRITE FILEMARKS, SPACE, LOAD/UNLOAD. Adding CAP_SYS_RAWIO
  changed nothing.
* The real blocker is ownership, not capabilities: devices: reproduces the node
  with the host's uid/gid/mode and the image drops to the unprivileged openblade
  user via gosu, so uid 1000 got EACCES on open() until the node's gid was added
  as a supplementary group. Hence group_add with a required OPENBLADE_TAPE_GID,
  which fails loudly at compose time rather than as a confusing permission error
  on the first SCSI call.

Also noted in-file: the image ships mtx but not sg3_utils or LTFS, so only
read-only changer inventory works in-container as shipped.

Bare-metal venv remains the recommended bring-up path per the runbook; both
docs/hardware-setup.md and docs/deployment.md say so where they point here.

Phase 0 item 2 of docs/runbooks/real-i3-bringup-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…slots

Phase 2 of docs/runbooks/real-i3-bringup-plan.md: stand up a virtual tape
library so tests/hardware/ can exercise the real hardware backend with no
physical hardware.

setup.sh is idempotent: installs deps, builds and installs mhvtl from source,
applies the patch below, writes the config, starts the daemons, and verifies
via lsscsi + mtx. teardown.sh reverses it. env.sh discovers the device paths
(they move between boots) and prints the export block for the test suite.

The library is configured with product id "QUANTUM Scalar" so mhvtl selects
its Scalar personality module, whose SCSI element addressing (drives at
0x0100, storage at 0x1000) is the realistic Scalar i3 case rather than the
0-/1-based default. That is the element-addressing trap the bring-up plan
asks this phase to rehearse.

Three upstream obstacles had to be solved to get here; all are documented
inline where they bite:

- mhvtl's scalar_pm.c aborts vtllibrary with heap corruption. Two snprintf
  bounds exceed their alloc_vpd() page (VPD 0x80 and 0x83), and both
  functions free the old page without storing the new one back into
  lu->lu_vpd[], leaving a dangling pointer. Fixed in
  patches/0001-scalar_pm-fix-vpd-overflow-and-uaf.patch.

- mhvtl's own systemd units stop its daemons from working. ProtectClock=yes
  implies a DeviceAllow= rule, which flips the cgroup device policy to
  "closed" and denies the daemon its own /dev/mhvtl<n> node;
  ProtectKernelTunables=yes makes /sys read-only so the daemon cannot write
  add_lu to register its LU, leaving a daemon that reports "active" while no
  SCSI device ever appears. Worked around with /run drop-ins.

- lsscsi prints the mediumx row before udev creates its /dev/sg node, so a
  naive readiness check sees a changer with no usable device path.

setup.sh also prints the SCSI-address -> /dev/st -> serial correlation on
every run: /dev/stN numbering follows daemon registration order, and we
observed target 1/2/3 mapping to st2/st1/st0 on one boot and st0/st1/st2 on
the next. That is the "wrote to the wrong drive" trap, so it is reported
rather than assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Adds `serial` to ScsiInquiry, parsed from the "Unit serial number:" line.
Verified against the installed sg3-utils 1.46 (`sg_inq` 2.10 20210328): plain
`sg_inq DEVICE` fetches the Unit Serial Number VPD page (0x80) by DEFAULT, so no
flag change is needed — `-o/--only` is what suppresses it. That same version
prints "Peripheral device type:" rather than "Device type:", which the existing
regex did not match, so the device-type pattern now accepts both spellings.

Also parses the `sg_inq --export --page=0x80` form (SCSI_SERIAL=...) and adds a
three-drive Scalar i3 `mtx status` fixture. The fixture pins the known gap that
"Storage Element N IMPORT/EXPORT:" rows are not parsed.
… mismatch

RealLibraryBackend mapped mtx Data Transfer Element order onto discovery device
order and ignored OPENBLADE_DRIVE_DEVICES entirely. With >=2 drives that is the
"wrote to the wrong drive" bug from the i3 bring-up runbook.

New openblade/hardware/correlation.py binds each drive element to a host device:

- OPENBLADE_DRIVE_SERIAL_MAP="<serial>:<dte>,..." declares the mapping; every
  configured device is probed with sg_inq at backend construction and compared
  against it. A declared serial that is not attached, an attached drive that is
  not declared, a duplicate serial, a drive with no serial, or an element index
  beyond the changer's drive count raises DriveCorrelationError (a
  SafetyViolationError) and the backend refuses to start.
- With no map, correlation falls back to positional order and logs a
  DRIVE ORDER UNVERIFIED warning listing the observed serials, so the operator
  can build the map from the log. Single-drive behaviour is unchanged.
- Dry runs probe nothing and are reported as unverified rather than claimed.

OPENBLADE_DRIVE_DEVICES is now authoritative over discovery order, and
connect-i3 reports the correlation plus how it was established.

The READ ELEMENT STATUS/DVCID route was rejected deliberately: it needs a new
binary dependency and a hex descriptor parser that cannot be validated against
any captured real-i3 output. Rationale is in the module docstring.
…suite

- tests/integration/test_three_drive_concurrency.py: three jobs on three drives
  with disjoint scheduler + JobQueue ownership, the shipped scalar-i3-50-3
  profile asserted at three drives, and MockLibraryBackend asserted configurable
  at 1/2/3/6 drives.
- tests/hardware/test_sharded_operations.py: the timeout test sized the pool at
  min(drives, 2), making it a 2-drive test on a 3-drive library; it now sizes to
  the attached drives.
- tests/hardware/test_device_discovery.py: sg_inq is exercised for drives 0-2
  (skipping absent ones) instead of drive 0 only, plus a check that attached
  drives report distinct serials, which OPENBLADE_DRIVE_SERIAL_MAP depends on.
Covers OPENBLADE_DRIVE_DEVICES, reading serials with plain sg_inq, declaring
OPENBLADE_DRIVE_SERIAL_MAP, the startup refusal on mismatch, the unverified
positional fallback, and why READ ELEMENT STATUS was not used.
…output

Found by the first run of tests/hardware/ against a real medium changer
(mhvtl rehearsal rig, Phase 2 of docs/runbooks/real-i3-bringup-plan.md).
Both parsers were written against hand-made sample strings that do not match
what mtx actually prints.

1. mtx is inconsistent about whitespace around '=':

     Data Transfer Element 0:Full (Storage Element 6 Loaded):VolumeTag = OB0007L8
           Storage Element 1:Full :VolumeTag=OB0001L8

   _BARCODE_RE required the tight form, so every tape loaded in a DRIVE
   parsed as barcode=None. find_drive_by_barcode() was permanently blind and
   archive jobs failed with "Barcode ... not found in inventory" about a tape
   sitting in drive 0. SAMPLE_MTX_LOADED uses the tight form on its drive
   line, which is why no unit test caught it. This would have fired on the i3
   the first time a tape was loaded.

2. _SLOT_RE matched "Storage Element (\d+):" and so did not match
   "Storage Element 9 IMPORT/EXPORT:Empty". All four I/E slots vanished from
   the inventory, leaving len(slots)=8 while the header reported 12 slots, and
   making a tape parked in the mailslot invisible to find_slot_by_barcode().
   Phase 3.4 of the bring-up plan predicted i3 I/E slots would be where these
   regexes diverge; they were right.

MtxSlotInfo gains is_import_export so callers can tell the two apart, and the
parsed slot count now agrees with the header.

Regression tests are anchored on SAMPLE_MTX_REAL_SCALAR - output captured
byte-for-byte from the rig, including the trailing barcode padding - rather
than on another hand-written sample, since hand-written samples are exactly
how these bugs hid. Mutation-checked: restoring the tight VolumeTag regex
fails 1 of 7, restoring the I/E-blind slot regex fails 3 of 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…ait for release

Three defects found running tests/hardware/ against real LTFS (2.4.8.4, sg
backend) on the mhvtl rehearsal rig. None were visible without a real LTFS
binary; the module's sample strings are fictional.

1. device_list() could never succeed. `ltfs -o device_list` writes its entire
   output to STDERR and exits 1 even on success, so raise_on_error() raised
   unconditionally and parsing result.stdout would have found nothing anyway.
   The real output format is also nothing like the sample the regex was built
   from:

     Device Name = /dev/sg1 (14.0.1.0), Vendor ID = IBM     , \
     Product ID = ULT3580-TD8     , Serial Number = OBLADE_D01, ...

   Now parses stdout+stderr, judges success by whether any device parsed, and
   accepts both formats. LTFSDevice gains `serial`, because correlating drives
   by serial is the documented mitigation for the wrong-drive trap - device
   ordering is not trustworthy.

2. LTFS was handed the wrong device node. Its sg backend addresses drives as
   /dev/sgN; library.py supplies /dev/stN. This does not fail cleanly - LTFS
   reads the wrong device and reports "No index found in the medium", which
   looks like blank or corrupt media. On the i3 that is a support incident
   where the operator concludes the cartridge is bad.

   Added discovery.resolve_sg_device(), which reads the mapping from
   /sys/class/scsi_{tape,changer}/<node>/device/scsi_generic/ and is applied in
   format_tape/mount_readonly/mount_readwrite. The mapping must be READ, not
   derived: st and sg numbers are allocated independently, and this rig
   routinely produces st0 -> sg1 and st2 -> sg4.

3. unmount() returned while the drive was still held. umount returns when the
   kernel detaches the filesystem, but the LTFS FUSE process lives on to flush
   its index and close the drive; until it exits the next mount of that drive
   fails with "Cannot open device: failed to open /dev/sgN (16)" (EBUSY). Any
   unmount/remount cycle hits this, and so does unload-then-reload on a real
   library. Added wait_for_ltfs_release() (procfs poll, bounded) and report
   device_released in the result details.

Mutation-checked: a naive st->sg name derivation fails 5 of 7 resolve tests;
removing the real-format branch fails 4 of 5 LTFS parser tests.

Note for whoever owns library.py: _ordered_drive_devices() prefers
block_device, i.e. the REWINDING /dev/stN node, which is the source of (2) and
is the corruption footgun Phase 0.3 of the bring-up plan calls out. ltfs.py now
defends itself, but that should prefer sg_device. Detail in
docs/runbooks/mhvtl-rehearsal.md section 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…har-only flag

Two defects in tests/hardware/ helpers, both of which would have produced
false failures on the first real i3 session.

1. _resolve_scsi_path() normalised /dev/nst0 to /dev/st0 so it could match
   lsscsi's block-device column, then RETURNED the rewinding node. sg_inq
   against a rewinding node exits 50 ("close error: No medium found") whenever
   the drive is empty, because closing it attempts a rewind - the INQUIRY
   itself succeeds and the command still reports failure. Drives are empty
   most of the time on a real library, so this was a guaranteed false failure,
   not an occasional one. Now prefers the sg node, falling back to
   discovery.resolve_sg_device().

2. _format_tape() passed the barcode as mkltfs --tape-serial, which takes
   exactly 6 alphanumeric characters. Real LTO barcodes are 8 (six plus a
   media-type suffix such as "L8"), so it failed every time with
   "LTFS15029E Tape serial must be 6 characters." The barcode belongs in
   --volume-name, which is what the product code in ltfs.py already used
   correctly. The helper also passed /dev/nstN where LTFS needs the sg node.

The second bug existed in TWO hand-rolled copies, in test_ltfs_operations.py
and test_performance.py, both diverging from the product's correct version.
Both fixed; collapsing them onto LTFSCommandBackend is left as follow-up.

_unmount_ltfs in both files now waits for wait_for_ltfs_release() as the
product does, so a remount cannot race the still-closing LTFS process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Fills the gaps the first full test pass exposed in the rig itself.

- format-scratch.sh: the archive/restore and sharded jobs MOUNT a scratch tape
  without formatting it first, so blank media fails with "medium is not
  partitioned" surfacing as "readwrite mount failed". Phase 4 of the bring-up
  plan sequences format before archive/restore, so the rig now supplies
  formatted scratch media as a precondition. setup.sh runs it when mkltfs is
  present and says plainly what is untested when it is not.

- reset.sh: returns every loaded tape to its home slot. A test that fails part
  way through leaves media in a drive, and the next run then skips itself with
  "Drive 0 is not empty" or "Scratch barcode ... is not currently present" -
  cascading skips that look like passes.

- mhvtl.conf: CAPACITY 500 -> 8000 MB. The suite writes 100 MB payloads and
  several in a row; 500 MB cartridges fail mid-test with ENOSPC from inside the
  LTFS mount, which reads as an OpenBlade bug and is not. Backing files are
  sparse, so this costs only what is actually written.

- setup.sh waits for the /dev/sg nodes rather than the lsscsi rows, and prints
  the SCSI-address -> /dev/st -> serial correlation on every run.

- README.md: the configuration, the three upstream mhvtl bugs the rig works
  around, and the two traps it exists to expose (drive order is not device
  order; sg/st/nst are not interchangeable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
What was run, what it found, and what it did NOT prove.

Phase 2 of real-i3-bringup-plan.md executed end to end: mhvtl and LTFS both
built from source (neither is packaged on Ubuntu), one changer + three LTO-8
drives + 8 barcoded slots, and the full tests/hardware/ suite driven against
the real hardware backend for the first time.

Result: 24 pass / 8 fail / 24 skip on the first run, 48 pass / 0 fail / 8 skip
at the end. Six product defects found and fixed, each with the failure output
that exposed it and a note on how it would have presented at the i3. Two
suspected bugs in library.py are reported rather than changed, since that file
is owned elsewhere.

Section 6 is deliberate: TapeAlert/diagnostics, robotics timing and failure
modes, raw element addressing, media compatibility and control-path bridging
are all still unrehearsed. Phases 3 and 4 stand unchanged. What this removes is
the class of failure where the parsers and device plumbing are wrong - and it
found six of those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…nodes

validate_ltfs_capabilities() computed

    device_list_ok=any(current.device == device for current in devices)

`devices` comes from LTFS, which only ever enumerates /dev/sgN. `device` is
whatever the caller named - and the CLI help for `hardware validate-ltfs` says
"Tape device path such as /dev/st0", while Phase 4.1 of the bring-up plan
passes /dev/nst0. The two can never compare equal, so the flag was false on
every healthy library, with the contradicting evidence printed immediately
above it:

    {"discovered_devices": [ ...three drives, with serials... ],
     "device_list_ok": false}

That is a false alarm at the first gate of the i3 bring-up, which is the worst
possible place for one.

Resolves both sides to their sg node before comparing, keeping the literal
comparison as a fallback for hosts where the sysfs mapping is unreadable.
Verified against the mhvtl rig with all three spellings of the same drive -
/dev/st1, /dev/nst1, /dev/sg2 - all now report true. Mutation-checked:
reverting to raw equality fails test_matches_tape_node_via_sysfs.

Also records in the runbook a defect in sg.py, which is owned elsewhere and so
is reported rather than fixed: _DEVICE_TYPE_RE matches "Device type:" but real
sg_inq prints "Peripheral device type:", so every device on the bus parses as
device_type="unknown". The value is multi-word ("medium changer"), so the
label and the [^\s]+ capture both need fixing together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
ruff format checks Python inside markdown code fences. The snippet is a
keyword argument, not a statement, so ruff wanted to rewrite it as an
assignment - which would have misrepresented the code being quoted and added
this file to the repo's unformatted count. Plain fence instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Review found three ways the "never write to the wrong drive" property could
still be violated, plus overstated claims about what is actually checked.

- Stop claiming the element assignment is verified. The serial check proves the
  SET of attached drives matches the declaration; it cannot detect a transposed
  or rotated map (the 1-based-bay-number mistake). `verified` is renamed
  `serials_verified`, the log and connect-i3 field say only that, and the module
  docstring plus docs/hardware-setup.md state the limit and give the one-time
  manual check (load into element k, confirm ONLY the correlated device sees the
  tape). The docs now spell out that indices are 0-based, with a worked table.
- Probe the generic /dev/sgN node, not /dev/nstN. The st driver allows a single
  open, so inquiring on a drive LTFS holds fails with EBUSY; the sg node always
  answers. This also matches the repo's other sg_inq call sites.
- Run the duplicate-serial check on BOTH paths, not just the declared one: two
  devices reporting one serial (e.g. /dev/st0 and /dev/nst0) are one drive and
  can never be a correct configuration. Duplicate entries in
  OPENBLADE_DRIVE_DEVICES are refused for the same reason.
- Refuse at startup when a declared map leaves a drive element with no device:
  the scheduler sizes itself from the changer, so that element would get a
  cartridge loaded and strand it at mount.
- Typed errors only: sg_inq failures (missing binary, timeout, non-zero rc) and
  an uncorrelated element now raise DriveCorrelationError with a curated
  message naming the fix, instead of leaking CommandError/KeyError; connect-i3
  prints the message rather than a traceback, and reports correlation warnings.
- An empty device list warns instead of raising — connect-i3 is the diagnostic
  you run *because* no drive appears, so it must still produce a report.
- Dry runs cannot apply a serial map (serials need a live probe) and now say so
  explicitly instead of presenting a positional guess as the plan.

Every guard is mutation-checked: disabling it fails a named test. The
transposition limitation is pinned by a test so the API cannot quietly start
claiming more than it checks.
Follow-up to 4a8ae99, from adversarial review. Parsing I/E elements was right;
putting them in `MtxStatus.slots` alongside storage slots was not.

`SlotState` carries no is_import_export field, so the distinction is erased at
the library.py boundary and every consumer would have started seeing 12 slots
where it saw 8. The sharp edge is routes_aml_move_medium.py::_first_empty_slot(),
which returns the first UNOCCUPIED slot: on a full library - the normal state of
a production library - the first empty element is the operator mailslot, so an
unload would eject the cartridge to the front panel. routes_aml_library.py
would also have double-counted I/E slots in slotsTotal, on a parity-gated AML
response.

So `slots` stays "data storage slots you may park or unload a tape into",
`import_export_slots` holds the mailslot elements, `all_slots` merges them in
element order for anyone who wants both, and `slot_count` - the figure from
mtx's own header, which counts both kinds - equals the two lists together.

Nothing downstream changes behaviour; the information simply stops being
discarded, which was the actual bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Follow-up to 3d6009a, from adversarial review. The unmount wait was added but
nothing acted on its result, so the invariant it was supposed to protect was
still unenforced - just 60 seconds slower.

- `unmount()` computed `device_released` and threw it away: callers marked the
  drive UNMOUNTED regardless, and `can_unload_drive()` gates purely on that, so
  an unload could still be green-lit with LTFS holding the drive. Pulling a
  cartridge then can discard an index that has not been written. Success now
  MEANS the drive is free, not that umount exited 0. Judging on release also
  makes retries work: a second call whose umount says "not mounted" reports
  success once LTFS has gone.

- `wait_for_ltfs_release()` failed open. `_ltfs_processes_holding()` swallowed
  the /proc enumeration error and returned [], so under hidepid, in a
  container, or as a different uid, "I could not look" read as "released". It
  now returns None for that case and the wait reports False. For a gate
  protecting an unload, unknown must not mean safe.

- The timeout was 60s of blocking sleep, reachable from an async request
  handler (routes_ltfs.py). Observed release on the rig is well under a second;
  20s is now the bound, named as a constant.

- discovery.resolve_sg_device() logs a warning when no sg node can be found
  instead of silently returning the tape node - that fallback is exactly what
  produces LTFS's "No index found in the medium", i.e. it looks like bad media
  rather than a bad device path.

Adds the command-level tests review found missing: which argv format_tape and
both mount helpers actually build (a RecordingRunner, since the hardware suite
can only assert this against a live library), device_list's stderr/exit-1
handling, the unmount gate, and a procfs scan exercised against a real live
process named `ltfs`. Four new mutations all caught: dropping resolve_sg_device
from the three call sites (4 fail), device_list back to stdout+raise_on_error
(1), unmount judging on the exit code (2), unreadable /proc treated as released
(1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
… or library

Adversarial review found two data-loss paths and several over-broad ones. New
_rig.sh holds the shared guards so every script gets them.

BLOCKER - format-scratch.sh could format a data tape. find_slot() used an
unanchored awk `index($0, want)`, so any substring matched:

    exact OB0008L8 -> slot 7    # correct
    partial OB000  -> slot 1    # OB0001L8, a DATA tape
    suffix L8      -> slot 1    # matches everything
    empty ""       -> slot 1

`format-scratch.sh OB0008L8 ""` therefore formatted the scratch tape and then
OB0001L8. The script also never checked what was actually in the drive before
running mkltfs, which formats whatever it finds. Now: barcodes are validated as
6-8 upper-case alphanumerics (all of them, before anything is touched), matched
against the WHOLE VolumeTag, and after loading the changer is re-read so the
LOADED cartridge's VolumeTag must equal the requested barcode before mkltfs
runs. That last step is the positive barcode confirmation AGENTS.md requires;
the first two only decide what to load.

BLOCKER - reset.sh unloaded drives without checking for a live LTFS mount,
against the "never unload while LTFS is mounted or dirty" non-negotiable, in
the one new script that issues unloads. Worse because it exists to clean up
after FAILED runs, which is exactly when a mount has been left behind. Both it
and format-scratch.sh now refuse, list what is holding the drive, and print the
command to clear it - unmounting someone else's dirty volume is not a decision
a cleanup script should make.

Also:

- Scripts selected the changer as "first mediumx lsscsi reports". Phase 3 of
  the bring-up plan cables a real i3 to this same host; a real HBA usually
  enumerates at a LOWER SCSI host number than mhvtl's dynamic one, and a real
  Scalar i3 reports the same QUANTUM vendor string. They now key on the rig's
  unit serial and refuse to guess. Drives are restricted to the changer's SCSI
  host for the same reason. (mhvtl's Scalar personality prefixes VPD 0x80 with
  the vendor id, so the serial is compared as its last token, not loosened to a
  substring.)

- setup.sh/teardown.sh claimed to touch only what the rig created; /etc/mhvtl
  and /opt/mhvtl are mhvtl's OWN defaults and may hold another operator's
  library. Setup now backs up a pre-existing config before overwriting and
  moves library_contents.30 aside rather than deleting it; --purge removes only
  the media barcodes named in our library_contents.10 and restores the backup.

- MHVTL_REF pinned to the full 59f32ee SHA instead of `master`: patches/ is
  byte-exact against it and the recorded results are from that build. Fetch
  reworked to handle a raw SHA, which `git clone --branch` cannot take.

- make_vtl_media's fallback dropped --home-dir, so a failure for an unrelated
  reason wrote media where nothing else looked.

- The patch now carries a header explaining all three defects, the alloc_vpd
  arithmetic, and the truncation it makes explicit but does not remove.

shellcheck -S warning clean. Guards verified against the live rig: partial,
suffix and empty barcodes all refused; both scripts refuse while an LTFS mount
is held; happy path still formats both scratch tapes with confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Two of the findings were fixes that worked and were still wrong, which is worth
writing down: the I/E-slot flag (erased downstream, would have made the AML
unload route eject cartridges to the mailslot) and the unmount wait (computed a
result nothing read, so the invariant stayed unenforced).

Also records that the first mutation round was not enough - it covered the
parsers but not the plumbing around them - and that one assertion in the
hardware tests was vacuous in exactly the way the project rules name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
tests/hardware/test_performance.py defaults OPENBLADE_PERF_RESULTS_FILE to
.openblade_perf_results.json in the repo root, so running the hardware suite
left an untracked artifact behind every time. Noticed while running that suite
for the first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Section 4 now reports findings in library.py AND sg.py, both owned elsewhere;
the title said library.py only. 3.8 was an H2 numbered as a subsection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
… it were

Both new docs are Phase 2 of docs/runbooks/real-i3-bringup-plan.md, but that
file only exists on chore/py312-baseline-and-quantum-refs (1d8c565) - the
relative links resolved to nothing on this branch. Reference it by path with
the git show command to read it, rather than shipping two dead links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…list), gap comment retired

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…verbatim

Unknown-source loaded drives, unlabeled media, storage elements at 4096+/
I/E at 768+: parse verbatim and reach the mtx command line unchanged — no
renumbering or range assumption anywhere between parser and robotics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
… guarded writes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…rage work

The 2026-08-22 session (checkpoint caca9241) left a CI job and README
sections describing a Flask NAS UI and an iBlade parity-coverage generator
whose implementing files are still untracked. Landing that tail alone would
have pinned CI red: verified against a clean HEAD tree, the parity generator
exits 1 on a missing, untracked emulator_contract input, and the
web-flask-smoke job mypy/pytest/builds a package that does not exist there.

Those four files are stashed rather than discarded; this note explains what
the work was, why it could not be finished inside a tree-reconcile task
(the parity half is T3 per CLAUDE.md), and how to recover and land it with
its prerequisites.
Seeded generator producing 1,073 entries / 429.6 MiB: nested dirs (unicode and
spaces in names), ~1,000 small text files, 60 mid-size binaries, 6 clips of
10-45 MB, one 120 MB blob for BLOCK_STRIPE, 5 empty files and a symlink.
Content depends only on the seed, so the manifest reproduces byte-for-byte on
the real i3 later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Amantux and others added 28 commits September 12, 2026 02:08
74 tests across four files.

tests/unit/test_mailslot_service.py (28) -- slot listing, import with an
auto-picked and an explicit destination, export refusal, forced export, the
catalog offline flag, the audit trail, and the simulator's I/E state
(element numbering, JSON round trip, old snapshots without a mailslot,
seeded high element addresses).

MUTATION-CHECKED, both guards, restored byte-for-byte afterwards:
  * delete `if assessment.carries_data: raise` in _guard_export ->
    test_export_refuses_a_cartridge_carrying_data,
    test_export_refuses_when_a_sibling_tape_in_the_group_holds_data,
    test_a_refused_export_still_leaves_an_audit_row, and the CLI's
    test_export_refuses_a_cartridge_with_data_and_names_it all fail (4),
    while test_export_with_force_moves_the_cartridge_anyway still passes.
  * make the catalog-less branch `return` instead of raise ->
    test_export_through_a_catalogless_repo_fails_closed fails (1).

tests/unit/test_tree_restore.py (15) -- a seeded three-tape catalog. Proves
the tree is preserved (alpha/same.txt and beta/same.txt do not collide),
that prefix matching is component-aware (/photo must not drag in
/photos-old), that a block_stripe file spanning tapes comes back whole, that
one offline cartridge fails that file and the job but not the run, and that
failure text is curated (no raw device paths from an OSError).

tests/unit/test_cli_new_commands.py (23) -- CliRunner. Every success case
parses stdout as one whole JSON document; progress lines and the
chosen-slot narration are asserted present on stderr and ABSENT from
stdout. Also pins the bare `restore --path/--to` and `archive
--volume-group/--path` invocations that scripts/campaign/ runs.

tests/integration/test_sharded_archive_cli_api_parity.py (2) -- drives
run_sharded_archive through the API's own call shape and through the CLI
over identical trees on identical libraries, then diffs the catalog rows:
paths, sizes, checksums, shard counts, profiles, block sizes and the
per-shard tape layout. Both modes. A default that drifts on one side fails
here.
This repo tracks 66 __pycache__/*.pyc files (pre-existing). `git add -A` in the
/assist commit swept up a regenerated openblade/api/__pycache__/main.cpython-310.pyc.
Restored to master's blob, so the branch carries no bytecode churn.
The library cross-check warns instead of refusing when NO declared serial
appears in the library's drive list, because that is indistinguishable from the
two sides spelling serials differently. The warning said only "not
cross-checked", which undersells the other possibility: the controller may be
driving a different library than the drives attached to this host, and then a
load puts a cartridge into an element whose drive is not here.

Both the warning text and the two module docstrings now name that case and tell
the operator how to settle it. No behaviour change — the refusal rule (partial
overlap refuses, disjoint warns) is unchanged.
A new runbook rather than an edit to real-data-campaign.md, which stays as
the record of what the campaign found. This one covers the three items its
"Does not exist" table listed that now have an operator surface, with the
mhvtl transcript for each: list/export/import round trip confirmed against
`mtx` element by element, a 4-file tree restore verified byte-for-byte
(including the two same-named files in different subdirectories that a
basename-only restore would have collapsed), the export refusal with exit 1
and empty stdout and the media not moving, and a block-striped archive over
three lanes reassembled back byte-for-byte.

Section 4 is the honest half: per-file load/unload is unchanged because the
tree restore reuses the existing per-file services rather than
reimplementing them; sharded archive still catalogs under the absolute
source path; `--lanes` often finds nothing on a fresh volume group; and
docs/wiki/reference/cli.md is stale on this branch by design -- it is a
generated artifact owned elsewhere.
Found by the path-escape test added alongside it, which is the point of
writing that test.

`_relative_parts` strips `""`, `"/"`, `"."` and `".."` from the components
it joins onto `--dest`, then fell back to `path.name` when nothing survived.
For the catalog path `/vg/..` that basename is literally `".."`, so the
fallback put the escape straight back and the destination became
`<dest>/..`. Catalog rows are not trusted input here: `create_file_record`
normalises with `PurePosixPath`, which does not collapse `..`.

Now it raises the typed `UnsafeCatalogPathError` instead. The CLI already
curates OpenBladeError through `safe_job_error`, so the operator gets the
message and exit 1 rather than a surprise write outside the destination.

The test asserts the real invariant -- never outside `--dest` -- accepting
either a contained path or a typed refusal, with a separate test pinning
that the traversal-only case is the refusal.
…the mailslot

The defect-3.9 shape from the other direction. `_find_empty_slot` picks
"the first empty slot" for an unload; now that I/E elements exist and are
numbered past the storage slots, a library with no free storage slot must
refuse rather than fall through to the mailslot and put a loaded cartridge
on the front panel. It does -- both backends keep I/E out of
`inventory().slots` by construction -- and this pins it.
The middleware asked "is auth enabled?" twice: once via resolution.enabled
and again via a `token is None` guard that mypy needed for narrowing. The
second was unreachable, but it called call_next() -- an unreachable
fail-OPEN branch in the one function that decides whether a request is
authenticated, one edit away from being reachable.

Now a single `expected is None` check does both jobs. No behaviour change;
268 auth tests still pass.
Adversarial review of the diff; all three verified by repro in the
simulator before and after, and mutation-checked after.

1. **Defect 3.9, reopened by my own op type.** `_guard_export` assessed
   `request.barcode` while `_export` moved whatever was in `request.slot_id`.
   `POST /tape-ops/execute` binds both from the request body, so naming a
   blank cartridge and pointing `slot_id` at a loaded slot ejected a tape
   holding archived files past a guard that had just reported "carries no
   data" -- and the catalog still called it online. Reproduced end to end.
   The source slot is now resolved FROM THE BARCODE; `slot_id` is only ever
   a cross-check, and a disagreement is refused. Import refuses likewise
   when the element does not hold the barcode the request names.

2. **`ie_slot` reached `mtx transfer` unvalidated.** `_dest_slot` refuses an
   unvalidated MOVE destination with a comment explaining exactly this
   incident; the new op types skipped it. `ie_slot` is now checked against
   the library's own `import_export_slots()` and the destination storage
   slot against `inventory().slots`, both for occupancy too. Typed as
   `ImportExportSlotError` so the specific, device-path-free reason survives
   the error curation instead of becoming "Tape export operation failed".

3. **The catalog `exported` flag was written only by MailslotService**, so
   an export through `/tape-ops/execute` moved the media and left every
   restore path believing the data was online -- the exact failure the
   service's own comment warns about. Moved beside the move, in the
   orchestrator, where the repo is already in hand.

Also: IMPORT/EXPORT now take the barcode lock (a concurrent restore could
load the tape between resolve and move), and `assess_export` no longer
refuses a cartridge that carries nothing just because a SIBLING in its
volume group does. That reasoning was wrong -- a shard is a file instance,
so a tape holding part of a striped file is caught by the real check -- and
it refused routine scratch-tape rotation with the false sentence "still
carries archived data". A guard that cries wolf is a guard that gets
`--force`d. Sibling data is still reported, as context. In exchange the
guard now also refuses while a `pending` write is in flight to the tape.

Tree restore: catalogued records with nothing archived are reported as
`skipped` instead of silently dropped from a run that says "completed";
an interior `..` is refused rather than stripped (stripping made
`/vg/a/../x.txt` and `/vg/x.txt` collide); and a sharded-restore error
keeps its curated text instead of being flattened to "Job failed
(RuntimeError)".

CLI: `restore file --dest` treats a non-existent path as a file unless
`--into-dir` is passed (`is_dir()` is false for a directory that does not
exist yet, so `--dest /tmp/outdir` wrote a *file* named `outdir`); the
catalog lookup moved ahead of the job row so a typo leaves no orphan job;
skipped files are called out on stderr; and a library with no mailslot now
says so instead of claiming the mailslot is full.

Tests: 11 new, attacking the orchestrator directly rather than the service,
since that is the surface `/tape-ops/execute` exposes. Mutation-checked
(each restored byte-for-byte): re-trust `request.slot_id` -> 1 fails; drop
the `ie_slot` validation -> 1 fails; drop the catalog state write -> 4 fail.
`test_a_library_with_no_mailslot_refuses_by_type` was passing for the wrong
reason (it asserted only the exception class, which the "all slots
occupied" branch also satisfies) and now asserts the message.
Re-ran every flow on the rig against the reviewed code (0463867) and
updated the transcript: the refusal text changed, the summary gained
filesSkipped/skippedPaths, and there is now a second export transcript
showing a blank tape rotating out of an active volume group WITHOUT being
refused — the half of the guard that keeps the other half credible.

Section 3 records that the first draft of these op types reopened defect
3.9 on the API surface and that only an attack on that surface found it;
section 4 adds the deliberate non-fix (`--force` is a plain flag where
`format` demands a safety token) so the next person decides it rather than
inherits it.
…rving the app

Adversarial review found a wrong-drive write this feature was built to prevent.

**The bug.** `drive_device(drive_id)` is asked about the LIBRARY's element
address — `inventory()` builds it from `GET /aml/physicalLibrary/elements` ->
`address`, and that is what `ltfs.mount()` passes in via
`find_drive_by_barcode`. But `OPENBLADE_DRIVE_SERIAL_MAP` is documented in
`correlation.py` as *mtx* Data Transfer Element numbering, 0-based, and the i3
web UI numbers drive bays from 1. Nothing reconciled the two number spaces. A
map that is merely shifted passed the sg_inq check AND the library serial
cross-check — both report success — and then handed back a neighbouring drive's
device. Silent LTFS write to the wrong cartridge, which is exactly the failure
`_UNVERIFIED_CORRELATION` refuses positional order to avoid.

Fixed by `_refuse_on_element_address_mismatch`: the declared element ids must
EQUAL this library's drive element addresses, checked against a live
`inventory()` before the correlation is cached. Equality both ways — a declared
element the library lacks means the wrong number space; a library element the
map omits would have a cartridge loaded into it with no device to mount it,
which `correlate_drives` already refuses for the SCSI backend.

The emulator test that should have caught it was a tautology: it invented the
element ids with `enumerate()` and asserted against the same range, never
reading a real element address. It now derives them from `inventory()`, and
there are new refusal tests plus a mutation check proving the guard is what
stops the wrong-drive result.

Also from the review:
- `library_drive_serials` caught only `ScalarHttpError`, so a connect/timeout/TLS
  failure escaped as a raw httpx error — breaking its "None means unreadable"
  contract and putting upstream text (which for OPENBLADE_SCALAR_URL can embed
  credentials) into a traceback. Now caught; the log records the exception TYPE,
  not its text.
- The refusal message told operators to read element ids off `connect-i3`, an
  mtx command that cannot run on this transport. It now names the AML elements
  endpoint, which is the actual source of truth.
- `build_drive_correlation`'s docstring claimed the AML backend passes
  `element_count`; it does not and cannot. Says so, and points at the stronger
  check that replaces it.

/assist, same review:
- It was the app's only sync route handler, so it borrowed from AnyIO's shared
  40-token default threadpool and held a token for up to max_rounds x timeout
  (12 min). A few requests could starve every sync dependency in the process —
  including the AML emulator surface. Now `async def` with an explicit hand-off
  under its own small capacity limiter, and a bounded queue wait that sheds load
  with a curated 503 before calling the model.
- Each turn now gets its own DB session. `AppContext.catalog` wraps one
  process-global SQLAlchemy Session; the assistant reads it from a worker thread
  and calls `expire_all()` between rounds, which could expire rows out from under
  a request on the loop thread. `inventory_service` holds no session, so it stays
  shared.
- A failed read-only guard leaked an httpx.Client per request.
… sweep

Adversarial review found two blockers; both were proven live before and after.

1. BYPASS: /aml/proxy/libraries/{id}/probe was exempt. It is mounted under
   /aml but is NOT emulator wire contract (not a matrix endpoint; it 404s in
   scalar_api_only mode) -- it is an OpenBlade-native route that opens an
   outbound connection to an operator-supplied host:port and relays a
   username/password. The only thing in front of it was the AML session, whose
   shipped default credential is admin/password. Proven reachable with no API
   token; now 401 without one, 200 with. The prefix is a mount point, not a
   statement of ownership, so classification gets an explicit native-exception
   list rather than a bare string prefix.

2. VACUOUS SWEEP: 64 of 190 swept operations carry their own
   Depends(require_auth) and already answered 401 with this layer removed, so
   asserting status alone tested nothing for a third of the surface. The sweep
   now asserts the native 401 BODY, which is distinguishable from the AML
   layer's. Deleting the middleware went from 134 failures to 201.

Also fixed from the same review:

- Classify on the path the router matches (scope path minus root_path), not a
  prefix re-derived from X-Forwarded-Prefix. Under `uvicorn --root-path /ob`
  the old code gated /ob/health and /ob/aml/... -- fail-closed, but it broke
  the health exemption and the AML contract in a supported deployment.
- The 401 now carries nosniff/X-Frame-Options/Referrer-Policy. add_security_
  headers is registered earlier and so never saw a short-circuited rejection,
  making the 401 the only response in the app without them.
- Tests for the two invariants that were comments: middleware registration
  order (scope enforcement must stay outermost so emulator-only mode 404s),
  and that no gated path has an exempt-looking tail, which is what makes
  trusting X-Forwarded-Prefix safe.
- i3 conftest uses the cached token accessor instead of re-reading the file
  per request, and now raises a named error instead of silently destroying a
  Basic-auth AML credential it cannot carry.
- Docs: /aml/proxy row added; corrected a false claim that a trailing space in
  the token-file path fails startup (it is stripped); documented that browsers
  see a CORS error rather than a readable 401.
Second adversarial review found a real bypass in the fix for the first one.

classification_path() fell back to stripping X-Forwarded-Prefix when the ASGI
scope carried no root_path -- i.e. in every deployment not started with
--root-path. The header is client-controlled and the strip matched on segment
boundaries, so an attacker could reduce the CLASSIFIED path to any suffix of
the real path while Starlette still ROUTED on the original:

    GET /catalog/aml/instances    X-Forwarded-Prefix: /catalog
    -> classified "/aml/instances" (exempt), routed to /catalog/{file_id}

53 native operations answered something other than this layer's 401 that way.
They stopped at 404/405 only because no resource happens to be NAMED "aml" --
a pool, dataset, share or library with that id turned each into live
unauthenticated access, and the same trick reopened /aml/proxy.

The docstring's safety argument was the tell: it reasoned about route
TEMPLATES ("no native route is mounted at a path whose tail is /aml") and
forgot that parameter VALUES are attacker-supplied. The test I wrote to pin
that invariant had the identical blind spot -- it walked template tails, so
/catalog/{file_id} yielded /{file_id} and passed while the hole was live. 274
tests were green over a working bypass.

Fix: classify only on scope["path"] minus the SERVER-set scope["root_path"],
and delete the header branch entirely -- classification_path no longer takes a
header argument at all. Accepted consequence: behind a proxy that announces
its prefix only via X-Forwarded-Prefix, the AML surface is classified native
and gated. That fails closed, is immediately visible, and is fixed with
--root-path (server-side config, not a forgeable header).

Tests replacing the blind-spot one:
- live attack table asserting the forged header cannot change the outcome
- every parametrized native route re-probed with "aml"/"iblade"/"health"
  substituted into its path parameters
- classification_path's signature is pinned to (scope) so nobody reintroduces
  the header as a convenience

Also from the same review: restore the previous app context on fixture
teardown instead of leaking it into later modules; correct the fixture comment
that claimed no writes reach handlers (the mutation test deliberately makes
them); cover require_auth's bearer fallback directly, since the i3 hook moves
the session to a cookie in token mode and cannot see a regression there; and
fix three doc claims (exemption count, the vacuous description of the sweep,
"byte-for-byte") plus document the --root-path requirement.
`ruff>=0.6` in the dev extra let CI resolve whatever was newest on PyPI
(0.16.7 today) while developer venvs sat on 0.15.22 — three enforcement
points (hook, venv, CI) that could disagree at any time.

Measured both candidates against this tree first: 0.15.22 and 0.16.7
produce an identical 13 `ruff check` findings and an identical 176-file
`ruff format` set, because `select` is already explicit so 0.16's widened
defaults never apply. Churn is zero either way, so pinned to 0.15.22 —
the version `.venv` already carries, so no worktree reinstalls. CI needs
no workflow edit: it installs ruff only via `pip install -e '.[dev]'`.

Also documents that 27 `# noqa` markers reference unselected rules
(BLE001 x23, S3xx/S4xx x4). Enabling BLE = 44 findings, S = 3,748; both
deferred as scoped follow-ups rather than silently expanded here.
`ruff check --fix` offered ZERO safe autofixes on this tree (the "4 hidden
fixes" are all --unsafe-fixes), so every one of these was made by hand and
reviewed individually. No mechanical-autofix commit exists as a result.

B904 x4 (routes_aml_operations.py) — the two int() parse guards raise
`from None` deliberately: ValueError/TypeError text can echo the caller's
raw payload and that detail string is client-facing. The two that already
surface `str(exc)` chain with `from exc`, matching what they expose.

SIM103 (routes_aml_operations.py) — `_is_missing` returns the condition.
SIM105 x2 (protocol_gateway.py, test_job_error_sanitization.py) —
contextlib.suppress; the gateway one also drops a now-inapplicable
`# noqa: BLE001` since there is no longer an `except` clause.
SIM102 (tests/i3/conftest.py) — collapsed nested if.
E702 + E741 x2 (scripts/security_agents/check_emulators.py) — split the
semicolon statement, renamed the ambiguous `l` to `lib`.
F841 (test_cli_backend_selection.py) — removed a dead `console_script =
None` assignment that its own comment says the -m invocation replaced.

All twelve are behaviour-preserving: no response body, status code, or
control flow changes. Remaining: 1 SIM222, handled in the next commit.
The only finding that is not a defect. `assert X or Y or True` in the
partial-restore fault test looks like a forgotten debug hack; it isn't.
The load-bearing assertion is the status-code check above it (a missing
tape must not 500), and the 200-body field name is outside the
manual-documented matrix, so a conforming emulator may report it
differently. Tightening the assert would fail the i3 suite against a
compliant implementation.

`# noqa: SIM222` with that reason written at the site, per the rule that
a noqa is never bare. `ruff check .` is now clean: 13 -> 0.
CLAUDE.md's command list described `make lint` as "(ruff)" with no word
about versioning, and docs/tech-debt.md still described a ~180-finding /
~160-file backlog that no longer exists — stale rules are worse than no
rules.

CLAUDE.md gains a short lint-toolchain section: the pin is exact and
load-bearing (CI installs ruff only through the dev extra), `ruff check`
is expected to be 0, noqa needs a written reason, format lands alone.

tech-debt.md's lint entry is rewritten as history, recording that the
~180 count fell to 13 because `A002` was ignored exactly as that entry
recommended, and listing the two things still outstanding: the assistant
package's single unformatted file, and the 27 inert noqa markers for the
unselected BLE/S families (measured at 44 and 3,748 findings).
…rmat-only + build artifacts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…he clean baseline

The two routes_aml_operations fallback handlers served raw exception text
(argv+stderr class) at an unauthenticated-by-default boundary — routed
through safe_job_error with a source-shape regression test. backend-lint
loses continue-on-error now that the pinned toolchain reports zero findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
Integration collision: the new MailslotService read library.inventory()/
find_slot_by_barcode() directly — invisible on its branch, caught by the
import guard on the merged tree. Same resolution as the assistant's earlier
SAFETY_003 fix: the service that exists is the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
The contract validator has failed on every branch since PR #10 removed the
emulator service from the root compose — all three i3-emulator CI suites
died at that preflight for two months. Restored as an off-by-default
profile pinned to the contract's GHCR image. typer/click pinned to the venv
versions: CI resolved a newer typer whose bare-group invocation rendered a
usage error instead of the callback (the exact toolchain-skew class the
ruff pin closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
…light, boot-order in change-gates

CI's color-forcing terminal exposed a real defect: rich print_json
colorized stdout, so 'openblade … | jq' breaks under FORCE_COLOR — all 14
data-channel sites now emit plain JSON (emit_json). All CLI consoles get
highlight=False (auto-highlighting split tokens like [1/3] mid-span);
bare-group guidance echoes plainly instead of a width-wrapped BadParameter
panel; unit conftest pins NO_COLOR/COLUMNS so tests are byte-stable; a
FORCE_COLOR=1 run now matches a plain run. Also reorders the
emulator-change-gates job: its server boot always came AFTER the pytest
step that dials it — masked for months by the contract validator failing
first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2
@github-actions
github-actions Bot merged commit bd4f3b1 into master Sep 13, 2026
21 of 22 checks passed
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.

1 participant