Repository navigation
Hardware bring-up program: assistant tiers, auth, capacity, mailslot/restore CLI, FUSE, TapeAlert, CI gates, clean lint baseline - #44
Merged
Conversation
…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
…he real i3 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integrates eight workstreams built and verified against the local mhvtl rig (3 drives, real LTFS) and the in-repo i3 emulator:
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.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.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.hardware drive-healthwith T10-spec severity table.perform()not the prompt; 28/28 mutations killed; live-rig transcript in the wiki.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 lintgreen for the first time.First real runs to watch here:
wiki-reference, blockingbackend-lint,mhvtl-weekly(dispatch).🤖 Generated with Claude Code
https://claude.ai/code/session_013S1qbQs31YSox1azQChqs2