Skip to content

Persistent drive leases, reconciliation, NAS persistence, protocol rig, compat capture, MFA pending, capacity budget - #46

Merged
github-actions[bot] merged 242 commits into
masterfrom
feat/persistent-drive-leases
Oct 11, 2026
Merged

github-actions[bot] merged 242 commits into
masterfrom
feat/persistent-drive-leases

Conversation

@Amantux

@Amantux Amantux commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Summary

Three fleet rounds on top of the persistent-drive-leases work. Everything below is verified locally (sharded gate 2909 passed / 0 failed / 14 skipped; live i3 suite 175 passed; protocol rig 33/33 x3; contract 24 passed; ruff 0 / format clean / mypy strict 0; actionlint + zizmor clean; cross-repo emulator contract validation passes).

Round 1–2 (safety + persistence)

  • Persistent drive leases + physical_state_unknown journal with a real consumer: drive and barcode refusal in the scheduler and every load helper, POST /jobs/recovery/reconcile/{drive}, fail-closed when the journal write itself fails (lease is held, typed JournalWriteError).
  • JobQueue persisted over catalog jobs + leases, heartbeated, reconciliation-gated.
  • NAS domain persisted (pools protection/replication, reservations with atomic reserve, export sets, vaults, VG replication, file spans).
  • scsi+fake-ltfs contract pairing over a stateful mtx fake; SMB cross-session lock conflict and NFS mode-bit scenarios on the containerised rig.
  • FUSE cache open-time integrity record + persisted LRU index + chunked pread.
  • Emulator-side fault injection (OPENBLADE_EMULATOR_FAULT_PROFILE); mutation baseline for 6/6 safety modules.

Round 3 (this push)

  • NFSv4 ACLs are a real scenario: Fedora 41 Ganesha 5.9 stage with VFS ACL mapping (pinned); a named-user DENY ACE is enforced across uids over NFS.
  • Capacity: pool-level reservation budget on /nas/archive-plan; budget and capacity are server-derived from inventory + catalog (client-supplied tape_capacities/available_tapes with pool → 422, unknown pool → 404); booking happens at POST /nas/ingest/start / share-push (409, no job), released on terminal state or executor exception without masking the original error. Scratch health component counts scratch from library inventory; GET/PUT /nas/scratch-thresholds.
  • Compatibility capture: tools/compat_capture.py capture/diff/sanitize (refuses source: captured without firmware + attestation); 12 self-captured emulator regression cases, 0 drift against the inferred set.
  • MFA-pending session modelled in the AML emulator within the manual-documented scope (no new routes/fields; Warning: 199 - "MFA Required" header only; 401 on everything but the MFA challenge/key/logout until a valid TOTP). Labelled inferred.
  • Phase 7: bridge test added to the mhvtl lane; roadmap corrected (bridge implemented, scalar_http no longer raises).

Reviewer

Adversarial review ran on every round. Round 3's blocker (caller-controlled pool budget) and all five should-fixes are closed in this push; every new guard is mutation-checked.

Still open (recorded in docs/tech-debt.md / roadmap)

  • Appliance-gated: real captures, drive-identity bridge certification (mhvtl has no AML endpoint), MFA wire shape, hard power-cycle persistence.
  • Soft-reboot automation for the destructive lane is drafted but not committed pending an operator decision.
  • book_plan_capacity books against plan.pool, not the start request's pool_id; share-push leaves a registered plan with no job on 409.

🤖 Generated with Claude Code

https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

Amantux and others added 30 commits October 9, 2026 06:33
…ery (roadmap 7/8)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Adds DriveLeaseRecord (new table via create_all), the frozen DriveLease
domain dataclass, and CatalogRepository acquire/heartbeat/release/
lease_is_live/live_leases/release_leases_for_job. One live lease per
drive and the monotonic fencing token are enforced under SQLite
BEGIN IMMEDIATE (verified against installed SQLAlchemy 2.1.4/pysqlite).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…rtbeat

DriveScheduler keeps its public shape but delegates to a LeaseStore
protocol: InMemoryLeaseStore (default; existing behaviour for unit
tests) and CatalogLeaseStore (shared across processes via the catalog,
polled at 0.25 s). DriveHandle gains lease_id/fencing_token; verify()
raises the new typed StaleLeaseError, heartbeat() extends the ttl.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…tep 3)

verify() before each READ_WRITE mount (both stripe paths), before the
shard write batch, and before unmount/unload+commit; heartbeat once per
batch after the writes. StaleLeaseError aborts the job (re-raised past
the batch's generic handler); the existing finally still unmounts and
unloads. archive.py/restore.py/tree_restore.py, the six construction
sites and AppContext wiring are NOT done yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ock-stripe writes

- AppContext gains lease_store (CatalogLeaseStore over context.catalog).
- The six DriveScheduler construction sites (archive/restore routes, CLI
  restore, restore-tree, archive-sharded) pass store= and job_id=; the CLI
  sites now build the scheduler after their catalog job exists. The tree
  restore route's worker thread uses a CatalogLeaseStore over its own
  session (the context session is not thread-safe).
- sharded_archive block-stripe path: verify before the shard write batch and
  before unmount/unload, heartbeat after writes.
- store stays optional on DriveScheduler: ~40 test constructions omit it.
- Integration test: a lease held through a second catalog session makes
  POST /archive/sharded wait until it is released (mutation-checked).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…eport-only)

Bootstrap hook and GET /jobs/recovery route not yet wired.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ET /jobs/recovery

Recovery runs right after the catalog opens, before anything can claim a
drive. The report is read-only on the native surface: bearer-gated by the
existing middleware (the auth sweep enumerates it) and 404 under
OPENBLADE_SCALAR_API_ONLY. Wiki API reference regenerated for the new route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…tation deviations in the spec

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ed one

A lease poll from a worker thread could commit or roll back another
thread's pending rows on the shared Session. Lease ops now open a
short-lived Session over the engine; on a file-backed catalog that is a
separate connection, so BEGIN IMMEDIATE is a real cross-process lock.
Adds expired_leases() and set_lease_physical_drive() for recovery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…process may own

The catalog is shared by the API and the CLI, so 'state == running' is
not evidence a job is dead. Recovery now fails only jobs whose lease
stopped heartbeating, releases live leases of already-finished jobs as
bookkeeping, and leaves everything else alone. Inventory read goes
through InventoryService (SAFETY_003). Mutation-checked: the test fails
if recovery regresses to failing every running job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…t; persist physical drive

- On StaleLeaseError the error path no longer unmounts/unloads: the drive
  may already hold the new lease-holder's mounted tape. Physical state is
  logged as unknown for reconciliation. Mutation-checked.
- DriveScheduler keeps held leases alive from a daemon thread (ttl/3), so a
  single long LTO write cannot expire its own lease. Mutation-checked.
- record_physical_drives() persists where a pre-loaded cartridge actually
  is, so the recovery report reconciles the right drive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…cleanup rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Sharded-archive lifecycle states. Stored as plain strings in
file_instances.state (no CHECK/enum type), so no migration is needed;
every catalog 'archived/restorable' filter is an explicit allowlist of
{archived, verified}, so the new states are excluded by construction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Striping is a performance choice; only redundant copies make a policy
protected. Erasure coding is modelled but rejected by require_plannable().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
postcheck --read-only-appliance-checks wraps every probe in ReadOnlyProbe so
no non-GET request reaches the appliance, then GETs /inventory/ and
/jobs/recovery. rollback --to <digest> refuses tags, redeploys application
code only via OPENBLADE_IMAGE, and prints the tape-state notice before and
after. Both guards mutation-checked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ore service

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ion, unmount shutdown

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ver the Hydrator

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
… chain

finalize_tape_generation() is the single place that orders metadata writes:
manifest -> catalog-shard -> tape.json -> commit-<generation>.json. Manifest v2
adds generation_id, sibling_tapes, placement and protection; v1 still parses,
unknown schemas raise UnsupportedManifestSchemaError. TapeJson gains
current_generation/previous_generation (its existing 'generation' is LTO media).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…staged deploy

build -> scan (pip-audit + trivy HIGH+, SBOMs) -> sign (keyless cosign +
SLSA provenance, id-token only here) -> verify -> release + staging (suites,
read-only postcheck, rollback) -> production behind environment approval.
All actions pinned to SHAs; digest-pinned compose examples for both envs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Amantux and others added 28 commits October 11, 2026 03:32
…rd cases replayable

The end-to-end capture leaked a session token in an application/xml login body
and an expires= timestamp in Set-Cookie. Redaction also collapsed right/wrong
passwords; non-login passwords now become <redacted-invalid>. Directory recipes
skip the tool's own outputs. XML rule mutation-checked (removing it fails 1 test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
… to harness

self-captured is treated like inferred (regression guard, never fidelity);
redacted request passwords replay with the emulator default / a wrong value.
--diff over the 15 existing inferred cases showed 0 drift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…pture loop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ive-plan

Route the upload plan through plan_with_pool_reservation and map
ReservationError to 409. Mutation-checked: bypassing the helper fails both
new tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…logout

require_auth now rejects an MFA-pending session with the existing 401
(cookie kept); POST /users/login/mfa, GET /users/login/mfa/{type}/key and
logout use require_auth_mfa_pending_ok. A valid TOTP code clears the flag;
an invalid one keeps it. Inferred behaviour, mutation-checked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Add CatalogRepository.delete_nas_reservations_for_owner and call it from
run_ingest_job once the job reaches archived/failed/cancelled (the job
carries the plan, whose plan_id is the reservation owner). A non-terminal
exit keeps the booking until its 1h expiry. Mutation-checked: removing the
release fails test_finished_job_releases_its_plan_reservation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Corpus cases may declare "setup": {"mfa": {"user", "type"}}; the harness
enables TOTP for that user via GET /aml/users/login/mfa/{type}/key +
PUT /aml/users/mfa before replaying the request. Adds the inferred
aml-auth-login-mfa-pending case (200 + Warning "MFA Required") and
corrects logout.json fidelity_notes, which still called MFA,
session-expiry, max-sessions and default-password states unmodelled.

Mutation-checked: with the setup call disabled the case fails
(Warning lacks "MFA Required").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…elements

- token/serial/password-class keys now mask every scalar leaf beneath them
  (a list/dict value was previously walked and leaked, e.g.
  {"x-auth-tokens": ["eyJ..."]}).
- _scrub_xml matches elements that carry attributes and scrubs attribute
  values whose name hits a key rule (sessionId="...").
- Basic credentials were already covered by the Bearer|Basic rule; now tested.

Mutation-checked: reverting the list masking, the attribute pass, the
element-attrs pattern, or dropping Basic each fails the new tests.
Self-capture re-run over the 12 aml recipes: no sanitizer-attributable
changes (sanitize() is identity on all 12 committed files), so the corpus
is not regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ts (B1)

With `pool` set, /nas/archive-plan and share-push now source available_tapes
and tape_capacities from library inventory (InventoryService) + catalog via
nas.capacity.server_budget_request. Body-supplied available_tapes /
tape_capacities / reserved_bytes / pool_reserved_bytes -> 422; unknown pool
-> 404. The planner's pool budget check keys on `pool`, not on a non-empty
tape list, so an empty server tape set blocks instead of skipping.
Replaces routes_upload._available_data_tapes (which used library.inventory()
directly and silently dropped all catalog barcodes via a dead dict branch).

Mutation-checked: disabling the field refusal fails the 4 x 422 tests;
disabling the pool lookup fails the 404 test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
… too

"MFA verification required" was invented text on an undocumented surface.
Strict mode now answers 412 only for the documented default-password
condition, else 200; MFA is signalled solely by Warning: 199 - "MFA Required".
Tests: strict+default-password keeps the documented 412 summary; strict
without it is 200 with only the MFA warning (fails if MFA re-enters the
412 condition — mutation-checked). Compat note and contract-notes row updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Verified on the pinned digest:
  docker run --rm fedora@sha256:f1a3fab4... dnf list --available nfs-ganesha nfs-ganesha-vfs
  nfs-ganesha.x86_64     5.9-4.fc41 fedora
  nfs-ganesha-vfs.x86_64 5.9-4.fc41 fedora
Globs are quoted so the RUN shell cannot expand them. docker build
--target nfs-ganesha deploy/nas-protocols succeeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
plan_with_pool_reservation -> plan_with_pool_budget (budget only). New
book_plan_capacity books total*copies under owner=plan_id with a
server-derived capacity ceiling; /nas/ingest/start and share-push call it
before start_ingest_job and map ReservationError to 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…om pool budget (S1)

count_scratch_cartridges counted catalog rows, but blank tapes are not rows.
count_scratch_tapes counts inventory slots not in a volume group and not
catalogued as written; server_data_tapes also skips VG members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…3 test)

run() converts failures to FAILED, but if it raises anyway (e.g.
_mark_dataset_failed on a session in PendingRollbackError) the job can
still be ARCHIVING, so the terminal-status check skipped the release and
the booking sat until TTL. An escaping exception now releases too.

Tests: executor raises + release raises -> RuntimeError surfaces and the
"reservation release failed" warning is logged; executor raises with a
non-terminal status -> booking released. Mutation-checked: removing the
try/except fails the first; dropping the raised-path fails both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…scratch

Capacity reservations decision: client capacity fields 422 / unknown pool
404 when pool is set, booking at ingest start (409, no job) not preview,
release on terminal state or executor exception, scratch counted from the
library inventory. Open risks: booking uses plan.pool not the start
request's pool; share-push 409 leaves a registered plan with no job.
tech-debt adjusted to match. Wiki API reference regenerated: unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…test

B1 makes /nas/archive-plan derive the tape set server-side when `pool` is
set and reject client-supplied tape fields with 422; the end-to-end
archive/recall test still posted `available_tapes`, so it failed 422.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…t ingest start, inventory scratch, safe release
…quest

With `pool` set, /nas/archive-plan now rejects client-supplied
available_tapes/tape_capacities (422) so the budget cannot be inflated; the
demo was still sending the tape list and failed on the first run of the gate.
The unused `get_context` import that fed it is dropped too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
@github-actions
github-actions Bot merged commit 069694c into master Oct 11, 2026
27 checks passed
github-actions Bot pushed a commit that referenced this pull request Oct 11, 2026
…s, appliance preflight, reboot setting (#48)

* ci(auto-merge): wait for every required check and dispatch master lanes after merge

Trigger on workflow_run of both CI and operability. Before merging, read the
required contexts from branch protection (fallback ["ci-gate",
"operability-gate"] on 403/404, which GITHUB_TOKEN normally gets) and merge
only when every one is "success" on the PR head SHA (check runs, newest per
name, plus combined commit statuses). Pending -> notice and exit; the other
workflow's completion is the retry (PR #47 merged-while-in-progress failure).
Failed -> notice and exit. The merge pins sha=head to avoid racing a push.

After a successful merge, dispatch ci.yml and operability.yml on master: a
GITHUB_TOKEN merge emits no push run, so full-suite never ran on the merge
commit (PR #46). Job grants checks:read, statuses:read, actions:write
per-job; workflow-level stays contents:read.

decide() is a pure function with three worked examples in comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(readme): document trusted auto-merge two-workflow wait and post-merge dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(safety): consume format safety token atomically before the job

delete_safety_token now runs DELETE under BEGIN IMMEDIATE and returns
whether this caller consumed the token; FormatService.confirm consumes
first and refuses on False, so concurrent confirms with one token run
the job once and a failed job does not re-arm the token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(auth): fail closed at startup in production on open auth gates

Production refuses to start (typed ConfigurationError, names vars, never
values) when OPENBLADE_API_TOKEN/_FILE is unset, CORS origins contain '*',
or the AML admin/service password or service token is the default.
Elsewhere one multi-line banner lists every open gate. validate_config
reports the same set, so scripts/validate_config.py stays the gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* test(hardware): opt-in appliance soft-reboot persistence test

Reboots via POST /aml/system/reboot only when OPENBLADE_REBOOT_TESTS=enabled
on a real-hardware run; otherwise skips. Refuses to reboot unless the library
is quiescent (no live leases, no drive loaded or mounted). A dropped
connection after the POST counts as accepted; login_ok treats
HTTPException as down; polls every 2s within bounded 300s/600s waits.
Does not call recover_after_restart (no report-only mode); the lane's
recovery-report step is the recovery assertion.

A no-hardware unit test pins that RebootClient.request keeps the read-only
guard (refuses POST /aml/media/move and POST /aml/system/reboot); mutation-
checked: widening request makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(hardware-destructive): opt-in reboot persistence step

Runs tests/hardware/test_reboot_persistence.py only when repo var
HARDWARE_REBOOT_TESTS == 'true' and every earlier step succeeded
(success(), never always()), exporting OPENBLADE_REBOOT_TESTS=enabled.
Placed after the recovery report and the lane's after-snapshot (so the lane
diff is not polluted by the reboot) and before the snapshot upload; same
environment approval. timeout-minutes: 20; TLS verification pinned on
(OPENBLADE_APPLIANCE_INSECURE=false); -p no:cacheprovider --tb=short.
An unset var yields a visible ::notice:: instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* feat(hardware): add read-only appliance preflight tool

python -m tools.hardware.preflight --lane readonly|destructive [--json]:
env, tools, device/inquiry/serial/by-id/serial-map, mtx status drive count,
SACRIFICIAL_BARCODES inventory + not-loaded, appliance login/logout,
armed-env warning. Route tools/hardware/* to the unit category.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* test(hardware): unit-test every preflight check with fakes

Mutation-checked: disabling the duplicate-serial and allowlist-loaded
guards each fails its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(auth): make the test-runner route dev-only and loopback-targeted

POST /api/test-runner/run returns 404 unless OPENBLADE_ENABLE_TEST_RUNNER=true
and OPENBLADE_ENV != production, and rejects any i3_aml_url whose host is not
localhost/127.0.0.1/::1/host.docker.internal (SSRF via spawned pytest env).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(runbooks): document the opt-in reboot persistence setting

§2 gains HARDWARE_REBOOT_TESTS; §5 "Reboot persistence" describes the
two-level opt-in (repo var + OPENBLADE_REBOOT_TESTS), the success()-only
ordering after the lane's after-snapshot, the quiescence precondition, the
bounded waits, the pinned TLS verification, and why the test does not call
recover_after_restart. The hard power-cycle stays a manual fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(hardware): run the preflight tool instead of inline env checks

All three rig lanes call python -m tools.hardware.preflight --json, print
the report on failure and upload preflight.json. The job-level
OPENBLADE_REAL_HARDWARE_ENABLED is blanked for that step so the preflight
never runs armed. HARDWARE_RIG_AVAILABLE gating is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(auto-merge): address review — keep retries, Actions-only checks, fail on dispatch error

- Drop the job-level conclusion=='success' filter: decide() already enforces
  success per required check, and skipping non-success completions could strand
  a PR that was waiting on that workflow.
- Count only check runs whose app is github-actions, so a look-alike gate
  name from another app cannot mask a real failure.
- core.setFailed when a post-merge dispatch fails, so a missing full-suite
  run on master is visible instead of a warning on a green job.
- Per-head-SHA concurrency group (no cancel) so near-simultaneous CI and
  operability completions do not race two merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(readme): state the auto-merge fallback list and master-tip dispatch accurately

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* build: add make hw-preflight target

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(runbook): add §0 Day one checklist and reference the preflight

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* feat(deploy): bind compose to loopback; add opt-in production LAN override

docker-compose.yml publishes 8000/8010/5173/5175 on 127.0.0.1 only.
deploy/lan/docker-compose.lan.yml (make lan-up/lan-down) publishes api/web/
web-flask on ${OPENBLADE_BIND_IP:-0.0.0.0}:8090/8091/8092 via ports !override,
OPENBLADE_ENV=production, and requires every secret via ${VAR:?}.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci: only PR runs cancel in-progress runs; dispatched master runs never cancel each other

Auto-merge now dispatches CI and operability on master after every merge. With
cancel-in-progress unconditional, two quick merges cancelled the first merge's
full-suite, leaving a commit on master with no post-merge result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* feat(auth): warn on insecure Flask cookies in production; document LAN exposure

validate_config warns (insecure_session_cookies) when production sets
OPENBLADE_WEB_SECURE_COOKIES off. Tests pin the existing Flask refusal
without FLASK_SECRET_KEY and Secure-by-default cookies in production.
docs/deployment.md gains 'Exposing on a LAN'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(nas): refuse ingest start when pool_id differs from the plan's booked pool

start_ingest booked capacity against plan.pool but created the dataset in
request.pool_id, so a mismatch wrote to an unbudgeted pool and a pool-less
preview could start into any pool with nothing booked. Now 409 on mismatch
and the job is created in plan.pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* feat(safety): enforce scratch-media allowlist in FormatService on real backend

Barcode-list parsing moves to openblade/domain/media_allowlist.py (the CI
tool now imports it). On a REAL backend, dry_run and confirm refuse any
barcode not in OPENBLADE_SCRATCH_BARCODES; an empty allowlist refuses
everything. Simulator behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(nas): book share-push capacity before registering the plan

A refused booking (409) used to leave the plan orphaned in the in-memory
registry. Book first, register after, and drop the plan via the new
unregister_archive_plan if job creation fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* feat(safety): real backend requires an API token; accept simulator alias

load_config raises RealHardwareDisabledError when OPENBLADE_BACKEND=real
and OPENBLADE_REAL_HARDWARE_ENABLED=true but no OPENBLADE_API_TOKEN(_FILE)
is set, and validate_config reports it as BLOCKING. OPENBLADE_BACKEND=
simulator (the Dockerfile value) now maps to mock explicitly, and any
other unknown value logs a warning instead of silently mocking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(safety): document new gates; flag vacuous operator_acknowledgment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(nas): roll back the capacity booking when an ingest start fails

Both start paths (start_ingest and share-push) now release the plan's
booking and unregister the plan if anything after book_plan_capacity raises,
then re-raise the original error. release_plan_capacity logs and swallows a
failed release so it can never mask the start failure (booking expires by TTL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(decisions): mark the capacity start-path risks resolved

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(hardware): mint a masked per-run API token in every real lane

Real mode now refuses to start without OPENBLADE_API_TOKEN. Each rig lane
mints one per run (masked) before preflight/pytest; mhvtl threads it through
the explicit sudo env lists and env.sh emits it (keeping the caller's).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* refactor(hardware): share the read-only ApplianceClient between the lane and preflight

Move ApplianceClient, ApplianceResponse, ReadOnlyViolation and the request
guard into openblade/hardware/appliance_client.py. The read-only conftest
re-exports them (fixtures and RebootClient unchanged); preflight's duplicate
_UrllibAppliance becomes a thin probe over the shared client, keeping its
scheme check and curated unreachable error. Every preflight request now also
passes the read-only guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(config): redact credentials from OPENBLADE_DB_URL in validation findings

The startup banner logs WARNING findings verbatim; a postgres URL with a
password leaked into logs. Add redact_url() (scheme+host+path, creds ***).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(nas): ingest refuses to auto-format on a real backend

_ensure_formatted minted its own SafetyToken and called ltfs.format, so on
OPENBLADE_BACKEND=real ingest could mkltfs any unformatted tape, bypassing
the scratch allowlist and the persisted single-use token. On a real backend
(same detection FormatService uses, now real_backend_from_config) it now
raises FormatRequiresConfirmationError naming the barcode and the two-phase
/ltfs/format flow; run() marks the job FAILED and nothing is written.
Simulator keeps the direct auto-format.

Mutation-checked: dropping the real-backend check fails the refusal test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(format): normalise barcodes once at the FormatService boundary

is_scratch_barcode compared strip().upper() but dry_run/confirm used the raw
barcode, so " abc123" passed the allowlist and then failed as unknown.
dry_run and confirm now normalise (strip().upper()) before the allowlist,
inventory lookup, token and catalog. Mutation-checked: an identity
normalize_barcode fails the new test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(hardware): preflight FAILs (exit 1) when run armed

check_armed only WARNed when OPENBLADE_REAL_HARDWARE_ENABLED was set,
although preflight is read-only and must never run armed. It now FAILs,
so main() returns EXIT_FAIL. The three lane preflight steps already blank
the flag (hardware-nightly-readonly.yml:88, hardware-library-smoke.yml:89,
hardware-destructive.yml:102), so lanes are unaffected. Mutation-checked:
reverting to WARN fails both armed tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* docs(agents): ingest never formats on a real backend

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(auth): reject blank, short (<12) and default AML passwords

password_problem() strips whitespace, enforces 12 chars and rejects known
defaults; shared by the production BLOCKING gate, the dev WARNING, and the
aml_state getters (production raise), so a ' ' value no longer passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(auto-merge): require owner approval on head SHA for non-owner trusted authors

Copilot PR content is steered by issue text; it now merges only when
Amantux's latest substantive review is APPROVED on pr.head.sha (pure
reviewGate(), listReviews). Amantux still merges directly. README updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* test(format): claim a safety token from two independent repositories once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* test: armed-real-backend fixtures supply the now-required API token

Real mode refuses to load without OPENBLADE_API_TOKEN(_FILE); the five tests
that arm real hardware through monkeypatched env now set a token, and the
clean-env fixture clears it so default assertions stay meaningful.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* fix(capacity): isolate rollback cleanups so the original start error propagates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

* ci(operability): positive-control production config carries an API token

Production now refuses to promote with the native API open (native_api_open),
which is exactly what the gate is for; the positive control must therefore set
OPENBLADE_API_TOKEN like any real production deployment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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