Skip to content

fix(mcp): answer an un-decodable envelope id instead of silently dropping the frame (D47, ub-cnv) - #438

Merged
miguelramos merged 12 commits into
mainfrom
ubcnv-envelope-id-reject
Aug 6, 2026
Merged

fix(mcp): answer an un-decodable envelope id instead of silently dropping the frame (D47, ub-cnv)#438
miguelramos merged 12 commits into
mainfrom
ubcnv-envelope-id-reject

Conversation

@miguelramos

Copy link
Copy Markdown
Member

Closes ub-cnv. Decision D47 (v1.0.1 patch slot), spec-first.

The defect

A JSON-RPC frame whose envelope request id cannot be decoded fell through rmcp's untagged message union into the Notification variant and was silently dropped — no reply, no diagnostic, no stderr line at default verbosity. An rmcp client correlating by id waits forever: Peer::send_request uses no options, so await_response is an untimed await, and the client drops id-less errors.

Duplication was the minority route. Reproduced live on main, each of these was silent: the same id twice, two different ids, "id":null, "id":{}, "id":true, an integer beyond 64 bits, and 1e2 — while the identical value written 100 was answered normally. The honest class predicate is the bytes carried a top-level id and the decode produced a Notification.

Worse before the handshake: as the first frame on a connection it killed the process — exit 1, with a non-JSON-RPC blob written onto stdout, the framing channel.

The fix

The owned transport recovers the envelope id from the raw bytes before the parse, answers -32600 Invalid Request on it, and drops the frame — never delivering it, because delivering is what causes the pre-handshake death.

  • Recovered id when the bytes yield one unambiguously (repeated equal occurrences); id omitted otherwise.
  • A frame with no id is a legitimate Notification and stays silent — verified byte-identical on both binaries across nine shapes. Replying to it would violate JSON-RPC's rule against answering notifications.
  • The deliberate rmcp-parity drops of the forked compatibility filter stay silent, and fall outside the predicate structurally rather than by special case.
  • Detection is a DeserializeSeed walking only the root object, comparing decoded keys (an escaped spelling cannot slip past) and decoded values, with no short-circuit, delegating the type check to rmcp's own RequestId decoder. The variant match is exhaustive, so a future rmcp variant is a compile error rather than a silent hole.

Contract-neutral: CONTRACT_VERSION and CONTRACT_HASH unmoved, no new error code — -32600 is rmcp's transport-level code, not our taxonomy.

Before / after, on raw bytes

frame main this branch
same id twice silent -32600 on that id
two different ids · null · 1e2 · {} silent -32600, id omitted
no id silent silent — unchanged
clean request after ok ok, no desync
pre-handshake exit 1 + blob on the framing channel -32600, then a successful handshake on the same connection, exit 0

Answer-and-drop proven by effect, not by reading: six in-class frames carrying issue-creating payloads were each answered and the subsequent listing returned an empty store.

Gates

Design Review — pass with ten must-fixes → revision → delta-verify FAIL on three defects, each sitting inside the repair of its own item → escalated under the two-iteration rule → Miguel authorised direct closure → closed and verified.

Verify — pass with four must-fixes. 26 of 26 catalogue mutants killed, zero survivors, plus ~30 further mutants across two lenses with disjoint harnesses and no non-equivalent survivor. Fix pass → re-verify PASS.

Full local gate green: fmt, clippy pedantic, 1703 tests, insta --check, doc-lint (19 docs), knowledge-lint (61 pages), check-layering, all five claims scripts as bare relative paths, and the run-report gate.

Two things for the reviewer's eye

1. A coverage claim was corrected rather than shipped. One commit message asserted a property-test cell fails alone under a given mutant. It cannot: a sibling cell splices the same boundary value using the real decoder as its oracle, so both must fail together. The message was reworded (branch unmerged; tree byte-identical before and after — git diff between the old and new tips is empty). The defensible statement: the property test is non-vacuous because under a collector mutation a fixed value table cannot express — truncating a string id in the collector — it is the only failing cell; under the type-table mutant it and the sibling fail together, as they should.

2. An equivalent mutant was declared, not faked. The Verify gate asked for eight visitor arms to be graded, on the premise that a missing root-shape cell would do it. That premise was false: deleting the arms and keeping them both end at the same verdict through different branches, so no byte corpus can distinguish them. The fixer said so and wrote the limit into the test's own doc instead of claiming a kill.

Residuals — disclosed, tracked, not claimed closed

  • ub-nbz — the reply is lost whenever rmcp cancels the receive() future under concurrent traffic (0/40 idle, 25/40 at four in flight, 39/40 at eight; one harness, unreplicated, recorded as such). Pre-existing: the shipped -32700 arm behaves identically. Out of D47's decided scope; Miguel ruled disclose-and-track.
  • ub-788 — the -32700 arm omits a readable id, so duplicated method/jsonrpc frames still leave an rmcp client pending. Blocked on this PR.
  • ub-og3 — the non-JSON-RPC blob on stdout at startup failure (this PR removes one route to it, not the defect).
  • ub-q1u — an intermittent storage stress-test failure observed during the gate; unrelated to this branch.

A behaviour is REMOVED and said so plainly: a cancellation notification carrying an un-decodable id currently cancels a matching in-flight request inside rmcp's serve loop; it is now answered and dropped, so that stops happening. A conforming cancellation, which carries no id, is unaffected. Miguel ruled: disclose, do not preserve — preserving it would mean delivering after answering, which reopens the pre-handshake fatality.

Gotchas worth carrying forward

  • A required check whose rows pin landings from a later step than the commit that mints it is red by construction — the new gate demanded a Track-step export from an implementation commit.
  • A claims-check script committed without its executable bit passes every local sh invocation and dies in CI, which invokes it as a bare relative path.

Run-report: .knowledge/wiki/runs/2026-08-06-envelope-id-reject.md.

miguelramos and others added 12 commits August 5, 2026 16:41
A JSON-RPC frame whose envelope `id` cannot be decoded falls through rmcp
1.7.0's untagged message union into the Notification variant and is silently
dropped: no reply, no diagnostic, and a client that correlates by id waits on
an untimed await forever. Duplication is only the MINORITY route in — a `null`
id, a wrongly-typed id, a number outside what `RequestId` accepts (including
the exponent spelling of an integer), and the `id` key written with a \u escape
are each equally silent. A frame with NO `id` member is a legitimate
Notification and its silence is CORRECT; it stays explicitly out of scope.

D47 answers the class OUT-OF-BAND with `-32600 Invalid Request` on the id
RECOVERED from the raw bytes (one valid id, or several all EQUAL), falling back
to the id OMITTED when the bytes are ambiguous or the value is no representable
`RequestId`, and then DROPS the frame instead of delivering it — which also
removes a fatality nobody had connected to this defect: the same frame sent
before `initialize` terminated the server with `ExpectedInitializeRequest`.

This is the SPEC half, landing first per PROCESS.md §2. It mints a NEW D-id
rather than amending D43 because the class decided here is not the duplicate-key
class and because it REVERSES D43 clause (6) — a separately-motivated decision
under PROCESS.md §3, so both rows carry reciprocal cross-references.

Cascade:
- PRD §4: the D47 row; the reciprocal SUPERSEDED note on D43 clause (6); NFR-18
  widened from ~2 to ~2.35 unbounded per-line passes, with both surviving
  envelope residuals named rather than implied.
- Spine §5.6: the envelope enumeration re-opened member by member (it omitted
  `id` entirely and stated the `params` case absolutely; both were wrong), and
  the CLOSED out-of-band list gains its fourth member.
- Crate plan, crate CLAUDE.md, implementation plan, roadmap (markdown + the
  rendered v1.0.1 card, which is outside every doc-lint corpus).
- D-range bump D1..D46 -> D1..D47 at every site the PROCESS.md §3 count-free
  LIST names: the three prose sites plus the live-range knob of all three
  shipped required check scripts. `docs/PRD.md` D46 row and
  `implementation-plan.md:40` are NOT bumped — they are frozen records of D46's
  own cascade.

No contract movement: `-32600` is rmcp's transport-level code, not our 36-member
taxonomy. `unblock.mcp.v1.9` stands and no `CONTRACT_RE` knob moves.

Refs: ub-cnv
…me (D47)

The transport now recovers the envelope `id` from the RAW BYTES before the
parse, answers `-32600 Invalid Request` on it, and DROPS the frame.

`src/envelope_id.rs` (NEW, crate-private) is the predicate: a `DeserializeSeed`
over the ROOT object that keeps every top-level `id` member's value verbatim and
`IgnoredAny`s every other member, yielding occurrence COUNT, mutual EQUALITY and
`RequestId`-recoverability as a trichotomy. Three properties are normative and
each carries a unit cell: keys are compared DECODED (a byte prefilter for the
literal bytes `"id"` is unsound — it misses the \u-escaped spelling, which the
shipped binary already answers on); ROOT LEVEL ONLY (the opposite discipline
from `dup_key`, which descends at any depth); and NO short-circuit, which is
exactly the property a collector fused into `dup_key`'s root loop would lose.

Recoverability delegates to rmcp's OWN `RequestId` deserializer rather than to a
hand-written type table, so agreement with the real decoder is structural.
Equality is decided on the DECODED `serde_json::Value`, never on raw spans —
`"a"` and its \u escape are ONE id and must be recovered, while `1` and `1.0`
are unequal and take the conservative omitted arm.

`src/wire.rs` gains the branch inside `receive()`'s `Ok(Some(..))` arm, guarded
by an EXHAUSTIVE match on `JsonRpcMessage` so an rmcp bump adding a fifth
variant is a compile error rather than a silent hole, and so all request traffic
pays zero. The shipped `-32700` arm is refactored onto the same `answer_error`
helper, which makes the two out-of-band arms identical by construction; both
still encode through rmcp's own codec, so there is no hand-rolled byte path.

DEVIATION from the implementation package, reported rather than absorbed: the
package specifies `answer_error(&self, ..)` and calls the borrow "checked". It
does not compile — `Transport::receive` requires a `Send` future and `&Self` is
`Send` only if `Self: Sync`, which this transport is not. The helper takes the
write half (`&Arc<Mutex<Option<W>>>`) instead, which satisfies both the `Send`
bound and the disjoint-borrow requirement against `line`. The doc comment states
this so the next reader does not "restore" the simpler signature.

Measured over raw stdio, before and after, on the same frames: every class shape
went from SILENT to answered, the recovered-id shapes answer ON that id
(including the escaped key and the escaped-value pair), a frame with NO id stays
silent, the F17 parity drop stays silent, the duplicated-`method` residual is
still `-32700` with the id omitted (ub-788, deliberately open), and a clean
request afterwards is answered normally. Pre-`initialize`, the same frames went
from exit 1 with an `INTERNAL_ERROR` blob to exit 0 with the handshake
completing; an id-LESS notification there still exits 1, which is out of D47's
class by decision.

No contract movement: `unblock.mcp.v1.9` stands, `ErrorCode::ALL` stays 36.

Refs: ub-cnv, PRD §4 D47
A single whole-stream byte-identity assertion would stay GREEN with the entire
-32600 arm deleted: all 17 shipped corpus entries were re-measured through
rmcp's own AsyncRwTransport and NONE of them diverges under D47 — the
Notification-delivering entries carry no id, and every id-carrying entry is a
Request, an Ok(None) drop or a hard parse error. The risk was VACUITY, not
breakage, so the repair is structural rather than a new assertion.

- PARITY tier: the 17 kept, now LABELLED so divergences are attributable, plus
  four never-answered negatives the shipped corpus had no shape for — a NESTED
  params.id, a well-formed id on a notifications/* method (already a
  CustomRequest today), a duplicated method whose -32700 still omits the id, and
  a string VALUE whose bytes spell an id member's key. F17 is commented in place
  as the deliberate parity drop, whose exclusion is STRUCTURAL (it returns
  Ok(None) and never becomes a message) rather than a carve-out.
- DIVERGENCE tier: the 23 new frames, each asserted on EXACT expected bytes
  including the recovered-versus-omitted id, because 'differs from rmcp' is
  satisfied by a mutant writing garbage.
- SET-EQUALITY guard: the stream positions where we diverge from rmcp must be
  exactly the declared tier. Without it, an over-firing predicate that also
  answered the id-less F8/F12 keeps both other tiers green.

Plus kind-coverage as a SET (a count rots; a set cannot be off-by-one against
itself) and a corpus non-vacuity cell — including the D23-specific guard that
its two id occurrences are NOT byte-identical while their decoded values ARE
equal, without which the one cell that kills a raw-span value comparator
silently degenerates into a copy of D02.

Two shipped code comments that this work established FALSE are corrected here:
the NS2 corpus comment generalised a method-dependent outcome past what it
tests, and the compatibility-filter comment credited the -32700 with 'not
leaving the client waiting' — that reply omits the id, which an rmcp client
drops, so the client IS left waiting (the ub-788 residual).

Refs: ub-cnv, PRD §4 D47
…shake

Three suites, each homed where only it can prove something:

- crates/unblock-mcp/tests/envelope_id_duplex.rs (NEW) carries the EFFECT
  ORACLE. The most plausible wrong implementation of D47 is not 'forget to
  reply' but 'recover the id, rebuild the frame as a Request, and deliver it' —
  which answers on the right id, writes plausible bytes, recovers the
  connection, and passes every channel-only assertion in the whole change. The
  only thing that distinguishes it is that the tool RAN, so a class tools/call
  naming a destructive action is asserted to leave the store fingerprint
  unmoved.
- crates/unblock-cli/tests/envelope_id_frames.rs (NEW) drives a real child over
  real pipes. request_raw correlates BY ID and returns, which is the strongest
  available proof that the hang is over: on main that call cannot return,
  because nothing is ever written for that frame.
- crates/unblock-cli/tests/mcp_lifecycle.rs gains the pre-handshake half, whose
  observation channel is client.seen_lines and NOT stdout_snapshot():
  capture_stdout() CONSUMES the stdout reader and read_response panics once it
  is gone, so it cannot be called before the ping_barrier()/initialize() these
  cells must perform. Written like the sibling D38 cell, every line of interest
  would be consumed into seen_lines while stdout_snapshot() came back empty, and
  the two negatives that are the entire pin on 'the pre-handshake death is gone'
  would pass over an empty collection. Each therefore opens with a non-emptiness
  guard.

The NS4 cell is SPLIT rather than inverted, because it does not go red under
D47: its shipped frame carries 424242/999999 — DIFFERING — so the reply takes
the omitted arm and both of its assertions stay TRUE. Only its prose was false.
A straight inversion would have been wrong AND would have left the recovered
arm — the half the unambiguity rule turns on — with zero end-to-end coverage.
NS4a drives EQUAL ids (genuinely silent on main, so it is the before/after
witness); NS4b keeps the original frame and both original negatives, plus a new
assertion that a reply now exists at all.

L-N2 pins a defect D47 deliberately does NOT fix — an id-LESS notification
before initialize still exits 1 — and is partly redundant with the shipped D38
cell on purpose: that cell provokes its Err with exactly this frame, so a
predicate that swallowed no-id frames would turn a D38-era cell red for a reason
having nothing to do with D38. The coupling is now documented instead of latent.

Harness: RawDuplexClient gains seen_lines (a reply with an OMITTED id is
invisible to seen_ids and read_response would loop past it), and the effect
oracle moves into tests/common so both duplex suites share ONE copy instead of
drifting.

Refs: ub-cnv, PRD §4 D47
Mint `scripts/checks/d47-envelope-id-claims.sh`, wire it as a step of the
required `doc-lint` job, and add it to the two count-free enumerations of
shipped check scripts (`docs/PROCESS.md` section 3 and
`docs/plans/ci-cd-and-distribution.md` section 2.1) — including the "newest
reference" sentence in both, which must name this script now that the list
grew: by the self-row rule a script never pins its own knob, so the D46
sibling stopped covering the enumeration the moment D47 joined it.

The gate is deliberately POSITIVE-ONLY. A negative sweep for the framing
D47 retires is unfindable in principle once a claim is rewrapped across two
lines, so every row is a spelling-independent presence landing: the
predicate module and its `scan` entry point, the transport's `-32600` reply
constant, the EXHAUSTIVE `JsonRpcMessage` match (a `_` wildcard would
compile, pass every cell and silently reopen the hole), the decoded-key
rule, the recovered arm's `Some(id)`, the gated shared corpus, the
store-effect oracle, the disclosed `-32700` residual, both tracker ids, the
rendered roadmap card that sits outside every lint corpus, this gate's own
two-sided wiring, and the live D-range at every file the section 3 list names.

The `CONTRACT_RE` knob pins `unblock.mcp.v1.9` as UNMOVED: `-32600` is
`rmcp::model::ErrorCode`, a transport-level type in another crate, so D47
mints no `ErrorCode` and bumps no contract. An unstated "we didn't bump" is
indistinguishable from an oversight.

Refs: D47, ub-cnv
Carries ub-788 (the deliberately-unclosed -32700 id-omission residual that the
D47 row names) and ub-og3 (the stdout framing-channel defect) into the git
record, so the PRD names no id absent from it.

Discharges the d47 claims gate's P10 row. That row pins a TRACK-step landing
while the script itself is minted in the implementation commit, so the gate was
red by construction until this landed — a package sequencing defect the
implementer reported rather than worked around by weakening the assertion.
… rows

The gate script shipped mode 100644 while all four siblings are 100755, and
the required `doc-lint` job invokes every one of them as a bare relative path
with no interpreter — so its first CI run would have died with a permission
error (exit 126), not a rule failure. Restore the executable bit.

Two of its rows were also satisfiable by prose. P4 (the decoded-key rule) and
P5 (the recovered-id arm) were presence predicates over a bare token, and both
tokens also appear in a doc comment and a test body — so each row stayed GREEN
under the exact mutant its own reason text named: rewriting the production
recovered arm from `Some(id)` to `None` left the script exiting 0 while still
printing that the arm was in the tree. That is a false coverage claim inside a
required CI gate, i.e. the class this gate exists to prevent.

Both are re-anchored as Q rows on their PRODUCTION lines, the remedy the D46
sibling states verbatim. Proven, not asserted: `Some(id)` -> `None` at
wire.rs:326 now exits 1 (vanished anchor); a raw-span key type at
envelope_id.rs:192 exits 1 (anchored line fails the requirement); deleting that
loop exits 1 (vanished anchor). The P codes are not reused and the survivors
are not renumbered — the gap is documented in the header instead, so no prior
reference to a P code silently changes meaning. Both floors move to 12: the two
rows crossed tables, they were not dropped, and the total stands at 24.

Refs: ub-cnv, D47
…s (D47)

The crate plan's `src/envelope_id.rs` row promised three cells that did not
exist, and a plan row is normative prose: it said "plus a proptest that `scan`
agrees with `RequestId::deserialize` for any spliced `Value`" while what shipped
was a fixed 14-value table whose own doc line repeated the "for ANY value"
claim; it named an `Absent` cell for a non-object root and for an `id` inside a
ROOT-LEVEL array element, where the shipped cell drives an array nested under
`params`; and it named an `Unusable` empty array, which the shared corpus spells
`[1]`. Miguel's ruling was to land the tests, not to soften the plan.

- A15 is the property itself, over a bounded recursive `serde_json::Value`
  strategy. It is non-vacuous under a mutation a fixed value table cannot
  express: truncate a string id inside the collector and A15 is the ONLY cell
  that fails. Under the coarser mutant — `RequestId::deserialize` replaced by a
  hand-written type table — A15 and A12 fail TOGETHER, which is what should
  happen rather than evidence for either cell: they splice the same boundary
  values, so a counterexample drawn from that table is a counterexample to both.
- A12 keeps the 14-value table as its named-boundary regression corpus, with
  its doc line corrected to claim exactly that.
- A14 covers the root shapes: every scalar root, a string root spelled `"id"`,
  an array root, and `[{"id":3}]`.
- The empty-array id joins A5 alongside the corpus's `[1]`.

A14's doc states what it does NOT do, because the opposite reading is the one a
reader will take: the eight visitor arms it exercises are NOT graded by it.
Deleting all eight leaves the whole 168-test suite green — `deserialize_any`
then fails and `scan` maps a failed seed to `Absent` too, so the verdict is
identical either way. That is an equivalent mutant by construction, not a hole
a byte corpus can close.

Refs: ub-cnv, D47
The branch stated the answer-on-the-recovered-id guarantee without a caveat,
and there is one. rmcp polls the transport as one arm of an unbiased
`tokio::select!` (rmcp 1.7.0 `src/service.rs:805`, the `receive()` arm at
`:813`), so a losing poll DROPS that future — and the D47 reply is written
inside `receive()`, so on cancellation it is never written, while the frame
goes with it because the loop clears its line buffer on the next iteration.
The Verify gate's attack lens measured 0/40 lost on an idle connection, 25/40
with four requests in flight and 39/40 with eight.

Miguel's ruling was DISCLOSE AND TRACK, not fix, and the reasons are recorded
rather than assumed: it is pre-existing (the shipped `-32700` arm has the same
shape and the same loss rate on main), and the pre-handshake fatality this arm
removes occurs by definition with zero requests in flight — the regime measured
at 0/40 — so that half of the fix is unconditional. The transport write path is
untouched here.

The residual lands in the two places the branch already discloses its other
one: the D47 decision row's residual list and the `wire.rs` module doc. The
mutex claim is scoped at `answer_error` itself to what it ever established —
byte-atomicity, one whole frame per guard — with cancellation named as the
separate hazard it does not cover.

`ub-TBD` is a PLACEHOLDER at both sites, to be replaced by the tracker id of
the issue opened for this residual before the PR is opened.

Refs: ub-cnv, D47
The Track step's two same-commit duties for ub-cnv (PROCESS.md §6/§8): the
regenerated git record and the wiki run-report, on top of the gated work.

The export carries 47 records, including the three issues this work opened —
ub-og3 (the non-JSON-RPC blob on the framing channel), ub-nbz (the -32600 lost
to a cancelled receive()) and ub-q1u (the libsql stress flake reported rather
than swept) — plus the ub-cnv comment thread through the Implement outcome.
It is a generated artefact under the D5 export-only model and was produced over
the tracker tool, never hand-edited.

The report records what the diff cannot: the class predicate and why
duplication was only the minority route, the four binding rulings, the two
impossibilities escalated rather than absorbed (the explicit-null reply rmcp's
own type cannot serialize, and the eight visitor arms that are an equivalent
mutant), the full gate history including a delta-verify that failed inside the
repair of its own findings, the mutation result (26 of 26 catalogue mutants
killed, zero survivors), the four open residuals, and two gotchas worth
carrying forward — a required check that pins a landing from a later step is
red by construction, and a claims script without its executable bit passes
every local `sh` invocation while dying in CI.

It lands BEFORE the commit that adds the P15 row, so the row's landing exists
in the record at every commit rather than only at the tip.

Refs: ub-cnv, D47
The decision row disclosed a FOURTH v1.0.1 residual — the -32600 is lost
whenever rmcp cancels the receive() future — under the placeholder `ub-TBD`,
which the issue now exists to replace, and left two roadmap sites saying
"Three residuals stay OPEN" above a list the same decision had grown.

- Both `ub-TBD` sites become `ub-nbz`: the D47 row's clause (8)(v) and the
  transport module's matching disclosure. A named residual in the top document
  of the hierarchy must resolve, or it is worse than the vagueness it replaced.
- The v1.0.1 card loses its count word entirely rather than gaining a bigger
  one, in both the markdown roadmap and its RENDERED twin, and both lists gain
  the concurrency residual. The list is the rule and carries no count — a
  derived count has already rotted at this exact site five times. The HTML is
  the published artefact and sits outside every lint corpus, so only the D47
  gate's own row can notice it going stale.
- Two sentences in the transport's module documentation are aligned with the
  figures the decision row states: 0 of 40 lost with the connection idle,
  25 of 40 with four requests in flight, 39 of 40 with eight, from ONE harness
  and unreplicated. "Idle: never lost" claimed more than one unreplicated
  harness measured.

Documentation only — no behaviour changes, no contract byte moves.

Refs: ub-cnv, ub-nbz, D47
The D47 row now names TWO residual issues, ub-788 and ub-nbz, and the gate
pinned only the first. An id a decision row names and the tracker does not
carry is a dangling reference in the top document of the hierarchy, which is
exactly the failure P10 exists to prevent — so its sibling gets the same
treatment.

P15 mirrors P10 rather than widening it: one row matching either id would go
green with the other dangling. The P-table floor moves 12 -> 13 with the row it
counts, and the closing line names both ids instead of one.

The row's landing is already in the record — the tracker re-export commit
precedes this one deliberately, because a required row that pins a landing from
a LATER step is red by construction, which this decision's own gate learned the
hard way with ub-788.

Verified by running the script the way CI does, as a bare relative path
(`sh script` never exercises the permission bit).

Refs: ub-cnv, ub-nbz, D47
@miguelramos
miguelramos merged commit 7790db0 into main Aug 6, 2026
23 checks passed
@miguelramos
miguelramos deleted the ubcnv-envelope-id-reject branch August 6, 2026 10:56
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