Skip to content

feat(proof): proof-admin install bundle over the existing topic publish path (P0) - #297

Draft
echobt wants to merge 17 commits into
mainfrom
droid/795020b8-sn100-p0-topics-table-admin
Draft

echobt wants to merge 17 commits into
mainfrom
droid/795020b8-sn100-p0-topics-table-admin

Conversation

@echobt

@echobt echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Dynamic-topics P0 — remapped onto the existing admin publish path per the
Arch §11 hardcoding-inventory delta: cortex scoring and its DB are already
multi-topic, so P0 wraps what exists instead of inventing a parallel registry.

This PR must NOT be merged by the agent. Owner holds merge until the
staging A→Z run.

What this is now

A topic already has one home: the operator-signed TopicDocument
published through POST /v1/admin/proof/topics and persisted in
proof_topic_version (migration 0020). Its bindings are signed topic data
too — constraints.params carries the in-guest runner and its pinned pack
digest. This PR adds the missing procedure, not a second registry.

Step Existing path reused
Sign xtask proof-topic (sr25519, base-proof-topic-v1)
Acceptance TopicDocument::validate + verify_signature — the same pair the admin route runs
Publish POST /v1/admin/proof/topics (operator bearer)
Persist proof_topic_version via RlmStore::put_topic_version
Custom id PROOF_VM_RUNNER_CUSTOM_IDS + pack under PROOF_VM_AGENT_EXPERIMENT_PACK_DIR

Changes

  1. RlmStore::latest_topics — a read-only registry view over the same
    proof_topic_version rows (DISTINCT ON (topic_id) … ORDER BY version DESC), in both the Postgres and memory stores, pinned by the shared
    contract test. No new table, no new column, no migration.
  2. crates/proof-topic-bundle — the bundle carries the signed document
    verbatim plus a host block that must agree with it. Runner / pack /
    custom id are read from the document's own constraints.params via
    proof-experiment::ExperimentBinding, so every binding has exactly one
    copy. A host expectation that contradicts the document is a reject, never
    an override.
  3. bins/proof-admintopic validate runs the same acceptance the
    publish route runs; install --dry-run prints that publish call plus the
    host env; topic list / topic show read the registry view.
  4. Docsdocs/PROOF.md § Topic install bundles rewritten,
    docs/COMPLETENESS.md row, docs/ARCHITECTURE.md bin entry.

Removed (the parallel table this slice had added)

Migration 0024_proof_topics.sql / the proof_topic table, db::topics, and
its integration tests — they duplicated proof_topic_version, which is
already the durable topic table. A second table would have been a second
source of truth for the same facts.

KEPT, as instructed

  • Runner rlm_fc_in_guest_harbor.
  • The proof-experiment anti-hardcode locks (untouched).
  • Signed-topic bindings stay topic data.

NOT P0 (per the delta)

Front-end debrand and the harbor-trials-v1 rename are P1; neither is
touched here.

Topics are RLM-based — the CLI only hands control to the RLM

The admin CLI's job is to ask the topic's RLM to install and set itself
up
. The bundle carries an rlm section that owns everything
topic-specific, and Rust never interprets it:

RLM-owned What it is
rules anti-cheat rules the RLM ticks before any paid inference
migrations SQL migrations the topic's install needs
apis APIs the topic exposes
submission_format the shape a miner submits
scoring how the topic scores

The bundle crate checks the section's shape only (object or array,
bounded at 256 KiB) and carries it byte-for-byte into the plan as
rlm_install. It does not know what a rule, migration, API, submission
format, or scoring function means — recognising them would mean the crate
knows the topic. Unknown content is not an error.

The install plan leads with the hand-off and names the RLM's own lifecycle
steps (provision -> propose_rules -> baseline, the existing TopicSetup
driver), then the publish call and host env. The CLI does not run those steps
and does not read into the section.

Two guard tests enforce the boundary, both verified to fail on what they
forbid:

  • no_topic_literal_appears_in_this_crates_logic (bundle) and
    the_cli_does_not_bake_in_topic_behavior (CLI) scan the non-test source
    with comments stripped — prose may explain the rule, a literal in a
    let/match/if is caught. Injecting topic_id == "tb4" fails the CLI
    guard.
  • The CLI guard also forbids naming a metric, a task, a benchmark, or the RLM
    section's own keys. The first draft enumerated
    rules/migrations/apis/submission_format/scoring in its output and the
    guard caught it — so the CLI now says the section is handed over without
    listing what is in it.

Seed ids stay strings: tb4 and tbench appear only in fixtures, operator
examples, and docs. The bundle crate's non-test source has no topic literal
at all.

Owner defaults (locked)

Default Value Where
First topic slug tb4 the signed document's id
Temporary alias tbench proof_topic_alias (migration 0024), row tbench → tb4
Storage shared challenge DB, topic_id discriminant proof_topic_version — no per-topic schema
Metal install Owner-only, staging first --owner-metal-ack gate
Custom id tbench the document's metric.custom_id

Alias map (0024_proof_topic_alias.sql): a row is alias → topic_id
and nothing else — no name, no pins, no status, no document. Everything else
stays in proof_topic_version, so an alias cannot drift from the topic it
names and retiring it is just deleting the row. This is deliberately not a
second topic table. Resolution is fail-closed in both stores: an alias whose
topic has no published version resolves to nothing, never to an empty
document, and put_alias refuses an unpublished topic outright. base_app
gets DELETE here (unlike the journal tables) because retiring a temporary
alias is the intended end state.

tbench is two different bindings and they are not the same mapping: the
temporary alias (retire with topic alias rm tbench once links move)
and the runner registry's custom id (PROOF_VM_RUNNER_CUSTOM_IDS=tbench,
the scoring binding, permanent).

Owner-only metal gate: --env metal is refused (exit 2) without
--owner-metal-ack, which asserts an Owner authorized the install and that
staging passed for that bundle. The refusal names the staging command to run
first. --env staging is never gated. The gate is an operator assertion, not
a verified precondition — it exists so a live target cannot be reached by a
default or a copy-pasted staging command.

P0 scope — deliberately not done

  • No behavior change. No new table, no new route, no allocator change,
    and nothing on a scoring path changed. latest_topics is a read-only view.
  • A real install is not implemented (exit 3): publishing needs the
    operator bearer, which stays on the host. install --dry-run prints the
    call for an operator to run.
  • topic enable / disable / seal are stubs (exit 3) pointing at the real
    answer: a topic's lifecycle is the signed document's status, so re-sign
    and re-publish.

HOLD — do not merge

Owner holds the merge until the staging A→Z run. Left open unmerged even
though CI is green.

Greptile

  • Greptile review requested
  • Findings fixed or answered

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • cargo deny check
  • cargo run -p xtask -- loc-cap (bundle 360, proof-admin 468, proof-rlm-store 797 — under 1500)
  • cargo run -p xtask -- consensus-lint
  • cargo run -p xtask -- spec-check
  • cargo run -p xtask -- design-check
  • cargo run -p xtask -- external-docs-check
  • bash deploy/scripts/assert-compose-matrix.sh
  • DB-backed: latest_topics + alias contract in both stores, plus
    two DB-gated CLI tests (a document persisted through the scoring path's own
    PgRlmStore read back via topic list/show, and the tbench → tb4 alias
    resolving end to end)
  • CLI exercised by hand: validate (accepts the Arch-default bundle,
    refuses a wrong-key signature and a contradicting host block), install --dry-run (prints the runnable publish block + 4 host env lines), the
    metal gate refusing without --owner-metal-ack and resolving with it, the
    alias set|list|rm round trip, stubs exit 3
  • Committed dry-run fixture for the Owner A→Z walkthrough:
    bins/proof-admin/tests/fixtures/tb4.install-bundle.json +
    tb4.pin.toml + README-dry-run.md. Both documented commands are run on
    every test run by the_committed_dry_run_fixture_still_validates_and_plans.
  • No live metal. No Lium rent, no Firecracker, no route call.

Two failures in my sandbox are pre-existing and unrelated (both reproduce
on a clean tree):
crates/db/tests/gateway_store.rs (3 tests, fail against a local Postgres 18
that CI does not have) and
proof-challenge-bin::seed_pf_allocator_refuses_boot_when_a_topic_dir_cannot_be_read
(relies on chmod 000 blocking reads, which does not hold as root; verified
passing as a non-root user, which is how CI runs).

Risk

Low. Two things ship, both bounded and both stated here rather than
implied:

Schema: crates/db/migrations/0024_proof_topic_alias.sql only. It
adds proof_topic_alias (alias → topic_id + a topic_id index) and a
BEFORE INSERT/UPDATE trigger pair (proof_topic_alias_no_shadow,
proof_topic_version_no_shadow) that makes an alias collision with a
published slug fail closed in both directions. That trigger sits on the
publish pathINSERT into proof_topic_version — and is a
publish-path integrity guard, not scoring math: it cannot change a score,
a payout, or a sealed vector. The migration does not ALTER or DROP
anything; the 0020 tables keep their columns, keys, and grants.

Code: no route change, no scoring change, no allocator change, no
BASE_* / deployed-path / crypto-domain-tag change. The CLI reads existing
proof_topic_version rows and prints an operator procedure; a real install is
still exit 3.

Earlier revisions of this body claimed "no schema change" and described a
0024 topics table that was removed in the remap. Both were wrong for HEAD
and are corrected here and in docs/PROOF.md / docs/COMPLETENESS.md.

Naming

I did not rename BASE_* environment variables, deployed host paths,
GHCR baseintelligence/base package names, or base-*-v1 cryptographic
domain tags.

Dynamic-topics slice 1 (Arch pins): a per-topic install registry and the
operator CLI that writes it. No behavior change — nothing on a scoring path
reads the new table, no route and no allocator changes, and the compiled-in
topic bindings stay (P4).

- db: migration 0024 adds `proof_topic` to the shared challenge DB, keyed by
  `topic_id` (the discriminant), with `display_name`, `version`, `environment`,
  `runner_id`, `aliases`, `enabled`, `config` JSONB, `pin_rlm`,
  `pin_experiment`, `pack_digest`, `n_concurrent`, `sealed_custom_value`,
  `schema_version`, the bundle verbatim + its digest, and timestamps. Shape
  CHECKs guard the slug, the `sha256:<64 hex>` pins, a finite baseline, and
  the alias array. Mutable (enable/disable, re-install, seal) so `base_app`
  gets SELECT/INSERT/UPDATE and never DELETE: a topic is disabled, never
  dropped.
- db::topics: upsert / list / get over runtime sqlx (no compile-time DB),
  plus DB-gated integration tests (install, ordering, re-install keeps
  created_at and does not silently disable, CHECK refusals, app-role grants).
- crates/proof-topic-bundle: the topic install bundle schema v1 — parse,
  validate, canonical digest, install plan. Unknown keys are refused, every
  pin is `sha256:<64 hex>` or absent (never invented), and an in-guest
  `runner_id` without a `pack_digest` is refused.
- bins/proof-admin: `topic validate` (no writes), `topic install [--dry-run]`
  (dry-run needs no database), `topic list`, `topic show`. Installing writes
  `enabled = false`; `topic enable` / `disable` / `seal` exit 3 with a clear
  "not implemented in this slice". Process-level tests cover validate,
  dry-run, the env mismatch, the missing-database path, and the stubs.
- docs: PROOF.md § Topic install bundles, COMPLETENESS row, ARCHITECTURE bin.

Arch defaults: first topic slug `tb4` with alias `tbench`; shared DB with a
`topic_id` discriminant. Alias resolution, routes (P1), the allocator (P2),
the full install (P3), and removing the hardcoded bindings (P4) are later
slices.

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

@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a topic-administration bundle and CLI over the existing signed-topic publication path, adds latest-topic and alias views to the RLM stores, and adds collision-safe alias persistence.

  • Preserves the complete RLM installation object as raw JSON during hand-off.
  • Validates bundles against the same signed-document checks used by the admin publish route.
  • Provides dry-run publication instructions and read-only topic inspection.
  • Adds temporary topic aliases with cross-table slug-collision protection.
  • Corrects the staging walkthrough to use boot-time embedded migrations rather than an unavailable cargo sqlx command.

Confidence Score: 4/5

The PR is not yet safe to merge because duplicate named RLM fields can bypass the bundle’s required shape and explicit-null validation.

The raw RLM hand-off now preserves the enclosing object as intended, and the previous migration walkthrough issue is fixed. However, validation still collapses duplicate keys into a map before checking them, allowing invalid content to remain in the exact bytes accepted for RLM installation.

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

Important Files Changed

Filename Overview
crates/proof-topic-bundle/src/lib.rs Preserves the complete RLM object verbatim, but duplicate named fields can evade shape and null validation.
bins/proof-admin/src/main.rs Adds validation, dry-run publication planning, registry inspection, alias administration, and fail-closed lifecycle stubs.
crates/db/migrations/0024_proof_topic_alias.sql Adds temporary aliases and serialized cross-table collision guards without duplicating topic records.
crates/proof-rlm-store/src/pg.rs Adds latest-topic registry views and fail-closed alias operations over the existing topic-version table.
bins/proof-admin/tests/fixtures/README-dry-run.md Correctly directs staging operators to boot-time embedded migrations instead of the unavailable sqlx CLI.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    B[Install bundle] --> V[Bundle and signed-document validation]
    V --> R[RLM raw install hand-off]
    V --> D[Dry-run publication plan]
    D --> A[Existing admin topic endpoint]
    A --> T[(proof_topic_version)]
    L[proof-admin list/show] --> T
    X[proof_topic_alias] --> T
Loading

Reviews (15): Last reviewed commit: "fix(proof): carry the whole RLM section ..." | Re-trigger Greptile

Comment thread bins/proof-admin/src/main.rs Outdated
Comment on lines +306 to +324
if opts.json {
let body = serde_json::json!({
"ok": true,
"installed": true,
"topic_id": plan.topic_id,
"environment": plan.environment.as_str(),
"bundle_digest": plan.bundle_digest,
"enabled": false,
});
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into())
);
return Ok(());
}
print_plan(&plan);
println!();
println!(
"Installed {} (DISABLED). Enabling is a later slice: nothing scores this topic yet.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Reinstall Status Is Wrong

Reinstalling a topic deliberately preserves its existing enabled state, but both success-output paths always say the topic is disabled. An enabled topic therefore remains enabled while operators and automation are told the opposite, creating avoidable configuration mistakes. Return the persisted state in the install result before rendering it.

Artifacts

Evidence from the check

  • Runs a disposable migrated PostgreSQL instance, installs, enables, reinstalls, and queries the proof topic, demonstrating the output/state mismatch.

Command output from the check

  • Captured execution shows the reinstall JSON says enabled false while the direct database query returns enabled true, confirming the defect.

Evidence from the check

  • Records environment availability and runs the repository test covering preservation of enabled state during a topic reinstall.

Command output from the check

  • Captured test run passes the targeted reinstall-preservation test; it also records unset database URLs and absence of local PostgreSQL executables.

Evidence from the check

  • Checks Docker availability and executes the real-install-without-database CLI test, confirming the initial environment's database constraint.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +335 to +338
fn is_digest(s: &str) -> bool {
s.strip_prefix(DIGEST_PREFIX)
.is_some_and(proof_canon::is_hex64)
}

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 Canonical Digests Pass Validation

The digest validator accepts uppercase and whitespace-padded SHA-256 pins, then leaves those values unchanged in the install plan. The database accepts only an exact lowercase, unpadded digest, so a bundle can successfully validate or dry-run but fail during a real install. Require exactly sha256: followed by 64 lowercase hexadecimal characters before accepting the bundle.

Artifacts

Evidence from the check

  • The authored script creates the two bundle variants and invokes the real proof-admin dry-run install command, showing the validator accepts the noncanonical values.

Command output from the check

  • The control command completed with exit code 0 and emitted canonical lowercase pin digests, establishing the accepted persistence-compatible shape.

Command output from the check

  • The noncanonical command completed with exit code 0 and emitted uppercase and whitespace-padded pin digests unchanged, proving validation accepts persistence-incompatible values.

Command output from the check

  • The focused bundle and CLI regression suites completed successfully with 26 passing tests, showing the reproduced path executed in the normal testable stack.

Command output from the check

  • The DB topic test target compiled and returned success, but the source test gate causes all five tests to return early without DATABASE_URL, so live PostgreSQL rejection could not be executed.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread bins/proof-admin/src/main.rs Outdated
Comment on lines +352 to +355
/// A `u32` that fits the row's `INTEGER` columns. The bundle's own bounds keep
/// every value far below `i32::MAX`, so this only guards the cast.
fn clamp_i32(v: u32) -> i32 {
i32::try_from(v).unwrap_or(i32::MAX)

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 Oversized Values Are Changed

Bundles with version or n_concurrent above i32::MAX pass validation, but installation silently writes i32::MAX to the typed columns. The stored row then disagrees with the validated, digest-covered bundle, so later consumers can act on values the operator did not install. Reject out-of-range values instead of clamping them.

Artifacts

Evidence from the check

  • Creates boundary and overflow bundle files, runs the actual no-DB proof-admin dry-run path, and records command metadata plus output; it demonstrates the validator accepts the overflow input.

Command output from the check

  • Actual proof-admin CLI output for a bundle with version and n_concurrent equal to i32::MAX; it exits 0 and shows both boundary values unchanged.

Command output from the check

  • Actual proof-admin CLI output for a bundle with version and n_concurrent equal to i32::MAX plus one; it also exits 0 and exposes the unbounded values that the persistence mapper will clamp.

Command output from the check

  • Records the command, repository working directory, and zero exit status for the authored focused reproduction script; it confirms both comparison captures were produced by execution.

Command output from the check

  • Records the process-level proof-admin CLI test command and its 11 passing tests; it confirms existing CLI coverage remains green while missing the oversized-integer case.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +71 to +76
CONSTRAINT proof_topic_aliases_bound CHECK (cardinality(aliases) <= 8),
CONSTRAINT proof_topic_aliases_shape CHECK (
cardinality(aliases) = 0
OR array_to_string(aliases, ',') ~ '^[a-z0-9][a-z0-9-]{1,62}(,[a-z0-9][a-z0-9-]{1,62})*$'
),
CONSTRAINT proof_topic_aliases_not_self CHECK (NOT (topic_id = ANY (aliases))),

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 Null Aliases Break Reads

The alias constraints allow a TEXT[] containing a NULL element because the joined-string check omits NULL values and CHECK expressions accept unknown results. The typed topic reader expects every alias to be a string, so one accepted row makes topic list and show fail while decoding aliases. Add a constraint that rejects NULL array elements.

Artifacts

Command output from the check

  • Captured the database environment check, showing DATABASE_URL was unset and only Docker was available, ending with the takeaway that no configured local database endpoint existed.

Evidence from the check

  • Authored and executed script that creates the relevant constraints and inserts a text array with a NULL alias element, ending with the takeaway that it exercises the candidate condition on PostgreSQL.

Command output from the check

  • Captured the executed Docker PostgreSQL 16 reproduction, where INSERT succeeded and the returned row contains `{valid-alias,NULL}`, ending with the takeaway that the migration permits the invalid typed value.

Command output from the check

  • Captured the migration constraints and the Vec<String> decoding/list/show code paths, ending with the takeaway that an accepted NULL array element reaches an incompatible typed decode.

Command output from the check

  • Captured the executed db topic test command with four unit tests passing and DATABASE_URL-gated integration tests skipped, ending with the takeaway that existing tests do not cover NULL alias rejection.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread crates/proof-topic-bundle/src/lib.rs Outdated
Comment on lines +429 to +432
let canonical = self.canonical()?;
if canonical.len() > MAX_CONFIG_BYTES {
return Err(BundleError::ConfigTooLarge(canonical.len()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Config Limit Includes Metadata

The advertised config-size limit is applied to the complete canonical bundle instead of the config object. A configuration below 16 KiB can be rejected solely because otherwise valid metadata increases the bundle size, and the error incorrectly attributes that rejection to the config. Measure the canonical config object alone.

Artifacts

Evidence from the check

  • The uploaded Bash script generates and executes a Rust harness against the repository crate for minimal and metadata-heavy bundles, demonstrating the measurement scope.

Command output from the check

  • Running the probe with minimal metadata measured a 16,010-byte canonical config and a 16,142-byte bundle, which validation accepted.

Command output from the check

  • Running the same probe with valid extra metadata kept the config at 16,010 bytes but grew the bundle to 17,164 bytes, which validation rejected as `ConfigTooLarge`.

View artifacts

T-Rex Ran code and verified through T-Rex

Five findings from the Greptile review of #297, each with a regression test.

- digest pins (P1): accept exactly `sha256:` + 64 lowercase hex. The row's
  CHECK is `^sha256:[0-9a-f]{64}$`, but `proof_canon::is_hex64` trims and
  accepts uppercase, so `sha256:AB…` or `sha256: ab… ` validated and
  dry-ran and then failed a real install on the host that matters. A local
  `is_lower_hex64` checks the one spelling both places agree on.
- numeric columns (P1): refuse `version` / `n_concurrent` above `i32::MAX`
  instead of clamping to it. A clamped row disagreed with the validated,
  digest-covered bundle an operator reviewed; out of range is now
  `IntColumnOverflow`, and the CLI's clamp is gone.
- NULL aliases (P1): `array_to_string` drops NULL elements, so the
  joined-string shape check accepted `{tbench,NULL}` — and the typed reader
  decodes every element as a `String`, so that one row made `topic list` and
  `topic show` fail for the whole table. `array_position(aliases, NULL)` is
  the probe that holds, in its own constraint so each one fails for one
  reason.
- reinstall status (P2): report the **persisted** state after install. A
  re-install deliberately leaves `enabled` alone, so telling an operator (or
  an automation reading `--json`) that a live topic is disabled was the
  mistake that produces a surprise on a live host.
- config bound (P2): measure the `config` object alone, not the whole
  canonical bundle, and report that number in the error.

Tests: bundle 18 (was 15), db topics 6 (was 5), CLI 14 (was 11) — including
a DB-gated reinstall test that enables a row and asserts the re-install
reports `enabled: true`.

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 five findings from the previous review are addressed in df22cbbf:

  • P1 digest pins — now sha256: + exactly 64 lowercase hex (is_lower_hex64), matching the column CHECK; uppercase/padded pins are a validate-time reject.
  • P1 numeric columnsversion / n_concurrent over i32::MAX are refused (IntColumnOverflow), never clamped; the CLI clamp is gone.
  • P1 NULL aliases — new proof_topic_aliases_no_null constraint using array_position(aliases, NULL), since array_to_string drops NULLs.
  • P2 reinstall status — install now reads back and reports the persisted enabled state.
  • P2 config bound — measured on the config object alone, and the error reports that size.

Regression tests added for each: bundle 15→18, db topics 5→6, CLI 11→14 (incl. a DB-gated reinstall test asserting enabled: true survives).

Comment thread bins/proof-admin/src/main.rs Outdated
Comment on lines +309 to +318
// would be exactly the mistake that leads to a surprise on a live host.
let persisted = db::get_topic(&pool, &plan.topic_id)
.await
.map_err(|e| Failure::Error(format!("read back {}: {e}", plan.topic_id)))?
.ok_or_else(|| {
Failure::Error(format!(
"install {} reported success but the row is missing",
plan.topic_id
))
})?;

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 Committed Install Reports Failure

The install is committed before the separate get_topic query runs. If that read fails, the command returns an error even though the topic was successfully installed, so automation may retry and overwrite a newer concurrent install. Return the persisted fields from the upsert itself, for example with RETURNING, so the write and reported result have one outcome.

Greptile's re-review of #297: the install committed the upsert and then ran a
separate `get_topic` readback. If that read failed the command returned an
error for an install that had already landed, so an automation could retry
and overwrite a newer concurrent install.

`upsert_topic` now returns the row via `RETURNING` (reusing the same column
list the reads use, so the write and the read cannot drift apart), and the
CLI reports the persisted state from that value: one statement, one outcome.
The returned `enabled` is still the persisted one a re-install deliberately
leaves alone.

Tests: `db` topics 6 → 7 with an explicit "the returned row is the persisted
row" case (including a re-install that reports `enabled: true`).

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

The readback finding is fixed in e7be5738: upsert_topic now returns the committed row via RETURNING (reusing the same column list the reads use), and the CLI reports the persisted state from that value. The write and the reported result are one statement, so a successful install can no longer surface as a failure. New test s3b_the_upsert_returns_the_persisted_row pins it (db topics 6→7).

Arch §11 hardcoding inventory: cortex scoring and its DB are already
multi-topic. The hardcoding is branding/docs/FE plus the `tbench-harbor-v1`
alias, not a missing topics table. So P0 must wrap the path that already
exists, not invent a parallel one.

Removed (the parallel registry this slice had added):
- migration `0024_proof_topics.sql` and its `proof_topic` table
- `db::topics` (upsert/list/get) and its integration tests

Those duplicated `proof_topic_version` (migration `0020`), which is already
the durable topic table, written by the scoring path. A second table would
have been a second source of truth for the same facts.

Added, leaning on what exists:
- `RlmStore::latest_topics` — a read-only registry **view** over the same
  `proof_topic_version` rows (`DISTINCT ON (topic_id) … ORDER BY version
  DESC`), implemented by both stores and pinned by the shared contract test.
  No new table, no new column, no migration.
- `crates/proof-topic-bundle` reworked: the bundle now carries the signed
  `TopicDocument` verbatim plus a `host` block that must **agree** with it.
  Runner id, pack digest, and custom id come from the document's own
  `constraints.params` (read through `proof-experiment::ExperimentBinding`),
  so every binding has exactly one copy. A host expectation that contradicts
  the document is a reject, never an override. `pack_dir` is a directory, not
  a digest.
- `bins/proof-admin` reworked: `topic validate` runs the same acceptance
  `POST /v1/admin/proof/topics` runs (`TopicDocument::validate` +
  `verify_signature` against `config/proof-pin.toml`), and `install
  --dry-run` prints that publish call plus the host env. `topic list` /
  `topic show` read the registry view. A real install is not implemented
  (exit 3) because publishing needs the operator bearer, which stays on the
  host; `enable` / `disable` / `seal` are stubs pointing at re-sign +
  re-publish.

KEPT: runner `rlm_fc_in_guest_harbor`, the proof-experiment anti-hardcode
locks, and the signed-topic bindings.
NOT P0: FE debrand and the `harbor-trials-v1` rename.

Tests: bundle 15, CLI 12 (incl. a DB-gated case that persists through the
scoring path's own store and reads it back through the CLI), store contract
extended to the view.

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

Remapped per the Arch §11 hardcoding-inventory delta in 15a4b549. Cortex scoring/DB are already multi-topic, so this no longer adds a topics table.

Removed the parallel registry: migration 0024_proof_topics.sql / the proof_topic table, db::topics, and its tests — they duplicated proof_topic_version (migration 0020), which the scoring path already writes.

Now leans on what exists:

  • RlmStore::latest_topics — a read-only view over the same proof_topic_version rows (DISTINCT ON (topic_id) … ORDER BY version DESC), both stores, pinned by the contract test. No new table, no migration.
  • proof-topic-bundle — carries the signed TopicDocument verbatim plus a host block that must agree with it. Runner id / pack digest / custom id are read from the document's own constraints.params via proof-experiment::ExperimentBinding, so each binding has one copy; a contradicting host value is a reject, never an override.
  • proof-admin topic validate runs the same acceptance POST /v1/admin/proof/topics runs (TopicDocument::validate + verify_signature); install --dry-run prints that publish call plus the host env; list/show read the registry view. A real install exits 3 (the bearer stays on the host).

Kept: runner rlm_fc_in_guest_harbor, the proof-experiment anti-hardcode locks. Not P0: FE debrand, harbor-trials-v1 rename.

Still do not merge — Owner holds merge until staging A→Z.

@echobt echobt changed the title feat(proof): topics table + proof-admin install skeleton (P0) feat(proof): proof-admin install bundle over the existing topic publish path (P0) Sep 14, 2026
Comment thread bins/proof-admin/src/main.rs Outdated
Comment on lines +401 to +403
" --data-binary @{} \\",
signed_document_hint(bundle_path)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Dry-run Command Cannot Run

The dry run prints --data-binary @<extract .topic from …>, but this placeholder is invalid shell syntax rather than a document file. Substituting the bundle file also fails because the publish endpoint accepts a TopicDocument, not the bundle envelope. Operators therefore cannot use the advertised command to publish a bundle they just validated. Print an executable extraction step and reference its output file. This is non-blocking, but it makes the installation procedure unusable.

Artifacts

Evidence from the check

  • The executable Bash script runs and captures the CLI dry-run and HTTP route-contract checks, providing the exact authored validation source.

Command output from the check

  • A valid signed fixture ran through `proof-admin topic install --dry-run`; its generated curl contains the placeholder and Bash rejected it with exit code 2, confirming the advertised command cannot be pasted and run.

Command output from the check

  • An authenticated full bundle received HTTP 422 while its extracted TopicDocument received HTTP 201 Created, confirming the endpoint contract and the required body shape.

Command output from the check

  • The authored validation script completed successfully with exit code 0 after capturing both runtime evidence files.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: the dry run printed `--data-binary @<extract .topic from …>`,
which is not shell syntax, and substituting the bundle file would fail too —
the publish route takes a `TopicDocument`, not the bundle envelope. So the
advertised procedure could not actually be run.

The plan now prints two real steps:

1. `jq '.topic' '<bundle>' > /tmp/proof-topic-document.json && proof-admin
   topic validate --bundle '<bundle>' --pin config/proof-pin.toml`
2. the `curl` against the existing route, reading that extracted file

Paths are single-quoted for `sh` with embedded quotes escaped, so a path with
a space cannot turn the printed command into a different one. The test asserts
the extraction step passes `sh -n` and that no `<extract` placeholder remains.

`PUBLISH_PATH` names the challenge-nested path the printed call uses.

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

Fixed in c36def43. The dry run now prints two runnable steps instead of a placeholder:

1) Extract the signed document (the route takes a TopicDocument, not the bundle):
     jq '.topic' '<bundle>' > /tmp/proof-topic-document.json && proof-admin topic validate --bundle '<bundle>' --pin config/proof-pin.toml

2) Publish it (existing route, operator bearer):
     curl -sS -X POST \
       -H "Authorization: Bearer $PROOF_ADMIN_TOKEN" \
       -H 'content-type: application/json' \
       --data-binary @/tmp/proof-topic-document.json \
       <host>/challenge/proof/v1/admin/proof/topics

Paths are single-quoted for sh with embedded quotes escaped, and the test asserts the extraction step passes sh -n plus that no <extract placeholder remains. Still do not merge — Owner holds merge until staging A→Z.

Comment thread bins/proof-admin/src/main.rs Outdated
Comment on lines +422 to +429
"jq '.topic' {bundle} > /tmp/proof-topic-document.json && \
proof-admin topic validate --bundle {bundle} --pin config/proof-pin.toml"
);
let publish = format!(
"curl -sS -X POST \\\n \
-H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\\n \
-H 'content-type: application/json' \\\n \
--data-binary @/tmp/proof-topic-document.json \\\n \

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 Shared temporary file race

The printed procedure extracts and validates a document, then later reads the predictable shared path /tmp/proof-topic-document.json for publication. Another local process can replace that file between the two steps, causing the authenticated request to publish a different topic than the operator validated. Use a private unique file with cleanup and bind validation to the exact bytes sent, or make extraction, validation, and publication a single operation.

Artifacts

Evidence from the check

  • The authored Bash script executes the extraction, validation, and curl procedure against a local request-capture server and demonstrates the race.

Command output from the check

  • The control run validates and extracts SAFE without replacement, then the receiver gets SAFE.

Command output from the check

  • The replacement run validates SAFE, replaces the fixed temporary file with ATTACKER, and captures ATTACKER at the receiver.

Command output from the check

  • The CLI test confirms the current output uses the same fixed temporary path for extraction and curl.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: the printed procedure wrote the extracted document to a fixed
`/tmp/proof-topic-document.json` and published it in a later step. Any local
process could replace that file between validation and publication, so the
document the route received would not be the one the operator validated —
and the route is authenticated, which makes it a real substitution path.

Extraction and publication are now **one block**:

  PROOF_TOPIC_DIR=$(mktemp -d) \
    && jq '.topic' '<bundle>' > "$PROOF_TOPIC_DIR/document.json" \
    && chmod 600 "$PROOF_TOPIC_DIR/document.json" \
    && curl … --data-binary @"$PROOF_TOPIC_DIR/document.json" <host>… \
    && rm -rf "$PROOF_TOPIC_DIR"

`mktemp -d` creates the directory 0700, the document is 0600, and the path
variable and the file it names cannot drift apart because they are the same
shell block. The test now asserts the private directory is used, that the
fixed shared path is gone, that extraction and publication are one block, and
that the block passes `sh -n`.

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

Fixed in 2bfbd438. Extraction and publication are now one block using a private mktemp -d directory (0700) with the document at 0600, so no fixed shared path exists for a local process to swap between validation and publication:

PROOF_TOPIC_DIR=$(mktemp -d) \
  && jq '.topic' '<bundle>' > "$PROOF_TOPIC_DIR/document.json" \
  && chmod 600 "$PROOF_TOPIC_DIR/document.json" \
  && curl -sS -X POST \
       -H "Authorization: Bearer $PROOF_ADMIN_TOKEN" \
       -H 'content-type: application/json' \
       --data-binary @"$PROOF_TOPIC_DIR/document.json" \
       <host>/challenge/proof/v1/admin/proof/topics \
  && rm -rf "$PROOF_TOPIC_DIR"

The path variable and the file it names cannot drift apart because they are the same shell block. The test asserts the private directory is used, the fixed /tmp path is gone, extraction and publication are one block, and the block passes sh -n. Still do not merge — Owner holds merge until staging A→Z.

Owner defaults locked: first topic slug `tb4` with temporary alias
`tbench`; shared challenge DB with a `topic_id` discriminant (not a
per-topic schema); metal `topic install` is Owner-only, staging first.

Alias (migration `0024_proof_topic_alias.sql`):
- A row is `alias -> topic_id` and nothing else: no name, no pins, no
  status, no document. Every one of those stays in `proof_topic_version`,
  so an alias cannot drift from the topic it names, and retiring it is
  deleting the row. This is not a second topic table.
- Shape CHECKs (slug, not-self) plus a `topic_id` index. `base_app` gets
  DELETE here because retiring a temporary alias is the intended end
  state — unlike the journal tables.
- `RlmStore::{put_alias, resolve_alias, aliases_for, delete_alias}`, in
  both stores, pinned by the shared contract test. Resolution is
  fail-closed on both sides: an alias whose topic has no published version
  resolves to **nothing** (`None`), never to an empty document, and
  `put_alias` refuses a topic that is not published yet.
- `proof-admin topic alias set|list|rm`, and `topic show` resolves an
  alias to its canonical slug and says which topic it hit.

Owner-only metal gate:
- `--env metal` is refused (exit 2) without `--owner-metal-ack`, which
  asserts that an Owner authorized the install and that staging passed for
  that bundle. The refusal names the staging command to run first.
- `--env staging` is never gated. The plan output states which gate
  applied (`owner_gate acknowledged` / `n/a (staging)`).

Docs record the locked defaults, including that `tbench` is two different
bindings: the temporary alias (retirable) and the runner registry's custom
id (the scoring binding, permanent).

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

Locked Owner defaults in 58105ce4.

Alias map (migration 0024_proof_topic_alias.sql) — the first topic slug is tb4 with tbench as a temporary alias:

  • A row is alias → topic_id and nothing else. No name, no pins, no status, no document — those stay in proof_topic_version, so an alias cannot drift from the topic it names. Deliberately not a second topic table.
  • Fail-closed resolution in both stores: an alias whose topic has no published version resolves to None, never to an empty document, and put_alias refuses an unpublished topic outright. The Postgres read is an EXISTS join, not a bare row lookup.
  • base_app gets DELETE here (unlike the journal tables) because retiring a temporary alias is the intended end state.
  • proof-admin topic alias set|list|rm, and topic show resolves an alias to its canonical slug.

Owner-only metal gate: --env metal is refused (exit 2) without --owner-metal-ack, which asserts an Owner authorized the install and staging passed for that bundle. --env staging is never gated.

Still do not merge — Owner holds merge until staging A→Z.

Comment on lines +221 to +240
async fn put_alias(&self, alias: &str, topic_id: &str) -> Result<(), StoreError> {
// Fail closed before the write: an alias must name a topic that
// actually has a published version, or resolution would hand back
// nothing and look like an unknown topic.
if self.latest_topic(topic_id).await?.is_none() {
return Err(StoreError::Malformed(format!(
"alias {alias:?} names topic {topic_id:?}, which has no published version"
)));
}
sqlx::query(
"INSERT INTO proof_topic_alias (alias, topic_id) VALUES ($1, $2) \
ON CONFLICT (alias) DO UPDATE SET topic_id = EXCLUDED.topic_id, \
updated_at = now()",
)
.bind(alias)
.bind(topic_id)
.execute(&self.pool)
.await?;
Ok(())
}

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 Canonical Topics Can Be Shadowed

When tb4 and another-topic are both published, this accepts put_alias("tb4", "another-topic") because it validates only the target topic. topic show tb4 resolves aliases before canonical IDs, so it returns another-topic’s signed document instead of the real tb4 document. Reject aliases that collide with an existing published canonical topic ID in both store implementations.

Artifacts

Evidence from the check

  • Authored Rust test source publishes tb4 and another-topic, writes the colliding alias, and reproduces the same alias-first selection used by topic show; it makes the conflicting behavior executable.

Command output from the check

  • Executed baseline test with only canonical tb4 published; it shows no alias resolution and selection of tb4, establishing the expected canonical behavior.

Command output from the check

  • Executed collision test after publishing both topics and writing tb4 to another-topic; it shows the write succeeds and alias-first selection returns another-topic, confirming the defect.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: an alias could take another published topic's canonical slug, so
`topic show <slug>` resolved through the alias and returned a *different*
topic's signed document. An alias must never change what a published id
means.

Guarded in both directions, at three layers:

- **Write** (`put_alias`): refuse an alias that is itself a published topic
  id, and refuse a topic that is not published (already the rule).
- **Read** (`resolve_alias`): the Postgres lookup gains
  `AND NOT EXISTS (… WHERE s.topic_id = a.alias)`, so a row written before
  this guard still cannot shadow a canonical slug. The memory store returns
  `None` when the alias names a published topic.
- **Schema** (`0024_proof_topic_alias.sql`): a `BEFORE INSERT OR UPDATE`
  trigger on `proof_topic_alias` *and* on `proof_topic_version`, because a
  writer going straight to SQL bypasses the store. `topic_id` alone is not
  unique in `proof_topic_version` (keyed `(topic_id, version)`), so this
  cannot be a UNIQUE constraint.

Both trigger directions were probed against Postgres: claiming a published
slug as an alias is refused, and publishing a topic whose id an existing
alias claims is refused, with neither probe writing a row.

Tests: the shared contract now covers the collision (refused at write,
`None` at read) for both stores.

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

Fixed in f0a0c6dd. An alias can no longer shadow a published topic's canonical slug — guarded in both directions at three layers:

  • Write (put_alias): refuses an alias that is itself a published topic id.
  • Read (resolve_alias): the Postgres lookup gains AND NOT EXISTS (SELECT 1 FROM proof_topic_version s WHERE s.topic_id = a.alias), so a row written before this guard still cannot shadow a slug; the memory store returns None.
  • Schema (0024_proof_topic_alias.sql): a BEFORE INSERT OR UPDATE trigger on proof_topic_alias and on proof_topic_version, since a writer going straight to SQL bypasses the store. topic_id alone is not unique in proof_topic_version (keyed (topic_id, version)), so this cannot be a UNIQUE constraint.

Both trigger directions were probed against Postgres: claiming a published slug as an alias is refused (alias other-topic is already a published topic id) and publishing a topic whose id an existing alias claims is refused (topic tbench is already claimed as an alias), with neither probe writing a row. The shared contract test covers the collision for both stores.

Still do not merge — Owner holds merge until staging A→Z.

Comment on lines +60 to +65
IF EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = NEW.alias) THEN
RAISE EXCEPTION 'alias % is already a published topic id', NEW.alias
USING ERRCODE = 'check_violation';
END IF;
ELSE
IF EXISTS (SELECT 1 FROM proof_topic_alias WHERE alias = NEW.topic_id) THEN

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 Concurrent Slug Claims Bypass Guard

Concurrent publication of a topic and creation of an alias using the same slug can both pass their cross-table checks before either transaction commits. Both rows are then stored, leaving a canonical slug claimed as an alias. Resolution fails closed, but the unusable alias remains in alias listings until an operator removes it. Serialize claims for each slug across both trigger paths, such as with the same transaction-scoped advisory lock before checking and writing.

Artifacts

Evidence from the check

  • Authored executable script that starts isolated PostgreSQL 16, applies the repository migrations, runs sequential and concurrent inserts, and queries the resulting invariant; it provides the exact executed source.

Command output from the check

  • Captured control-run output showing the alias insert is rejected after the topic is already committed, leaving one topic row and zero alias rows; the ordinary non-concurrent guard works.

Command output from the check

  • Captured concurrent-run output showing both transactions committed and the final query returned `invalid_shadow_state=true`; the cross-table trigger race is confirmed.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: the alias shadow guard was only atomic per statement. Under READ
COMMITTED two concurrent claims for the same slug each see no row from the
other, so **both commit** and the slug is shadowed after all.

Reproduced against Postgres before fixing: an alias insert and a topic
publish for the same slug, each holding an open transaction, both committed
(1 alias row + 1 topic row for one slug).

Fixed by serializing claims on the slug with a transaction-scoped advisory
lock (`pg_advisory_xact_lock(hashtextextended(slug, 0))`), taken:

- in the migration's trigger, before the `EXISTS` check, in both directions;
- in `put_alias`, which is now **one transaction** — the lock is released at
  the end of its transaction, so issuing the lock and the insert as separate
  statements would drop it before the insert and reopen the race.

Re-running the same race now yields one winner and one refusal
(`topic race-slug is already claimed as an alias`); sequential paths are
unchanged. The lock is keyed on the slug, so unrelated topics never contend,
and it is released automatically at commit/rollback.

Test: a `multi_thread` regression that races two claims for one slug and
asserts at most one commits and that a published canonical slug never
resolves through an alias.

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

Fixed in 625cc6f0. You were right that the guard was only atomic per statement — under READ COMMITTED both concurrent claims committed.

Reproduced first (before fixing): an alias insert and a topic publish for the same slug, each in an open transaction, both committed → 1|1 (one alias row and one topic row for the same slug).

Fix: claims on a slug are serialized with pg_advisory_xact_lock(hashtextextended(slug, 0)), taken:

  • in the migration trigger, before the EXISTS check, in both directions;
  • in put_alias, which is now one transaction — the lock releases at the end of its transaction, so issuing the lock and the insert as separate statements would drop it before the insert and reopen the race.

Re-ran the same race: one winner, one refusal (topic race-slug is already claimed as an alias) → 1|0. Sequential paths unchanged. The lock is keyed on the slug (unrelated topics never contend) and releases automatically at commit/rollback.

New multi_thread regression races two claims for one slug, asserts at most one commits, and asserts a published canonical slug never resolves through an alias.

Still do not merge — Owner holds merge until staging A→Z.

Comment on lines +336 to +348
let mut second = doc.clone();
second.id = "race-slug-v0".into();
store.put_topic_version(&second).await.unwrap();

// Race an alias claim against a *different* alias claim for the same slug.
// One must win; the loser must be refused, never silently applied.
let slug = "race-slug-v0";
let a = store.clone();
let b = store.clone();
let (ra, rb) = tokio::join!(
async move { a.put_alias(slug, &doc.id).await },
async move { b.put_alias(slug, "race-slug-v0").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.

P2 Cross-Table Race Untested

The test publishes race-slug-v0 before starting its concurrent work, then races two alias writes. Both writes can be refused by the existing canonical-topic check before either reaches the alias insert, so this does not exercise an alias claim racing canonical publication for an initially unclaimed slug. Removing the publication trigger's shared lock could therefore restore the cross-table race while this regression test still passes. Race put_alias against put_topic_version for the same previously unclaimed slug and assert that exactly one claim commits.

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!

Artifacts

Evidence from the check

  • The executable script prints the relevant test and trigger lines, records database availability, and runs the exact focused test; it provides the reproducible validation command.

Command output from the check

  • Captured output from executing the evidence script shows the pre-publication setup, the publication trigger lock branch, `DATABASE_URL=unset`, and the focused test passing through its skip path; it confirms runtime database validation was blocked.

Evidence from the check

  • The command-generated diff captures the exact authored evidence script that was executed; it confirms the uploaded command content.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: the previous regression did not cover the cross-table race it was
named for — it pre-published the contested slug and only raced alias writes,
so it passed with or without the lock. Verified that directly: with the
advisory lock stripped from both layers, the old test still passed.

The replacement is a **deterministic** detector rather than a timing race. It
holds an alias insert open in one transaction, then tries to publish a topic
with that slug on another connection:

- with the shared transaction-scoped lock, the publish **blocks** until the
  holder ends, which is the pass condition;
- without it, the publish sees no committed alias and succeeds immediately.

Confirmed it fails without the lock (`the publish did not block on the slug
claim … Ok(PgQueryResult { rows_affected: 1 })`) and passes with it, so a
future change that drops the lock cannot slip through.

`tokio` gains the `time` feature for the bounded wait.

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

Fixed in a16afbca. You were right — the old regression passed with or without the lock. I verified that directly: stripping the advisory lock from both layers still left it green.

The replacement is a deterministic detector instead of a timing race. It holds an alias insert open in one transaction, then tries to publish a topic with that slug on another connection:

  • with the shared transaction-scoped lock, the publish blocks until the holder ends → pass;
  • without it, the publish sees no committed alias and succeeds immediately → fail.

Confirmed both ways: it fails without the lock (the publish did not block on the slug claim … Ok(PgQueryResult { rows_affected: 1 })) and passes with it, so a future change that drops the lock cannot slip through.

Still do not merge — Owner holds merge until staging A→Z.

Comment on lines +372 to +375
// Releasing A lets B proceed; the invariant still holds because A's claim
// is then visible and B is refused.
holder.rollback().await.expect("rollback holder");
drop(publisher);

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 Post-lock Rejection Untested

The regression confirms that publication initially waits for the alias transaction, but it then rolls the alias back and drops the blocked publication without observing its result. If the advisory lock remains but the publisher's collision check is removed, the alias can commit and the publication can subsequently succeed, leaving both tables claiming the same slug while this test still passes. Commit the alias transaction, await the publication with a timeout, and assert that it is rejected.

Artifacts

Evidence from the check

  • Authored shell script starts an ephemeral PostgreSQL database, runs the exact contract test before and after removing only the publisher-side collision check while retaining the advisory lock, and restores sources on exit. The script captures the proof runs.

Command output from the check

  • Executed the specified database-backed Rust contract test against migrated PostgreSQL with the original trigger. The test passed, establishing the baseline.

Command output from the check

  • Executed the same database-backed Rust contract test after removing the topic publisher's post-lock alias collision check but retaining the advisory lock. The test still passed, showing the regression escapes this test.

Command output from the check

  • Attempted to execute a corrected release-and-await test against the mutant, but compilation failed because the authored correction passed a transaction rather than the publish future to `timeout`. This is not conclusive behavioral proof.

Command output from the check

  • Captured wrapper output from the final mutation-script attempt. The wrapper exited 101 because the follow-up corrected detector did not compile.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile: the regression verified the block but not the rejection. Removing
the publisher-side collision check while keeping the lock left the test green,
so a realistic path — both an alias and a topic claiming one slug — was not
covered.

The test now commits the held alias claim and asserts the waiting publish is
**refused** by the collision check (`already claimed as an alias`), then reads
back that exactly one claim exists and the refused publish wrote no topic row.

Verified both ways: with the publisher-side check removed (lock retained) the
test fails with `the publish was admitted after the alias claim committed, so
both claimed the slug`; restored, it passes. So it now covers both layers —
the lock that serializes the claim and the check that rejects the loser.

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

Fixed in f38ace35. You were right that the test verified the block but not the rejection — removing the publisher-side collision check while keeping the lock left it green.

The test now does both, because each covers a different layer:

  1. Blocking — the publish waits for the in-flight alias claim (lock layer).
  2. Rejection — after the held claim commits and releases the lock, the waiting publish must be refused by the collision check (already claimed as an alias), and the read-back confirms exactly one claim exists and the refused publish wrote no topic row.

Verified both ways: with the publisher-side check removed (lock retained) it fails with the publish was admitted after the alias claim committed, so both claimed the slug; restored, it passes.

Still do not merge — Owner holds merge until staging A→Z.

Owner reinforcement: topics are RLM-based and autonomous. The admin CLI only
asks the RLM to install and set the topic up — it is the hook that hands
control to the RLM Topic Install Bundle. Nothing topic-specific may be
compiled into challenge, gateway, orchestrator, or CLI code.

New `rlm` section on the bundle, owned by the RLM and opaque to Rust:

    rules · migrations · apis · submission_format · scoring

The crate checks the section's *shape* (an object or array, bounded at
256 KiB) and carries it byte-for-byte into the install plan as `rlm_install`.
It does not know what a rule, a migration, an API, a submission format, or a
scoring function means — recognising them would mean this crate knows the
topic. Unknown content is not an error; a shape Rust has never seen is data.

The install plan now leads with the hand-off and names the RLM's own
lifecycle steps (`provision -> propose_rules -> baseline`, the existing
`TopicSetup` driver), then the publish call and host env. The CLI does not run
those steps and does not read into the section.

Two guard tests enforce the boundary, and both were verified to fail on the
thing they forbid:

- `no_topic_literal_appears_in_this_crates_logic` (bundle crate) and
  `the_cli_does_not_bake_in_topic_behavior` (CLI) scan the non-test source
  with comments stripped, so prose may explain the rule while a literal in a
  `let`/`match`/`if` is caught. Injecting `topic_id == "tb4"` fails the CLI
  guard; restoring passes.
- The CLI guard also forbids naming a metric, a task, a benchmark, or the
  RLM section's own keys — the first draft enumerated
  `rules/migrations/apis/submission_format/scoring` in its output and the
  guard caught it.

Seed ids stay strings: `tb4` and `tbench` appear only in fixtures, operator
examples, and docs. The bundle crate's non-test source has no topic literal
at all, and its doc comment that *named* the seed ids was reworded rather
than exempted.

Docs: the RLM-owned boundary and the hand-off are recorded in PROOF.md,
COMPLETENESS.md, and ARCHITECTURE.md.

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

Owner reinforcement in 8e36538a: topics are RLM-based / autonomous, so the admin CLI only hands control to the RLM. No topic-specific behavior may be compiled into challenge/gateway/orchestrator/CLI code.

New opaque rlm section on the bundle, owned by the RLM: rules · migrations · apis · submission_format · scoring. The crate checks shape only (object or array, bounded 256 KiB) and carries it byte-for-byte into the plan as rlm_install. It never reads into it — recognising a rule or a scoring function would mean the crate knows the topic. Unknown content is not an error.

The install plan leads with the hand-off: it names the RLM's own lifecycle steps (provision -> propose_rules -> baseline, the existing TopicSetup driver), then the publish call and host env. The CLI does not run them and does not read the section.

Two guard tests enforce this, both verified to fail on what they forbid:

  • no_topic_literal_appears_in_this_crates_logic (bundle) and the_cli_does_not_bake_in_topic_behavior (CLI) scan non-test source with comments stripped, so prose can explain the rule while a literal in a let/match/if is caught. Injecting topic_id == "tb4" fails the CLI guard.
  • The CLI guard also forbids naming a metric, task, benchmark, or the section's own keys. My first draft enumerated the five keys in its output and the guard caught it, so the CLI now describes the hand-off without listing the contents.

Seed ids stay strings: tb4/tbench appear only in fixtures, operator examples, and docs — the bundle crate's non-test source has no topic literal at all.

Still do not merge — Owner holds merge until staging A→Z.

Comment thread crates/proof-topic-bundle/src/lib.rs Outdated
Comment on lines +350 to +365
pub struct RlmSection {
/// Anti-cheat rules the RLM ticks before any paid inference. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<serde_json::Value>,
/// SQL migrations the topic's install needs. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub migrations: Option<serde_json::Value>,
/// APIs the topic exposes. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub apis: Option<serde_json::Value>,
/// The topic's submission format. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub submission_format: Option<serde_json::Value>,
/// The topic's scoring definition. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub scoring: Option<serde_json::Value>,

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 RLM Bytes Are Rewritten

The RLM hand-off is parsed into JSON values and then serialized again for rlm_install. An input with reordered properties and duplicate object keys therefore reaches the plan with reordered properties and only the final duplicate value retained. This breaks the promised byte-for-byte hand-off, so an RLM cannot receive the exact installation content that the operator submitted. Preserve the validated source bytes separately and use those bytes for the hand-off.

Artifacts

Evidence from the check

  • The authored executable constructs a duplicate-key RLM JSON payload, invokes the real parser and planner, and asserts that source bytes are not preserved, proving the issue.

Command output from the check

  • The executed source-side capture records the supplied RLM bytes and shows parsing retained only the final duplicate key value, proving source information was lost.

Command output from the check

  • The executed plan-side capture records the serialized `rlm_install` with reconstructed key order and reports `BYTE_FOR_BYTE=false`, proving the RLM receives different bytes.

Evidence from the check

  • The authored narrow test builds a valid bundle, runs both populated and explicit-null RLM cases, and asserts their install-plan outcomes; it reproduces the null-to-omission behavior.

Command output from the check

  • Executed the populated-RLM baseline test from /home/user/repo; parsing and shape validation succeeded and the generated plan retained rlm_install.

Command output from the check

  • Executed the valid bundle with rlm.rules explicitly set to null from /home/user/repo; parsing and shape validation succeeded while the generated plan omitted rlm_install.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread crates/proof-topic-bundle/src/lib.rs Outdated
Comment on lines +350 to +365
pub struct RlmSection {
/// Anti-cheat rules the RLM ticks before any paid inference. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<serde_json::Value>,
/// SQL migrations the topic's install needs. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub migrations: Option<serde_json::Value>,
/// APIs the topic exposes. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub apis: Option<serde_json::Value>,
/// The topic's submission format. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub submission_format: Option<serde_json::Value>,
/// The topic's scoring definition. Opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub scoring: Option<serde_json::Value>,

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 Null RLM Fields Disappear

If a bundle explicitly sets an RLM field to null, deserialization turns it into an absent optional value. The shape validation accepts it and the generated plan omits rlm_install entirely. A malformed explicit value can therefore silently remove the RLM installation request rather than being rejected, leaving the topic without the setup the bundle attempted to provide. Track field presence independently or reject explicit null values during parsing.

Artifacts

Evidence from the check

  • The authored executable constructs a duplicate-key RLM JSON payload, invokes the real parser and planner, and asserts that source bytes are not preserved, proving the issue.

Command output from the check

  • The executed source-side capture records the supplied RLM bytes and shows parsing retained only the final duplicate key value, proving source information was lost.

Command output from the check

  • The executed plan-side capture records the serialized `rlm_install` with reconstructed key order and reports `BYTE_FOR_BYTE=false`, proving the RLM receives different bytes.

Evidence from the check

  • The authored narrow test builds a valid bundle, runs both populated and explicit-null RLM cases, and asserts their install-plan outcomes; it reproduces the null-to-omission behavior.

Command output from the check

  • Executed the populated-RLM baseline test from /home/user/repo; parsing and shape validation succeeded and the generated plan retained rlm_install.

Command output from the check

  • Executed the valid bundle with rlm.rules explicitly set to null from /home/user/repo; parsing and shape validation succeeded while the generated plan omitted rlm_install.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile, two defects in the RLM hand-off:

1. **The bytes were reconstructed.** Each part was a `serde_json::Value`, so
   parsing and re-serializing reordered keys, **collapsed duplicate keys**,
   and normalised whitespace. The operator would sign off one bundle and the
   RLM would receive another — the opposite of a verbatim hand-off.
2. **An explicit `null` was silently dropped.** `Option<Value>` folds
   `"rules": null` into "absent", so a part the operator wrote vanished from
   the install.

Each part is now `Box<RawValue>` — the exact JSON text, carried untouched.
`RawValue` has no `PartialEq`, so equality is defined on the raw bytes, which
is the honest comparison for a hand-off. A hand-written `Deserialize` (the
derive cannot express this) keeps explicit `null` as the text `null`, keeps
unknown keys an error, and preserves each part's bytes; `Serialize` writes
them straight through.

The shape check now refuses an explicit `null` (`RlmExplicitNull`) instead of
dropping it: a part the operator wrote is never silently discarded. The
object-or-array check reads the raw first byte, so it still holds without
parsing.

Verified through the real CLI: a bundle whose `scoring` is
`{"b": 1, "a": 2, "a": 3, "sp": "x   y"}` reaches the plan's JSON output with
key order, duplicate keys, and inner whitespace all intact, and
`"rules": null` is refused with a message naming the field.

Tests: bundle 18 -> 20, with regressions for both defects (the byte-exactness
test splices its awkward part as *text*, so it does not itself round-trip
through a `Value`).

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

Both RLM hand-off defects fixed in 34495f28.

1. Bytes were reconstructed. Each part was a serde_json::Value, so re-serializing reordered keys, collapsed duplicate keys, and normalised whitespace — the operator would sign off one bundle and the RLM would receive another. Each part is now Box<RawValue>: the exact JSON text, carried untouched. RawValue has no PartialEq, so equality is defined on the raw bytes (the honest comparison for a hand-off). A hand-written Deserialize keeps each part's bytes and still refuses unknown keys; Serialize writes them straight through.

2. Explicit null was silently dropped. Option<Value> folded "rules": null into "absent". The custom deserializer keeps it as the text null, and the shape check now refuses it (RlmExplicitNull) rather than discarding a part the operator wrote.

Verified through the real CLI, not just unit tests:

  • a bundle whose scoring is {"b": 1, "a": 2, "a": 3, "sp": "x y"} reaches the plan's JSON output with key order, duplicate keys, and inner whitespace intact;
  • "rules": null is refused: rlm.rules is an explicit null; a part the operator wrote is never silently dropped.

Bundle tests 18 → 20. The byte-exactness regression splices its awkward part as text, so the test does not itself round-trip through a Value (which is the lossy path under test).

Still do not merge — Owner holds merge until staging A→Z.

@echobt

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review HEAD 34495f28 — prior findings addressed (alias shadowing, claim race, digest pins, RLM hand-off). Close stale threads if fixed. Merge HOLD (Owner staging A→Z).

@echobt

echobt commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai Owner staging dry-run skeleton PASS on HEAD 34495f28. Please finish review / mark Safe to merge status when ready. Merge remains HOLD for Mathis/Arch GO after your green.

echobt and others added 3 commits September 14, 2026 14:33
…claims

Two deliverables for the Owner A→Z walkthrough.

**Committed dry-run fixture** under `bins/proof-admin/tests/fixtures/`:

- `tb4.install-bundle.json` — a Topic Install Bundle for slug `tb4` with
  alias `tbench`, target `staging`, carrying a signed `TopicDocument` and an
  illustrative RLM section.
- `tb4.pin.toml` — the pin that document is checked against.
- `README-dry-run.md` — the exact `validate` / `install --dry-run` commands,
  the migrate note, and what the fixture is *not*.

`--pin` is required and the README says so up front: the fixture is signed
with the test mini-secret, so the default `config/proof-pin.toml` (the real
trust root) correctly refuses it. Both documented commands are exercised on
every test run by `the_committed_dry_run_fixture_still_validates_and_plans`,
so a schema change that breaks the fixture fails CI instead of reaching the
Owner. `regenerate_dry_run_fixture` (env-gated) regenerates both files, so
they cannot be hand-edited out of sync with their signature.

**Corrected schema claims.** The docs said "no new table, no behavior
change" while the same row cited migration `0024` — contradictory, and wrong
at HEAD: `0024_proof_topic_alias.sql` **does** ship. Both `docs/PROOF.md` and
`docs/COMPLETENESS.md` now state it exactly: `0024` only; adds
`proof_topic_alias` plus the `BEFORE INSERT`/`UPDATE` trigger pair that fails
closed when an alias would shadow a published slug — a publish-path integrity
guard, not scoring math; no `ALTER`/`DROP`; `0020` tables keep their columns,
keys, and grants.

No P0 scope expansion: no new route, no scoring change, a real install is
still exit 3.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The first fixture-generation run used a relative dump path and wrote a second
copy under `bins/proof-admin/bins/proof-admin/tests/fixtures/`. The committed
fixture lives at `bins/proof-admin/tests/fixtures/`; this removes the stray
tree.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Two corrections to `README-dry-run.md`, both found by running the commands
rather than reading them:

- The package is **`proof-admin-bin`**, not `proof-admin` — this repo names
  binary packages with a `-bin` suffix (`trustroot-bin` → `trustroot`,
  `validator-bin` → `validator`), so `cargo run -p proof-admin` fails with
  "package(s) proof-admin not found". The README now gives
  `cargo run -p proof-admin-bin -- …` and notes the package-agnostic
  `cargo run --bin proof-admin -- …`.
- `--pin` is required and is now called out before the commands, not after:
  the fixture is signed with the test mini-secret, so the default
  `config/proof-pin.toml` (the real trust root) correctly refuses 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

Two Owner/Arch deliverables in b265617b (plus e3d70b19, dad74696).

1. Corrected the schema claims. The body and docs said "no new table, no behavior change" while the same row cited 0024 — contradictory and wrong at HEAD. Now stated exactly, in the PR body Risk, docs/PROOF.md, and docs/COMPLETENESS.md:

Schema: crates/db/migrations/0024_proof_topic_alias.sql only. It adds proof_topic_alias (alias → topic_id + index) and a BEFORE INSERT/UPDATE trigger pair that fails closed when an alias would shadow a published slug. That trigger sits on the publish path (INSERT into proof_topic_version) and is a publish-path integrity guard, not scoring math — it cannot change a score, payout, or sealed vector. No ALTER/DROP; 0020 tables keep their columns, keys, and grants.

2. Committed dry-run fixture at bins/proof-admin/tests/fixtures/: tb4.install-bundle.json (slug tb4, alias tbench, target staging, signed document + RLM section), tb4.pin.toml, and README-dry-run.md. the_committed_dry_run_fixture_still_validates_and_plans runs both documented commands on every test run, so the fixture cannot rot.

Two things I found by running the commands rather than reading them, both now documented:

  • the package is proof-admin-bin, not proof-admin (repo -bin convention), so cargo run -p proof-admin fails; the README gives the working form plus cargo run --bin proof-admin;
  • --pin is required — the fixture is test-key-signed, so the default config/proof-pin.toml (real trust root) correctly refuses it.

Also removed a stray nested directory (bins/proof-admin/bins/...) that a first generation run created via a relative path.

Still do not merge — Owner holds merge until staging A→Z.

flow:

```bash
cargo sqlx migrate run # or: restart the service with BASE_DATABASE_URL set

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unsupported Migration Command

The staging walkthrough says there is no manual SQLx migration step, but then presents cargo sqlx migrate run as one. A clean checkout does not provide the separately installed sqlx-cli subcommand, so this command exits before applying the migration. Remove the standalone command and direct operators to the established service-restart path, or document the required CLI installation and database connection configuration. This is non-blocking, but it can interrupt the Owner staging procedure.

Artifacts

Evidence from the check

  • The authored script models a clean checkout with an empty Cargo home, checks workspace manifests for sqlx-cli, and runs the exact documented command; it provides the reproducible validation procedure.

Command output from the check

  • The executed focused validation shows no sqlx-cli manifest entry and that `cargo sqlx migrate run` exits 101 because Cargo has no sqlx subcommand; the documented command cannot run in a clean checkout.

View artifacts

T-Rex Ran code and verified through T-Rex

Greptile, two findings, both reproduced before fixing.

**The enclosing `rlm` object was still rebuilt.** Each named part was raw, but
the object around them was deserialized into fields and re-serialized in
`RLM_KEYS` order, so the object's own key order did not survive. Reproduced
through the real CLI: submitting `{"scoring": …, "rules": …, "apis": …}` gave
the plan `["rules", "apis", "scoring"]`.

`RlmSection` now holds **one** `Box<RawValue>` — the whole object, verbatim.
`validate_shape` parses a *copy* to check the shape (an object; each named
part an object or array; no explicit `null`; bounded), and the hand-off is
always the original bytes. Checking is not transforming. A key this crate has
never heard of is no longer refused either: it is the RLM's business, which is
what "opaque" should have meant from the start. Re-verified: the same input
now reaches the plan as `["scoring", "rules", "apis"]`.

**`cargo sqlx migrate run` is not a command in this repo.** `sqlx-cli` is not
a workspace dependency and is absent from a clean checkout (`error: no such
command: sqlx`). The README no longer presents it; migrations are embedded in
the `db` crate and applied on boot where `BASE_DATABASE_URL` is set, so the
staging path is the service restart.

Tests: bundle 20, reworked for the single raw object. The byte-preservation
test now asserts at the **object** level (keys out of canonical order, a
duplicate key, inner whitespace) and splices its input as text, so it cannot
itself pass through the lossy path it guards.

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

Both findings fixed in f298c4d7, each reproduced first.

1. The enclosing rlm object was still rebuilt (P1). You were right that fixing the parts was only half the fix: the object around them was deserialized into fields and re-serialized in RLM_KEYS order. Reproduced through the real CLI — submitting {"scoring": …, "rules": …, "apis": …} produced ["rules", "apis", "scoring"] in the plan.

RlmSection now holds one Box<RawValue>: the whole object, verbatim. validate_shape parses a copy to check shape (an object; each named part an object or array; no explicit null; bounded) and the hand-off is always the original bytes — checking is not transforming. Re-verified: the same input now reaches the plan as ["scoring", "rules", "apis"]. A key this crate has never heard of is no longer refused either; that is the RLM's business, which is what "opaque" should have meant from the start.

2. cargo sqlx migrate run is not available (P2). Confirmed: sqlx-cli is not a workspace dependency and a clean checkout answers error: no such command: sqlx. The README no longer presents it — migrations are embedded in the db crate and applied on boot where BASE_DATABASE_URL is set, so the staging path is the service restart.

Tests: bundle 20, reworked for the single raw object. The byte-preservation test now asserts at the object level (keys out of canonical order, a duplicate key, inner whitespace) and splices its input as text, so the test cannot itself travel the lossy path it guards.

Still do not merge — Owner holds merge until staging A→Z, and this awaits human GO.

Comment on lines +465 to +467
let parsed: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(text).map_err(|e| BundleError::Parse(e.to_string()))?;
for (key, value) in &parsed {

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 Duplicate RLM Keys Bypass Validation

validate_shape converts the preserved RLM object into a serde_json::Map, which collapses duplicate keys before checking them. A bundle such as "rules": null, "rules": [] can therefore pass the no-null and object-or-array checks even though the exact content handed to the RLM still contains the invalid occurrence. Validate every occurrence or reject duplicate named keys so malformed RLM content cannot bypass bundle acceptance.

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