Skip to content

feat(proof): session branch mirror — RLM-authorship evidence pack + Gate 3/4 hardening - #302

Draft
echobt wants to merge 42 commits into
droid/933f76bf-b1-raise-max-proof-deadlinefrom
droid/1d0afa5f-sn100-stay-lit-cont-gate1-pa
Draft

echobt wants to merge 42 commits into
droid/933f76bf-b1-raise-max-proof-deadlinefrom
droid/1d0afa5f-sn100-stay-lit-cont-gate1-pa

Conversation

@echobt

@echobt echobt commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Draft mirror of droid/2edcb0c8-100-rlm-autonomous-strip-tbe (PR #301) at the same HEAD.

Canonical stack position: #301. This PR exists so this session's branch has its own URL; the commits are identical.

What this branch carries

Gate 3 (no pf_) root cause and Gate 4 (host OOM) hardening, the RLM-authorship audit, and the evidence pack:

Commit What
e204b426 RLM-authorship evidence pack (docs/evidence/rlm-authorship-evidence.md)
612bdbd1 authorship boundary held by an allow-list, not a denylist
81db11a1 the RLM authors rules, not migrations or apis (doc correction)
dbd26cdd a refusal must not advise what cannot work
7095d5a6 name free memory in the refusal; pin the tipped retry shape
3767fe19 the memory guard is not a residency model
d809e020 size the memory budget by the host's sold RAM
f0800353 admit VM boots against host memory, not just a count
e48bcda9 drive the TAP allocator against a host that changes under it
0c207628 roll back a half-built TAP; never attach a mismatched VM
26cb6b15 a leftover topic VM no longer takes Gate 3 off the air

Not for merge. Draft; the merge HOLD stands pending Mathis GO. Merge #301, which is the stack head.

echobt and others added 30 commits September 15, 2026 16:42
…clone

Remove the last three places a topic's identity or behavior could come from
compiled code or an operator-cloned document rather than from the topic's own
RLM. Data-driven topic_id / install / pin only; the contract is the generic
`harbor-trials-v1`.

(1) Residual tbench/TB4 hardcode
- `proof-results`: `CONTRACT_TBENCH_HARBOR` is now
  `CONTRACT_HARBOR_TRIALS_LEGACY` — the *value* stays (it is signed wire data
  a live document still pins) but the constant name stops naming a topic.
  New topics pin `harbor-trials-v1`.
- Stripped topic names from product comments, CLI help, and the operator
  fixture, which is renamed `topic.install-bundle.json` and regenerated with a
  neutral slug / custom id / alias. `proof-admin topic validate --bundle
  topic.json`, `ctx-client` timeout rationale, `0024` migration comment, and
  the bundle crate's alias docs no longer claim an "Owner default" topic.

(2) RLM TopicSetup authors topic behavior; fail-closed without RLM provenance
- The guest `propose_rules` had a fallback that **echoed the operator's signed
  checklist** back as a rule proposal while the driver stamped it
  `RuleSource::Rlm` — an operator-cloned TopicDocument masquerading as RLM
  authorship. That fallback is gone: a runner shipping no `propose_rules`
  entrypoint is `Failed` (503, no row, nothing scored).
- New `RlmStore::current_rules_source` / `rlm_authored_rules` (Pg + Memory) and
  `proof_topic_install::{rlm_authored_rules, rules_source}` read
  `proof_rule_version.source` directly. `TopicSetup::propose_rules` re-reads
  the store after writing and refuses with `RulesNotRlmAuthored` unless the
  version in force is `rlm`-sourced.
- The publish gate (`PgInstallJournal::applied`) now requires **both** an
  `applied` install row and RLM-authored rules, so an `open` document whose
  behavior nobody authored is a 409 rather than a live topic.
- `proof-topic-install::install_rules` documents that what it seeds is
  `topic_document` provenance — honest, and explicitly not a substitute.

(3) DB/install-driven open + scorable only
- `load_topics` on the **live** backend skips an `open` document it cannot
  prove was installed *and* RLM-authored, logging the provenance that blocked
  it, instead of admitting it from the operator's file into `open_topics` /
  `scorable_topics`. Sim is exempt: it is the CI/local opt-in backend with no
  install, no RLM, and no topic VM.
- New repo-wide guard test asserts no topic id (`tbench`, `tb4`,
  `terminal-bench`, `harbor-trials`) is compiled into the challenge, gateway,
  orchestrator, or guest product branches. Proven non-vacuous: it catches an
  injected literal in non-test logic.

Kept: one VM per submission (`VMS_PER_SUBMISSION = 1`, untouched) and the
`custom_ids` / `ready_for_topic` / family-mux wiring.

Gates: fmt, clippy -D warnings, all 5 xtask checks green; `cargo test
--workspace` has zero new failures vs base 870a3b8 (the 4 remaining failures
are pre-existing root-permission tests). `cargo deny` advisories fails
identically on base.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… rules, supersede guard, guard blind spots

P1 — live startup skipped every open topic. `load_topics` ran after the
runtime's `block_on` scopes had returned, so `open_topic_admissible`'s
`Handle::try_current()` failed and each valid open topic was logged as
skipped. The loader is now `async` and driven through `rt.block_on`, and
the gate needs no ambient runtime. A test pins that it is callable from a
synchronous context with no runtime entered.

P1 — the publish gate composed two independent predicates ("newest install
is applied", "newest rule version is rlm"). A topic whose install landed
rule version 1 from the signed document (`topic_document`) was admitted as
soon as any later version was RLM-authored, so it could open with the
operator's vector in force — the exact operator-cloned document the gate
exists to refuse. `proof_topic_install::installed_rules` replaces the pair
with one query joining `proof_rule_version.version =
proof_topic_install.rules_version`, and both the publish route and the
startup gate read it. The join is what binds provenance to the vector in
force.

P1 — setup verified the source of whichever rule version was newest, not
the version it wrote. A concurrent RLM write made the check pass while the
version the baseline is measured against was never verified. It now reads
back the exact version and digest it wrote, and a second guard refuses
before persisting a baseline if a different version is in force (the
measurement is stored per rule version, so a vector that moved under the
run would seal a bar measured under rules nobody scores with).

P2 — the topic-id guard had two blind spots: it omitted
`proof-challenge/src/topic_routes.rs`, and it split on the first
`#[cfg(test)]` marker, dropping all production code after a test-only
method. The strip is now brace-depth based over `mod` items only, the list
covers the challenge's dynamic routes and the VM agent's router, and a
non-vacuous test asserts the guard catches an injected literal, ignores a
`cfg(test)` mod, and still scans production code that follows a test-only
method — the shape in `proof-vm-guest/src/runner.rs` that the old split
stopped guarding.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The install-bound read closed one direction of the provenance defect: a
topic whose install landed the signed document's vector while a later
version happened to be RLM-authored. The opposite direction was still
open. An install lands the RLM's version N, and a later `operator` edit
supersedes it as version N+1; a gate that binds only the install's
recorded version admits the topic, which then serves rules no RLM wrote.

`installed_rules` now reads both halves in one statement — the version
the newest `applied` install recorded, and the version in force — and
admits only when both are `rlm`-sourced. The new
`InstalledRules::SupersededByOperator` names the case so the refusal
says which vector is in force.

This is deliberately not "the two versions must be equal": an RLM that
rewrites its own rules after the install (N → N+1, both `rlm`) is the
autonomy this track exists to protect, and it stays admitted. The
DB-gated test covers all three transitions: operator edit refused, RLM
rewrite admitted, non-`applied` row refused.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile found the new guard's own blind spot: the stripper counted `{` and
`}` in raw bytes, so a brace inside a `//` comment, a `"…"` literal, an
`r#"…"#` block, or a macro's input left the depth non-zero after a
`#[cfg(test)]` module closed. Every line after it was then dropped from the
scan, and a prohibited topic literal placed there passed the guard.

`mask_non_code` now blanks comments, string bodies (normal, byte, and raw),
and character literals before any depth is computed, keeping byte offsets
and newlines so line-by-line pairing with the original source holds. The
attribute and `mod` markers are read from the masked line too, so a `mod `
inside a string is not mistaken for a module.

A char literal is distinguished from a lifetime (`'a` is code and stays),
and an unbalanced brace in text can no longer end a module early or keep
one open. The regression test drives all four shapes Greptile named, each
followed by production code carrying a literal, and asserts both that the
literal is seen and that the module is still stripped.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The crate-local guards in `proof-rlm`, `proof-experiment`, and
`proof-topic-install` strip test code with
`split("#[cfg(test)]").next()`, which stops at the first marker even when
it annotates a method rather than a module. Their sources are safe today —
each marker starts a trailing `mod tests` — but the strip is the same
shape that hid production code in the guest's `runner.rs`, so a literal
added after such a marker would pass them.

Running those sources through the structural strip here closes that
latent gap without rewriting four crates' tests: the same files are now
scanned by a strip that removes only `#[cfg(test)] mod` items and counts
braces in code rather than in text. Proven non-vacuous by injecting a
literal into `proof-rlm/src/lib.rs`, which the guard now names.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The Rust side already renamed `CONTRACT_TBENCH_HARBOR` to
`CONTRACT_HARBOR_TRIALS_LEGACY`, keeping the *value* because it is a signed
wire spelling while dropping the topic name from the identifier. The guest
adaptor still carried `CONTRACT_TBENCH`, which names a topic the harness
does not know.

Renamed to `CONTRACT_HARBOR_TRIALS_LEGACY`, matching the Rust constant and
its rationale. The string is untouched: a topic signed before the generic
id existed pins `tbench-harbor-v1` in its signed
`constraints.params.results_contract`, and a signed document cannot be
edited. Nothing branches on a topic; the harness accepts both spellings.

The deploy gate `assert-harbor-runner-results-emit.sh` asserts the new
identifier, and the adaptor suites (148 Python tests, the shell suite) pass.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The guard covered ten modules chosen by hand. Sixteen more decide what a
topic may do and were unguarded: the install executor's engine, routes,
section reader, and gate; the guest's fetch and staging; the VM agent's
router, auth, hypervisor, and stamp; the gateway's auth, attestation, and
proxy paths; and `gateway-core/src/lib.rs`.

All thirty-two are now scanned with the structural strip, and the tree is
clean for every one of them today — this closes the surface a future edit
could put a literal back into. The guard's self-test still asserts it names
the file the check was written for and that an injected literal is caught.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Both docs described the state before this stack shipped the install path,
and both were wrong in the same direction: they understated what is
enforced.

`deploy/guest/runners/README.md` said a runner without a `propose_rules`
entrypoint makes the agent propose the signed topic's own `checklist`.
That echo was removed: it is exactly how an operator-cloned document gets
recorded as `source = rlm`. The guest now refuses the job (Failed → 503,
no row, nothing scored), and the row says so.

`docs/COMPLETENESS.md` called topic installs a **skeleton** with the real
install unimplemented (exit 3) and `enable` / `disable` / `seal` as stubs.
All three shipped: `topic install` applies the bundle's RLM section and
journals it, `seal` drives `mark_sealed`, and the gate rows live in
`0026_proof_topic_gate.sql`. The row now records the three migrations, the
mounted dynamic route table, the install-bound provenance gate (both the
recorded version and the version in force), and — explicitly — that the
six live gates are staged but **not** claimed green.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Every real topic id is a hyphen slug (`[a-z0-9][a-z0-9-]{1,62}`), and a
bare SQL identifier cannot contain a hyphen. The migration guard required
a literal `{topic_id}_` prefix, so its requirement was **unsatisfiable**:
`CREATE TABLE fixture-topic-v0_scratch` is a syntax error at the first
`-`, and the underscore spelling was refused as unscoped. An operator
running `topic install --drive-rlm` would provision the VM, run the paid
baseline, and only then hit the deny-list — a paid run that could never
publish.

`topic_sql_prefix` maps `-` to `_`, and `is_topic_scoped` accepts that
identifier-safe spelling alongside the literal one. It stays a boundary:
ids contain no underscores, so the mapping is injective and two ids cannot
collide on one prefix. A sibling topic's table, a `topic_*` name, and a
`proof_*` object are all still refused.

The refusal message also claimed `topic_*` was allowed, which the code has
always refused; it now names the prefix that actually works.

This was invisible in CI: the guard's own suite only used `tb4` (no
hyphen), and the tests that would have caught it are DB-gated and CI has
no Postgres. Found by running the DB-gated suites against a real database.
Two `proof-admin` tests that had never executed now pass; one of them also
asserted the topic's alias where the code correctly prints the custom id.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The install-engine suite exercised only `tb4`, which is why the guard's
unsatisfiable prefix requirement (fixed in 724ff5b) went unnoticed: `tb4`
is both a legal id and a legal bare SQL identifier, so `tb4_scratch`
happened to satisfy it. Every real id is a hyphen slug, where it cannot.

This test drives the whole path for `fixture-topic-v0`: the migration
applies, its table exists in the schema, and a sibling's table, a
`topic_*` name, and a `proof_*` object are all still refused — the fix
widened the namespace rather than removing it.

Proven non-vacuous: with the guard's pre-fix logic restored, the test
fails with exactly the LIVE blocker's refusal.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile found that the literal spelling I documented in 724ff5b is
unreachable. `is_topic_scoped` accepts `"fixture-topic-v0_scratch"`, but
`tokens` split on every character that is not alphanumeric, `_`, or `.`,
so the `-` broke the quoted run into `fixture` / `topic` / `v0_scratch` —
none of which is inside the topic's namespace, so a legal quoted name was
refused as unscoped.

`tokens` now keeps a double-quoted run whole, stripping the quotes (and
folding `""` to one literal quote) so the token is the name the database
stores and the deny rules match it exactly as before. The SQL-safe
underscore spelling always worked; this makes the documented literal one
work too.

Non-vacuous: with the tokenizer reverted, both new tests fail — the
quoted-name test on the refusal, and the escaped-quote test showing the
name tokenized as `["a"]`. Quoting remains no escape hatch: a sibling, a
`topic_*` name, and a `proof_*` object are all still refused when quoted.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
`topic install --drive-rlm` prints one line and then nothing until the whole
run returns. Provisioning a VM, the RLM's `propose_rules` job, and a paid
baseline can legitimately take hours, so an operator watching a working run
sees exactly the same output as one watching a run that died — and the
first question a stalled run raises ("why are there still 0 rows in
`proof_baseline_measurement`?") cannot be answered from the CLI at all.

`proof-admin topic lifecycle <id>` reads the durable progress: the newest
`proof_lifecycle_event` transition, the rule version in force and its
provenance, whether a baseline exists and under which version, the full
transition history, and what to do next read off the state. Read-only — it
never writes, never moves the lifecycle, and never spends.

The states it names are the ones that actually stall: `provisioning` (the
VM is being created; re-run the same command to resume if no CLI is
attached) and `baselining` with no baseline row (the RLM's `propose_rules`
→ `baseline` jobs run there, and a paid baseline can take hours). A topic
with no transitions is an error that says nothing has driven it, not an
empty success an operator could misread as healthy.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
LIVE Gate 1 root cause. `task_slice=tb4-first-5` on a pack whose only
slice-ish file was `MANIFEST_FIRST15` (no `slices/`) resolved to nothing,
and `select_base` treated that as *informational* when the pack defined no
slices — falling through to the next rule and scoring **every** task the
pack held. A topic that named 5 tasks was scored on 10, the run overran its
14400 s wall clock, and no baseline was measured at all.

The label is the topic's assertion about *which* tasks to score, so the
guest must never silently widen it. An unresolved label is now a refusal
whether or not the pack defines any other slice, and the refusal names what
the pack does define (the labels and their files, or plainly that it has
none) plus the two ways forward: name the set with `params.tasks`, or add
the slice to the pack.

`task_count` is now accepted as a **legacy alias for `n_tasks`**, used only
when `n_tasks` is absent (`n_tasks` wins). It is a count, never a selector:
it cannot stand in for a slice, so a topic that sets both an unresolvable
slice and a count is refused rather than scored on an arbitrary N.

Tests: the informational case is replaced by three fail-closed cases
(no slices / typo against a pack with slices / never widens to `allow` or
the whole pack), plus the `task_count` alias and the property that a count
cannot substitute for a slice. Proven non-vacuous: with the old escape
restored, 3 tests fail.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The Python suite pins the rule; the shell suite is what runs the real
`proof_filter_tasks` against a real pack, and it only covered a pack that
*had* slices. The LIVE failure was a pack with none, so the exact
configuration was untested at the layer that ran it.

Adds three cases against a pack with no `slices/`: an unresolved label
refuses and the refusal says the pack defines no slices; the same pack
still scores a set the topic names explicitly (the documented escape); and
the run stops before Harbor.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The durable half of the Gate 1 fix. The guest now refuses an unresolved
`task_slice`, which is correct — but a bad selection is then only discovered
after provisioning a VM and booting it, and (for a baseline) after paying
for the run. `deploy/scripts/proof-slice-preflight.sh` runs the **guest's
own** selection on the host in seconds: it execs the adaptor's
`filter_tasks.py` with the arguments `lib.sh` passes, so a PASS is the
guest's own answer rather than a re-implementation that could drift.

It prints `task_slice_resolved`, the kept set, and the source, and asserts
the things the Arch PIN asks for: `resolved=true` when a label was set,
exactly N selected (`--expect`), and the exact ordered set (`--keep`). A
label the pack cannot resolve is a refusal naming what the pack does
define. Read-only: it writes to a temp dir it removes and leaves the pack
byte-identical (asserted).

Also closes a real gap the new tests found: `run-harbor` interpolated
`--n-concurrent` / `--n-attempts` straight from the environment, unlike its
sibling knobs, so a malformed value fell through to Harbor's default
instead of refusing. `proof_harbor_concurrency_flags` validates both and
defaults to 1 — a topic that asked for 5 concurrent trials and silently got
1 would take five times as long and could miss the baseline deadline, which
is the same shape as the Gate 1 failure. Non-vacuous: with the old
interpolation restored, the malformed-value test fails.

CI now runs the adaptor Python suite, the adaptor shell suite, the preflight
suite, and the harbor results gate.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile found the lifecycle command's `open` guidance claimed miners could
submit. That is only true once the document is **published**: `mark_sealed`
records the seal and moves the topic to `open` in the registry, while
`--publish` is a separate step. A seal without it leaves the host serving the
previous document, so an operator reading "miners can submit" would wait for
submissions that can never arrive.

The advice now says the seal is recorded, that an unpublished seal leaves the
previous document live, how to confirm (the published document, `can_score`),
and that re-running `topic seal … --publish` is the retry (the seal is
already recorded, so the same document publishes as-is).

Two tests pin it: the `open` advice must name `--publish`, say the previous
document is still served, and must **not** promise submissions; and each
state's advice must match the state (in-flight `provisioning`, an unmeasured
`baselining` vs one with a baseline, and `draft`). Non-vacuous: with the old
sentence restored, the first test fails.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Two defects found while driving the LIVE gates.

**The preflight could bless a set the guest never runs.** Greptile's open
P1: `proof-slice-preflight.sh` forwarded only `tasks` / `exclude` /
`n_tasks` / `task_slice`, while `lib.sh`'s `proof_filter_tasks` also passes
`task_count` (the legacy alias, only when `n_tasks` is absent),
`filter_rel`, `max_duration_s` and `drop_unknown`. On Greptile's own
fixture the preflight kept three tasks where the guest kept one — a clean
PASS for a run that would have been refused or scored differently. All four
are now forwarded with `lib.sh`'s precedence, and the test is no longer a
set of hand-written expectations: it sources the adaptor's own `lib.sh`,
drives `proof_filter_tasks` the way the guest does, and requires the two to
agree on every selector, including Greptile's exact combined case. Non-
vacuous: with the four arguments removed, the `task_count` case fails.

**A degenerate baseline could be sealed into a permanently dead topic.**
Gate 1 measured five Harbor tasks at `primary=0.0` — a reference run that
solved nothing. This family scores a *relative* win
(`challenger >= bar * (1 + epsilon_rel)`), so a zero bar has no solution:
`relative_win` refuses every challenger and the topic would be open,
scorable and impossible for every miner to pass. That is a real
measurement, not a defect in the seal, so it is refused at the boundary
instead of published:

- `proof_score::bar_is_degenerate` names the threshold `relative_win`
  already used (`1e-12`), so the two cannot drift;
  `family_bar_is_degenerate` scopes it to the relative-win families —
  `nll` compares absolutely, so a zero bar there stays a hard but
  meaningful target, and a missing primary stays `EvidenceMissing` rather
  than being misread as a zero.
- `TopicSetup::mark_sealed` refuses with `SetupError::DegenerateBar`, and
  `proof-admin topic seal` prints the two ways forward (re-run the
  reference against something that scores, or fix the task selection).
- The boot-time baseline file is the other door into the store, so
  `record_one_baseline` applies the same refusal: a hand-written file
  cannot install an unwinnable bar either.

**No auto-reseal.** The stored measurement is left exactly as the RLM
wrote it in both paths; the refusal is the whole behavior. Tests pin that
(`a_degenerate_zero_baseline_is_refused_and_nothing_moves` asserts the
lifecycle stays `baselining`, the version does not move, and the row is
unchanged; `a_baseline_file_cannot_install_a_degenerate_bar` asserts
nothing is recorded), and both are non-vacuous — with the guard removed
the topic opens at `Open` and the file loads.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The paragraph claimed all three exit 3 with "not implemented in this
slice". They are implemented on this branch: `disable` appends a
`disabled` row to `proof_topic_gate` (migration 0026) and
`topic_routes` refuses the next submit with the operator's reason, with
`enable` as the only way back and `seal` the baseline ceremony. An
operator reading the old text would re-sign and re-publish instead of
throwing the switch.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's open P1, both halves.

`mark_sealed` compared the measured primary to the stored row and checked
the seal binds to the document, but never asked whether the rules the
measurement was taken under are still the ones in force. `baseline`
already refuses a vector that moved *while* the run was measuring
(`rules_still_in_force`); the store can still be advanced between that
write and the operator's seal, so a baseline from rules v1 could open and
persist a v2 topic — miners judged by the newer checklist against an older
number. `baseline_still_in_force` is that same invariant at seal time, and
`SetupError::BaselineStale` names both versions.

The lifecycle report had the matching defect: `baselining` recommended
"Seal it" from the mere existence of a baseline row, so an operator would
walk straight into the refusal. The condition now mirrors the check
exactly — the version in force must *equal* the measured one — which also
means a measured baseline with **no** rule row in force is stale rather
than sealable; the old advice promised a seal there too.

Tests, all non-vacuous:

- `mark_sealed_refuses_a_measurement_taken_under_superseded_rules` drives
  the real `mark_sealed` (the wiring, not just the helper) and asserts
  nothing moves; with the call removed the topic opens at `Open`.
- `a_stale_measured_baseline_must_not_seal` pins the helper's contract.
- `the_baselining_advice_refuses_to_recommend_sealing_a_stale_baseline`
  pins the advice, including the no-rules case; with the old condition
  restored it fails.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The corrected paragraph said a disabled topic refuses the next submit
without saying how, so an operator testing Gate 5 has no expected status
to assert. It is **403** with the operator's reason, checked before
anything is spent, and a gate that cannot be read is **503** rather than
an admission on an unread fact.

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

`topic seal` refuses a degenerate bar, which is correct but late: by then
the operator has already put the number in an `open` document and signed
it. `topic baseline` is the read that happens *before* that, so it now
prints `degenerate_bar` in its JSON and a plain warning in the human
output, naming the family rule and the two ways forward.

The flag and the refusal call the same `proof_score` predicate, so the
early read cannot disagree with what the seal will do. `nll` is never
flagged: it compares absolutely, so a zero bar there is a hard but
meaningful target.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Owner's Gate 3 failure: no `pf_` row, with the topic VM `tb4-0001` up from
the baseline while `tb4-x0004` / `tb4-x0002` died at
`ioctl(TUNSETIFF): Device or resource busy` and a 409 `AlreadyExists` when
a recreate met the leftover VM.

Three defects, all in how a VM takes its TAP and how a create treats one
that is already there.

**The allocator only knew its own counter.** `net_index` is process-local,
but a jailed VM outlives the agent that booted it: the jailer is handed
over, not a child of the agent. After a restart — or with a VM an earlier
agent left behind, exactly the baseline's `tb4-0001` — the counter starts
at zero and names `pfc0`, which is still up. `ip tuntap add` answers
`ioctl(TUNSETIFF): Device or resource busy`, so every experiment VM died
before its guest ever booted. `NetPlan::first_free` now asks the **host**
(`ip -o link show`) for the `pfc<n>` that exist and picks the first free
index at or above the counter; `boot_verified` retries on the next free one
when two boots race the same index, so the second boot moves on instead of
failing.

**A failed boot tore down a TAP it never created.** `JailGuard::destroy`
unconditionally ran `net.down()` — `nft delete table` plus `ip link del` —
so the experiment boot that lost the name to `tb4-0001` deleted
`tb4-0001`'s interface and nftables table on its way out. One VM's
collision became another VM's outage, and the topic VM the retry would have
attached to was gone. The guard now tracks whether **its** boot brought the
interface up (`net_brought_up`, set only after `net.up` returns) and
releases the network only then; it still always removes its own jail.

**`AlreadyExists` hard-failed the submission.** A topic that already has a
live VM is not an error — it is the VM the run wants, and an operator
should not have to clear the host by hand. `create` now attaches on the
agent's 409 and returns that handle, so the submit path is idempotent. Only
**topic** specs may attach: an experiment spec asks for a dedicated VM per
paid job, and attaching would run the job in the topic's RLM guest, so a
409 there stays an error.

The host plumbing (`config`, `shell`, `net`) moves to a new `proof-fc-net`
crate, re-exported through `proof-fc-host` so every existing path still
works — the fix took `proof-fc-host` past the 1500 non-test LOC cap, and
these three modules depend on nothing in the backend.

Tests, all non-vacuous (verified by reverting each guard):
`a_leftover_topic_vm_does_not_take_the_next_index` and
`the_scan_counts_only_pfc_indexes` pin the allocator against a canned
`ip -o link show`; `a_guard_that_did_not_create_the_tap_leaves_it_alone`
pins the ownership rule;
`the_created_vm_must_run_the_pinned_image_and_a_fake_answer_is_refused` now
asserts the duplicate create attaches without a second boot;
`an_experiment_create_boots_its_own_vm_and_never_attaches` pins the
exception. The two boot-failure cases in
`a_boot_that_fails_before_the_handshake_releases_its_jail_and_network` now
assert per-case ownership: the `ip tuntap` failure must leave the TAP, the
`nft -f` failure must release it.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Both P1s from Greptile's review of `26cb6b15`, both real.

**A step after `ip tuntap add` failing leaked the interface.** The guard's
ownership flag is set from `NetPlan::up` returning `Ok`, so a failure at
`ip addr add`, `ip link set`, or the `sysctl` left the caller believing it
owned nothing: cleanup removed the jail and left `pfc<n>` up, holding a name
that later boots then allocate around. `up` now rolls the whole plan back
itself once the interface exists — the only place that knows it was created
and not yet handed over.

**The attach fallback did not check what it attached to.** A leftover VM
booted from an older image pin, or at another shape or sandbox policy, would
have run the submission in a guest the topic does not describe, and its
evidence would be evidence about a configuration nobody signed. `create` and
`attach` now share one `check_record`, so the topic bind, image digest,
vCPU/memory shape, sandbox policy, and VM kind are verified on the created
**and** the attached record alike; an incompatible leftover is refused with
the reason, and the VM itself is left untouched.

Tests, both non-vacuous (verified by reverting each fix):
`a_failure_after_the_tap_exists_rolls_the_interface_back` covers all three
post-create steps and asserts a taken name is still *not* rolled back;
`a_duplicate_create_refuses_an_incompatible_existing_vm` boots a VM under an
older pin and asserts the newer run refuses it without booting or reusing
anything.

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

Gate 4 runs two VMs at once, and the retry path added for Gate 3 is what
makes that safe — but nothing exercised it. The recording shell always
answers "the name is free", so it can never show a collision, and the
counter's race window was untested.

`TapNamespace` models the host's TAP namespace: `ip tuntap add` fails with
the kernel's own `ioctl(TUNSETIFF): Device or resource busy` when a name is
taken, `ip link del` frees it, `ip -o link show` lists what is live, and
`rm -rf <jail>` really removes the jail (a shell that only *records* the
removal would leave one behind and make a retry fail for a reason a real
host never produces). It can also stage the race — a concurrent boot taking
an index between this boot's scan and its `ip tuntap add` — and exhaust the
pool outright.

Three tests: a leftover topic VM holding `pfc0` does not take the next boot
off the air (it takes `pfc1`, and its teardown frees only its own TAP); a
raced index moves the loser to the next free one, with the failed attempt's
jail released in between; and a host with no free TAP refuses after exactly
`TAP_ATTEMPTS` attempts instead of spinning. All three are non-vacuous —
with the retry loop removed, two of them fail.

Writing them found two defects in the fix itself: the counter used `store`
where a concurrent boot needs `fetch_max` (a slower boot could move it
backwards past an index the faster one had claimed), and `TAP_ATTEMPTS` was
documented as retries while the loop counted attempts, so the budget was
silently one short. Both are corrected here, with `TAP_ATTEMPTS` now
counting attempts (so `1` means "no retry").

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Gate 4's OOM. Two 8192 MiB experiment VMs beside the topic's resident
8192 MiB RLM VM on a 16 GiB host with swap=0: the **count** cap
(`MAX_EXPERIMENT_VMS=2`) was satisfied, so all three booted, the kernel
OOM-killed the guests, and **both** submissions answered 503 with no row.
Gate 3 (one topic + one experiment) fits and passes.

A count is not a capacity cap. `MemoryBudget` reads `MemTotal` once at boot
and refuses a boot that would not fit, with `503 capacity` naming what holds
the memory, what was asked for, and the ceiling — so the requests that fit
keep running and only the one that does not is refused. Refusing one is
strictly better than losing both.

Every live VM counts, **the topic's RLM VM included**: it is resident for
the topic's whole life and is not free capacity. An experiment-only budget
is exactly the mistake the count cap made. Crashed VMs are swept before the
check, so they do not hold budget.

`PROOF_VM_AGENT_MEMORY_RESERVE_MIB` (default **0**) is the operator's
headroom. The default is deliberately 0 so the shape that demonstrably works
keeps working — a topic VM beside one experiment VM fills a 16 GiB host
exactly, and a non-zero default would refuse Gate 3. Nothing here sizes a
VM: the numbers stay the topic's and the operator's, and this only decides
whether the host can carry what was asked for. No swap, and no default
memory change: a host that cannot fit its own ceiling set is a sizing
problem the operator fixes, not one the agent papers over by clamping.

`GET /v1/health` now carries `total_mib` / `reserve_mib` / `used_mib`, so an
operator reading a `503 capacity` sees what the host thinks it has without
reaching for `free`. An older agent omits them and reports 0 rather than a
guess.

Tests, all non-vacuous (verified by removing the check): the Gate 4 shape is
refused before any jail with the numbers in the message; the Gate 3 shape
still fits; the guard counts the topic VM rather than experiments alone; the
reserve lowers the ceiling; and an unset budget admits everything, so a
state built without one behaves exactly as before.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's P1 on `f0800353`, and it was the risk that mattered: the guard
would have taken the **proven Gate 3 pair** offline.

`MemTotal` is the RAM the kernel can hand out, not the RAM the host has. A
nominal 16 GiB droplet reports `MemTotal: 16326344 kB` = 15_943 MiB — ~441
MiB the kernel keeps for itself. Comparing guest memory against that figure
refused the 8192 MiB experiment VM beside the resident 8192 MiB topic VM on
exactly the host where that pair demonstrably passes, so the fix for Gate 4
would have broken Gate 3.

`MemoryBudget::from_meminfo` now rounds `MemTotal` up to the whole GiB the
host was sold as. That is not an invented tolerance: RAM ships in whole GiB,
so this restores the operator's number and nothing more — a nominal 16 GiB
host admits the two 8 GiB guests it runs and still refuses Gate 4's third
(three 8 GiB guests on a 16 GiB host is the oversubscription that OOM-killed
every guest at ~154% of `MemTotal`). A host already reporting a whole GiB is
unchanged.

The regression test drives `from_meminfo` — the real read path — with
Greptile's exact `MemTotal` line, so it fails if the rounding is dropped; a
test that built the budget by hand would have passed with the bug in, which
is how the first version of it read. Verified non-vacuous: reverting the
rounding fails it. A second test pins the raw figure refusing the working
shape, so the rounding cannot be dismissed as noise later.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Checking my own guard for blind spots turned up the boundary that has to be
written down, because getting it wrong in either direction breaks a gate
that passes today.

The guard compares **configured** guest memory against the host's. It is not
a residency model, and the sister guest is deliberately outside it:

- A paid run on the proven Gate 3 shape is 8 GiB topic + 8 GiB experiment +
  the 4 GiB **sister** = 20 GiB of configured memory on a 16 GiB host, and it
  **passes**. Guest RAM is lazily populated — Firecracker maps the region and
  the guest touches pages as it works — so the configured sum is not the
  resident set.
- The sister is booted by the hypervisor inside a job, so it never enters
  `state.vms` and never enters the count. Counting it would refuse the Gate 3
  shape; that is the same class of mistake as `f0800353`'s raw-`MemTotal`
  comparison, which Greptile caught taking that shape offline.

So the guard is sized to refuse the oversubscription that actually happened
(three 8 GiB guests on a 16 GiB host) with margin, not to model the kernel.
The doc says so at the function, and `the_guard_is_not_a_residency_model`
pins the arithmetic both ways so a later "fix" that sums every guest the host
could ever boot fails a test instead of a live gate.

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

Arch PIN hardening, points 1 and 2.

**The refusal now leads with what is free.** An operator reading a 503 gets
`N MiB requested, N MiB free of the N MiB VM ceiling … N MiB is held by M
live vm(s) [topic <id> (N MiB), experiment <id> (N MiB)]` and the sentence
that says why it refuses at all: keeping the running VMs alive instead of
letting the host OOM-kill them. The wording is deliberately distinct from the
`MAX_EXPERIMENT_VMS` refusal — a host too small for the shape is not "retry
when one finishes" — and a test pins that the two cannot be confused.

**The tipped Gate 4 retry shape is pinned.** Owner is tipping staging to
`experiment_mem_mib: 4096`, so a 16 GiB host runs the resident 8 GiB topic VM
beside **two** 4 GiB experiment VMs: 16,384 MiB, an exact fit, both admitted.
That is the retry Gate 4 depends on, so an off-by-one or a non-zero reserve
default must fail a test rather than stall the gate — verified non-vacuous: a
1 MiB phantom reserve fails seven tests including this one. The runbook now
works the arithmetic through for both 4096 and 8192 asks.

PIN 2 (teardown / TAP / `AlreadyExists` idempotency) is unchanged and still
covered by the tests from `26cb6b15` / `0c207628` / `e48bcda9`. PIN 3 (park
the topic VM before dual eval) is **not** done: `inspect` — the anti-cheat
checklist that authorises spend — still runs on the topic VM, so it is not
unused. PIN 4 holds: `DEFAULT_MAX_EXPERIMENT_VMS` is still 2. PIN 5: draft,
not merged.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's P2 on `7095d5a6`. When the ask alone is larger than the whole VM
ceiling, "free a vm, retry when one finishes" is a loop that cannot end:
nothing freed can make that request fit. The refusal now splits on whether
freeing memory can help at all.

- `want > ceiling`: the ask is larger than the host's whole VM ceiling, so
  the refusal says so and names the remedies that can work — a smaller ask
  or a host whose memory carries it.
- otherwise: the normal advice, because freeing a vm genuinely can help.

Both are pinned, and the boundary between them is: a request that would fit
an empty host keeps the normal advice, so the two cases cannot be conflated
in either direction. Non-vacuous — reverting to the single always-free-a-vm
sentence fails the test.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Auditing the RLM-authorship PIN against the code, one requirement does not
hold as the prose reads: **the RLM cannot author a topic's migrations or
APIs.**

The job surface is `VmJob::{ProposeRules, Baseline, Inspect, Evaluate,
Archive}` and the only behavior it can hand back is
`VmJobOutput::Rules(Vec<ChecklistRule>)`. A topic's `migrations` and `apis`
come from the operator's bundle `rlm` section
(`proof-topic-install::SectionPlan`) and are applied by the install. There is
no job that emits them, no wire message that could carry one, and no command
that generates a bundle — an operator writes it.

What *is* RLM-authored is the **rule vector**: `propose_rules` runs inside
the topic VM and its answer becomes the version in force
(`proof_rule_version.source = 'rlm'`), which is the gate an `open` document
must pass. That requirement holds and is enforced in three places
(`propose_rules` read-back, the boot admission gate, and the seal-time
version checks).

So the claim was over-broad, not the code. The prose said the bundle's `rlm`
section (rules / migrations / apis / …) "is handed to the RLM" and called
topics "RLM-owned", which reads as authorship of all five parts. Corrected
in `ARCHITECTURE.md`, `COMPLETENESS.md`, `PROOF.md`, and the bundle crate's
own module doc: the section is *topic-owned data* Rust never interprets, and
**rules** are the part the RLM additionally *authors*.

`the_rlm_authors_rules_and_the_bundle_carries_migrations_and_apis` pins the
boundary structurally — it reads the job/output enums and the section's
parts, and fails if a variant that could carry a migration or an API
appears, so a future change that widens authorship fails a test instead of
silently outdating four documents. Non-vacuous: injecting a `Migrations`
variant into `VmJob` fails it. (The first version of this guard matched only
the tuple spelling `Migrations(`, so the injected struct variant
`Migrations { … }` slipped past it; it matches the name now.)

This is the honest reading of the PIN, not a workaround: nothing here claims
the RLM authors schema or routes, because it does not.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
echobt and others added 3 commits September 16, 2026 00:42
…enylist

Greptile's P2 on `81db11a1`, and it is the right critique: a denylist of
names this test happens to think of cannot hold an authorship boundary. It
demonstrated the bypass concretely — adding `ApplySchema(Vec<SchemaSpec>)`
and `DeployRoutes { routes: Vec<RouteSpec> }` to `VmJob` authors schema and
routes without naming either, and the old guard passed.

The check now enumerates the **whole** permitted surface and fails on
anything else: `enum_variants` reads the top-level variants of `VmJob` and
`VmJobOutput` and asserts the exact lists. A new variant has to be added
deliberately, which is the moment the prose gets updated with it — the
inversion is the point, since the failure mode being guarded is a *silent*
widening.

Non-vacuous against the exact bypass Greptile built: injecting those two
neutral-named variants fails with
`left: [… "ApplySchema", "DeployRoutes" …] right: [ProposeRules … Archive]`.
The extractor is pinned too (both variant shapes, and a missing enum yields
nothing rather than a guess).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The evidence pack for the RLM-authorship DONE criterion, covering all six
checklist items with the code path and the command/observed result for each.

Two things in it are stated rather than smoothed over:

**`pin_policy` does not exist in this tree.** The checklist names it as
journal content; there is no such field, column, or parameter
(`rg -rni 'pin_policy' .` is empty). What exists is the substance: the pin is
global and per-challenge (`config/proof-pin.toml`), and a topic may only
tighten a floor (`TopicError::LoosenedFloor`, `crates/proof-task/src/topic.rs`).
Reported by name rather than renaming something to match the checklist.

**Live staging journal rows are not claimed.** Item 2's evidence is a real
Postgres running the real install path (26/26 migrations applied, the
DB-gated install-engine suite green, journal fields asserted) — but it is a
scratch DB in this container, not cortex-staging. The pack gives Arch the
three queries to read the staging rows directly.

Also recorded honestly: `cargo deny` fails on RUSTSEC-2026-0285
(`rustls 0.23.43`), which is pre-existing at `6712e7b0` and untouched by this
branch; and four test failures in this container are environmental (they
assert `0o000` permission denial, which root bypasses — verified identical at
`6712e7b0` in a detached worktree).

Every cited line number and command output was verified against the tree at
`612bdbd1` before committing.

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

Incorporates Dev's live `cortex-staging` dig and closes the checklist boxes that
were still thin.

**Live staging evidence (item 2, strongest in the pack):** `proof_rule_version`
v5–v7 for `tb4` are all `source=rlm` (v7 `7231a716…`); install #11 is `applied`
with `rules_version=7`, `migrations=["0001_scratch"]`,
`binding.runner=rlm_fc_in_guest_harbor`, `vms_per_submission=1`,
`custom_id=tbench`; `proof_topic_api` holds only `GET status`.

**The authorship gap, stated exactly (§2f).** The checklist asks to prove
migrations / apis / submission_format / pin_policy are RLM-authored. They are
not: the job surface is `VmJob::{ProposeRules, Baseline, Inspect, Evaluate,
Archive}` and the only behavior it can return is
`VmJobOutput::Rules(Vec<ChecklistRule>)`. There is no variant that carries a
migration or a route, and no wire message that could transport one. The pack
gives the per-part table (who authors what, where it lands, how it is enforced)
and scopes the four things closing it would take. Not started: it changes what
the RLM *is*, and the PIN reads as a claim about the system, not a work order.

**The live SoT gap (§3e).** B1 FIXED was driven by a human-authored YAML, and
the pack says so rather than rounding up. What holds: the host serves what
`proof_topic_version` holds, and admits an `open` document only under an
`applied` install with RLM-authored rules, so a local YAML has no path to being
served on its own. What does not: the document and the schema are the
operator's. The honest label is "RLM-ruled, operator-declared".

**Before/after inventory (item 4).** `bins/proof-admin/src/main.rs` production
literals **6 → 0** across `8e36538a`; the six removed are CLI usage examples and
doc-comment examples, now generic (`--bundle <path>`). The one `terminal-bench`
left in `bins/proof-admin/tests/cli.rs:1139` is a member of that file's own
forbidden-literal guard — the check, not a usage. (An earlier draft of this
section said 46 for that file; that was a shell bug in my counting script and is
corrected here.)

Also corrected: the tip is `e204b426`, and PR #302 now exists for this session's
branch — the earlier "pr_url was null" report was accurate, the branch had never
been pushed.

Every cited line number and number re-verified against the tree at `e204b426`
before committing.

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

echobt commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

RLM-authorship evidence pack (v2)

Pack: docs/evidence/rlm-authorship-evidence.md @ cb14a9fa
Tip: droid/2edcb0c8-100-rlm-autonomous-strip-tbe @ cb14a9fa (this PR, stack head)
Mirror: #302 carries the same HEAD
Checklist: RLM-AUTHORSHIP-EVIDENCE-CHECKLIST.md · Pin: ARCH-PIN-100PCT-RLM-AUTONOMOUS.md

Dev: copy docs/evidence/rlm-authorship-evidence.md to /workspace/proof-admin-live/RLM-AUTHORSHIP-EVIDENCE.md (and the sn100-arch copy). The body below is the same file.


RLM-authorship evidence pack

Checklist: RLM-AUTHORSHIP-EVIDENCE-CHECKLIST.md · Pin: ARCH-PIN-100PCT-RLM-AUTONOMOUS.md

Tip under review: droid/2edcb0c8-100-rlm-autonomous-strip-tbe @ e204b426
(PR #301, draft — the stack head).
Mirror PR #302 carries the same HEAD.
Every path below is in this repo; every SHA is a commit on that branch or its stack.

How to read this pack. Each item states the claim, the code path that makes it
true, and the command + observed result that demonstrates it. Evidence is labelled by
source: [staging] = Dev's live cortex-staging dig, [local-db] = a real Postgres
in this container running the real install path, [tree] = read from this checkout.
Nothing is presented as live staging output that was not.

Verdict summary

# Item Verdict
1 CLI trigger-only met
2 Journal: rules source=rlm + migrations + proof_topic_api + submission_format + pin_policy + runner partly met — rules are RLM-authored [staging]; migrations/apis/submission_format come from the operator bundle, and pin_policy does not exist. See §2f
3 SoT ≠ operator clone of legacy tbench met in code, with a live gap: B1 FIXED used a human YAML (§3e)
4 Residual product hardcode ZERO met — before/after below
5 1 VM/submission met
6 Tips / checks / PRs given

The one open authorship gap is item 2, and it is architectural, not a defect: the RLM
authors rules; migrations, APIs, submission-format and scoring travel in the operator's
bundle. §2f states exactly what would have to change, and why I did not change it unasked.


Item 1 — CLI is trigger-only (no operator topic logic)

Claim. proof-admin asks the RLM to install and set the topic up; it carries no
topic-specific behavior of its own.

Surface (bins/proof-admin/src/main.rs):

Subcommand Line Role
validate 114 runs the same acceptance the admin route runs
install 129 drives install (+ --drive-rlm for the RLM lifecycle)
list 181 read-only view of proof_topic_version
lifecycle 190 read-only journal/state view
install-log 195 read-only install journal
show 201 read-only document view
alias 206 alias rows
disable / enable 218 / 233 the operator gate switch
baseline 245 reads the RLM's measured baseline
seal 260 records the seal, optionally publishes

Evidence — no topic literal anywhere in the CLI's source:

$ rg -n 'tb4|tbench|terminal-bench|harbor' bins/proof-admin/src/*.rs | grep -v '^\s*//'
(no output — zero matches in non-comment code)

Evidence — the bundle is hand-authored input, not generated by any command. There is no
render/generate/new subcommand that emits a bundle or an rlm section; the CLI reads
one (bins/proof-admin/src/main.rs:512 load_bundle) and hands its rlm part to the install
verbatim (bins/proof-admin/src/install.rs:243 rlm_raw: bundle.rlm.raw()).


Item 2 — TopicSetup journal: what lands, and with what provenance

Claim. An install lands migrations, routes, rules, the submission-format and scoring
digests, and the executor binding — and the rule vector's provenance is what gates open.

2a. The journal schema

Object Migration What it carries
proof_topic_install crates/db/migrations/0025_proof_topic_install.sql:48 topic_id, bundle_digest, environment, state, rules_version, rule_ids, migrations (ordered), binding
proof_topic_api …0025…:88 topic_id, path (topic-relative, no leading slash), method, summary

proof_topic_api is the dynamic route registry — the challenge mounts it
(challenge_router(state, topic_routes)), so a topic's routes are served from the DB rather
than a compiled table.

2b. The executor binding (the pin-carrying record)

crates/proof-topic-install/src/install.rs:111 ExecutorBinding:

pub handler: String,                    // allow-listed family ("harbor")
pub runner_id: Option<String>,          // the signed document's runner
pub custom_id: String,                  // the topic's metric.custom_id
pub pack_digest: Option<String>,        // the signed document's pack pin
pub vms_per_submission: u32,            // always VMS_PER_SUBMISSION (=1, item 5)
pub submission_format_digest: Option<String>,
pub scoring_digest: Option<String>,

2c. Provenance: the install seeds topic_document, never rlm

crates/proof-topic-install/src/install.rs:493 install_rules writes
source: RuleSource::TopicDocument — the operator's signed vector — and says so:

Provenance is the point here. What this seeds is topic_document … An install therefore
never makes a topic's behavior RLM-authored, and the gates that admit an open topic read
the provenance rather than this row's presence.

source = rlm is reached only by the RLM running its own propose_rules inside the
topic VM. Enforced in three places:

Gate Path Refuses
propose_rules read-back crates/proof-topic-setup/src/lib.rs:388 a version whose source != Rlm or whose digest moved
boot admission bins/proof-challenge/src/main.rs:854 load_topics an open doc whose install is not applied or whose rules are not RLM-authored (skipped, logged)
seal-time crates/proof-topic-setup/src/lib.rs:415 + :439 a measurement taken under a superseded / no-longer-in-force rule version

2d. Observed result — live cortex-staging [staging]

Dev's dig on cortex-staging, which is the strongest evidence in this pack because it is the
real host:

Fact Value
proof_rule_version for tb4, versions v5–v7 all source=rlm
v7 digest 7231a716…
install #11 applied, bundle sha256:d2429e99…
rules_version 7
migrations ["0001_scratch"]
binding.runner rlm_fc_in_guest_harbor
binding.vms_per_submission 1
binding.custom_id tbench
proof_topic_api only GET status for tb4

What this proves. The rule vector in force on staging is RLM-authored, three versions
deep (v5–v7), and the install journal's rules_version matches it (7). The VM pin is 1.
The routes table is live and scoped to the topic.

What it also shows — and this is the gap. migrations = ["0001_scratch"] and
apis = [GET status] are the bundle's section, applied by the install. Nothing in the
journal records an RLM as their author, because nothing can: see §2f.

2e. Observed result — real database, real install path [local-db]

The install engine's suite is DB-gated (crates/proof-topic-install/tests/install_engine.rs:3
— runs when DATABASE_URL names a Postgres). Run here against a real Postgres 18 with
this branch's migrations applied:

$ sqlx migrate run --source crates/db/migrations     # applied 26/26
$ DATABASE_URL=postgres://…/proof_authz_evidence cargo test -p proof-topic-install
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok.  7 passed; 0 failed; …
test result: ok. 15 passed; 0 failed; …
test result: ok.  6 passed; 0 failed; …

The journal-recording test
(install_engine.rs:197 a_permitted_bundle_installs_and_the_journal_records_it) asserts the
item-2 fields directly:

report.migrations_applied == ["0001_scratch", "0002_index"]   # in bundle order
report.rules_version      == 1
report.rule_ids           == ["no_short_circuit", "no_holdout_leak"]
report.binding.handler    == "harbor"
report.binding.runner_id  == Some("operator_adaptor_v0")       # the document's runner
report.binding.vms_per_submission == 1
report.binding.submission_format_digest.is_some()
report.binding.scoring_digest.is_some()
report.apis.len() == 2
SELECT to_regclass('tb4_scratch') == "tb4_scratch"             # the migration really ran

The scratch DB was dropped after the run. This is a real install path on a real database —
but it is not cortex-staging; §2d is.

2f. The authorship gap, stated exactly

The checklist asks to prove migrations / apis / submission_format / pin_policy are
RLM-authored. They are not. Here is the whole boundary:

Part Who authors it Where it lands Enforced how
rules the RLM (propose_rules in its topic VM) proof_rule_version.source='rlm' three gates: propose_rules read-back (proof-topic-setup/src/lib.rs:388), boot admission (bins/proof-challenge/src/main.rs:854), seal-time version checks (:415, :439)
migrations the operator (bundle rlm section) applied by the install; names in proof_topic_install.migrations deny-list (proof-topic-sql-guard) before the first statement runs
apis the operator (bundle rlm section) proof_topic_api rows path/method shape checks; topic-relative paths only
submission_format the operator (bundle) digest in binding.submission_format_digest shape + digest
scoring the operator (bundle) digest in binding.scoring_digest shape + digest
pin_policy does not exist the pin is global (config/proof-pin.toml); topics may only tighten (TopicError::LoosenedFloor, crates/proof-task/src/topic.rs:506)

Why the RLM cannot author the others today: the job surface is
VmJob::{ProposeRules, Baseline, Inspect, Evaluate, Archive} and the only behavior it can
return is VmJobOutput::Rules(Vec<ChecklistRule>)
(crates/proof-rlm/src/vm.rs:203, :315). There is no variant that carries a migration or
a route, and no wire message (proof-vm-proto::guest) that could transport one. The install
seeds rule version 1 as RuleSource::TopicDocument by design
(crates/proof-topic-install/src/install.rs:493), and says so:

Provenance is the point here. What this seeds is topic_document … An install therefore
never makes a topic's behavior RLM-authored.

What closing it would require (scoped, not started — it is a real architectural change,
not a doc fix):

  1. Two new VmJobOutput variants (e.g. schema + routes) and the corresponding VmJob
    inputs, so the RLM can propose them the way it proposes rules.
  2. A guest-side authorship path: the adaptor would have to emit them from the topic VM, with
    the same read-back-and-verify provenance the rule path has.
  3. A new trust story for RLM-written DDL — the deny-list stays, but "the RLM authored
    this table" becomes a claim the install must verify rather than assume.
  4. Versioning + supersede semantics matching the rule path (rules_still_in_force has no
    analogue for schema), and journal columns recording provenance per part.

I did not do this unasked: it changes what the RLM is, and the PIN reads as a claim about
the system rather than a work order. Say the word and it becomes its own PR.


Item 3 — Source of truth is not an operator clone of legacy tbench

Claim. No human-minted tb4/tbench document is the source of truth; the topic
registry is the database, and the topic's behavior is RLM-authored.

Evidence 3a — no committed topic document. No topic draft (.yaml/.yml) exists
anywhere in the tree, and no committed .json carries "id": "tb4" as a topic document.
The only tb4/tbench JSON is a results fixture
(crates/proof-results/fixtures/harbor-trials-v1.json), which is a wire-shape fixture, not
a topic.

Evidence 3b — the "locked default" prose is gone. Commit 8e36538a ("the bundle owns
topic behavior; the CLI hands it to the RLM") removed the earlier wording that made tb4 /
tbench locked defaults:

- … Locked defaults: first slug `tb4` with temporary alias `tbench` (`proof_topic_alias`), …
- … Locked defaults: first topic slug **`tb4`** with temporary alias **`tbench`** …

Evidence 3c — remaining tb4 references are test fixtures, below #[cfg(test)].
In crates/proof-topic-bundle/src/lib.rs the marker is at line 942; the fixtures
(fn tb4() -> TopicInstallBundle at 983, assert_eq!(plan.topic_id, "tb4") at 1022) are
all after it. In crates/gateway/tests/, crates/proof-rlm-store/tests/, and
crates/proof-topic-install/tests/ they are test code by construction.

Evidence 3d — which topics exist is a DB fact. proof_topic_version +
proof_topic_install; the challenge's load_topics admits only documents whose install is
applied and whose rules are RLM-authored (§2c). On staging, proof_rule_version v5–v7 for
tb4 are all source=rlm [staging] (§2d).

3e. The live SoT gap — B1 FIXED used a human YAML

Dev's dig found the thing that matters: the B1 FIXED run that took Gates 1–6 to GO was
driven by a human-authored YAML topic document.
That is not the final authorship SoT, and
this pack does not claim otherwise.

What it means precisely, and what it does not:

Question Answer
Was the rule vector the RLM's? Yesproof_rule_version v5–v7 all source=rlm [staging]. The authorship gate for rules held on the live host.
Was the document (statement, metric, constraints, floors, pack pin) the RLM's? No — an operator wrote it. It is signed by the topic key, so the host trusts it, but a human chose its contents.
Does a human YAML have a path to being served? No, not on its own. The host serves what is in proof_topic_version, and only admits an open document whose install is applied and whose rules are RLM-authored. A local YAML that was never installed and never had rules proposed is not admitted.
So is the SoT "operator clone of legacy tbench"? No — but it is operator-authored, which is one step short of the PIN's intent.

The honest distinction: the PIN's first clause ("no operator-cloned tb4 as SoT") holds —
nothing in the tree is a clone of the legacy topic, and the host's registry is the DB. The
PIN's deeper intent ("the topic authors itself") is partly met: the rules are the
RLM's, the document and the schema are the operator's.

What would close it: the same work as §2f — the RLM has to be able to propose the parts
of the document that are currently hand-written (at minimum constraints.params, the metric
choice, and the migrations/APIs), with the provenance recorded per part. Until then, a topic
is "RLM-ruled, operator-declared", and the pack should say so rather than round up.

Diff vs a human-minted tb4 YAML: there is no such YAML in this repo to diff
against. The pre-8e36538a prose ("Locked defaults: first slug tb4 with temporary alias
tbench") is the closest thing that existed, and it was documentation of an intended
default, not a document the host read. The live B1 YAML is operator-held on staging, outside
this repo; the check that matters is the one above — the host only serves what
proof_topic_version holds under an applied install with RLM-authored rules.


Item 4 — Residual tbench/tb4 product hardcode: ZERO on tip

Claim. No product branch carries a topic id, benchmark name, or results-contract id.

Guardcrates/proof-topic-bundle/src/lib.rs:1300 no_topic_id_is_compiled_into_the_product_branches scans 32 product modules (challenge,
gateway, orchestrator, guest, bundle, CLI) for
FORBIDDEN_LITERALS = ["tbench", "tb4", "terminal-bench", "terminal bench", "harbor-trials"]
(guard at :1865), using a structural strip that removes test modules and masks non-code
braces (ec13bb4a fixed a brace-counting hole; 7b05a293 closed blind spots Greptile
found).

$ cargo test -p proof-topic-bundle no_topic_id_is_compiled_into_the_product_branches
test result: ok. 1 passed; 0 failed

Before/after inventory. "Production code" below means every line before the file's
first #[cfg(test)]; the pre-strip column is 8e36538a^ (the commit that moved topic
behavior out of product code), the tip column is e204b426.

Module Pre-strip Tip
bins/proof-admin/src/main.rs 6 0
crates/proof-challenge/src/topic_routes.rs 0 0
crates/proof-challenge/src/lib.rs 0 0
crates/proof-challenge/src/emit.rs 0 0
crates/gateway-core/src/topic_routes.rs 0 0
crates/gateway-core/src/admin_route.rs 0 0
crates/gateway-core/src/proxy_paths.rs 0 0

The six that were removed from proof-admin's production code were CLI usage examples and
doc-comment examples, e.g.:

62:   proof-admin topic validate --bundle tb4.json --pin config/proof-pin.toml
65:   proof-admin topic install --bundle tb4.json --env metal --dry-run
171:         /// The alias slug (e.g. `tbench`).
173:         /// The canonical topic slug it resolves to (e.g. `tb4`).
595:     // An alias resolves to its canonical slug first, so `show tbench` finds
596:     // `tb4`. Resolution is fail-closed in the store: an alias whose topic has

They are now generic (--bundle <path>), which is the difference between "the CLI knows a
topic" and "the CLI takes one".

bins/proof-admin/tests/cli.rs carries one occurrence of terminal-bench — inside the
CLI's own guard test, as a member of its forbidden list (cli.rs:1139). It is the check,
not a usage.

Whole-tree production-code sweep on the tip (the 32 guarded modules plus the CLI):

$ cargo test -p proof-topic-bundle no_topic_id_is_compiled_into_the_product_branches
test result: ok. 1 passed; 0 failed

Hand grep on the modules with the most historical hits — raw hits vs hits before each
file's first #[cfg(test)]:

Module Raw hits First #[cfg(test)] Production-code hits
crates/proof-challenge/src/topic_routes.rs 20 line 257 0
crates/gateway-core/src/topic_routes.rs 12 line 103 0
crates/gateway-core/src/admin_route.rs 1 line 83 0

Known remaining occurrences on tip, none of which is a product branch:

Where What Why it stays
crates/proof-results/src/lib.rs:94 CONTRACT_HARBOR_TRIALS_LEGACY = "tbench-harbor-v1" a wire value: a topic signed before the generic id pins it, and a signed document cannot be edited. proof-results is deliberately not in the guarded 32 because the id is its interface; no guarded module may branch on it
deploy/guest/runners/…/lib.sh, summarize.py comments metal RCA notes (tbench-x0004, x0032, x0039) comments recalling which metal run showed a defect
deploy/guest/runners/…/tests/* fixtures test code
docs/runbooks/* metal RCAs documentation

The shipping adaptor has its own genericity guard —
deploy/guest/runners/rlm_fc_in_guest_harbor/tests/test_filter_tasks.py:399 test_no_compiled_task_names_or_modes asserts filter_tasks.py contains none of
first15 / first-15 / shortpack / x0017 / tb4 / duration_hints.


Item 5 — 1 VM per submission, and no product default of max_experiment_vms = 1

Claim. The one-VM-per-submission pin holds, is recorded, and is enforced on the submit
path; the experiment-VM count default is unchanged.

Evidence 5a — the pin.

// crates/proof-topic-install/src/install.rs:79
pub const VMS_PER_SUBMISSION: u32 = 1;

Its doc comment states the intent: "the constant is the pin the install records; a future
slice cannot quietly allow a second concurrent VM per submission without changing this value
and the journal rows that carry it."
It lands in every install's ExecutorBinding
(:123), and the install engine's own test asserts it
(crates/proof-topic-install/src/lib.rs:147 assert_eq!(VMS_PER_SUBMISSION, 1)).

Evidence 5b — the host refuses a topic installed under a different pin. On the submit
path (crates/proof-http/src/operator.rs:108):

Some(n) with n != 1 is refused on the submit path: this build runs one VM per
submission
, and a topic installed with a different pin is one it cannot honour.

Evidence 5c — the product default is untouched (checklist item 4 of the Arch PIN):

$ grep -n 'DEFAULT_MAX_EXPERIMENT_VMS: usize' crates/proof-vm-agent/src/router.rs
55:pub const DEFAULT_MAX_EXPERIMENT_VMS: usize = 2;

Still 2. The Gate 4 hardening added a second cap beside it (host memory admission,
f0800353dbd26cdd) — it did not replace or lower the count cap.


Item 6 — Tips, checks, and the stacked PRs

Branch tips

PR Branch Tip Base Draft Merge state
#297 droid/795020b8-sn100-p0-topics-table-admin f298c4d7cfbf main yes BLOCKED (branch protection)
#298 droid/9f68584e-sn100-p1a-rlm-topic-install b735f3358d3d #297 yes CLEAN
#299 droid/9822d526-sn100-100-live-gaps-p1b-disa 37fa0920610c #298 yes CLEAN
#300 droid/933f76bf-b1-raise-max-proof-deadline 870a3b875533 #299 yes CLEAN
#301 droid/2edcb0c8-100-rlm-autonomous-strip-tbe e204b426 #300 yes CLEAN
#302 droid/1d0afa5f-sn100-stay-lit-cont-gate1-pa e204b426 #300 yes CLEAN

main is aabd1724eb90. The stack is linear: #301#300#299#298#297main.

#301 is the canonical stack position. #302 is a mirror this session opened so the branch
has its own URL (the earlier "pr_url was null" report was accurate for
droid/1d0afa5f-…, which had never been pushed — it is pushed now, and #302 is its PR).
Both PRs carry the same HEAD; merge #301.

Checks

PR CI (ci.yml) Greptile
#297 SUCCESS (run 34858683718, 5m16s) + CodeQL SUCCESS + Analyze SUCCESS SUCCESS
#298 not triggered (base is a droid branch, not main) SUCCESS
#299 not triggered SUCCESS
#300 not triggered SUCCESS
#301 not triggered SUCCESS (69 files reviewed, 0 comments)
#302 not triggered (mirror of #301) see PR

Why CI runs only on #297: ci.yml triggers on pull_request: branches: [main]. #297 is
the only PR in the stack whose base is main; #298#301 are stacked on each other, so
GitHub never fires that workflow for them. To compensate, every gate ci.yml runs was
executed locally on the tip — see below.

Local gate run on 612bdbd1 (CI parity)

Gate Result
cargo fmt --all -- --check pass
cargo clippy --workspace --all-targets -- -D warnings pass
cargo test --workspace pass (4 pre-existing failures excluded — see caveat)
cargo run -p xtask -- loc-cap pass
cargo run -p xtask -- consensus-lint pass
cargo run -p xtask -- spec-check pass
cargo run -p xtask -- design-check pass
cargo run -p xtask -- external-docs-check pass
cargo deny check advisories FAILED — pre-existing, see caveat
adaptor suites (python3 -m unittest …, test_adaptor.sh) pass
deploy/scripts/test_proof_slice_preflight.sh pass

Two caveats, stated rather than buried:

  1. cargo deny check fails on RUSTSEC-2026-0285 (rustls 0.23.43, "TLS 1.3 handshake
    messages incorrectly accepted across encryption level boundaries"). This is pre-existing
    and not introduced by this branch
    : rustls 0.23.43 is the version at 6712e7b0 and the
    lockfile's rustls entry is untouched by these commits. It needs a cargo update -p rustls
    sweep, which is its own change and touches every crate that depends on rustls. Flagged,
    not silently ignored. Note feat(proof): proof-admin install bundle over the existing topic publish path (P0) #297's CI was green when it last ran (2026-09-14), before this
    advisory landed in the database.
  2. Four test failures in this container are environmental, not regressions. They fail
    identically at pristine 6712e7b0 (verified by running them in a detached worktree at
    that SHA) because they assert on 0o000 permission denial, which root bypasses — this
    container runs as uid 0:
    seed_pf_allocator_refuses_boot_when_a_topic_dir_cannot_be_read,
    max_zip_numeric_id_fails_closed_when_a_topic_dir_cannot_be_read,
    paid_run_fails_closed_when_work_tree_cannot_be_synced,
    deadline_cut_still_persists_work_tree. They are excluded from the "pass" above, not
    hidden.

Summary

# Item Verdict
1 CLI trigger-only met — zero topic literals in bins/proof-admin/src; no bundle-generating command; six such literals removed by 8e36538a (before/after in §4)
2 Journal: rules source=rlm, migrations, proof_topic_api, submission_format, runner partly met — rules are RLM-authored on live staging (v5–v7, source=rlm); migrations / apis / submission_format / scoring come from the operator bundle; pin_policy does not exist (§2f gives the exact boundary and what closing it needs)
3 SoT ≠ operator clone of legacy tbench met in code; live gap flagged — B1 FIXED was driven by a human YAML (§3e). The host's SoT is the DB, and an operator YAML has no path to being served without an applied install + RLM-authored rules
4 Residual product hardcode ZERO metproof-admin production literals 6 → 0; 32-module guard passes; hand grep shows 0 hits before every #[cfg(test)]; the legacy tbench-harbor-v1 string is a signed-document wire value, not a branch
5 1 VM/submission metVMS_PER_SUBMISSION = 1, recorded per install (1 on staging install #11), refused on the submit path when mismatched; DEFAULT_MAX_EXPERIMENT_VMS still 2
6 Tips / checks / PRs given — stack table above; CI fires only on #297 by design, local CI-parity run on the tip

The open item is 2, and it is architectural. The RLM authors the rule vector — proven
live. It cannot author migrations, APIs, submission-format or scoring, because no job or wire
message carries them (§2f). Closing that changes what the RLM is; it is scoped in §2f and
not started.

Not claimed: that the RLM authors the schema or the routes; that B1's human YAML is the
final authorship SoT; the pin_policy field by that name.

Not merged. PR #301 is a draft; the merge HOLD stands pending Mathis GO.

@echobt

echobt commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review — pack v2 (cb14a9fa). Same HEAD as #301; this mirror exists so the session branch has its own URL.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

Greptile Summary

The update adds a registry-aware preflight check for SQL object-name collisions between topics and adds regression coverage for existing registered-topic collisions.

Merge safety

Not merge-safe. The existing SQL namespace security issue can still occur when colliding topics are installed concurrently, and the existing VM memory-admission issue still permits an exact nominal-RAM allocation without budgeted host headroom.

Confidence Score: 2/5

Not merge-safe while the two outstanding existing issues remain.

The existing security thread remains outstanding: the registry-aware preflight check rejects collisions only with topics already visible in the registry. Since the topic is published after installation and no lock or durable claim protects the check-and-install interval, concurrent installs of colliding topic IDs can both pass and access the same SQL object. The existing host-memory admission thread also remains outstanding: the code rounds kernel-reported MemTotal upward to the next GiB and uses a zero default reserve, permitting configured VM allocations that exceed reported RAM and reserve no capacity for the host.

Files Needing Attention: None.

Reviews (2): Last reviewed commit: "fix(proof): close the cross-topic SQL na..." | Re-trigger Greptile

return true;
}
bare.starts_with(&format!("{topic}_"))
bare.starts_with(&format!("{topic}_")) || bare.starts_with(&format!("{sql_topic}_"))

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 security Separate topic SQL namespaces

The prefix-only check lets the valid topic aa authorize aa_b_scratch through aa_, while the valid sibling topic aa-b uses that same object name through its normalized aa_b_ prefix. The migration gate accepts CREATE, INSERT, and SELECT on this shared name for the shorter topic, so a migration from aa can read, modify, or drop tables belonging to aa-b in the shared database. Use an unambiguous, non-overlapping namespace encoding before merging.

How this was verified: A focused authorization check confirmed that both legal topic IDs pass the migration gate for aa_b_scratch, including cross-topic reads and writes.

Artifacts

Evidence from the check

  • Runs the authored Rust integration test before and after the normalized-prefix commit, showing whether a prefix topic is admitted to a hyphenated sibling's object namespace.

Command output from the check

  • Captured `cargo test` output from commit 6b89187 shows both `a`/`a-b` and legal `aa`/`aa-b` pairs authorize the same bare object, so the namespace collision predates the PR change.

Command output from the check

  • Captured `cargo test` output on HEAD shows `is_topic_scoped` and `check_migration` admit CREATE, INSERT, and SELECT across the legal `aa`/`aa-b` boundary, confirming the defect remains.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +202 to +208
pub fn nominal_total_mib(mem_total_mib: u64) -> u64 {
const GIB: u64 = 1024;
if mem_total_mib == 0 {
return 0;
}
mem_total_mib.div_ceil(GIB) * GIB
}

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 Preserve host memory headroom

The changed admission calculation rounds a kernel-reported 15,943 MiB MemTotal up to 16,384 MiB. With the default zero reserve, it admits one live 8,192 MiB VM plus another 8,192 MiB VM even though their configured total exceeds reported RAM by 441 MiB and leaves no budgeted space for the host OS, agent, Firecracker processes, or guest page faults. If those workloads commit sufficient memory, the host can OOM instead of rejecting the placement. Do not round available memory upward for default admission, or apply a conservative non-zero default reserve before merging.

Artifacts

Evidence from the check

  • Authored and executed script creates isolated probes for the parent of the rounding change and current HEAD, runs the real Rust admission path, captures complete output, and removes temporary source tests; it provides the reproducible comparison.

Command output from the check

  • Executed Rust probe against the parent of `d809e020` with a 15,943 MiB budget, zero reserve, and 16,384 MiB configured VM total; the admission was refused, showing the prior safe boundary.

Command output from the check

  • Executed Rust probe at current HEAD using the real `from_meminfo` path with the same meminfo and allocation; it rounded to 16,384 MiB and admitted the request, confirming the default headroom loss.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile's P1 security finding on the tip, reproduced independently before
fixing.

The migration deny-list namespaces objects by the topic's mapped prefix
(`-` → `_`). The mapping is injective, but its **prefixes are not
prefix-free**: `aa` and `aa-b` are both legal ids, and `aa_b_scratch` reads
as `aa` + `b_scratch` **and** as `aa-b` + `scratch`. Both pass
`is_topic_scoped`, so a migration approved for `aa` could create, read, or
drop a table belonging to `aa-b` in the shared database.

**The per-topic guard cannot fix this.** It sees one topic. Refusing every
name with an underscore after the prefix would close the hole but also refuse
ordinary names like `tb4_scratch_idx` — including the live shape's own index
naming. I implemented that first, watched it break three legitimate tests,
and backed it out: the question is about the **registry**, so it belongs
where the registry is visible.

- `proof_topic_sql_guard::claim_collisions` reports any name another
  registered topic also claims.
- `Installer::refuse_cross_topic_claims` runs it **before the journal
  opens**, so a collision writes nothing at all — no row, no rule, no table —
  and refuses with `InstallError::CrossTopicClaim`, naming the migration, the
  object, and both topics. A store it cannot read is a refusal too: a
  collision check that cannot enumerate the registry would pass by default.

Tests, both non-vacuous (neutering the check fails them):
`a_bare_name_two_topics_claim_is_reported_as_a_collision` and
`a_name_no_registered_sibling_claims_is_not_a_collision` at the guard, and
`a_migration_that_reaches_a_sibling_topics_namespace_is_refused` at the
install, which asserts the refusal plus the absence of a journal row and of
the colliding table.

Also updates the evidence pack with this fix, and records that the pack's
`cargo deny` / four-test caveats were re-checked: the four failures are
environmental (root bypasses `0o000`), and `cargo deny`'s rustls advisory is
pre-existing at `6712e7b0`.

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

echobt commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Security fix + pack update — c842598e

Greptile's P1: cross-topic SQL namespace collision — fixed

Your finding was correct and I reproduced it independently before touching anything.

The deny-list namespaces objects by the topic's mapped prefix (-_). The mapping is injective, but its prefixes are not prefix-free: aa and aa-b are both legal ids, and aa_b_scratch reads as aa + b_scratch and as aa-b + scratch. Both pass is_topic_scoped, so a migration approved for aa could create, read, or drop a table belonging to aa-b in the shared database.

Your suggested direction — a non-overlapping encoding — is not available without breaking real migrations, and I want to be explicit about why I did not take it. I implemented the strict version first (refuse any bare name with an underscore after the prefix). It closes your exploit exactly, but it also refuses tb4_scratch_idx and tb4_granted — ordinary names, including the live shape's own index naming. Three legitimate tests failed. So I backed it out: the per-topic guard cannot decide this, because it sees one topic.

The question is about the registry, so it is answered where the registry is visible:

  • proof_topic_sql_guard::claim_collisions(names, topic, others) reports any name another registered topic also claims.
  • Installer::refuse_cross_topic_claims runs it before the journal opens, so a collision writes nothing at all — no row, no rule, no table — and refuses with InstallError::CrossTopicClaim, naming the migration, the object, and both topics. A store it cannot read is a refusal too, so a collision check that cannot enumerate the registry cannot pass by default.

Tests, both non-vacuous (neutering the check fails them): a_bare_name_two_topics_claim_is_reported_as_a_collision and a_name_no_registered_sibling_claims_is_not_a_collision at the guard; a_migration_that_reaches_a_sibling_topics_namespace_is_refused at the install, which asserts the refusal and that no journal row and no colliding table exist.

The memory finding — acknowledged, and deliberately unchanged

Your second P1 argues the budget should not round MemTotal up, or should default to a conservative reserve. I understand the reasoning and I am not ignoring it, but the two constraints are in direct tension and the evidence decides it:

  • The proven Gate 3 pair is 8192 + 8192 MiB of guest memory on a host reporting 15,943 MiB. It passes on real hardware. Without the rounding, the guard refuses it — which is the regression you caught in f0800353, and the reason the rounding exists.
  • Gate 4 did OOM, at three guests (24,576 MiB configured, ~154% of MemTotal). That is what the guard now refuses.

So the guard is a heuristic sized to refuse the observed failure with margin, not a residency model — and the pack says so, including that guest RAM is lazily populated and the sister guest is deliberately outside the count. A conservative default reserve would take the working shape offline; that is an ops decision (raise PROOF_VM_AGENT_MEMORY_RESERVE_MIB, or size a bigger host), not a code default I should pick unilaterally. It is documented at the function and pinned by the_guard_is_not_a_residency_model.

Pack

docs/evidence/rlm-authorship-evidence.md now carries this fix as its own section, alongside the six checklist items, Dev's live staging dig, the before/after hardcode inventory, and the authorship gap.

Gates on c842598e: fmt, clippy -D warnings, workspace tests (as CI runs them), the DB-gated install + guard suites against a real Postgres, loc-cap, consensus-lint, spec-check, design-check, external-docs-check.

@greptileai review

echobt and others added 8 commits September 16, 2026 10:26
The authorship pin said the RLM must author rules + SQL migrations + dynamic
APIs + submission_format + pin_policy. The wire could only carry rules: the
job surface was `VmJobOutput::Rules(Vec<ChecklistRule>)`, so the install had
to take the other four parts from the operator's bundle. This closes that.

**One document, five parts.** `proof-topic-authoring` is the new home of what
a topic's RLM authors — the shape, the bounds, the canonical digests, and the
tightening rule that makes a pin policy a policy (a topic may raise a floor,
lower a ceiling, and never the reverse; three knobs are equalities, not
tightenings, because every topic is measured against the same image and
machine class). It holds no database and no VM, which is the point: the guest
checks an answer before it becomes a job output, and the install checks it
again before it applies anything, and both link the same code.

**The wire carries the set.** `VmJobOutput::Authored(Box<TopicAuthoring>)` is
what `ProposeRules` answers, and `VmJob` carries the set an RLM is
re-authoring. `Rules(Vec<ChecklistRule>)` survives for an adaptor baked
before the set existed — and it is treated as what it is: a **fragment**. The
host records the rules with honest `rlm` provenance and refuses to treat the
topic as set up, naming the parts that have no author. It never widens a
fragment into a set, because the parts that would fill it are the operator's.

**The install applies the RLM's set, and says who wrote what.** When the
driver got one, `Installer` applies *it* and not the bundle's section: the
declaration of intent is superseded by the topic's own answer. The journal's
`binding.authorship` records `rlm` (or `topic_document`) per part, with each
part's digest and, for migrations and routes, what landed — so "the RLM
authored this topic" is a fact an audit reads back per part rather than a
label on the row as a whole.

**Fail-closed, three times over.** The guest holds an answer to the deny-list
and to the document's own knobs before it answers; the setup driver refuses
an incomplete set (`IncompleteAuthoring`, naming the parts) rather than
proceeding; the install holds the set to the pin, which the guest cannot see.
A migration reaching another topic's namespace, a route inside the admin
namespace, a policy that loosens a global floor: each is refused where it
arrives.

**Two crates moved to make room, unchanged in behavior.** `proof-rlm-lifecycle`
(the transition table and owner hooks, out of `proof-rlm`) and
`proof-vm-staging` (the staging layer, out of `proof-vm-guest`) — both were at
1494/1495 of the repository's 1500-line per-crate cap, and the authoring
surface needs the room. Both are re-exported under their old paths, so no
caller changes.

Tests: the guest's whole-set path (complete set travels; another topic's set,
a denied migration, and an incomplete set are each refused by name; a
rules-only answer stays a fragment), and an end-to-end install against a real
Postgres where the operator's section and the RLM's set differ in every part,
so a run that quietly applied the bundle would fail every assertion. The
bundle guard flipped with the code, as its own doc comment said it would.

Docs corrected where they claimed the old boundary: `docs/PROOF.md`,
`docs/COMPLETENESS.md`, the bundle crate's header, and the guest's.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ner run that proves it

Rewrites the pack against the tip that closed the authorship gap.

**Item 2 is now met in code, and the pack says where it is not yet live.**
The gap pack v2 stated exactly (§2f: no job or wire message could carry a
migration or a route) is closed: `VmJobOutput::Authored(TopicAuthoring)`
carries all five parts, the install applies the RLM's set rather than the
bundle's section, and the journal records `binding.authorship` per part with
a digest each. §2d keeps Dev's live staging dig as it stands and says plainly
that install #11 predates the change — its `migrations`/`apis` came from the
bundle and its binding carries no `authorship` entry. §2g is the Owner LIVE
run that moves the host onto the new shape, with the exact journal entry that
proves it.

**Item 3's distinction is stated rather than rounded up.** The RLM authors the
topic's **behavior**; the **document** (statement, metric, floors, pack pin)
stays the operator's, because miners need a signed document and only the
operator holds the `proof` key. §3e is rewritten as a before/after table so
the claim is legible, and the B1 FIXED YAML's status is explicit: it cannot
produce an `authorship: rlm` row, because an install from a bundle records
`topic_document` provenance and the publish gate refuses to open on it.

**Item 4's inventory is measured, not asserted.** Two counts per module, both
over production code: `raw` (comments included, what a reader greps) and
`logic` (comments stripped, what the guard enforces). The earlier pack's
"6 → 0" for `proof-admin` is reproduced as raw 6 → 0 / logic 2 → 0, and the
two comment-only survivors in `proof-topic-ops` are now 0 raw as well — this
tip made those doc examples generic too.

**Two new caveats, both about this container rather than the change.** The
`rustls` advisory is confirmed pre-existing (the lockfile's rustls entry is
untouched; the diff adds only the three new crates), and six failures that
appear only with `DATABASE_URL` set are in crates this branch does not touch
(`git diff c842598 -- crates/db crates/gateway-store-pg` is empty).

**New runbook:** `docs/runbooks/proof-rlm-authorship-install.md` is the Owner
ceremony — preconditions, the `authoring.json` contract with every refusal it
can produce, the four commands, the journal entry that proves it worked, and
the rollback notes. It says explicitly that it is not a re-run of the B1 YAML
and why that ceremony cannot satisfy it.

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

Both are P1, both reproduced before fixing, and both are in the same check —
the one place that can see the whole registry.

**1. Function bodies bypassed the collision check.** The check scanned
`statement.blanked`, which blanks dollar-quoted bodies *along with* string
literals — so object names inside a body were never compared to the registry.
Greptile's reproduction: topic `aa` installs
`CREATE FUNCTION aa_delete_sibling() … $$ DELETE FROM aa_b_scratch $$ LANGUAGE
sql` while sibling `aa-b` is registered, the install completes, and calling
the function deletes the sibling's rows. The body is what runs, so the body is
what the check reads: `collision_in` scans `blanked` **and** `bodies`.

**2. Unpublished installs were invisible, and the check was not atomic.** The
check enumerated `proof_topic_version` only, so a topic that had installed but
not yet published did not exist as far as it was concerned: `aa` and `aa-b`
could both install and both create `aa_b_scratch`. A topic claims a namespace
by installing into it, so the claim is read from the **journal** too. And
under READ COMMITTED two concurrent installs each see no row from the other
and both commit — the same defect with a race on top.

Both close in one place. `Installer::claim_namespace` is a single transaction
that takes `pg_advisory_xact_lock` (the pattern this repo already uses for the
alias slug race), reads the union of `proof_topic_version` and
`proof_topic_install`, and writes the `pending` row that records the claim
**inside the same transaction** — so the next install to take the lock sees
it. A refusal rolls back and writes nothing: no row, no rule, no table.

The advisory lock is deliberately coarse — one lock for all installs — because
installs are rare operator actions and a per-namespace lock would have to
reason about prefix overlap, which is the very thing that is hard here. It is
a transaction lock, so a crashed install cannot wedge it.

**Tests, each verified non-vacuous.** Neutering the body scan fails
`a_function_body_that_reaches_a_sibling_is_refused`; narrowing the registry
read back to `proof_topic_version` fails
`two_unpublished_installs_cannot_claim_the_same_object`. A third test races
two installs and asserts exactly one is refused.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's P1 on the whole-set change, and it was right: the wire carried
`VmJob::ProposeRules.current` but the driver always sent `None`. An adaptor
that cannot read its previous set cannot **retain** the parts it is not
changing, so a second authoring run was a rewrite from nothing — and the
install would apply that lossy set, silently dropping a migration the topic
still needs.

Three places, because the set has to be stored, carried, and reachable:

1. **Stored.** `proof_topic_authoring` (migration 0027) keeps every set an
   RLM authored, versioned and append-only, with the canonical digest beside
   it. Read from the store rather than held in memory, so a restart — or a
   different operator process — is handed the same set. The version advances
   under an advisory lock, so two concurrent authoring runs see each other
   and report a `VersionGap` rather than racing into a duplicate-key error.

2. **Carried.** `TopicSetup::current_authoring` populates the job's `current`,
   and a topic with no prior set carries `None` — the first run is unchanged.

3. **Reachable by the adaptor.** The guest writes the previous set to
   `$PROOF_WORK_DIR/current-authoring.json` and exports its path as
   `PROOF_CURRENT_AUTHORING_FILE`: always set, empty when there is none, so an
   adaptor branches on one variable rather than on a variable's presence.

**A fragment is not stored as the set in force.** A rules-only answer is
persisted as *rules* (honest `rlm` provenance) and **not** as the authored
set — storing it would hand the next run a set that was never authored, and
the re-authoring path would treat a partial answer as the retention baseline.

Tests, each verified non-vacuous:

- `a_re_authoring_run_is_handed_the_set_the_first_one_wrote` — first run
  carries `None`, the set is persisted, the second carries **that** set
  complete, the store advances to v2. Neutering `current` back to `None`
  fails it.
- `a_rules_only_answer_is_not_persisted_as_the_authored_set` — the fragment
  path: `IncompleteAuthoring`, no stored set, rules still stored as `rlm`.
- `the_previous_set_survives_a_new_driver` — a different `TopicSetup` over the
  same store is handed the same set, so nothing depends on in-memory state.

Docs: the Owner runbook documents the new adaptor input, and the evidence pack
and completeness table say what re-authoring now guarantees.

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

Two more Greptile P1s on the whole-set change, both reproduced before fixing.

**1. A re-install did not remove the routes of the set it replaced.**
`register_apis` was insert-only and `proof_topic_api` was `SELECT, INSERT`
for the application role, so the table could only grow. When an RLM-authored
install superseded a bundle-authored one, the bundle's routes were still
there — and the mux loads **every** row for a topic, so a miner could still
reach an endpoint the topic's current install does not declare while the
journal said the newer set was in force.

The install now **reconciles**, in one transaction: it deletes the topic's
rows the new set does not claim, upserts the set's rows, and bumps the
topic's route revision (migration 0028, which grants the DELETE this needs).

The mux's change signal had to change with it: the generation probe was
`count(*) FROM proof_topic_api`, and a count cannot see a replacement — delete
one, insert one, and it is unchanged while the routes are not. It reads the
sum of `proof_topic_route_revision` now: monotonic per topic, bumped in the
same transaction as the reconciliation.

**2. An accepted pin policy had no effect on scoring.** The check accepted a
policy *tighter* than the signed document and the install recorded it, but
scoring reads the document (`proof-score::nll_gates` takes
`topic.epsilon_nll`; the split-regression gate takes
`topic.epsilon_topic_max_regress`). So the host recorded the RLM's tighter
number while challengers were judged by the document's — a threshold nobody is
scored against, in either direction.

`PinPolicy::agrees_with_document` (renamed from `tightens_document`) makes it
an **equality**: a policy restates the document — proving its RLM considered
the knob — and cannot choose for it. A topic that wants a tighter floor puts
it in the document, where it is signed and verifiable.

Tests, each verified non-vacuous:

- `a_re_install_replaces_the_route_set` — bundle routes, then an RLM set with
  one different route: only the new route is in the table and the mux answers
  `NotRegistered` for both old paths. Neutering the delete shows all three,
  which is exactly the reported bug.
- `a_same_count_replacement_still_moves_the_generation` — one route replaced
  by one route: the count is unchanged, the generation must still move.
- `a_pin_policy_restates_the_signed_document_and_cannot_diverge` — restating
  accepted, diverging refused by name in both directions.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's follow-up on the re-authoring fix: the rules went through
`put_rules` and the set through `put_authoring`, two separate store
operations, so a failure between them would leave newer rules with the
**previous** set. A retry would then be handed a set whose rules are not the
ones in force — the two halves of one fact ("this topic's RLM authored *this*
at rule version *N*") disagreeing.

`RlmStore::put_authoring` now takes both and lands them in **one
transaction**:

- Postgres: one `BEGIN`, one advisory lock, the set insert, the rule insert
  (with the same version check `put_rules` runs), one `COMMIT`. A vector that
  does not advance rolls the set back with it.
- Memory: one lock, the same check, both pushes or neither.

`the_rules_and_the_set_land_in_one_write` pins it: a first run lands both at
v1; a second whose rules do not advance is refused and appends **no** set, so
the set in force stays the one that landed with the rules in force; the next
valid run advances both. `the_atomic_write_still_reports_rlm_provenance`
confirms the provenance read is unchanged.

The fragment path still uses `put_rules`: a rules-only answer is not a set, so
there is nothing to pair it with.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile caught a real bug in the previous commit's Postgres path:
`proof_rule_version.digest` is `NOT NULL` and CHECK'd as 64 hex, and the new
paired insert omitted it — so **every** complete authoring would have been
rejected by the database. The memory store did not, because its `RuleSet` is
the object itself; only the real database could say so.

The insert now binds `rules.digest()`, exactly as `put_rules` does.

**And the contract test now covers it.** `store_contract.rs` runs one contract
against both stores, so the paired write is exercised against a real Postgres
in CI — which is what makes this class of defect (a column the memory store
does not have) a test failure rather than a production surprise. Reverting the
digest bind fails it with the not-null violation, verbatim.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Greptile's last review (of `dc6ca1a4`) scores **5/5**: "Safe to merge; there are
no outstanding blocking issues."

The pack now carries the finding-to-fix table, so a reader does not have to
reconstruct the review history: nine findings across this branch, each fixed in
a named commit, each with a regression test that was verified non-vacuous by
neutering the fix and watching the test fail.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant