Conversation
`dispatch_crdt_apply_admitted_outcome` compared the plan's collection --
`QualifiedCollection::new(database_id, collection)`, so `{database_id}/{name}`
on any database but the default -- against the bare name its caller typed, and
`CrdtAdmissionInvalidPlan` came back as XX000 before any policy was consulted.
A `CRDT MERGE` in a non-default database therefore never reached the RLS
decision its own policy store already carried; in `default` the two forms
coincide, which is why nothing surfaced.
Reduce the request to the canonical form before comparing, and hand the same
string to the preview that fences the apply: the CRDT engine is keyed by that
name, so a preview built from the bare name read a different (empty) document
than the apply it was fencing.
Two places need the bare form and keep getting it: the catalog lookup
(`get_collection` qualifies internally) and the vShard route. Routing stays on
the form each caller passed, because every entry point derives its task vShard
from that same string -- re-deriving it here would move work between cores on a
path this change is not about.
Callers disagree on the form by design: the SQL, HTTP, and sync entry points
pass the name the client typed, while the native raw dispatch passes the
stored, already-qualified one. De-qualification goes through
`target_identity::bare_collection_name`, which strips the prefix only when it is
really there, so an already-bare name and a collection whose own name contains
`/` both survive untouched.
…rors Two surfaces still flattened a classified failure while the routed pgwire paths already carried its class. - CRDT MERGE: the admission and apply failures now map through `error_to_sqlstate`, so a policy denial — `ExternalCrdtPostImagePolicy::deny` returns `RejectedAuthz` — reaches the client as `42501` (INSUFFICIENT_PRIVILEGE) instead of `XX000`. The "authorization returned no capability" site keeps `XX000`: that one is an internal invariant by decision, not a class a client can act on. - HTTP stream: the in-band error lines carry the numeric NodeDB code (shape and malformed-batch failures) and the status the gateway map already computed. Consumer trace for the class change: the two wire assertions that pinned the placeholder move with it — `nodedb/tests/wire/cases/crdt_write_rls_database_scope.rs` asserted `XX000` and now asserts `42501`, and the comment above the first one no longer describes the old wrapper. No other test, doc, or path keys on the CRDT merge SQLSTATE (`grep XX000` over `tests/wire/cases` finds no other crdt or merge site). The pgwire stream and DDL-dispatch sites are not touched here; the HTTP shaping surface above is the whole second half. Also refreshes the `response_shape/schema.rs` module comment, which still claimed nothing consumes the module — the session caches the schema with the physical tasks, and shaping receives it as `projection`. Verification: `cargo nextest run -p nodedb --test wire --all-features --cargo-profile ci --profile ci -E 'test(~crdt_write_rls_database_scope)'` fails on the pre-change tree (the denial surfaces as `XX000`) and passes with this change (`42501`).
…r reads Admission routed the apply from the form its caller happened to pass while the planner derives its task vShard from the database-qualified collection. For a collection in a non-default database the two disagreed, so the sequencer slot, the preview dispatch and the raft entry could fence a different vShard than the one the plan's own writes land on. Route all of them from the canonical key the CRDT engine is already keyed by. The two planner catalog reads had the mirror-image bug: the caller routes the plan on the database-qualified collection, but the catalog keys collections by the bare name. On a non-default database both missed — `get_collection` silently declared neither gate, losing CRDT convergence and the BALANCED boundary with it, and `declared_primary_key` read a declared key as undeclared, leaving its NOT NULL unenforced. Reduce both through `target_identity::bare_collection_name`, which strips the prefix only when it is really there and is identity for `DatabaseId::DEFAULT`. The restore path keeps routing on its caller's own form: its plan and its apply are both built from that same string, so its vShard is already consistent.
…rrors The catalog gate reads in `crdt_gate`, `update_delete::shared` and `implicit_edges::catalog` still passed the collection the caller routed on, which is database-qualified, while the catalog keys collections by the bare name. In a non-default database every one of them missed, and each miss silently answered "no": a CRDT collection read as plain, so a predicate UPDATE/DELETE and an `ON CONFLICT DO UPDATE` skipped the refusal that keeps CRDT convergence authoritative; an edge-bearing collection read as plain, so a primary-key-equality UPDATE/DELETE skipped the mirrored-edge cleanup; and the edge-bearing marker was never set, so that cleanup could not find the collection at all. Reduce all three through `target_identity::bare_collection_name`, as the sibling gate reads already do; it strips the prefix only when it is really there and is identity for `DatabaseId::DEFAULT`. `crdt_state` and `crdt_apply` flattened every classified failure to `XX000` at three sites. Map them through `error_to_sqlstate`, so the policy denial an `ExternalCrdtPostImagePolicy::deny` produces reaches the client as `42501`, the code `CRDT MERGE` already reports, instead of a class a client cannot act on. Covered by four non-default-database variants mirroring the existing default-database gate tests (CRDT predicate UPDATE, CRDT predicate DELETE, CRDT upsert, and the reserved-edge-field expression UPDATE) and a wire assertion that `crdt_apply` reports `42501` for a post-image its policy forbids.
EnRaiha
force-pushed
the
fix/stream-classification
branch
from
September 25, 2026 19:58
2bc878c to
9c0cb47
Compare
`plan_needs_implicit_edge_recon` fed the database-qualified collection from the plan straight to a catalog keyed by the bare name, so in every non-default database the read missed, `has_implicit_edges` read false, this gate returned `None`, and the OLLP/Calvin dependent-edge reconnaissance never routed. The mirrored edges of a PK-equality UPDATE/DELETE were therefore never cleaned up — and the plan lowers exactly those writes to `Bulk*` so this gate picks them up, so the lowering had no effect outside the default database. This is the same defect the sibling planner gates had; the bare name is what the catalog stores. The returned collection still comes from the plan, because that is the routing key, and both call sites take only the `database_id`. The restore path's comment claimed its bare collection form was intended. It is not: the registry qualifies it before the engine is keyed, so restore still addresses a different document than an ordinary apply in a non-default database. That is pre-existing and left alone, but the comment now says so instead of asserting the opposite.
The fix had no test: `plan_needs_implicit_edge_recon` had zero call sites anywhere in the suite, and the existing non-default-database graph test sends an expression update that the planner rejects at plan time, so it never reaches this gate. Reverting the fix left every test green. Two tests now drive the gate directly: seed a catalog with an edge-bearing collection in a non-default database, hand the gate a `BulkUpdate` carrying the database-qualified collection the planner builds, and assert it fires and returns that qualified key. The second covers `DatabaseId::DEFAULT` as the identity case. Reverting the bare-name lookup fails the first and leaves the second passing, which is the red arm this needed. The restore-path comment also claimed the collection registry qualifies its bare string before the engine is keyed. It does not: the string reaches `from_stored` and the tenant engine's collection map verbatim, which is what makes restore disagree with an ordinary apply. The comment now says that.
EnRaiha
force-pushed
the
fix/stream-classification
branch
from
September 27, 2026 06:59
f468c66 to
fd748c3
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two surfaces still flattened a classified failure while the routed pgwire paths already carried its class:
CRDT MERGEwraps its admission/apply failures underXX000, so an RLS write-policy denial —ExternalCrdtPostImagePolicy::denyreturnsRejectedAuthz— reached the client as an internal fault (ddl/neutral/dsl/crdt_merge.rs:90,101,166).http/routes/query_stream.rs:173,209).Change
error_to_sqlstate, which renders42501for a policy denial. No new class and no second mapping path. The"authorization returned no capability"site staysXX000: CRDT MERGE renders a policy denial as XX000 #344 records it as an internal invariant by decision.Consumer trace for the class change — the two wire assertions that pinned the placeholder move with it, in the same commit:
nodedb/tests/wire/cases/crdt_write_rls_database_scope.rs:141,202expectedXX000and now expect42501, and the comment above the first one no longer describes the old wrapper.grep XX000overtests/wire/casesfinds no other crdt/merge site.Follow-up: the same denial was also routed on the wrong key
Writing the wire test for that class change surfaced a second, larger defect underneath it.
In a non-default database, the admission gate routed on the caller's bare collection name while the planner derives the vShard from the database-qualified name. Admission and the planner therefore disagreed about which vShard a CRDT write belonged to: a policy-denied write was refused for the wrong reason, or admitted when it should not have been. Behind that, three planner catalog reads passed the same qualified name to a catalog keyed by the bare one, so the collection read as "not CRDT" and the CRDT engine was never seeded. That is why the client saw
XX000before the policy ever ran — and why fixing the class alone was not enough.crdt_admissionkeys on the canonical collection, so admission and the planner agree on the vShard.crdt_gate,update_delete::sharedandimplicit_edges::catalogreduce the routed name through the existingtarget_identity::bare_collection_name, as the sibling gate reads already did. It strips the prefix only when it is really there and is identity forDatabaseId::DEFAULT, so the default-database path is byte-for-byte unchanged.DdlError::new("XX000", e.to_string())sites inneutral/crdt_ops.rsmap througherror_to_sqlstatetoo, socrdt_stateandcrdt_applyreport the same42501the MERGE path now does. Theok_or_else(… "authorization returned no capability")atcrdt_ops.rs:206is deliberately left asXX000: it is a synthetic invariant breach with no underlyingErrorto classify.Effect in every non-default database: a predicate
UPDATE/DELETEon a CRDT collection and anINSERT … ON CONFLICT (id) DO UPDATEare refused again instead of silently taking the non-convergent path, and an edge-bearing collection gets itsmark_collection_edge_bearingflag, so the mirrored-edge cleanup runs.Changed files
4 commits on
1ff35512b— 13 files, +305 −32.nodedb/src/control/crdt_admission.rsnodedb/src/control/planner/implicit_edges/catalog.rsnodedb/src/control/planner/sql_plan_convert/dml/balanced_gate.rsnodedb/src/control/planner/sql_plan_convert/dml/crdt_gate.rsnodedb/src/control/planner/sql_plan_convert/dml/insert/identity.rsnodedb/src/control/planner/sql_plan_convert/dml/update_delete/shared.rsnodedb/src/control/server/http/routes/query_stream.rsnodedb/src/control/server/response_shape/schema.rsnodedb/src/control/server/shared/ddl/neutral/crdt_ops.rsnodedb/src/control/server/shared/ddl/neutral/dsl/crdt_merge.rsnodedb/tests/wire/cases/crdt_write_rls_database_scope.rsnodedb/tests/wire/cases/engine_surface_crdt_document.rsnodedb/tests/wire/cases/engine_surface_graph.rsEvidence
All five new tests are red on the unpatched tree and green with the fix.
1ff35512bwith the fix absentnextest run --no-fail-fast -p nodedb --test wire -E '<the 5 tests>'20260925T195039-unit2-new-tests-red-base-nofailfast-base.log9c0cb47f20260925T195131-unit2-new-tests-green-fix-exitcode-fix.log9c0cb47fcargo fmt --all -- --check+ repo preflight vsorigin/main20260925T195215-unit2-fmt-and-preflight-exitcode.log9c0cb47fnextest run -p nodedb --test wire-p nodedb --libnextest run -p nodedb --libnextest run -p nodedb --test inproccargo clippy -p nodedbRed-arm integrity: the red worktree is detached at
1ff35512bwith only the three test files applied —grep -c bare_collection_nameoverimplicit_edges/catalog.rsandupdate_delete/shared.rs, andgrep -c error_to_sqlstateoverneutral/crdt_ops.rs, all return 0.Review
Independent Review 2 (fresh context, read-only, separate agent): PASS, 0 blockers, on
9c0cb47f3516.Its first pass failed on one real blocker and the fix is worth recording: the initial red run used nextest's default fail-fast, so it stopped at
4/5 tests runand the fifth test — the reserved-edge-field expressionUPDATE, the only new coverage for two of the three de-qualify hunks — never executed on the unpatched tree. Re-running the same filter with--no-fail-fastproduced5 tests run: 0 passed, 5 failed, exit 100. No source or test file changed as a result; the reviewed commit is the same one the green proof names.Points the reviewer settled rather than assumed: the denial arm maps to
INSUFFICIENT_PRIVILEGEaterror_map.rs:155and the unmapped catch-all aterror_map.rs:221preservesXX000plus the original message, so no previously-working code became less specific;get_committed_collectionkeys the table on(database_id.as_u64(), "{tenant_id}:{name}")atsecurity/catalog/collections.rs:308,316, which is what makes de-qualifying the correct direction; and each of the three changed functions has exactly one catalog collection read, so no sibling read was left qualified.Scope notes
42501→ 403,22xxx→ 400,57xxx→ 503) is deliberately not here: it depends on the SQLSTATE table the maintainers are settling in-tree for Routed response-shaper errors flatten to XX000; the numeric SQLSTATE mapper covers 20 of 81 codes #338, and it lives inresult_shape.rsrather than this change's files.expect_errorhelper, not by SQLSTATE; only thecrdt_applytest pins42501directly. Tightening the other four to assert the class too is left as a follow-up.Review 2 follow-up (3 commits added)
An independent, read-only Review 2 audit of
9c0cb47f3returned FAIL with 3 blockers. All three are addressed in the three commits now on top of it; the audit's own verdict on this head is below.Blocker 1 — the same defect survived at a sixth site, and it was the consumer of this PR's own change.
plan_needs_implicit_edge_recon(planner/calvin/dependent_recon.rs) fed the plan's database-qualified collection straight to a catalog keyed by the bare name. In every non-default database the read missed,has_implicit_edgesread false, the gate returnedNone, and the OLLP/Calvin dependent-edge reconnaissance never routed — so the mirrored edges of a PK-equalityUPDATE/DELETEwere never cleaned up. This is the exact class the five de-qualified reads fix, and it is the gate that had to act on the lowering this PR introduces (update_delete/update.rs,delete.rs). Fixed infae15ae0: the catalog read reduces to the bare name, while the returned tuple keeps the plan's qualified routing key. Both call sites (pgwire/.../pre_dispatch.rs,native/dispatch/edge_recon_gate.rs) were checked and observe no change in the tuple.Blocker 2 — the restore path's new comment asserted the opposite of the truth. It claimed the collection registry qualifies the bare string before the engine is keyed. It does not: the string reaches
from_storedand the tenant engine's collection map verbatim, which is precisely why restore and an ordinary apply address different documents outsideDatabaseId::DEFAULT. The mismatch is pre-existing and left alone; the comment now states it instead of denying it.Blocker 3 — the lint gate is red on
main, not on this branch.cargo clippy --profile ci -p nodedb --all-targets --all-features -- -D warningsexits 101 onclippy::nonminimal_boolatplanner/sql_plan_convert/dml/vector_primary.rs:161. That line is byte-identical toorigin/mainand is not in this diff, so it was never this PR's to fix, and a one-lineis_none_orrewrite was briefly carried here to unblock the lint.It has since been removed from this branch so the PR stays exactly its own scope. The consequence is explicit: while
origin/mainstill carries that lint, this branch's lint job is red for a reason unrelated to its own code, and merging the rewrite PR first turns it green again. Do not read a red lint here as a fault in this change — the diff contains no clippy diagnostics.The test blocker, and the red arm
The audit also required a test that the fix could fail:
plan_needs_implicit_edge_reconhad zero test call sites anywhere, and the existing non-default-database graph test sends an expression update that the planner rejects at plan time — it never reaches this gate, so reverting the fix left the whole suite green.f468c66eadds two tests that drive the gate directly: seed a catalog with an edge-bearing collection under a non-defaultDatabaseId, hand the gate aBulkUpdatecarrying the database-qualified collection the planner builds, and assert it fires and returns that qualified key; a second coversDatabaseId::DEFAULTas the identity case.Red arm, run by reverting only the bare-name lookup:
gate_fires_for_an_edge_bearing_collection_in_a_non_default_databaseFAILED — "the gate must fire … a qualified catalog lookup misses and the mirrored edges leak"; the default-database test still passed2 passed; 0 failedEvidence at this head:
cargo clippy --profile ci -p nodedb --all-targets --all-features -- -D warnings→ exit 0;bash ~/scripts/nodedb-preflight.sh→ exit 0, no VIOLATION lines; the targeted wire suites (engine_surface_crdt_document,crdt_write_rls_database_scope,engine_surface_graph,sql_transactions_graph*) → 29/29 passed.Left as follow-ups, not folded in here: the six byte-identical db-prefix strippers across the tree (five
strip_db_prefixcopies plusbare_collection_name) are worth consolidating intotarget_identity, and a typedQualifiedCollection::from_request(db, form)would have made blocker 1 impossible to write — the audit's strongest refactor signal. The restore-path routing mismatch still stands. Both are separate changes; this PR stays a correctness fix.