Skip to content

feat(proof): SN100 live gaps — publish path, topic disable, P1b allocator (P1b, draft) - #299

Draft
echobt wants to merge 8 commits into
droid/9f68584e-sn100-p1a-rlm-topic-installfrom
droid/9822d526-sn100-100-live-gaps-p1b-disa
Draft

echobt wants to merge 8 commits into
droid/9f68584e-sn100-p1a-rlm-topic-installfrom
droid/9822d526-sn100-100-live-gaps-p1b-disa

Conversation

@echobt

@echobt echobt commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE. Draft only, by Owner instruction (Mathis LOCK 2026-09-15 SN100).
Keep this draft — and #297 / #298 — open and unmerged until the Owner's 100% LIVE
gate passes and the Owner gives an explicit GO. Auto-merge is forbidden and none
is configured.

What this is

P1b: the five gaps between the P1a RLM topic-install work and a 100% LIVE dynamic
topic. Stacked on droid/9f68584e-sn100-p1a-rlm-topic-install (#298) — review
that first; this branch is its delta (9 commits) on top of b735f335.

HEAD: 37fa0920

The five gaps

# Gap Where it landed
1 Publish path without a localhost rewrite proxy 87e32024 — gateway forwards the one publish route; the challenge serves its own prefix
2 topic disable fail-closed b6d84340proof_topic_gate (migration 0026), CLI, submit-path 403
3 P1b allocator: vms_per_submission=1, concurrent 2-submit ⇒ 2 VMs f703108e, 37fa0920 — pin enforced at submit; per-submission leases; shared-VM path serialized
4 Strip remaining tbench hardcode in challenge/gateway/orch ab1b5320 — results contract, harness default, router guard test
5 Baseline → scorable wiring (install without --skip-baseline) 8805b849, afa8c815topic baseline / topic seal

1. Publish through the gateway, no rewrite proxy

proof-admin topic install publishes to POST /challenge/proof/v1/admin/proof/topics.
Both hops refused it: the gateway answered 403 for every /v1/admin/* path, and
the challenge's topic mux swallowed /challenge/proof/… as a topic id with no
registered routes (404). The operator's workaround was an ephemeral rewrite proxy.

  • Gateway forwards exactly one admin route: POST, the publish path, Proof
    challenge id, and a well-formed Authorization: Bearer <token>. The scheme is
    required
    — a bare value, another scheme, or a lowercase scheme is a 401. (The
    challenge's own admin_ok still accepts a bare token on a master-local call;
    that is a different surface, and the gateway is the public edge.) Every other
    admin route, the topic-id form, and a GET keep their 403.
  • Challenge serves its own routes under its own prefix (/challenge/proof/…), so
    one publish URL works against the gateway and the service directly.
  • Topic routes may not claim v1/admin… (refused at install, never resolved by
    the mux), so no topic route can read like an operator route.

2. topic disable is a real switch

The CLI commands were stubs that exited 3, so the only way to stop a topic taking
submissions was to re-sign its document — a ceremony that does not wait for an incident.

  • proof_topic_gate (migration 0026) is an append-only journal: newest row per topic
    is the state, the rows before it are who/when/why. enabled clears a disable.
  • The CLI appends the row and refuses an id that is not published (a typo cannot
    silently "disable" nothing); topic show prints the gate.
  • The challenge reads it on POST /v1/submissions before the nonce is spent: a
    disabled topic is a 403 naming the reason, an unreadable gate is a 503, and a
    re-submit after topic enable lands with the same signature and nonce.
  • GET /v1/proof/topics and /v1/proof/topics/{id} carry disabled
    (+ disabled_reason).

3. One VM per submission, and two submissions at once

vms_per_submission = 1 was recorded in the install journal and read by nothing; the
scorer held a per-topic lease for a whole run, so two submissions to one topic
were a queue.

  • The submit path reads the pin from the topic's newest install row and refuses any
    other value with 503, no row, nonce unspent. A topic with no install row is not
    refused for that.
  • RlmScorer leases per submission ((topic_id, digest)); the topic's shared
    state (lifecycle, best pointer) is written under a separate topic lock taken only
    for a write, so a paid run never blocks another run.
  • Where the second VM comes from: a topic that selects an in-guest runner gets one
    experiment VM per paid job, so two submissions are two VMs in parallel. A topic that
    selects none has a single VM shared with the cheap jobs, and the KVM host refuses a
    second concurrent job on one VM — so that path takes a per-topic lock for the whole
    run (inspect included) and the second submission waits rather than racing into a 409.
    For the Owner's LIVE 2-VM check this means the topic must select an in-guest runner
    (the tb4 bundle shape does).
  • The lifecycle is a phase marker, not a per-run counter: a second submission while
    one is evaluating keeps evaluating, and the scorer's in-flight count decides
    whether a persisted evaluating is stale or live.
  • The promotion compare-and-swap still refuses a crown whose best pointer moved.

4. tbench hardcode in scoring/routes

The scoring path had one true topic-shaped default: the Python harness defaulted an
absent results_contract pin to tbench-harbor-v1. It now defaults to the generic
harbor-trials-v1; the tbench-named id stays accepted because it is a wire value
a signed document may pin (and a signed document cannot be edited). No production Rust
branches on a topic id — gateway-core has a test asserting the router mentions none.

5. Baseline → scorable

topic install --drive-rlm (without --skip-baseline) left the topic at baselining
and nothing could turn that into a scorable topic.

  • topic baseline <id> reads the stored measurement and prints the primary and the
    metrics_commitment an open document must carry (computed from the stored run
    and the pin's image digest).
  • topic seal <id> --document <open.json> [--publish] hands that measurement to
    TopicSetup::mark_sealed — the same call the runtime's tests drive — then publishes
    through the admin route. The seal is wired to an unwired orchestrator and a
    refusing key probe, so sealing can never provision anything.
  • A failed publish is retryable: re-running the same command detects "already open
    under this document" and publishes without re-running the lifecycle move. A draft, a
    different signature, or any other lifecycle still goes through mark_sealed and
    still gets its refusal.
  • The install prints Scorable / NOT scorable yet — <what is missing> and --json
    reports scorable + remaining.

Greptile

Three review rounds; four P1s raised, all fixed with a test that fails without the fix:

Finding Fix
Gateway forwarded a non-Bearer Authorization value scheme + non-empty token required (9515ad3f)
TTL reaping leaked the in-flight count, pinning the topic reaping releases the count with the lease (37fa0920)
A failed publish could not be retried through topic seal already-sealed detection skips only the lifecycle move (37fa0920)
Two paid submissions without an experiment binding shared the topic VM shared-VM path serialized per topic (37fa0920)

Latest review: 5/5, no outstanding issue.

Verification

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace — 76 suites pass; two failures are pre-existing on
    b735f335
    (chmod 000-based tests that root bypasses, and cargo deny advisories)
  • xtask loc-cap, consensus-lint, spec-check, design-check, external-docs-check — all pass
  • New tests include: gateway publish forwarding (incl. the bare-header 401), challenge-prefix
    routing, admin-namespace reservation, disable→submit→enable with an unspent nonce, the
    listing flag, the allocator pin, two concurrent submits ⇒ two runs, two experiment VMs
    per topic, shared-VM serialization (fails with vm vm-0 is running a job when the lock
    is removed), the in-flight reap balance, and the seal commitment round trip.

LOC cap

Three crates went over the 1500-line cap, so ab1b5320 extracts per the repository's
own convention (gateway-core::admin_route / ::proxy_paths, proof-http::operator,
proof-submit::SubmitBody, and the new proof-topic-ops). No behaviour change.

Out of scope (Owner LIVE, after this tip)

Owner A→Z: baseline measured on metal, miner → pf_, the 2-VM check (needs a topic that
selects an in-guest runner), and disable fail-closed against the live topic. This branch
spends nothing and tips no metal.

`proof-admin topic install` publishes to
`POST /challenge/proof/v1/admin/proof/topics`, and both hops refused it:
the gateway answered 403 for every `/v1/admin/*` path, and the challenge's
own topic mux swallowed `/challenge/proof/...` as a topic id it holds no
routes for (404 `topic_route_not_registered`). The operator worked around it
with an ephemeral rewrite proxy in front of the gateway.

Three changes, each narrow:

- The gateway forwards exactly one admin route: `POST` on the publish path,
  for the Proof challenge id, with a bearer present (presence only — the
  gateway never holds the operator token; the challenge still compares the
  hash). Every other admin route, the topic-id form, and a `GET` of the
  publish route keep their 403.
- The challenge serves its own routes under its own prefix
  (`/challenge/proof/…`), so the same publish URL works against the gateway
  and against the challenge service directly. A topic id is unaffected:
  `proof` resolves to the static prefix, not to the mux.
- A topic may not claim a path inside the challenge's admin namespace
  (`v1/admin…`): refused at install time, and never resolved by the mux, so a
  row written before this rule cannot answer like an operator route either.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@echobt

echobt commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

Draft (do not merge). Stacked on #298. Gap 1 of 5 in this push: the operator publish now reaches the challenge through the gateway (and through the challenge's own prefix) without a rewrite proxy; see the PR body for the exact gates and tests.

echobt and others added 2 commits September 15, 2026 07:14
`proof-admin topic disable` / `topic enable` were stubs that exited 3, so the
only way to stop a topic taking submissions was to re-sign its document — a
ceremony that does not wait for an incident.

The gate is now real, and fail-closed end to end:

- `proof_topic_gate` (migration 0026) is an append-only journal: the newest
  row per topic is the state, the rows before it are who turned it off, when,
  and why. `enabled` clears a disable; nothing is deleted.
- The CLI appends the row (`topic disable --reason …`, `topic enable`), and
  refuses an id that is not published (or an alias of one) so a typo cannot
  silently "disable" nothing. `topic show` prints the gate state.
- The challenge reads it on `POST /v1/submissions` **before the nonce is
  spent**: a disabled topic is a 403 naming the operator's reason, an
  unreadable gate is a 503 (never an admission), and a re-submit after
  `topic enable` lands with the same signature and the same submit_nonce.
- `GET /v1/proof/topics` and `/v1/proof/topics/{id}` carry `disabled` (+
  `disabled_reason`), so a miner does not read a stopped topic as open work.

The gate changes no document, cancels no in-flight evaluation, and leaves
emission alone, so a topic disabled mid-epoch cannot silently break the leaf
a seal depends on.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…e uses

`topic install --drive-rlm` (without `--skip-baseline`) leaves the topic at
`baselining` with a measurement in `proof_baseline_measurement`, and nothing
in the CLI could turn that into a scorable topic: the operator had to hand-build
a `BaselineMeasurement`, find the commitment, and call the runtime's
`mark_sealed` from somewhere else. The gap was the wiring, not a missing check.

Two commands close it:

- `topic baseline <id>` reads the stored measurement and prints the primary,
  the rule version, and the **`metrics_commitment`** an `open` document must
  carry. It is computed from the stored run and the pin's eval image digest,
  so "the sealed value is the value the RLM measured" is a property of the
  command.
- `topic seal <id> --document <open.json> [--publish]` builds the measurement
  from that stored run and hands it to `TopicSetup::mark_sealed` — the same
  call the runtime's own tests drive, so the CLI cannot seal something the
  scoring path would refuse. `--publish` then posts the sealed document
  through the admin route (the install gate still applies: an `open` document
  needs an `applied` install).

The seal is deliberately unwired from any VM: `mark_sealed` provisions
nothing, and the setup it is handed carries `UnwiredVmOrchestrator` plus a key
probe that refuses, so a future change that made sealing reach for a host
would stop instead of running on the control plane.

`topic seal` replaces the exit-3 stub, so the CLI's not-implemented machinery
is gone: every command exits 0/1/2.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

Greptile Summary

The reviewed fixes address the previously reported authorization, publish-retry, abandoned-run accounting, and shared-VM concurrency behavior. The author has marked this pull request as a draft; it should not be merged until it is marked ready.

Confidence Score: 5/5

The reviewed findings are addressed; once the pull request is marked ready, no outstanding reviewed issue blocks merging.

The previous gateway finding is fixed: public operator-route forwarding now requires the Bearer scheme and a non-empty token. The previous publish-retry finding is fixed: retrying publication for the same signed document already open in the local lifecycle does not repeat the lifecycle transition.

Reviews (3): Last reviewed commit: "fix(proof): three concurrency/retry defe..." | Re-trigger Greptile

Comment thread crates/gateway/src/proxy.rs Outdated
Comment on lines +351 to +356
pub fn has_operator_bearer(headers: &HeaderMap) -> bool {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer ").or(Some(raw)))
.is_some_and(|token| !token.trim().is_empty())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Require Bearer Scheme

The gateway treats any nonempty Authorization header as an operator bearer because it falls back to Some(raw) when the value does not start with Bearer . As a result, Authorization: operator-token forwards the newly exposed publish request to Proof even though the gateway contract requires a nonempty Bearer authorization value. Require the Bearer prefix and a nonempty token suffix before forwarding.

How this was verified: An in-process gateway request with a raw Authorization value reached the mocked Proof upstream and returned 201.

Artifacts

Gateway administrative publish contract script

  • Authored executable script that creates isolated base and PR-head worktrees, injects the in-process Axum/Wiremock probe, and captures both executed runs; it is the exact validation source used.

Gateway publish behavior before change

  • Captured base-commit output for requests through an in-process gateway and mocked Proof upstream; all publish and administrative variants return 403 before the change.

Gateway publish behavior after change

  • Captured PR-head output showing intended routing behavior and forwarding of a non-Bearer Authorization value.

View artifacts

T-Rex Ran code and verified through T-Rex

echobt and others added 4 commits September 15, 2026 07:32
Two gaps, one change: the allocator pin was recorded and never enforced, and
the scorer held a **per-topic** lease for the whole run — so two submissions
to one topic were a queue, and `vms_per_submission = 1` was a journal entry
nothing read.

Allocator pin (enforced):

- The submit path reads the pin from the topic's newest install row and
  refuses a topic installed under anything but 1 with **503, no row, nonce
  unspent** — this build runs one VM per submission, and running a topic
  under a binding its install never recorded would make the journal a lie.
  A topic with no install row (staged before the journal existed) is not
  refused for that.
- The read is folded into the operator gate (`InstallJournal::submit_gate`),
  so a submission costs one journal read, not two.

Concurrency (the two-VM path):

- `RlmScorer` leases **per submission** (`(topic_id, digest)`), not per
  topic: two submissions of one topic are two paid runs, each in its own
  Firecracker VM. The topic's shared state — the lifecycle and the best
  pointer — is written under a separate topic lock taken only for a write,
  so a paid run never blocks another run.
- The lifecycle is a *phase* marker, not a per-run counter: a second
  submission while one is evaluating keeps `evaluating`, and the scorer's own
  in-flight count is what decides whether a persisted `evaluating` is stale
  (a dead run) or live (another submission working). `recover_stale` no
  longer moves the phase out from under a run that is still going.
- The promotion compare-and-swap still refuses a crown whose best pointer
  moved, so parallel runs cannot both crown off one stale bar.

Tests: two concurrent submits reach the runner as two runs (HTTP path), two
submissions of an experiment topic ask for **two** experiment VMs and both
are destroyed after their jobs, and a topic installed under another pin is
refused.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The install left the topic unscorable and said so only in prose that did not
name the commands that finish the job. Two changes:

- `InstallReport` carries the document's own status, and the install prints a
  plain `Scorable` / `NOT scorable yet — <what is missing>` line. `--json`
  gains `scorable` and `remaining`, so a script can tell "run the next
  command" from "something is wrong" without parsing English.
- The closing advice is the real path: `topic baseline <id>` (read the
  measurement and the commitment), sign the open document, `topic seal <id>
  --document … --publish`. Every branch of it ends at the same two commands,
  because the install never makes a topic scorable on its own — whichever way
  it was run.

`--skip-baseline` is described as what it is: a pause, not a path.

The dry-run fixture README documents the same three commands, plus migration
0026 (the operator gate) in the staging-migrate section.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…moved

The workspace caps a crate at 1500 non-test lines, and this branch's changes
pushed three of them over: `proof-http` (1562), `gateway` (1529) and
`proof-admin` (1863). The cap is per **crate**, so moving code between modules
of the same crate does not help; the repository's own answer — the reason
`gateway-core` and `proof-topic-setup` exist — is to extract the subject into
its own crate. This does that, with no behaviour change:

- `gateway-core::admin_route` owns the one forwarded admin route and its
  bearer floor, with the four gates and the tests. `gateway::proxy` re-exports
  them and keeps only the HTTP shape of the refusal.
- `gateway-core::proxy_paths` owns the read gates the proxy applies before
  dialing (operator-local report bodies, miner-controlled viewer paths), with
  their tests.
- `proof-http::operator` owns the journal the submit path reads
  (`InstallJournal`, `SubmitGate`, the publish and submit gates) and the
  topic-VM orchestrator diagnostic.
- `proof-submit` owns `SubmitBody` and `SubmitBody::authenticate`, beside the
  `SubmitFields` it has to agree with: a field added to the wire type without
  a matching signed field would be a value a miner sends and nobody
  authenticates. `is_digest_of_nothing` and `nonce_from` move with it.
- `proof-topic-ops` is new: the operator procedures that reach a live host
  (`drive`, `baseline`, `seal`) plus `PublishTarget`. `proof-admin` keeps the
  argument parsing and the printing, and its exit codes still follow the same
  usage/error split (`OpsError`).

`VmAgentHealth` is now the wire type itself (`proof_vm_proto::AgentHealth`)
rather than a five-field mirror of it, so the route cannot publish a shape the
agent does not send.

Verified: workspace clippy clean, `loc-cap`/`consensus-lint`/`spec-check`/
`design-check`/`external-docs-check` pass, and the touched crates' tests are
green. `cargo deny` advisories and two `chmod 000`-based tests fail the same
way on the base commit (they rely on permission bits, which root ignores).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile (P1): `has_operator_bearer` accepted a bare `Authorization:
operator-token`, so the newly forwarded publish route would pass through a
header form the contract does not allow. The gateway is the public edge and
the challenge's own `admin_ok` accepts a bare token on a **master-local**
call, so the two differ on purpose — but the gateway must not forward a form
it never documented.

`Authorization: Bearer <token>` with a non-empty token after trimming is now
required; a bare value, another scheme, and a lowercase scheme are all a 401
naming what is expected. Tests pin each of those, and the proxy integration
test drives the bare-header case against the real router.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@echobt

echobt commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

All five gaps are now in this branch (7 commits). The P1 you raised is fixed in 9515ad3f: has_operator_bearer now requires the Bearer scheme with a non-empty token — a bare value, another scheme, and a lowercase scheme are all a 401 — and both the unit test and the proxy integration test drive those cases.

Also in since your last review: the LOC-cap refactor (ab1b5320) that extracted gateway-core::admin_route (where that predicate now lives), gateway-core::proxy_paths, proof-http::operator, proof-submit::SubmitBody, and the new proof-topic-ops.

Still a draft: do not merge.

Comment thread crates/proof-topic-ops/src/seal.rs Outdated
Comment on lines +215 to +227
let setup = seal_setup(store);
setup
.mark_sealed(&document, args.pin, &registered, &sealed)
.await
.map_err(|e| OpsError::error(seal_failure(&e, &canonical)))?;
let commitment = sealed.commitment();

if args.publish {
let admin = PublishTarget::resolve(args.admin_url, args.admin_token_file)?;
admin
.publish(&document)
.await
.map_err(|e| OpsError::error(publish_failure(&e, &canonical)))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed publishes cannot retry

The seal is committed locally before the remote publish is attempted. If publishing fails, the topic is already open locally but remains unpublished remotely. Retrying topic seal --publish calls mark_sealed again, which rejects the now-open topic before the publish request can run, leaving the operator without a recovery path.

Artifacts

Evidence from the check

  • The authored script runs the focused lifecycle integration test that performs the first seal and the rejected retry, ending with the exact assertions exercised.

Command output from the check

  • The initial command capture ran the focused real lifecycle test successfully, showing the executable test and its zero exit status.

Command output from the check

  • The final command capture ran the same focused test successfully and prints the assertions that first open/version-2 transition succeeds and a second seal is illegal, establishing the unrecoverable retry state.

View artifacts

T-Rex Ran code and verified through T-Rex

All three were real; each is fixed with a test that fails without the fix.

**Reaping a run leaked its in-flight count.** `reap_abandoned` removed a
stale pending entry without balancing the `enter` that preceded it, and
`on_persisted` could not repair it (the entry it would have used was the one
reaped). The topic then stayed at "something is in flight" forever, so
`recover_stale` skipped the recovery that unsticks a persisted `evaluating`.
Reaping now releases the count with the lease; the new test asserts 1 (not 2)
after a reap, then 0, then that a fresh run is admitted.

**A failed publish could not be retried.** `topic seal` called `mark_sealed`
(moving `baselining → open`) *before* the HTTP publish, so the retry the
operator is told to run was refused by the lifecycle the previous attempt had
already moved. `seal` now detects "already open under this document" and
skips `mark_sealed` for that case only — a draft, a different signature, or a
lifecycle anywhere else still goes through it and still gets its refusal.

**Two paid submissions shared one VM.** A topic that selects no in-guest
runner has a single VM (shared with the cheap jobs) and the KVM host refuses
a second concurrent job on one VM, so two submissions arriving at once had
one of them fail rather than wait. `RlmScorer` now serializes that path on a
per-topic lock held from the first job of the run (the anti-cheat `inspect`,
which is also a job on that VM) to the last. An experiment topic takes no
lock: each paid job is its own VM, which is what keeps two submissions
genuinely parallel there.

The test fake now models the host's one-job-per-VM rule (and can be told to
slow a job) — without that it could not observe the race at all: the test
failed with `vm vm-0 is running a job` when the lock was removed, and passes
with it. That check is the point of the fixture, not a convenience.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@echobt

echobt commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

All three P1s are fixed in 37fa0920, each with a test that fails without the fix:

  1. Reaping leaked the in-flight countreap_abandoned now releases the count with the lease. Test: after a reap the count is 1 (was 2), then 0, then a fresh run is admitted.
  2. Failed publish could not be retriedtopic seal detects "already open under this document" and skips mark_sealed for that case only; a draft, a different signature, or any other lifecycle still goes through it and still gets its refusal. SealOutcome::already_sealed is on the wire for --json.
  3. Two paid submissions shared one VMRlmScorer serializes the shared-VM path on a per-topic lock held for the whole run (inspect included). An experiment topic takes no lock, so its submissions stay parallel.

For (3) the test fake now models the host's one-job-per-VM rule and can slow a job; with the lock removed the test fails with vm vm-0 is running a job, and passes with it.

Still a draft: do not merge.

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