Skip to content

feat(proof): RLM topic install executor under closed gates (P1a) - #298

Draft
echobt wants to merge 11 commits into
droid/795020b8-sn100-p0-topics-table-adminfrom
droid/9f68584e-sn100-p1a-rlm-topic-install
Draft

echobt wants to merge 11 commits into
droid/795020b8-sn100-p0-topics-table-adminfrom
droid/9f68584e-sn100-p1a-rlm-topic-install

Conversation

@echobt

@echobt echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #297 (droid/795020b8-sn100-p0-topics-table-admin). DRAFT — do not merge.

P1a of the dynamic-topics install path: the executor behind proof-admin topic install. #297 built the bundle, the validation, and the dry run; this makes the install real.

What this adds

proof-topic-install — the executor crate.

  • sql_guard: a topic's SQL runs in the shared challenge database under a topic_id discriminant, so a migration that reached outside its own namespace could rewrite another topic's rules, forge a promotion, or drop the tables scoring reads. Refused by object (every proof_* object by prefix — so a table added later is covered — plus _sqlx_migrations, the roles, the catalogues), by statement (DROP DATABASE/SCHEMA/ROLE/OWNED/EXTENSION, GRANT/REVOKE, SET/RESET/BEGIN/COMMIT, COPY, maintenance verbs, SECURITY DEFINER, server-side file access), and by namespace — every table a statement creates, writes, or reads must be its own ({topic_id}_*, topic_*, {topic_id}.…). Strings, comments, and dollar-quoted function bodies are blanked before scanning, so a denied word inside a literal is data and a denied statement cannot be smuggled in by quoting. Function bodies are scanned, not trusted.
  • handler: an RLM section is operator-supplied JSON, not a signed document, so a handler name in it is untrusted input. Only the generic in-guest runner (Firecracker) and an operator-baked Harbor adaptor over it may be bound. A path, a URL, or a command line is refused by shape; a well-formed but unknown id is refused with the allow-list in the message. The signed document keeps sole authority over the runner.
  • section: strict reads of the parts an install applies. A key inside a part it reads is refused (a step nothing would perform); a part it has never heard of travels, because that is the boundary working. submission_format and scoring are recorded as canonical-JSON digests and otherwise untouched.
  • install: the engine — migrations checked as a whole before the first statement runs, then applied per-migration in one transaction and resumed from the journal; routes recorded under the topic's own prefix; the rule vector installed through the store the scoring path reads; the executor binding recorded, including the vms_per_submission = 1 pin.

0025_proof_topic_install.sql adds proof_topic_install (the install journal) and proof_topic_api (the topic's dynamic routes, paths stored relative so the resolver owns the prefix). Both append-only for base_app.

proof-admin topic install now performs the procedure: publish the signed document through the existing admin route (bearer read from a file, never printed), apply the RLM section, point the declared aliases at the topic, and — with --drive-rlm --owner-approved — drive the RLM's own lifecycle. --skip-baseline stops before the baseline job. A new topic install-log reads the journal back.

Bundle aliases: a bounded, shape-checked list of temporary compatibility slugs (never the topic's own id), because an alias is a lookup key and not topic data.

Two failure shapes

  • A pre-flight refusal (deny-list, handler allow-list, section shape, an open custom id this host does not register) writes nothing at all — no row, no rule, no table.
  • A step failure appends a failed journal row naming the step; applied migrations stay applied and are recorded, so a re-run resumes rather than restarts.

An install never publishes an open document, so a failed install leaves the topic draft/disabled and miners cannot submit to it. Every refusal prints rollback notes.

Verification

  • cargo test -p proof-topic-install — 53 tests: an adversarial deny-list suite (quoting, comments, dollar-quoted bodies, case, schema qualification, IF NOT EXISTS) that also asserts legitimate migrations still apply, the handler allow-list, and the section reader.
  • crates/proof-topic-install/tests/install_engine.rs — 10 tests against a real Postgres: the happy path, both failure shapes, resume, rollback, and both gates holding through the engine.
  • bins/proof-admin/tests/cli.rs — the P0 stub assertions replaced by the P1a contract (a real install refuses without a master and a bearer; the bearer never reaches stdout or stderr; the two spend gates are checked before any I/O).
  • fmt, workspace clippy, every xtask gate: clean. Every workspace suite green except four pre-existing root-only permission tests (chmod 000 + expect-failure, which root ignores) — confirmed failing identically on the base branch.

Note: CI does not run on this PR because it targets the #297 branch, not main.

Out of scope (P1b–d)

Full concurrent 2-VM E2E (P1b), FE debrand of harbor-trials-v1 (P1c), live TB4 hardcode retirement (P1d).

Do not merge

Owner runs the staging LIVE A→Z gate later. This PR stays draft; #297 stays draft.

P1a of the dynamic-topics install path: the executor behind
`proof-admin topic install`. The CLI is the trigger; this is the work.

The bundle carries its `rlm` section verbatim and opaque, so no topic
behavior is compiled in. That leaves the other half of the boundary: a
consumer that applies the parts an install knows how to apply, under two
closed gates that run before anything is touched.

- `sql_guard`: a topic migration runs in the shared challenge database
  under a `topic_id` discriminant, so it is deny-listed by object
  (every `proof_*` object by prefix, the sqlx bookkeeping table, the
  roles, the catalogues), by statement (`DROP DATABASE`/`SCHEMA`/`ROLE`/
  `OWNED`/`EXTENSION`, `GRANT`/`REVOKE`, session and transaction control,
  `COPY`, maintenance verbs, `SECURITY DEFINER`, server-side file
  access), and by namespace — every table it touches must be its own
  (`{topic_id}_*`, `topic_*`, `{topic_id}.…`). Strings, comments, and
  dollar-quoted function bodies are blanked before scanning, so a denied
  word in a literal is data and a denied statement cannot be smuggled in
  by quoting. Function bodies are scanned, not trusted.

- `handler`: an RLM section is operator-supplied JSON, not a signed
  document, so a handler name in it is untrusted input. Only the generic
  in-guest runner and an operator-baked Harbor adaptor over it may be
  bound; a path, a URL, or a command line is refused by shape.

- `section`: reads only the parts an install applies, strictly. A key
  inside a part it reads is refused (a step nothing would perform); a part
  it has never heard of travels, because that is the boundary working.

- `install`: migrations (checked as a whole, then applied per-migration in
  one transaction, resumed from the journal), routes recorded in the new
  `proof_topic_api` table under the topic's own prefix, the rule vector
  installed through the store the scoring path reads, and the executor
  binding recorded — including the `vms_per_submission = 1` pin.

`0025_proof_topic_install.sql` adds the journal and the route table, both
append-only for `base_app`, so an install that could be edited in place
would not be a journal.

Tests: 43, including an adversarial deny-list suite that tries quoting,
comments, dollar-quoted bodies, case, schema qualification, and
`IF NOT EXISTS`, and asserts the legitimate migrations still apply.

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

echobt and others added 2 commits September 14, 2026 15:38
The CLI was a trigger that printed a procedure. It now performs it, in
the order the procedure describes, with every authorization explicit.

- Publish the signed document through the existing admin route. The
  bearer is read from a file and never printed or logged; a missing URL,
  a missing token file, or an empty one is a **usage error before
  anything is written**, so a misconfiguration cannot leave a
  half-installed topic.

- Apply the bundle's RLM section through `proof-topic-install`:
  migrations under the deny-list, routes, the rule vector, the executor
  binding — all recorded in the install journal.

- Point the bundle's declared aliases at the topic. `aliases` is a new
  bundle field (`Vec<String>`, bounded, shape-checked, never the topic's
  own id) because an alias is a lookup key and not topic data.

- `--drive-rlm` drives the RLM's own lifecycle over the topic-VM
  orchestrator. It provisions a VM and runs a paid baseline, so it
  requires `--owner-approved`; `--skip-baseline` requires `--drive-rlm`,
  because without it the install never reaches the baseline job.

Two new commands: `topic install-log --topic <id>` reads the journal, and
`topic show` is unchanged.

Fail-closed: an install never publishes an `open` document, so a failed
install leaves the topic draft/disabled and miners cannot submit to it.
Every refusal prints rollback notes naming what is and is not changed,
and the journal records the attempt so a re-run resumes rather than
restarts.

Tests: the P0 stub tests are replaced by the P1a contract — a real
install refuses without a master and a bearer, the bearer never reaches
stdout or stderr, and the two spend gates are checked before any I/O.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The unit suite proves the gates; these prove the engine, end to end
against Postgres, in the convention the store's own contract tests use
(DB-gated on `DATABASE_URL`, so `cargo test --workspace` stays green
without one).

What they pin, in the terms an operator would ask:

- A permitted bundle installs: both migrations apply in order, the table
  really exists in the topic's namespace, the rules the scoring path
  reads are the ones the install landed, the routes read back through
  the mux's own query, and the journal's newest row is `applied`.
- A **pre-flight refusal writes nothing at all** — no table, no rule,
  and no journal row, because the deny-list runs before the journal
  opens. A denied bundle is a no-op, not a failed attempt.
- A **step failure does journal**: a migration the database rejected
  rolls back its own statements (one migration is one transaction) and
  appends a `failed` row naming it.
- A re-run resumes: applied migrations are skipped and named, the rules
  version is not bumped, routes are not duplicated, and a *new*
  migration in the bundle still applies.
- The gates hold through the engine, not only in the reader: an
  unregistered open custom id and an arbitrary handler both refuse
  before any write.

Writing them found two real bugs: the handler binding reported the
document's runner as a handler *family* (so a Harbor topic journaled as
`vm_backed`), and the failure shape was documented as "always journals"
when a pre-flight refusal journals nothing. Both are fixed here, and
`bound_runner` now records the family the section asked for while the
signed document keeps sole authority over the runner.

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

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

Greptile Summary

Summary

  • The publication route now rejects open documents when the topic has no successfully applied installation, but it accepts a replacement document when any earlier bundle for that topic was applied.
  • The command now publishes after installation, and the current SQL guard scans quoted function bodies and limits topic object names to the installing topic namespace.
  • Installation validation still occurs after optional RLM lifecycle, host, and baseline work.

Merge Safety

Do not merge until publication is tied to the installed bundle and static bundle validation occurs before optional RLM work.

Confidence Score: 3/5

The change is not merge-safe because a changed topic document can open without its corresponding install, and optional RLM effects can still precede static bundle acceptance.

The reproduced publication failure shows that applied_install reads only the newest state for a topic ID and does not compare the recorded bundle digest with the document being opened. The previous preflight-order finding remains outstanding: bins/proof-admin/src/install.rs calls drive_rlm before Installer::install, so lifecycle state, host work, and a paid baseline can occur before denied SQL, malformed section data, unsupported handlers, or invalid bindings are rejected. The prior quoted-function-body finding is fixed because single-quoted, escape-string, and Unicode-string executable bodies are decoded and scanned. The prior migration-checkpoint finding is fixed because each migration's journal progress is committed in the same transaction as its statements. The prior namespace-isolation finding is fixed because generic topic_* names are no longer accepted. The prior publish-order finding is fixed because the CLI installs before publishing. The prior requested-RLM-setup finding is fixed because --drive-rlm invokes the RLM driver rather than returning a skipped result.

Files Needing Attention: crates/proof-topic-install/src/install.rs

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced proofs for two posted P1 findings.
  • T-Rex executed the general-contract-validation-proof for the digest-gate and captured the before-output and after-output results, plus ran the authored validation script.
  • T-Rex attached artifacts that document the digest-gate validation results and the validation script used.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. General comment

    P1 Open-topic publication is not bound to the installed bundle digest

    • Bug
      • POST /v1/admin/proof/topics accepted an open document whose digest (45db76a4a5645ce134a69686e124a46323f74495986403d302d90d884cfaa750) differed from the digest of the prior simulated applied installation (dfb4158a8b61d9078077bfbf36ad9e8808ed8a3f5453cdafa7f3d7e598de8877) while retaining the same topic ID. The response was HTTP 201 Created.
    • Cause
      • The publish handler calls install_gate(&st, &doc.id), and InstallJournal::applied accepts only a topic ID. The durable query likewise selects only state for the newest row by topic ID, without selecting or comparing bundle_digest.
    • Fix
      • Bind publication to the exact installed artifact: derive a canonical digest for the submitted signed document/bundle, extend the journal gate contract to receive it, and require the newest applied install row's bundle_digest to equal it before admitting status: open.

    T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "feat(proof): refuse to publish an open t..." | Re-trigger Greptile

Comment on lines +194 to +214
'\'' => {
// String literal: kept for execution, blanked for scanning.
text.push(c);
blanked.push(' ');
i += 1;
while i < chars.len() {
if chars[i] == '\'' {
if chars.get(i + 1) == Some(&'\'') {
text.push_str("''");
blanked.push_str(" ");
i += 2;
continue;
}
text.push('\'');
blanked.push(' ');
i += 1;
break;
}
text.push(chars[i]);
blanked.push(' ');
i += 1;

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 Scan quoted function bodies

PostgreSQL permits executable SQL-language function bodies in single-quoted strings. This branch removes those body bytes from both inputs checked by the migration guard, but the installer later executes the unchanged statement. A topic can therefore install a function such as AS 'DELETE FROM proof_rule_version' LANGUAGE sql that accesses protected shared data after the migration is approved. Reject or decode and scan single-quoted function bodies before accepting the statement.

How this was verified: An executed check showed the protected DELETE remained in executable SQL while both guard scan inputs omitted it and the migration was accepted.

Knowledge Base Used: Proof challenge network and evaluation

Artifacts

Evidence from the check

  • The exact executable Rust test source used to construct the PostgreSQL function, inspect guard inputs, and assert that the guard allows it; it defines the tested condition.

Command output from the check

  • Output from executing the narrow Rust test in `/home/user/repo`; it shows the protected DELETE remains executable but is absent from scanned text, so the guard allows it.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +249 to +250
let pending_id = self
.journal(request, InstallState::Pending, None, &[], &[], &binding, "")

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 Checkpoint committed migrations

The initial pending journal row records an empty migration list, while each migration commits independently and its name is retained only in memory until the final applied row is written. If the process stops after a migration commits but before that final row is inserted, resume has no durable record of the committed migration and runs it again. Normal CREATE TABLE migrations then fail on an existing relation, and non-idempotent data migrations can apply twice. Persist each migration's completed state atomically with, or immediately following, its transaction.

Artifacts

Evidence from the check

  • Python source executed against the changed installer control flow and a durable-state model; it verifies that a committed migration is retried when the pending journal row has no migration names.

Command output from the check

  • Captured execution output from the focused validation; it shows an empty pending journal, no resume credit, and a duplicate-relation failure after retrying the committed migration.

View artifacts

T-Rex Ran code and verified through T-Rex

if schema == Some(topic.as_str()) {
return true;
}
bare.starts_with(&format!("{topic}_")) || bare.starts_with("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 Isolate topic namespaces

Every bare object name beginning with topic_ is accepted regardless of the installing topic ID. Approved SQL then runs through the shared database connection without selecting or enforcing a topic-specific schema. One topic can therefore update, delete, or drop another topic's topic_* table. Require names tied to the actual topic ID or enforce database-level per-topic isolation before executing migrations.

How this was verified: An isolated database execution approved one topic's update of a victim topic_* table and changed that table's value.

Knowledge Base Used: Proof challenge network and evaluation

Artifacts

Evidence from the check

  • Shell script that starts isolated PostgreSQL, generates and runs the narrow Rust test using the production guard and sqlx execution API; it demonstrates the exact validation method.

Command output from the check

  • Captured output from executing the validation script shows four topic IDs approving the same bare topic table and the database row becoming compromised, confirming no true per-topic isolation.

View artifacts

T-Rex Ran code and verified through T-Rex

The install section still described P0: a CLI that prints a procedure and
touches nothing. It performs it now, behind two closed gates, so the doc
and the fixture have to say what it does — and, more usefully, what it
refuses.

- `docs/PROOF.md`: a "Running the install for real" section with the
  exact command, the four steps in order, the two gates that authorize
  spend (`--drive-rlm` needs `--owner-approved`), the deny-list and
  handler allow-list as tables, and the two failure shapes. An install
  never publishes an `open` document, so a failed install leaves the
  topic draft/disabled; a pre-flight refusal writes nothing at all,
  while a step failure journals how far it got and resumes on re-run.
  The locked-defaults table gains the two new rows (the RLM gate and the
  `vms_per_submission = 1` pin), and the stale "the CLI does not read the
  section" paragraph is corrected: the CLI carries it, the install
  executor is its one consumer, and a part it has never heard of travels.

- The fixture bundle gains the parts a real install applies — a
  `migrations` entry, an `apis` entry, `scoring`, and the Owner-default
  `aliases: ["tbench"]` — so the committed artifact exercises the same
  path an operator's bundle will. The sample migration stays inside the
  topic's own namespace, which is what makes it legal under the
  deny-list.

- `regenerate_dry_run_fixture` resolved its output directory against
  `cargo test`'s working directory (the package), so the documented
  command wrote to `bins/proof-admin/bins/proof-admin/…`. It now
  resolves against the workspace root, so the command writes where it
  says it does.

- The install plan prints the aliases it will point at the topic, so an
  operator reading the dry run sees the whole procedure.

Gates: fmt, workspace clippy, all xtask gates, and every workspace test
suite except four pre-existing root-only permission tests (they fail on
the base branch too: they `chmod 000` a directory and expect the read to
fail, which root ignores).

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review — three commits added since the first review request: the real topic install executor in proof-admin (publish + RLM section apply + aliases + two owner gates), the DB-backed install engine test suite, and the docs/fixture update. All gates run locally: fmt, workspace clippy, every xtask gate, and every workspace suite green (four pre-existing root-only permission tests excluded — they fail identically on the base branch because they chmod 000 and root ignores it).

@echobt

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread bins/proof-admin/src/install.rs Outdated
Comment on lines +176 to +179
admin
.publish(&bundle.topic)
.await
.map_err(|e| Failure::Error(publish_failure(&e)))?;

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 Publish After Installation

The command publishes an accepted sealed open topic before it starts the fallible RLM installation. If a later migration, route, rule, binding, or journal operation fails, the topic remains published and eligible for submissions despite incomplete installation. Publish only after required installation succeeds, or prevent open topics from accepting submissions until installation is complete.

How this was verified: A real install published an open topic and then failed on a deliberately failing downstream migration.

Knowledge Base Used:

T-Rex Ran code and verified through T-Rex

Comment thread bins/proof-admin/src/install.rs Outdated
Comment on lines +271 to +281
if args.drive_rlm {
// The setup runs after the rules land; its own summary is written by
// the caller of the install (this CLI) once it returns.
return SetupSummary::Skipped {
reason: if args.skip_baseline {
"--skip-baseline: the RLM's rules were installed, no baseline was measured"
.to_owned()
} else {
"the RLM setup is driven by the operator over the topic-VM orchestrator".to_owned()
},
};

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 Run Requested RLM Setup

With --drive-rlm --owner-approved, this branch immediately returns a skipped setup result. It does not provision a VM, propose rules, or run the baseline, while the static installation can still complete and report success. Operators therefore receive a successful install result even though the explicitly requested RLM setup never ran.

Knowledge Base Used: Proof challenge network and evaluation

T-Rex Ran code and verified through T-Rex

Greptile found three P1s on #298. All three were real; each is fixed and
pinned by a test that fails without the fix.

1. **A single-quoted function body was executed without being scanned.**
   PostgreSQL accepts `CREATE FUNCTION f() RETURNS void AS 'DELETE FROM
   proof_rule_version' LANGUAGE sql`, and a string literal is normally
   *data*, so the scanner blanked it. A topic could therefore install a
   function that reaches a protected object by wrapping the statement in
   `AS '…'`. A function definition's literals are code: they are now
   decoded (`''` → `'`, so an escaped spelling cannot hide a statement)
   and scanned exactly as a dollar-quoted body is.

2. **The generic `topic_` prefix was a shared namespace, not a boundary.**
   Every topic shares one database, so `topic_scores` is one table that
   every topic's install can reach — approving it for topic A would let A
   write, truncate, or redefine the table B created. The allowance is
   gone: a name is the topic's only if it is `{topic_id}_*` or
   `{topic_id}.…`. Writing the test for this found a second hole: a table
   name after `TRUNCATE` was never checked at all, because the verb was
   not in the keyword list the scanner follows. Both are fixed, and
   `DELETE FROM x` still resolves to `x` rather than to `from`.

3. **A committed migration could be lost on a crash.** The migration and
   the journal row that records it committed in separate transactions, so
   a crash in between left a migration applied with nothing recording it;
   a resume re-applied it, and ordinary non-idempotent DDL (`CREATE
   TABLE`) failed on a duplicate relation. The row is now written **in
   the same transaction** as the migration it names, so the two states
   reachable are "applied and recorded" or "neither". A resume reads the
   progress rows, which is what makes it correct rather than lucky.

The guard moves to its own crate (`proof-topic-sql-guard`): it is pure
text analysis, and `proof-topic-install` had crossed the 1500 non-test
LOC cap. Separating it also means the rules can be tested — and read —
without a database.

Tests: 58 in the two crates, including a crash-resume test that fails
without fix 3, an escaped-quote test that fails without fix 1, and a
cross-topic test that fails without fix 2. The 11 DB-backed engine tests
pass against a real Postgres.

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review — all three P1s are fixed at 5745b308, each pinned by a test that fails without the fix:

  1. Single-quoted function bodiesCREATE FUNCTION … AS '…' LANGUAGE sql bodies are now decoded (''') and scanned exactly as dollar-quoted bodies are. Test: a_single_quoted_function_body_is_scanned_not_trusted, plus an_escaped_quote_in_a_function_body_does_not_hide_a_denied_statement. A literal that is not a function body is still data (INSERT … VALUES ('DELETE FROM proof_rule_version') is allowed).
  2. Cross-topic topic_* — the generic prefix allowance is gone. A name is the topic's only if it is {topic_id}_* or {topic_id}.…. Test: the_generic_topic_prefix_is_not_a_shared_namespace and a_sibling_topics_namespace_is_refused. Writing it found a second hole in the same area: a table name after TRUNCATE was never checked, because the verb was missing from the keyword list the scanner follows — fixed, with DELETE FROM x still resolving to x rather than to from.
  3. Durable resume — the migration and the journal row that records it now commit in one transaction, so the only reachable states are "applied and recorded" or "neither". A resume reads the progress rows. Test: a_crash_between_migrations_does_not_lose_a_committed_one, which dies mid-install and then resumes over a non-idempotent CREATE TABLE.

The guard also moves to its own crate (proof-topic-sql-guard) — it is pure text analysis, and proof-topic-install had crossed the 1500 non-test LOC cap.

Verification: 58 tests across the two crates; the 11 DB-backed engine tests pass against a real Postgres; fmt, workspace clippy, and all five xtask gates clean.

Comment thread crates/proof-topic-sql-guard/src/lib.rs Outdated
Comment on lines +325 to +329
if is_function_definition(blanked) {
for literal in single_quoted_literals(text) {
bodies.push(' ');
bodies.push_str(&literal);
}

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 Decode Escaped Function Bodies

PostgreSQL decodes E'…' escape strings and U&'…' Unicode strings before using them as SQL-language function bodies, but this path appends the literals after only doubled-quote decoding. A topic can therefore encode DELETE FROM proof_rule_version so the guard sees only escape sequences and accepts the migration, while PostgreSQL installs executable SQL that accesses protected shared state. Decode these literal forms using PostgreSQL semantics, or reject them in function definitions until they can be parsed safely.

How this was verified: A focused test showed that the guard accepted both encoded forms of DELETE FROM proof_rule_version while retaining their undecoded escape sequences.

Artifacts

Escaped function-body regression harness

  • The harness defines E-string and U& Unicode function bodies that PostgreSQL decodes to protected SQL, showing the exact inputs tested.

Escaped function-body test output

  • The test output shows both encoded function bodies were accepted and the test failed because neither was refused.

Database availability check

  • The environment check shows no database connection or local PostgreSQL executable was available for server-side execution.

View artifacts

T-Rex Ran code and verified through T-Rex

echobt and others added 4 commits September 14, 2026 16:28
A topic migration may write its function body as a string literal, and
PostgreSQL executes the *decoded* value. The guard scanned the written
spelling, so `E'\x44ELETE FROM proof\x5frule\x5fversion'` reached a
`proof_*` object without ever matching a deny rule.

Decode every literal form PostgreSQL reads before scanning a body:

- `E'…'`: backslash escapes (`\xhh`, `\ooo`, `\uXXXX`, `\UXXXXXXXX`,
  `\'`, `\\`) and `''` doubling.
- `U&'…'`: `XXXX` / `+XXXXXX` code points, with the escape character a
  following `UESCAPE 'c'` clause names.
- Adjacent literals separated by a newline, which PostgreSQL joins into
  one string (`'DROP TABLE proof'` + `'_topic_version'`).
- `CREATE PROCEDURE … AS '…'` and `DO '…'`, whose literals are code the
  same way a function body is.

Both the decoded value and the written spelling are scanned, and a
sequence PostgreSQL would refuse is kept as written. Four regression
tests fail without this change.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
`cargo fmt --all -- --check` is a required gate and these four files
were not rustfmt-clean on this branch, so CI's fmt step could not pass.
Formatting only: no behavior, no names, no logic.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
An install **wrote** the routes a topic claims into `proof_topic_api`;
nothing **read** them, so `/challenge/{topic_id}/…` had no answer and a
topic's own API was dead on arrival.

Read side (crates/proof-topic-install `routes`):

- `TopicRouteMux` resolves `(topic_id, method, path)` against the table and
  distinguishes a registered route, a method the route did not claim, and a
  path nobody registered — so the caller answers 405/404 instead of
  inventing a route.
- The cache is keyed by a **generation** (`SELECT count(*)`, sound because
  migration 0025 grants the app role `SELECT, INSERT` only, so the table is
  append-only): an install in another process is visible on the next
  request with no restart and no cross-process signal. `invalidate()` is
  the in-process form.
- An unreadable registry is an error, never a 404.

Serving (crates/proof-challenge `topic_routes`, wired in the binary):

- `/challenge/{topic_id}/…` answers 200 with the row the install wrote,
  405, 404, or **503** when the table cannot be read.
- The mux is wired from the same pool the RLM store uses; a host with no
  database serves the Proof routes alone.
- No topic route is compiled in: the table is the registry.

Gateway (crates/gateway-core `topic_routes`, crates/gateway proxy):

- An id the registry does not know is forwarded to the **Proof** backend
  with the topic id kept in the path, but only when it is topic-shaped
  (`^[a-z0-9][a-z0-9-]{1,62}$`, the table's own CHECK). The challenge is
  the gate, so a non-topic id is a 404 and a mistyped challenge id keeps
  the registry's own `no healthy backends`. `v1/admin/*` stays blocked.

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai re-review HEAD 9cb9f98 — E/U& sql_guard + mux proof_topic_api landed; please re-score

`topic install` published the signed document **first** and applied the
RLM section afterwards. An `open` document is submitable the moment it
reaches the registry, so a miner could submit to a topic whose
migrations, routes, and rules were not installed yet — and if the install
failed, the topic stayed live and uninstalled.

The publish is now the last fallible step: drive the RLM, apply the
section (both fail-closed, both journaled), then publish, then point the
aliases. A refused install publishes **nothing**, and a publish that
fails leaves the topic unpublished with nothing to undo — the re-run
skips the applied migrations and publishes. The old justification (the
topic has to exist before the rest can key on it) does not hold: the rule
store, the route table, and the journal key on `topic_id` alone, and the
RLM setup writes the document itself when it is absent. Only the aliases
need a published topic, so they stay last.

The failure messages and the module/runbook docs now say what is and is
not in place at each step.

Regression tests (bins/proof-admin/tests/cli.rs), both of which fail
against the old order:

- `the_publish_lands_only_after_the_install_is_green` reads the journal
  and the migration's table **inside** the publish handler, so it asserts
  the state a miner would have found at the instant the topic became
  reachable.
- `a_refused_install_never_publishes` drives a deny-listed migration and
  asserts the admin stub received no request at all, with no journal row
  and no table.

Also repaired, all pre-existing on this branch and all blocking
`cargo clippy --workspace` / `cargo test --workspace`:

- `bins/proof-challenge` did not compile: the mux wiring needs
  `proof-topic-install` and `sqlx` as direct dependencies, and the mux
  helper and queue drainer are extracted so `run` stays under the
  pedantic line cap.
- `bins/proof-admin` clippy: two `Option::is_none_or` sites, a
  `useless_format`, and two documented allows for the driver's explicit
  parameter list and the install's order-is-the-contract function.
- `crates/proof-rlm-scorer/tests/rlm_e2e.rs` did not compile:
  `FakeOrchestrator::new` takes the baseline primary and returns an
  `Arc`, so two call sites were missing the argument and double-wrapped.
  With the target compiling, two tests then failed on a missing owner-key
  fixture and one asserted a rules version the store's append-only
  versioning does not produce (the resume's proposal lands as v2); all
  three are corrected.

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

Three blockers from the last review are addressed on this branch (head d3c79b54):

  1. sql_guard escape/Unicode literals (crates/proof-topic-sql-guard): a function body written as a string literal is now decoded before it is scanned — E'…' backslash escapes (\xhh, \ooo, \uXXXX, \UXXXXXXXX, \'), U&'…' code points with UESCAPE 'c' honoured, adjacent literals a newline concatenates, and the literal bodies of CREATE PROCEDURE … AS '…' / DO '…'. Both the decoded value and the written spelling are scanned. Four new regression tests fail without the fix.
  2. Publish order (bins/proof-admin): the document is published after the install is green, not before. A refused install publishes nothing, so there is no open topic submitable before its migrations/routes/rules land. Two regression tests fail against the old order — one reads the journal and the migration's table inside the publish handler, the other asserts no request at all on a deny-listed migration.
  3. Mux READ (crates/proof-topic-install::routes, crates/proof-challenge::topic_routes, crates/gateway-core::topic_routes): the challenge now reads proof_topic_api and answers /challenge/{topic_id}/… from it, with the cache keyed by the table's generation so an install in another process is visible on the next request (503 when the table cannot be read, never a 404). The gateway forwards a topic-shaped id it does not know to the Proof backend with the topic id kept in the path.

Also repaired, all pre-existing on this branch and blocking cargo clippy --workspace / cargo test --workspace: bins/proof-challenge did not compile (missing direct deps, line cap), bins/proof-admin had four pedantic lint failures, and crates/proof-rlm-scorer/tests/rlm_e2e.rs did not compile (FakeOrchestrator::new signature), which had been hiding three broken tests.

Comment on lines +206 to +214
// The RLM setup is driven **before** the static half lands, so the rules
// the driver proposes are the topic's current version and the install
// keeps them rather than overwriting them with the bundle's vector.
let driven = if args.drive_rlm {
if !opts.json {
println!(" (driving the RLM: provision → rules → baseline)");
}
Some(drive_rlm(args, &bundle.topic, pin, &pool, &store).await?)
} else {

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 Preflight Before RLM Drive

With --drive-rlm, this runs drive_rlm before Installer::install validates the static install section. A bundle later refused for denied SQL, malformed section data, unsupported handlers, or invalid bindings can therefore already have persisted lifecycle and rule state, provisioned or attached a host, and run a paid baseline. The rejection path returns without compensating for those effects. Validate the complete static install plan before calling drive_rlm so a refused bundle writes nothing and does not touch a host.

Knowledge Base Used:

T-Rex Ran code and verified through T-Rex

…plied

The publish order was a **client** convention: `proof-admin topic install`
publishes last (d3c79b5), but a direct `POST /v1/admin/proof/topics`
could still put an `open` document in the registry before the topic's
migrations, routes, and rules existed. An `open` document is submitable
the moment it is published, so the route is where the rule has to live.

`POST /v1/admin/proof/topics` now refuses an `open` document with **409**
unless the topic's newest `proof_topic_install` row is `applied`:

| host state | `open` | `draft` |
|---|---|---|
| install `applied` | 201 | 201 |
| `pending` / `failed` / no row | 409 | 201 |
| journal unreadable | 409 | 201 |
| no journal on the host (no database) | 409 | 201 |

A `draft` is never gated: it is not submitable, and staging one is how an
operator stages a bundle. Every refusal says which case it was, so the
operator can tell "finish the install" from "fix the database" — the
reason is returned in the body rather than logged, because `proof-http`
has no logging dependency and this is the response the operator reads.

- `proof-http` gains the `InstallJournal` trait and the `install_journal`
  field on `AppState`; `None` is fail-closed.
- `proof-topic-install` gains `applied_install` / `is_installed`, the
  journal read the gate and `topic install-log` share, so the operator
  and the route cannot disagree about whether a topic is installed.
- `proof-challenge` gains `PgInstallJournal` over that read, wired in the
  challenge binary from the same pool the RLM store and route mux use.

The regression test fails without the gate: it drives all four host
states against one document, asserts the four refusals name their own
cause, asserts the same document still drafts on every one of them, and
asserts an applied install admits it.

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

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

Head is now b735f335. Two things changed since your last pass, plus the status of your six findings.

New: the publish gate is in the route, not just the CLI. POST /v1/admin/proof/topics now refuses an open document with 409 unless the topic's newest proof_topic_install row is applied:

host state open draft
install applied 201 201
pending / failed / no row 409 201
journal unreadable 409 201
no journal on the host (no database) 409 201

A draft is never gated (not submitable; staging one is how an operator stages a bundle). Every refusal names its own cause. proof-topic-install::applied_install is the single journal read the gate and topic install-log share, so the operator and the route cannot disagree. Regression test an_open_topic_publishes_only_when_its_install_is_applied drives all four states and fails without the gate.

Also: the CLI publishes last (d3c79b54, before this push) — drive the RLM, apply the section, then publish, then aliases. A refused install never reaches the admin route. The PR body now documents the design and why the reverse order was never necessary.

Your six findings, against current HEAD:

  1. "Scan quoted function bodies" — fixed in f6dd880 (predates this branch's tip): a single-quoted body is decoded and appended to the scanned text. Test a_single_quoted_function_body_is_scanned_not_trusted.
  2. "Checkpoint committed migrations" — already correct in the tree you reviewed: run_migration writes the journal row naming the migration inside the same transaction as the migration's statements. Test a_crash_between_migrations_does_not_lose_a_committed_one asserts a committed migration is durably recorded after a later one fails.
  3. "Isolate topic namespaces" — already fixed before your comment: the generic topic_* allowance was removed, so only {topic_id}_* and {topic_id}.… are in scope. Test the_generic_topic_prefix_is_not_a_shared_namespace asserts CREATE TABLE topic_scores is refused.
  4. "Publish After Installation" — addressed twice over: CLI publishes last, and the route now refuses an open document until the install is applied (above).
  5. "Run Requested RLM Setup" — already correct: --drive-rlm calls drive_rlm, which runs the real TopicSetup (provision → propose_rules → baseline) over the topic-VM orchestrator; the SetupSummary only records what that run did.
  6. "Decode Escaped Function Bodies" — fixed in 62e2a474: E'…' and U&'…' bodies are decoded with PostgreSQL semantics (including UESCAPE 'c', octal, \u / \U, and newline-concatenated adjacent literals) before scanning, and both the decoded value and the written spelling are scanned. Four tests fail without it.

Comment thread bins/proof-admin/src/install.rs Outdated
Comment on lines +197 to +204
// The RLM setup is driven **after** the static half lands, so the rules
// the driver proposes supersede a version the topic already has rather
// than being overwritten by the bundle's vector.
let driven = if args.drive_rlm {
if !opts.json {
println!(" (driving the RLM: provision → rules → baseline)");
}
Some(drive_rlm(args, &bundle.topic, pin, &pool, &store).await?)

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 Validate Before Driving Setup

With --drive-rlm, this calls drive_rlm before Installer::install parses the RLM section, checks every migration, and resolves the handler and binding. A malformed or denied install can therefore provision a VM, persist RLM rules, and run a paid baseline before returning a pre-flight refusal. Run the installer validation before driving the lifecycle so rejected bundles cannot spend resources or leave durable setup state.

Knowledge Base Used:

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +709 to +717
pub async fn applied_install(pool: &PgPool, topic_id: &str) -> Result<bool, InstallError> {
let state: Option<String> = sqlx::query_scalar(
"SELECT state FROM proof_topic_install WHERE topic_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(topic_id)
.fetch_optional(pool)
.await
.map_err(|e| InstallError::Db(e.to_string()))?;
Ok(state.as_deref() == Some(InstallState::Applied.as_str()))

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 Bind publication to bundle

The open-topic gate checks only that the newest install row for this topic ID is applied; it never compares that row's bundle_digest with the signed document being published. A changed signed bundle can reuse an earlier applied install for the same topic ID and receive 201 Created, even though its changed migrations, routes, rules, or executor settings were never installed. Require the published document's canonical bundle digest to match the newest applied install before opening the topic.

Knowledge Base Used:

T-Rex Ran code and verified through T-Rex

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