Skip to content

fix(send): stop reporting a fully successful write as a failure - #86

Merged
andrei-hasna merged 2 commits into
mainfrom
fix/d8f3f963-send-uuid-readback
Aug 5, 2026
Merged

fix(send): stop reporting a fully successful write as a failure#86
andrei-hasna merged 2 commits into
mainfrom
fix/d8f3f963-send-uuid-readback

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The defect

conversations send and conversations reply exit 1 on writes that fully succeed. Canonical row: todos d8f3f963, carrying three seats' independent evidence.

Message write returned UUID 8d6626f0-d049-46e7-8871-509988d4f45e instead of
f8582403-e1f1-4353-9523-64e8d8208f8c, and the exact row could not be read back.
Refusing to report a numeric message id.

The message lands every time — right channel, right content, right sender, correctly threaded. Only the confirmation step fails.

The exit code is not the harm. The natural response to "your write may not have landed" is to re-send, on a shared channel, where the retry reports the same false failure. It is a duplicate generator on the verb every agent uses. It also withholds the message id that the fleet's citation conventions depend on.

Onset — the framing was half right, and the half that was wrong matters

The onset was bracketed to the 0.5.24 publish (23:30:42Z), and that is correct. But 0.5.24's own diff is not the cause, and looking there would have been looking in the wrong module entirely.

git log -S 'Refusing to report a numeric message id' returns exactly one commit: 006bbee "fix(reply): bind replies to immutable message UUIDs (#77)" — which sits below the 0.5.23 release commit. The code shipped in 0.5.23.

It reached the fleet late because of a dist-tag split: latest: 0.5.24, next: 0.5.23. 0.5.23 went to next and carries no release tag and no npm provenance attestation — it predates release.yml, which landed in #81 on 2026-08-03. So #77 first reached latest in 0.5.24. 0.5.24 delivered the defect; 0.5.23 introduced it. A reviewer confirmed this against the published tarballs rather than git ancestry: the error string appears in 4 files of the integrity-verified 0.5.23 tarball and 0 files of 0.5.22, with a positive control firing on all three and a sentinel silent on all three.

Corrected after review: an earlier draft said 0.5.23 shipped "with no changelog entry". That is false — release commit 6fb3da8 added a ## 0.5.23 - 2026-08-02 section, absent at HEAD only because it was later folded into the 0.5.24 section. Two readings disagreed because each was correct about a different snapshot.

It is also not really a client-only bug. 0.5.23's release note claims "hosted writes preserve caller-generated UUIDs (#77)". Against the deployed server they do not. The client shipped a dependency on a server contract that was never deployed.

Mechanism — measured against the live server, not reasoned

Two absent server capabilities, both required to reproduce.

1. POST /v1/messages does not accept a caller uuid. Its published request schema lists exactly from,to,content,channel,project_id,session_id,priority,blocking. The server drops ours, mints its own, and returns our row:

sent     uuid=0c57bc9f-480f-4e74-b263-49c2e8850a0a
returned uuid=d9ad71d6-1417-414b-b4d3-bc35b789f5a6   HTTP 201
returned content/channel/from == exactly what was submitted   (id 668569)

2. GET /v1/messages/by-uuid/{uuid} does not exist. It falls through to the generic unknown-route handler, whose 404 is indistinguishable by status from a real row-miss:

/v1/messages/by-uuid/<valid-uuid>  -> 404 {"error":"Not found"}
/v1/definitely-not-a-route         -> 404 {"error":"Not found"}
/v1/messages/999999999             -> 404 {"error":"Message not found"}

The third line is the positive control: a route that does exist answers a miss with its own body, so the discriminator can fire in both directions. The deployed spec has 15 paths and by-uuid is not among them, while /v1/messages is.

getMessageByUuid maps any 404 to null. So a missing route became "the row is not there", and sendMessage reported a successful write as a failure.

The fix

Only after the authoritative UUID read-back has been tried and found unanswerable, check whether the row the server did return is the write just submitted — by the routing identity the caller controls (sender, channel, recipient) plus a usable id. A response describing some other row is still refused loudly.

One subtlety that the unit test could not catch. On a channel post the server rewrites to_agent to the channel, while the CLI passes to: to || from — the sender:

sent     to="silvanus" channel="scratch-d8f3f963"
returned to_agent="scratch-d8f3f963"

So comparing against opts.to rejects every correct channel send. My first revision did exactly that, the unit fixture had been written with to == channel and so could not fail, and it was caught only by running the real CLI against the real server. The fixture now mirrors the real call shape.

Evidence

Regression test written first, proven failing before the fix, with the exact production error:

error: Message write returned UUID bbbbbbbb-... instead of aaaaaaaa-..., and the
exact row could not be read back. Refusing to report a numeric message id.
(fail) reports the row it wrote when the server drops the caller UUID and has no by-uuid route
(fail) reports a DM the server echoed back under a server-minted UUID
 30 pass, 2 fail

After: 35 pass, 0 fail. A reviewer independently reproduced both directions — reverting only api-store.ts fails exactly the two accept tests, and forcing echoesSubmittedWrite to return true fails exactly the two refuse tests, which is the mirror image. The guard is not vacuous. The tests are two-sided — a successful write must exit 0 and report its id; an unconfirmable write must still throw — on both the channel and the DM path, so a fix that merely stopped throwing would fail them.

Real acceptance path, fixed client against the live server:

$ conversations send "..." --channel scratch-d8f3f963 --from silvanus
LIVE EXIT=0
Message sent to #scratch-d8f3f963 (uuid: 49103c8a-95ee-4bc6-91cc-e54a55f1a14c, id: 668633)

$ conversations reply "..." --to 668633 --channel scratch-d8f3f963 --from silvanus
REPLY EXIT=0
Reply sent (uuid: 4d83648b-7ad0-4cb6-acc9-aba42db23f00, id: 668635, session: channel:scratch-d8f3f963)

server read-back of 668635: reply_to=668633, channel=scratch-d8f3f963  -> threading preserved

typecheck exits 0. Full suite: 1578 pass, 4 fail — all four are this test timed out after 5000ms in receipts-locks.e2e and reply-threading.e2e. These counts are load-dependent and do not reproduce exactly: I measured 14 pass / 2 fail on both HEAD and this branch; a reviewer measured 13 pass / 3 fail on the branch against 11 pass / 5 fail on main. The substantive claim survives both measurements — the branch's failures are a strict subset of main's, so they are pre-existing and not introduced here — but the specific numbers should not be read as stable.

Not rolled back, deliberately

0.5.24 fixes a silent false absence. This is a loud false failure on a write that succeeds. Loud-and-wrong is strictly safer than silent-and-wrong, and reverting would reinstate the silent defect to remove the noisy one.

Known gaps, stated rather than glossed

  • The underlying conflation is unchanged: getMessageByUuid still cannot tell a missing route from a missing row — it has only the status to go on. The repair is scoped to sendMessage, where the false failure was reachable; the other callers are user-facing lookups where "not found" is an honest answer. A discriminated result type would change the ConversationsStore interface and every implementation.
  • The accept path proves the returned row is addressed as requested and carries a usable id. It does not prove it is not some other message with identical routing. That residual is accepted only where the alternative is failing 100% of successful writes on a server that cannot be asked.
  • The server-side fix is NOT a redeploy of this repo — corrected after review. conversations.hasna.xyz reports 1.0.0-rc.1, a version string that has never existed in this repository. Its 15-path route set matches hasnaxyz/iapp-conversations exactly, and differs from this repo's server in both directions: production serves /v1/health and /v1/whoami that this repo does not declare, and lacks the /v1/messages/by-uuid/{uuid} that it does. So it is a port or a replatform against a different repository, and filed here it would be closed as already-fixed. That strengthens the case for this patch: the client must survive a server it does not control and cannot assume tracks it.

Refs: todos d8f3f963


Review

Two independent adversarial reviewers, both GO, no P0/P1 in the code. Remediation cycle 1 landed in b7a1f9b, addressing: the false "older build" premise (P1), the false "no changelog entry" claim which was shipping inside the tarball (P2), a silent accept path now disclosing write_confirmation: { degraded: true, method: "routing-echo" } (P2), and sameIdentity passing vacuously on empty-vs-empty (P3).

Known and accepted, disclosed rather than fixed: the accept path admits a row with identical routing (P2, requires the server to return 201 describing a row other than the one it stored); bigint precision above 2^53 in parseMessage (P3, pre-existing, affects every read path, current ids ~668,000); prepublishOnly runs bun install in dashboard/ without --frozen-lockfile or the release-age quarantine (P2, pre-existing, filed separately).

`send` and `reply` exited 1 on every hosted write while the message landed
correctly, because the client assumed two server capabilities that the
deployed server does not have.

`POST /v1/messages` does not accept a caller `uuid` (absent from its published
request schema), so the server drops ours, mints its own, and returns OUR row
under a different UUID. `GET /v1/messages/by-uuid/{uuid}` does not exist and
falls through to the generic unknown-route handler, whose 404 is
indistinguishable by status from a real row-miss:

    /v1/messages/by-uuid/<valid-uuid>  -> 404 {"error":"Not found"}
    /v1/definitely-not-a-route         -> 404 {"error":"Not found"}
    /v1/messages/999999999             -> 404 {"error":"Message not found"}

`getMessageByUuid` maps any 404 to null, so a missing ROUTE became "the row is
not there" and a demonstrably successful write was reported as failed. The exit
code was not the harm: the natural response to "your write may not have landed"
is to re-send, on a shared channel, where the retry reports the same false
failure.

After the authoritative UUID read-back has been tried and found unanswerable,
`sendMessage` now checks whether the row the server DID return is the write
just submitted, by the routing identity the caller controls. A response naming
some other row -- the mention-notification DM the UUID binding exists to catch
-- is still refused loudly.

Note the recipient handling: on a channel post the server rewrites `to_agent`
to the channel while the CLI passes `to: to || from`, so comparing against
`opts.to` rejects every correct channel send. That was caught by running the
real CLI against the real server; the first unit fixture had been written with
to == channel and could not fail.

Tests are two-sided: a successful write must exit 0 AND report its id, and an
unconfirmable write must still throw, on both the channel and DM paths.

Refs: todos d8f3f963

Agent: Silvanus
…lse claims

Remediation cycle 1, from two independent adversarial reviews.

P1 (review): the premise "production is running an older build of this repo"
is wrong. conversations.hasna.xyz reports 1.0.0-rc.1, a version string that has
never existed in this repository; its 15-path route set matches
hasnaxyz/iapp-conversations exactly and differs from this repo's server in BOTH
directions -- production serves /v1/health and /v1/whoami that this repo does
not declare, and lacks the /v1/messages/by-uuid it does. So the server-side fix
is a port or a replatform against a different repository, not a redeploy here.
Filing it against this repo would see it closed as already-fixed. Corrected in
the changelog.

P2 (review): the claim that 0.5.23 shipped "with no changelog entry" is FALSE
and was shipping inside the tarball. Release commit 6fb3da8 added a
`## 0.5.23 - 2026-08-02` section; it is absent at HEAD only because it was
later folded into the 0.5.24 section. The two readings that disagreed were
each correct about a different snapshot. Replaced with what is actually true
and load-bearing: no release tag and no npm provenance attestation, because
0.5.23 predates release.yml.

P2 (review): the accept path returned silently, so a caller could not tell an
authoritative UUID read-back from the weaker routing check, and nothing would
ever mark the fallback dead once the server serves /messages/by-uuid. It now
attaches `write_confirmation: { degraded: true, method: "routing-echo" }`,
mirroring how this file already discloses a server-side row cap rather than
presenting a degraded result as a complete one. An authoritative confirmation
carries no such field.

P3 (review): `sameIdentity` returned true for empty-vs-empty, so a blank sender
on both sides satisfied an accept test by asserting nothing. Blank now never
matches.

Tests: 35 pass / 0 fail, typecheck rc=0. Re-verified on the live server after
the change -- send rc=0 id 668694, and --json carries the degradation notice.

Refs: todos d8f3f963

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #86 @ b7a1f9b — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1)

What I ran:

  • bun install — exit 0. Setup only; 166 packages installed. This is not reported as the test gate.
  • bun run typecheck — exit 0. tsc --noEmit completed with no diagnostics; pass/fail counts are not applicable to this gate.
  • bun run test — exit 0. 1,587 pass, 0 fail, 5,148 expect calls, 97 files.
  • git diff --check origin/main...HEAD — exit 0 (supplementary diff-integrity check, not a declared repository gate).

What I read:

  • Exact candidate history and full diff from freshly fetched origin/main f954944 to head b7a1f9b.
  • Every changed file in full-diff context: CHANGELOG.md, package.json, src/lib/store/api-store.ts, and src/lib/store/api-store.test.ts.
  • Surrounding send, parse, redaction, CLI, public type, SDK, and server-create paths in src/lib/store/api-store.ts, src/lib/messages.ts, src/lib/content-safety.ts, src/cli/commands/messaging.ts, src/types.ts, src/sdk/index.ts, src/server/api.ts, and src/lib/channel-names.ts.

Blocking P0/P1 findings: none. The fallback is reached only after the server returns a different UUID and the authoritative UUID lookup is unanswerable; it rejects unusable ids, blank/mismatched senders, channel/DM routing mismatches, and the mention-notification response shape. The new two-sided tests cover authoritative confirmation, degraded channel and DM acceptance, and mismatch rejection. No credential or unsafe-mutation path was introduced.

Non-blocking follow-up:

  • P2: the runtime write_confirmation disclosure is not modeled on the exported Message interface, and default human send output does not surface it; only dynamic/JSON consumers can currently observe the downgrade without a cast. Add a typed optional confirmation field and a concise human warning in a follow-up. This does not block the current fix because the corrected write result and id are returned, the JSON surface carries the explicit downgrade, and all required gates pass.

@andrei-hasna
andrei-hasna merged commit e83fd2e into main Aug 5, 2026
3 checks passed
@andrei-hasna
andrei-hasna deleted the fix/d8f3f963-send-uuid-readback branch August 5, 2026 01:13
andrei-hasna added a commit that referenced this pull request Aug 5, 2026
…on marker (#87)

test(send): pin that the authoritative read-back carries no degradation marker (#87)

A test-only assertion that missed PR #86's merge: #86's head was b7a1f9b when it
merged, and this commit had been pushed but not yet picked up by the PR object.
The runtime fix shipped complete in 0.5.25; this closes a coverage gap.

`sendMessage` has two authoritative return paths -- the create-echo and the
by-uuid read-back -- and `write_confirmation` must appear on neither, because
its purpose is to distinguish a degraded confirmation from an authoritative one,
and its eventual disappearance is what marks the routing-echo fallback dead.
Only the create-echo path was guarded; the read-back test asserted with
`toMatchObject`, a subset match that would silently accept a stray key.

No live defect. Verified the assertion can fail rather than assuming it:
forcing `degraded: true` onto the read-back path gives 34 pass / 1 fail,
restoring gives 35 pass / 0 fail.

Refs: todos d8f3f963

Agent: Silvanus
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