Skip to content

DEC-CA-0036: Miner-funded challenger legs with direct submissions - #235

Open
tensorlink-dev wants to merge 55 commits into
mainfrom
claude/cascade-miner-funded-compute-twm808
Open

DEC-CA-0036: Miner-funded challenger legs with direct submissions#235
tensorlink-dev wants to merge 55 commits into
mainfrom
claude/cascade-miner-funded-compute-twm808

Conversation

@tensorlink-dev

Copy link
Copy Markdown
Contributor

Implements miner-funded challenger training legs (DEC-CA-0036): miners submit generator code directly to the operator's intake service with their own Lium API key, code stays private unless it wins the throne, and training is billed to the miner's account instead of the operator's.

Summary

This PR adds the complete infrastructure for direct submissions and miner-funded compute:

  • Private submission store (cascade/funding/store.py): Content-addressed ZIP storage with ownership tracking; vault refs ride the existing repo@digest grammar under a reserved vault/direct repo id
  • Payer key vault (cascade/funding/vault.py): TTL-bounded, mode-0600 custody of Lium API keys; never logged or persisted beyond the submission lifecycle
  • Funded queue (cascade/funding/queue.py): Reveal-block-ordered entry queue with 1-live-per-hotkey rule; atomic file operations under exclusive flock
  • Intake service (cascade/funding/intake.py, main.py): HTTP endpoint for /v1/fund, /v1/submit, /v1/withdraw; signed hotkey auth; chain reveal oracle
  • Rent-fault taxonomy (cascade/funding/faults.py): Classifies Lium failures (auth/rate/capacity/infra) with no-burn rules ported from PRISM
  • Per-payer rentals (cascade/provision/funded.py): One pod per miner's key; payer-attributed ledger; teardown hydrates the right key from vault
  • Champion publisher (cascade/funding/champion.py): Publishes winning vault-ref code to champions/ per policy (crown/delay/dethrone)
  • Trainer integration (cascade/trainer/loop.py): [round] funded_mode field selection (off/shadow/required); funded entries drain in reveal-block order; terminal entries for stale/burned hotkeys
  • Miner CLI (cascade/miner/cli.py): cascade fund (sign and POST key), cascade submit (ZIP and fund in one request), cascade fetch king (anonymous champion fetch)

Key implementation details

  • Vault refs are byte-ordinary: A vault ref like vault/direct@sha256:<64hex> parses as a standard metro-v1:gen:hippius: commit on every deployed validator — participant sets cannot fork on the scheme
  • Fail-closed intake: Requests that cannot fund are rejected with actionable codes, never accepted-and-stuck; no balance pre-check (key validated by use)
  • Atomic queue mutations: Every operation reloads the file under exclusive flock before acting; no in-memory snapshot is trusted across operations
  • Ownership enforcement: A copied digest (e.g., the published champion's) can never enter a round as a different miner's submission
  • Hostile ZIP handling: Extraction admits regular files only, resolved strictly inside the destination; decompression bombs and path traversal are caught as StorageError (→ 400), never 500
  • Elastic cadence: Queue depth ÷ cap sizes the day's round cadence; funded entries are drained into round fields in seniority order
  • Terminal entries free slots: A hotkey with a done/failed/withdrawn entry can fund again; stale entries (key TTL expired) go terminal without burning

Configuration

New [round] settings:

  • funded_mode: "off" (default), "shadow" (read-only reporting), "required" (funded entries only)
  • funded_queue_path: Path to the queue file
  • funded_entry_ttl_hours: How long entries linger before expiry
  • champion_publish: "off", "crown", "delay", "dethrone"
  • champion_publish_delay_rounds: Reign delay before publication

New environment variables:

  • CASCADE_VAULT_DIR: Operator's private submission store
  • CASCADE_CHAMPION_BASE: Anonymous S3 endpoint for published champions
  • LIUM_API_KEY: Miner's API key (read by CLI, never on command line)

Testing

https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc

claude added 19 commits August 28, 2026 04:27
…029 groundwork)

Miner-funded compute plumbing ported from PRISM's control plane (base repo:
prism-lium-payer / lium-rent-pool): keys held memory-first with 0600 TTL-file
persistence for restart teardown, never logged or stored anywhere shared;
rent failures classified auth / rate_limited / no_capacity / infra with the
matching no-burn requeue semantics; funded submissions queue by reveal block
(earliest commit is the seniority claim) with a one-live-entry-per-hotkey
rule and an elastic-cadence sizing helper (rounds_needed, k-cap preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…r HTTP surface

POST /v1/fund carries X-Lium-Api-Key + X-Miner-Hotkey + X-Commit-Ref with an
sr25519 signature over a versioned canonical message; fail-closed intake with
PRISM-compatible error codes (missing_lium_api_key, bad_signature,
not_revealed, …). Keys go to the vault only after the queue accepts, are
refreshed on idempotent re-funds, and are forgotten on withdraw; the public
/v1/queue feed carries no key material. Reveal eligibility resolves against
the chain's revealed commitments (latest matching reveal per hotkey), with a
--trust-refs dev bypass that never grants seniority. No balance pre-check by
design: a key is validated by use and an underfunded one surfaces as a
classified rent failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
… funded rent/teardown/reconcile

LiumProvider grows an api_key field (repr-hidden): when set, every CLI call
runs with that key layered into the CHILD env only — argv and this process's
environ never carry it, and stderr is scrubbed before it can reach a log.
PodInstance records payer_hotkey (drop-when-default, so operator-account
ledgers serialise byte-identically); the orphan-reaper name gate accepts the
new funded stage. provision/funded.py rents one pod per challenger on the
miner's own key (PRISM's per-submission backend shape), classifies failures
through the funding taxonomy (only infra burns an attempt; a rent is never
auto-retried here — each attempt spends the miner's budget), and routes
teardown/reconcile through the payer vault, since a pod on a miner's account
is invisible to the operator's key. Keyless leftovers are returned loudly,
never swallowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
… elastic-cadence floor

funded_mode off/shadow/required ([round], digest-inert; shipped off).
Required mode drains the funded queue into the field at most finalist_cap at
a time, earliest reveal block first, ref-matched to the eligible reveal —
which trips the heat's fits-the-cap fast path, so no GPU screen runs and
every funded entrant duels; unfunded reveals wait unburned. Entries mark
in_round at selection, recover to queued on a torn round (the burn-after-heat
rule applied to funding), and settle done at the same moment the submission
burn fires. skip_unfunded_rounds skips an unfunded boundary entirely — no
king leg, no pods, no manifest; validators poll manifests and never schedule,
so a skipped boundary is consensus-invisible (the elastic-cadence floor; the
ceiling is the epoch grid itself via the existing scheduled epoch_blocks
switch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
Funds the miner's revealed submission with their own Lium API key: the key is
read from the environment only (never argv), refused over plain http to a
non-local intake, and sent as the X-Lium-Api-Key header of one POST signed by
the hotkey over the versioned canonical message (action-bound, so a captured
fund request can never replay as a withdraw). Wallet is used for signing
only — no chain connection. End-to-end test pins the CLI and the intake to
the same canonical bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…adence rounds (proposed)

The design node for the PRISM (sn100) port, the rollout runbook, and the
CLAUDE.md pointer. Records the owner's cost allocation (king + confirmation
legs stay operator-funded; a provisional dethrone re-trains on the operator's
account before it crowns), the consequences owned (no shared credentials on
payer-account pods; DEC-CA-0016 tenure re-denomination gates arming; dedup
stays pre-duel; no balance pre-check by design), and the arming order:
intake → shadow → required-on-testnet + the scheduled epoch_blocks grid
change → mainnet release-then-activate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…ber miner-funded node to DEC-CA-0036

Main claimed DEC-CA-0029..0035 (fork-anneal, bench-anneal, GP budget, even
mix, EMA bundle, init floor, toto2 constants) while this branch was in
flight — the same numbering collision the 2026-08-20 renumbering fixed once
before. The miner-funded elastic-rounds node moves to DEC-CA-0036; every
reference in code, config comments, docs, and the CLAUDE.md pointer moves
with it. Also folds in main's validate_corpus_target_points beside
validate_funded_mode (both sides added validators at the same site).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…scoped reap, hardened intake/CLI

Ten findings from the 2026-08-29 high-effort review of the DEC-CA-0036 diff:

- FundedQueue is now flock-serialised reload-before-mutate (sibling .lock
  file): the long-lived intake instance can no longer resurrect entries the
  trainer settled, and the ThreadingHTTPServer handlers stop racing on the
  shared tmp file. Reads always hit the current file, never a snapshot.
- teardown_funded verifies by RE-LISTING the payer's pods after terminate —
  LiumProvider.terminate's idempotent rm-failure swallow turned a revoked
  key's 401 into a silent leak on the miner's account; still-live and
  crashed teardowns are returned loudly, and one failure no longer aborts
  the sweep for other payers.
- reconcile_funded is scoped to payer_pod_pattern (cascade-*-funded-<that
  payer's slug>): the generic provisioner-name match could kill a miner's
  own unrelated cascade pods on their account (the 2026-07-13 over-reap
  class, aimed at hardware the operator never rented).
- Dead entries can no longer hold the elastic-cadence floor open: ref_mismatch
  and burned go terminal at selection, funding_expired kills queued entries
  outliving the key TTL — no perpetual king-leg drain from one unenterable
  fund. Shadow mode is strictly read-only (no recover/expire).
- cascade fund parses the intake hostname instead of substring-matching
  (http://localhost.evil.example no longer passes), and decodes non-JSON
  error bodies without a traceback. cascade-intake requires --queue-path
  (a CWD default silently split-brained the queue against the trainer) and
  returns clean 500s on handler errors.
- select_field is the trainer filter's ordering and rounds_needed feeds the
  skip-path telemetry, so neither helper is dead code drifting from an
  inline copy.

Full suite: 1692 passed, 21 skipped.

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

DEC-CA-0036 second half — nothing miner-hosted, losers private forever:

- SubmissionStore: content-addressed private ZIPs (0600, earliest uploader
  owns a digest), hostile-zip-safe extraction, per-dispatch staging that can
  only ever ship ONE entry's ZIP (a funded pod's payer can read the box).
- Vault refs ride the EXISTING hub repo@digest grammar under the reserved
  vault/direct namespace, so the chain payload is byte-ordinary: deployed
  validators parse it unchanged (they never fetch generator code), participant
  sets cannot fork, and only fetch_from_hub grows a branch (store → staged
  pod ZIP → published champion object).
- Intake POST /v1/submit: ZIP body with the signature bound to its sha256,
  optional X-Lium-Api-Key in the SAME request — the entry parks pending_reveal
  and auto-queues with the real chain reveal block when it resolves (lazy
  sweep on every request). Fail-closed: digest_mismatch / digest_owned /
  zip_too_large (rejected on declared length) / submissions_disabled.
- ChampionPublisher (the sn100 top-model pattern): champions/<digest>.zip +
  index public-read on the manifest bucket per [round] champion_publish
  (crown / delay / dethrone); a deposed vault king always reveals at
  hand-off; losers never publish. Trainer hook per round, best-effort.
- Trainer ownership guard drops a copied digest at field entry (byte-copies
  arriving as uploads still die at the DEC-CA-0008 dedup screen).
- CLI: cascade submit (verify → deterministic ZIP → one signed POST →
  timed-reveal commit; $LIUM_API_KEY funds in the same request) and
  vault-aware cascade fetch king (published champions resolve anonymously,
  clear message while the policy keeps the live king private).

All inert: submission_vault_dir = "" and champion_publish = "off" shipped.
Accepted trade recorded in the node: non-champion entries are no longer
third-party re-derivable — PRISM's posture, and the point. Full suite:
1710 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…ake, provisioner, CLI

Second high-effort review of the full DEC-CA-0036 diff (correctness + adversarial
security). Ten correctness findings fixed; the security pass then found no
exploitable vulnerabilities.

- champion.py: a deposed vault king whose hand-off publish fails now lands on a
  persistent 'unpublished' backlog retried every round — one bucket 500 can no
  longer erase a reign's audit trail (the 'history always re-derivable' contract
  the module promised but broke by overwriting state regardless of outcome).
- trainer/loop.py: _submission_store exports $CASCADE_VAULT_DIR from [round]
  submission_vault_dir, so the fetch path (dedup screen, king fetch, local
  train) can actually resolve vault refs — without it a fully configured store
  was invisible to every fetch, silently skipping direct submissions past the
  DEC-CA-0008 content screen.
- queue.py: /v1/queue redacts pending_reveal entries to a bare count — listing
  (hotkey, ref) before the timed chain reveal leaked the sealed next-round field.
  Entry TTL is now configurable (funded_entry_ttl_hours, tracks --ttl-hours) and
  expiry keys off last_active not funded_at, so a sold-out entry cycling through
  no_capacity requeues waits the whole drought while a truly idle one still dies.
- store.py: SubmissionStore.put does its ownership check-then-write under a
  flock — concurrent identical uploads from two hotkeys can no longer let the
  last meta-write steal 'earliest upload owns the content'.
- miner/cli.py: zip_repo_bytes filters to ALLOW_PATTERNS (drops .git, __pycache__,
  non-code) — a raw rglob shipped commit history + committer emails + any
  committed secret into the operator store and, on a throne, PUBLIC champions/;
  matching the Hub filter also keeps both channels' trees dedup-comparable.
- funded.py: rent_funded_pod's failure cleanup uses terminate_verified (re-list
  after rm) and returns the leaked pod id — a revoked key mid-launch no longer
  leaks a pod silently past the taxonomy.
- intake.py: submit_gate runs identity+signature on HEADERS before the HTTP
  layer buffers the upload body; submit reports funding='blocked-by-existing-
  entry' truthfully instead of falsely promising pending_reveal when a live
  entry for another ref already holds the slot.
- trainer/loop.py: mark_in_round re-checks the ref inside the lock — an intake
  ref-replace landing in the select→mark window no longer gets its new entry
  consumed by a round that trained the old ref (settled 'done' unpaid-for).

Full suite: 1719 passed, 21 skipped. Security review: no findings.

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

A focused review of the direct-submissions commit surfaced seven issues; four
were already fixed by round 2, one (pending_reveal expiry) is correct-by-design
given the entry-TTL/key-TTL coupling (documented), and TWO survived into HEAD:

- store.py: extract_zip_safely wraps mkdir/write_bytes in try/except OSError to
  StorageError, so a hostile ZIP naming 'a' (file) then 'a/b' (path under it)
  returns a clean 400 bad_zip at the intake instead of an uncaught OSError 500.
- champion.py: _publish returns False when the champions/index.json write fails
  after the ZIP upload — the index anonymous readers consume would otherwise
  stay stale forever, because returning True marked the reign published and
  never retried the index (the ZIP re-upload on retry is idempotent).

docs: note that a submit-with-key reveal must land within the key TTL (the key
lives in the vault under the same clock), so an over-delayed reveal correctly
fails rather than outliving its own key.

Full suite: 1721 passed, 21 skipped.

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

The round-3 convergence review flagged two adversarial-robustness gaps still
live in HEAD:

- store.py: extract_zip_safely now enforces MAX_EXTRACTED_BYTES (512MB) — a
  submission is generator source (a few MB), so a ~128MB deflate stream
  expanding to 100+GB is a bomb. Checked against the declared sizes (cheap)
  and the running total of bytes actually inflated (catches a lying header),
  so put()'s validation probe can't fill the operator disk (the box holds the
  eval pool + trainer wallet).
- champion.py: note_king advances the reign counter only on the FIRST call
  per round_id — a mid-round retry calling _maybe_publish_champion twice would
  otherwise inflate reign_rounds and fire the 'delay' reveal a round early,
  exposing a live king's private code. A king change is still always processed
  (it may carry a hand-off); publication is idempotent.

Full suite: 1723 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
… own zip-bomb guard

Reviewing the round-4 commit surfaced two bugs in that very fix:

- store.py: zf.read() raises zipfile.BadZipFile (a bad CRC, a truncated
  member, or a member whose real deflate exceeds its declared file_size) —
  which is NOT an OSError, so it escaped the except handler into a 500 on a
  hostile upload. Now caught alongside OSError → clean 400 bad_zip. infolist()
  is guarded the same way.
- store.py: the running-total check was unreachable (CPython caps read() at
  file_size, so the declared-sum guard already dominates) and its comment
  falsely claimed it caught lying headers — a lying header actually surfaces
  as the BadZipFile above. Reworded to what it honestly is: a backstop on the
  bytes actually written, not reliant on that internal cap.

The champion.py reign-idempotency change from round 4 was reviewed and
confirmed correct. Full suite: 1724 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
The round-5 review showed chasing exception types stayed incomplete — zf.read()
on a hostile member can also raise zlib.error (garbage deflate) and
NotImplementedError (unsupported compression method), neither an OSError nor
BadZipFile, so both still escaped as a 500. extract_zip_safely now treats the
read as the hostile-input boundary it is: ANY failure to inflate an untrusted
member becomes StorageError -> a clean 400 bad_zip. The regression test injects
zlib.error / NotImplementedError / EOFError directly, so the contract is proven
type-independently rather than by one hand-crafted CRC case.

Two cleanups the same review flagged: dropped the dead try/except around
infolist() (ZipFile.__init__ already reads the central directory and raises
there) and simplified the traversal check to the single is_relative_to()
conjunct (a path equal to dest is already relative-to it).

Full suite: 1724 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
The round-6 review showed symptom-patching the extractor kept missing corners
(three of four findings real). Rewrote extract_zip_safely as a proper
hostile-input boundary rather than a growing type-list:

- Streaming extraction (zf.open + 1MiB chunks) bounds RAM to one chunk
  regardless of a member's declared size, and enforces the cap on bytes
  ACTUALLY inflated — the backstop no longer leans on a CPython read()-caps-
  at-file_size internal.
- One try per member wraps the resolve, mkdir, and streamed inflate; ANY
  input failure (BadZipFile / zlib.error / NotImplementedError / EOFError /
  OSError / odd-name ValueError) becomes a clean 400 bad_zip. A genuine
  resource fault (MemoryError) is re-raised so an operator OOM surfaces as a
  500, not a mislabeled client error.
- The client-facing message no longer echoes the caught exception — an
  OSError carries operator-internal vault paths that must not reach the miner.
- NUL-in-name guard kept as defence-in-depth (CPython truncates at NUL before
  we see it, so the reviewer's 500 vector is in fact unreachable — the
  regression test asserts the real benign behavior, not a raise). Dead
  infolist() guard and a redundant traversal conjunct removed.

Regression tests inject every inflate-failure class plus MemoryError and a
path-leak OSError, proving the contract type-independently. Full suite: 1726
passed, 21 skipped.

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

The round-7 review found the extractor still conflated the untrusted-inflate
surface with the operator-side write surface (only MemoryError carved out), and
the intake picked HTTP status by substring-matching error text an attacker's
member name could hijack. Both fixed structurally:

- store.py raises typed SubmissionTooLarge / DigestOwned (StorageError
  subclasses); intake.submit dispatches HTTP status on the exception TYPE, so a
  bad-zip whose member is NAMED 'zip_too_large' is still 400 bad_zip, never 413.
- extraction classifies OSError by errno: a full/unhealthy operator disk
  (ENOSPC/EDQUOT/EIO/EROFS/EMFILE/ENFILE/ENOMEM) propagates → 500, while a
  structural fault the hostile ZIP caused (a member path clashing with a file)
  converts → 400. Operator infra incidents are no longer masked as client
  errors; MemoryError still propagates.

New tests: ENOSPC on the streamed write propagates as an operator fault, and a
member named 'zip_too_large' does not steer the intake response code. Full
suite: 1728 passed, 21 skipped.

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

The round-8 review found two remaining gaps in the same operator-vs-input
classification:

- _OPERATOR_ERRNOS omitted permission faults (EACCES/EPERM) plus EFBIG/EBUSY,
  so a mis-permissioned vault dir was mislabeled as a client 400 bad_zip
  instead of surfacing as a 500. Added them — EROFS was already operator, so
  EACCES/EPERM belong in the same 'operator box is unhealthy' class.
- Round 8's raw-OSError propagation (correct for the INTAKE path → 500)
  regressed the FETCH path: fetch_vault_snapshot feeds fetch_from_hub, whose
  contract is StorageError-on-failure and whose callers (dedup screen,
  promotion, heat-status) catch only StorageError. A trainer-side disk hiccup
  would have escaped as an uncaught OSError and crashed the round.
  fetch_vault_snapshot now wraps any OSError from extraction into StorageError,
  so the fetch path degrades gracefully while the intake path keeps its 500.

Tests: EACCES propagates from extraction; an ENOSPC during a vault fetch comes
back as StorageError. Full suite: 1729 passed, 21 skipped.

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

Three real bugs the round-9 review found latent in fetch_vault_snapshot:

- The temp dir was keyed by PID only, while its sibling fetch_from_hub uses
  PID+uuid: two same-process lanes fetching one digest into one dest collided
  on a single tmp tree, and each lane's finally-rmtree deleted the other's
  mid-extract. Now PID+uuid, matching fetch_from_hub — the 'concurrent lanes
  never read a torn tree' guarantee actually holds.
- The fetch-path OSError wrapper missed MemoryError, so a large-decompression
  OOM on a memory-pressured trainer escaped raw past callers that catch only
  StorageError and crashed the round. Now (OSError, MemoryError) both wrap.
- The wrapped message echoed the raw error, re-introducing the operator-path
  disclosure extract_zip_safely's own boundary was written to avoid. Dropped
  the interpolation (the cause chain is kept via 'from e' for server logs).

Two design-opinion findings (a typed OperatorFault subclass; a shared
atomic-extract helper across store.py/hippius.py) are noted but not taken —
they are refactors, not defects, and the code is correct as fixed.

Tests: both ENOSPC and MemoryError during a vault fetch come back as
StorageError with no operator path in the message. Full suite: 1729 passed,
21 skipped.

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

The round-10 review noted the path-leak assertion was vacuous for the
MemoryError case (str(MemoryError()) is ''), so the 'no operator path in the
reason' guarantee was only really exercised for OSError. Assert the EXACT
wrapped message instead — meaningful for both fault types.

Declined (with reasons, not defects): the OOM-during-error-handling
allocation edge (if a ~40-byte string alloc fails the process is already
doomed) and the unguarded fallback-rename race (a pre-existing pattern shared
with fetch_from_hub — fixing only this copy would desync the two).

Full suite: 1729 passed, 21 skipped. Security review: clean.

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

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cascade Ready Ready Preview Sep 3, 2026 2:25am UTC

Request Review

claude added 2 commits August 29, 2026 21:34
…ed with DEC-CA-0036 gates

Main's retry-after-settle path (reuse settled finalists, bypass entry
filters) composes with the funded/vault gates deliberately: on the reused
path ALL entry filters are bypassed — the settled heat already applied them,
and the funded entries were settled done at the original settle, so
re-filtering would empty the field and walk the king over. Champion
publication runs on every attempt (idempotent per round_id via the reign
counter guard), and _mark_funded_done stays unconditional at the settle
moment (idempotent on retry; a crash between burn and mark self-corrects
next round via the burned-hotkey terminal fail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
A full-diff review with fresh eyes found seven real bugs the commit-scoped
rounds missed by never examining the queue read/write interaction as a whole:

1. Queue read/write corruption (SERIOUS): unlocked reads shared a mutable
   self._entries with locked writers, so a read's reload could rebind the dict
   mid-write and serialise a torn/empty map — dropping a just-202'd fund or
   wiping the file. Removed the cached dict entirely: every op loads a FRESH
   local dict under a flock (writers LOCK_EX, readers LOCK_SH).
2. Content-Length not validated (SERIOUS): a negative value slipped past the
   size cap into rfile.read(-1) (read-to-EOF), and non-numeric raised an
   uncaught ValueError. Now a clean 400 bad_content_length.
3. Champion index erased on a TRANSIENT read failure: a bucket hiccup was
   treated as 'first publish' and rewrote the index from empty, dropping prior
   champions. Now distinguishes ObjectNotFound (real first publish) from a
   transient error (report not-done, retry — ZIP re-upload is idempotent).
4. reconcile_funded used raw terminate: a revoked-key orphan was reported
   killed while still billing. Now terminate_verified, confirmed kills only.
5. promote_pending held the flock across the chain resolver: a hung substrate
   poll would stall every queue op incl. the trainer. Now resolves UNLOCKED
   (snapshot under shared lock → resolve → apply under exclusive lock, with a
   ref re-check).
6. queue.fail select-race: a fail decided off a snapshot could terminally fail
   a miner's freshly re-funded entry. Added expect_ref guard (like mark_in_round).
7. Misleading 'blocked-by-existing-entry' on an idempotent same-ref re-submit;
   now reports 'already-funded' truthfully.

Also folds in main's r47/r48 settled-retry path, composed with the DEC-CA-0036
gates (all entry filters bypassed on the reused path; champion publish runs
every attempt, idempotent per round_id). Full suite: 1778 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
The final-pass convergence review confirmed no correctness bug survived; two
doc-level items:

- queue._locked is NOT re-entrant (each call opens its own fd; flock conflicts
  across fds even in one thread), so calling a public read/mutator inside a
  lock self-deadlocks. Documented the invariant so a future edit does not nest
  one — every method already works the yielded dict directly.
- intake submit-response docstring listed only pending_reveal|none; added the
  already-funded / blocked-by-existing-entry values and the bad_content_length
  400 this PR introduced.

Doc-only; no behavior change. (The latent 'reads need write perm on .lock'
narrowing is moot in practice — the transparency feed is served by the intake
writer via GET /v1/queue, never read off disk by a separate uid.)

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

The intake runs on the orchestrator (trainer wallet + eval pool), so it
gets in-app floors under the front proxy the runbook now mandates:

- make_server: per-socket request_timeout_s (slowloris eviction; the
  stdlib default is no timeout) and a BoundedServer capping concurrent
  handler threads — over the cap, connections get an immediate 503 +
  Retry-After without spawning a thread.
- ChainRevealResolver: the reveal poll joins a worker thread under a
  deadline; a hung substrate websocket serves the stale reveal table
  instead of pinning request threads, never stacks a second poll on a
  hung one, and harvests a late finish.
- cascade-intake flags: --max-connections, --request-timeout,
  --chain-timeout.
- docs/MINER_FUNDED_ROUNDS.md: 'Fronting the intake (DoS posture)' —
  proxy does volumetric defence, backstop knobs + residual risk stated.
- tests: slowloris eviction, flood 503 + cap recovery, resolver
  deadline/stale/harvest, poll-error propagation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYjSCaxgmwWQQXPjD1G6hc
…owercase .wallet)

Both signing sites called bittensor.wallet(...), which the branch's own pin
(bittensor==10.5.0) does not expose — cascade fund/submit crashed with
'module bittensor has no attribute wallet' before signing anything.
shared/chain.py already carries the <9 vs 9+/10 shim; use the same one here.
Found live on testnet 259 while rehearsing the funded path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
…s (owner 2026-09-02)

[round] funded_activation_block — the release-then-activate switch for the
whole DEC-CA-0036 machinery: before the block every funded_* knob reads
"off" and (because the flip is announced) boundaries HOLD, so no normal
rounds burn provisioner money between deploy and the flip; from the block
the armed config applies. Stamped from the live height each tick/round;
offline callers treat an unreached gate as CLOSED.

chain.toml armed (mainnet):
- funded_activation_block = 8991900 — projected ≈ Fri 2026-09-04 05:30 UTC,
  the 3h grid's closest boundary to the owner's 06:00 target
- epoch seam 3600 → 900 (≈3h rounds) at 8989200 (Thu ~20:30 UTC; boundary
  of both grids, load-checked)
- funded_mode=required, skip_unfunded_rounds=true, funded_pods=rent,
  field cap 8 + capacity probe, five-type SKU set, funded_king_rent=true,
  payer_vault_dir, the pinned worker image; direct submissions stay OFF
  (image gate)
- [training] expected_gpu unpinned ("") for per-round GPU choice —
  CONTRACT CHANGE: trainer + validators deploy together before the seam

Docs: MINER.md §6b (the miner funding walkthrough + failure-table rows),
llms.txt (funded-era round mechanics, workflow step 8, config values),
MINER_FUNDED_ROUNDS.md go-live checklist + block table, DEC-CA-0036
amendment (incl. the owner-accepted open gates: confirmation leg, tenure
re-denomination), CLAUDE.md pointer.

Tests: conftest neutralizes the armed pins (cadence pair, funded gate,
funded modes) exactly like expected_gpu/commit_floor; seam-literal tests
updated; gate + hold covered in test_funded_field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
…d go-live block (owner 2026-09-02)

[eval] scored_horizons / scored_from_block = 8991900 — PR #241's knobs, set
to the SAME block as [round] funded_activation_block so the two flips are
one coordinated validator update. Ignored by loaders without #241; on the
merged tree the ladder is () at 8991899 and (64, 256, 720) at 8991900
(verified). Runbook: #241 must be in the deployed release; check the
current pool's >= 784-step eligibility (the 720 rung sizes every rung)
before the block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
Same values; the rationale lives in docs/MINER_FUNDED_ROUNDS.md and the
DEC-CA-0036 node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
…uel settle (owner 2026-09-02)

- Drop the pre-gate hold: before funded_activation_block the funded
  knobs read "off" and legacy rounds keep running (last one Thu 20:30 UTC,
  6-8h); the first 12h boundary after it, 8992800 (≈ Fri 08:30 UTC), is
  the go-live block for BOTH the funded field and PR #241's ladder.
- Keep the 12h epoch grid (owner): epoch_blocks 3600, no scheduled seam,
  max_rounds_per_day 2. The 3h grid is a later switch.
- one_submission_per_hotkey bug: funded entries burned at the heat settle,
  BEFORE their legs ran, so a leg requeued unburned (sold-out/rate-limit/
  infra) was terminally failed as "burned" by the next round's filter.
  Funded entries now burn at the DUEL settle per outcome — trained or
  generator-failed → spent; auth/market/infra → still re-fundable.
  Testnet runs the rule off, which is why the E2E never showed it.
- Docs: spent-hotkey rollover rule (fresh hotkey, reveal after the Thu
  20:30 cutoff), 12h cadence, block table; seam tests build their pairs
  explicitly instead of matching shipped literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
…eardown (PRISM-level credential model)

A funded pod runs on the miner's Lium account and its payer has console
access, so everything in the worker's environment is theirs to read — and
the funded host was mirroring the operator final profile's forward_env
(Hippius S3 keys, Hub login, HF token) onto it.

- RemoteHost.isolated: the dispatcher forwards NOTHING from the
  orchestrator's environment to an isolated host (no forward_env, no
  WANDB_API_KEY extra) — only the host's own static_env.
- cascade/funding/robots.py: Harbor robot accounts, project-scoped,
  repository:push only, day-expiry backstop; minted per funded pod at rent
  (robot_id on the ledger), revoked at teardown and by every sweep.
- Credential resolution for a payer pod, in order: per-pod robot via a
  project-admin USER login (CASCADE_HUB_ADMIN_USERNAME/PASSWORD — Harbor
  forbids robots minting robots, and the operator's own Hub identity IS a
  project robot, found live) → a static push-only robot from
  CASCADE_FUNDED_HUB_USERNAME/PASSWORD (user logins in that slot refused)
  → fail CLOSED (leg skipped unburned, pod torn down). The operator's own
  login is never an option.
- Vault-ZIP staging appends to static_env instead of replacing it.
- Docs: credential model + operator env pairs in the runbook, pieces table,
  DEC-CA-0036 amendment; remaining PRISM delta (credential-free pod via
  SSH harvest, sealed vault) recorded for the worker-image release.

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

The trainer tracked a funded pod by NAME, which the account owner chooses
and can re-use — a relaunched pod under the same name was indistinguishable,
and a mid-leg swap only surfaced as an ssh drop classified infra (requeued,
unburned). Now, at rent: record Lium's pod id (pod_uid, on the ledger) and
ssh-keyscan the container's host key (generated at first boot); dispatch
runs StrictHostKeyChecking=yes against a per-pin known_hosts file placed
FIRST in the argv (ssh takes the first value); the platform id + RUNNING
status are re-verified on the payer's key before dispatch and when the leg
returns. Any mismatch — or a host-key refusal mid-leg — settles as
error_class 'tamper': miner fault, terminal, hotkey spent. Rent fails
closed (no pin → no leg). Live probes established the owner's actual
reach on a custom-image pod (rent-caller keys only, backups confined to
/workspace, no console), so pod replacement is the move this catches.

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

Every scorer — the validator's verdict, the king-pod bench, the audit
replay, the heat screen — imported and EXECUTED forecast_wrapper.py and
model.py shipped inside the checkpoint directory and built the model from
its config.json (evaluator.py: 'owner-produced and trusted; no static guard
or sandbox'). True while only operator pods produced checkpoints; under
miner-funded compute that is arbitrary code execution on all validators and
on the operator's king pod from any funded miner.

cascade/eval/checkpoint_guard.py: the two code files must be byte-identical
to this release's copies (model.py is toto2_model.py, already folded into
base_arch_digest; forecast_wrapper.py is the _FORECAST_WRAPPER_PY
template) — any other .py is refused; config.json['toto2'] must equal
Toto2Config.from_contract(contract); weights.safetensors is checked by
HEADER (tensor names/dtypes/shapes/offsets vs the pinned model's state
dict) and by a byte budget from the pinned param count, before any tensor
is allocated. Honest checkpoints pass byte-for-byte.

Wired: load_forecaster/evaluate_checkpoint take  (validator loop
passes the entry's size contract; audit replay the round's; heat screen the
screen contract; the bench sidecar refuses non-identical code before
invoking); trust_checkpoint_code=True is the explicit opt-out for archived
pre-guard checkpoints. The trainer fetches and guards every funded leg's
checkpoint before it can enter the manifest — a deviation settles as
'tamper' (hotkey spent), so validators and the bench never see it even
before they upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
…ealed-payer shape

With CASCADE_VAULT_KEY_FILE set (32 raw bytes / 64 hex, shared by the
intake and the trainer) every vault entry is stored as
{sealed: b64(nonce||ct||tag), stored_at, v} under AES-256-GCM with the
hotkey as associated data (pycryptodome, already in the chain extra): a
copied vault dir is useless without the key file, and a renamed entry
fails to unseal. Unset keeps the legacy plaintext form with a loud
startup warning; legacy entries hydrate and are re-sealed on their next
insert/refresh; a configured-but-bad key file fails loud rather than
falling back to plaintext.

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

PerIpLimiter: sliding-window requests-per-minute and concurrent-connection
caps per client IP (429 + Retry-After, /health exempt; rejected at the
socket before a handler thread spawns). Behind the mandated TLS proxy the
client IP is taken from X-Forwarded-For only when the peer IS
--trusted-proxy — the header is attacker-supplied from anyone else. The
runbook now carries a reference nginx front (limit_req/limit_conn, body
size = the ZIP cap, header/body timeouts, request buffering).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65PT2wahVQnhE1SjWXyGW
Funded challengers were never benched: the leg tore its pod down and the
post-publish sweep only knows pods, so no funded-round challenger could
reach the bench stream or the promotion pool.

- [telemetry] funded_bench (default on): the leg keeps its pod through
  publish and registers it as the bench host; each payer pod is torn down
  when its own sweep ends, with sweeps at bench-thread exit, round
  failure, the no-bench path and the next run_round for leftovers.
- Payer numbers are a filter, never a published fact: the top
  funded_bench_verify_top payer-reported challengers are pushed
  (guard-verified copy, over the pinned ssh) to a <role>-verify work dir
  on the king pod and re-benched there; within funded_bench_verify_tolerance
  the operator's numbers stand in, beyond it the entry is dropped (TAMPER).
  Unverified payer numbers are logged only. Wire format untouched.
- bench_hook: no HF_TOKEN to isolated hosts; push_checkpoint helper.
- remote: ssh_transport_options/build_scp_argv so the vault-ZIP scp honours
  the pinned host key (it used accept-new).
- _bench_host_for never guesses a pod for a funded challenger.
- Docs (MINER_FUNDED_ROUNDS, MINER §6b, llms.txt), DEC-CA-0036 amendment 4,
  chain.toml/chain.testnet.toml [telemetry] keys, tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9Qtvsw3SjMTWjkpYqRNaT
…or harvest

[round] funded_pod_checkpoint = "harvest" (new default): the funded worker
trains --local-only and uploads NOTHING (the pod env holds no credential at
all — no robot, no token); its receipt carries local_checkpoint_dir instead
of a trained_pointer (TrainedEntry still refuses non-hub pointers, keeping
un-harvested checkpoints out of manifests by construction). The orchestrator
pulls the checkpoint over the pinned ssh (harvest_remote_dir: tar streamed
through tarfile filter="data" — traversal/absolute/symlink members off the
miner-controlled stream fail the harvest), runs the ingest guard on the
local copy (deviation ⇒ tamper), and uploads under its own identity.
Harvest transport failures settle infra (requeue), never tamper.

- worker: --local-only flag; train_one split into _train_for_entry +
  train_one/train_one_local.
- remote: worker_argv local_only, LocalTrainReceipt/receipt_to_local,
  dispatch(local_checkpoint=), harvest_remote_dir.
- loop: _funded_harvest gate, robot minting skipped in harvest mode,
  _harvest_funded_checkpoint (pull → verify → upload → real entry).
- "robot" mode kept as the fallback for a pre---local-only image.
- chain.toml/chain.testnet.toml key, docs, DEC-CA-0036 amendment 5, tests
  (harvest leg, tamper/infra split, no-robot rent, traversal refusal,
  worker receipt).

Needs the worker image rebuilt from this release (--local-only is new CLI).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9Qtvsw3SjMTWjkpYqRNaT
…rom the image build context

_payer_vault/ (payer Lium keys!) and local orchestrator state were not in
.dockerignore — a worker-image build from a live orchestrator tree would
have baked them into a public image via COPY .

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9Qtvsw3SjMTWjkpYqRNaT
…ge context too

gitignored files still enter docker build contexts; hosts.toml carries live
pod addresses and the orchestrator's forward_env layout.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9Qtvsw3SjMTWjkpYqRNaT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants