feat(proof): SN100 live gaps — publish path, topic disable, P1b allocator (P1b, draft) - #299
Conversation
`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>
|
@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. |
`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 SummaryThe 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/5The 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 Reviews (3): Last reviewed commit: "fix(proof): three concurrency/retry defe..." | Re-trigger Greptile |
| 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()) |
There was a problem hiding this comment.
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.
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>
|
@greptileai review All five gaps are now in this branch (7 commits). The P1 you raised is fixed in Also in since your last review: the LOC-cap refactor ( Still a draft: do not merge. |
| let setup = seal_setup(store); | ||
| setup | ||
| .mark_sealed(&document, args.pin, ®istered, &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)))?; |
There was a problem hiding this comment.
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
- The authored script runs the focused lifecycle integration test that performs the first seal and the rejected retry, ending with the exact assertions exercised.
- The initial command capture ran the focused real lifecycle test successfully, showing the executable test and its zero exit status.
- 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.
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>
|
@greptileai review All three P1s are fixed in
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 Still a draft: do not merge. |
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) — reviewthat first; this branch is its delta (9 commits) on top of
b735f335.HEAD:
37fa0920The five gaps
87e32024— gateway forwards the one publish route; the challenge serves its own prefixtopic disablefail-closedb6d84340—proof_topic_gate(migration 0026), CLI, submit-path 403vms_per_submission=1, concurrent 2-submit ⇒ 2 VMsf703108e,37fa0920— pin enforced at submit; per-submission leases; shared-VM path serializedab1b5320— results contract, harness default, router guard test--skip-baseline)8805b849,afa8c815—topic baseline/topic seal1. Publish through the gateway, no rewrite proxy
proof-admin topic installpublishes toPOST /challenge/proof/v1/admin/proof/topics.Both hops refused it: the gateway answered 403 for every
/v1/admin/*path, andthe challenge's topic mux swallowed
/challenge/proof/…as a topic id with noregistered routes (404). The operator's workaround was an ephemeral rewrite proxy.
POST, the publish path, Proofchallenge id, and a well-formed
Authorization: Bearer <token>. The scheme isrequired — a bare value, another scheme, or a lowercase scheme is a 401. (The
challenge's own
admin_okstill 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
GETkeep their 403./challenge/proof/…), soone publish URL works against the gateway and the service directly.
v1/admin…(refused at install, never resolved bythe mux), so no topic route can read like an operator route.
2.
topic disableis a real switchThe 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 topicis the state, the rows before it are who/when/why.
enabledclears a disable.silently "disable" nothing);
topic showprints the gate.POST /v1/submissionsbefore the nonce is spent: adisabled topic is a 403 naming the reason, an unreadable gate is a 503, and a
re-submit after
topic enablelands with the same signature and nonce.GET /v1/proof/topicsand/v1/proof/topics/{id}carrydisabled(+
disabled_reason).3. One VM per submission, and two submissions at once
vms_per_submission = 1was recorded in the install journal and read by nothing; thescorer held a per-topic lease for a whole run, so two submissions to one topic
were a queue.
other value with 503, no row, nonce unspent. A topic with no install row is not
refused for that.
RlmScorerleases per submission ((topic_id, digest)); the topic's sharedstate (lifecycle, best pointer) is written under a separate topic lock taken only
for a write, so a paid run never blocks another run.
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
tb4bundle shape does).one is evaluating keeps
evaluating, and the scorer's in-flight count decideswhether a persisted
evaluatingis stale or live.4. tbench hardcode in scoring/routes
The scoring path had one true topic-shaped default: the Python harness defaulted an
absent
results_contractpin totbench-harbor-v1. It now defaults to the genericharbor-trials-v1; the tbench-named id stays accepted because it is a wire valuea signed document may pin (and a signed document cannot be edited). No production Rust
branches on a topic id —
gateway-corehas a test asserting the router mentions none.5. Baseline → scorable
topic install --drive-rlm(without--skip-baseline) left the topic atbaseliningand nothing could turn that into a scorable topic.
topic baseline <id>reads the stored measurement and prints the primary and themetrics_commitmentanopendocument must carry (computed from the stored runand the pin's image digest).
topic seal <id> --document <open.json> [--publish]hands that measurement toTopicSetup::mark_sealed— the same call the runtime's tests drive — then publishesthrough the admin route. The seal is wired to an unwired orchestrator and a
refusing key probe, so sealing can never provision anything.
under this document" and publishes without re-running the lifecycle move. A draft, a
different signature, or any other lifecycle still goes through
mark_sealedandstill gets its refusal.
Scorable/NOT scorable yet — <what is missing>and--jsonreports
scorable+remaining.Greptile
Three review rounds; four P1s raised, all fixed with a test that fails without the fix:
BearerAuthorization value9515ad3f)37fa0920)topic seal37fa0920)37fa0920)Latest review: 5/5, no outstanding issue.
Verification
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings— cleancargo test --workspace— 76 suites pass; two failures are pre-existing onb735f335(chmod 000-based tests that root bypasses, andcargo denyadvisories)xtask loc-cap,consensus-lint,spec-check,design-check,external-docs-check— all passrouting, 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 jobwhen the lockis removed), the in-flight reap balance, and the seal commitment round trip.
LOC cap
Three crates went over the 1500-line cap, so
ab1b5320extracts per the repository'sown convention (
gateway-core::admin_route/::proxy_paths,proof-http::operator,proof-submit::SubmitBody, and the newproof-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 thatselects an in-guest runner), and disable fail-closed against the live topic. This branch
spends nothing and tips no metal.