Fix send_message compose chain against the real Kaiser contract - #7
Merged
Merged
Conversation
send_message had never succeeded once. The commit path failed at the
first call with "GetComposeId response missing composeId (response
keys: str)", and the audit log had no phase="result" line in its
history.
Root cause: the send chain's response shapes were inferred from HAR
content.size values, never captured. Chrome DevTools' HAR exporter has
a small circular buffer and the 690 KB GetConversationList response
evicted every body in the compose flow. The tests then mocked those
inferred shapes, so a full green suite certified a guess.
Three divergences from the real contract, all verified live 2026-07-29:
1. GetComposeId returns a bare JSON string. The whole 130-byte body is
"WP-<128 chars>", so response.json() yields a str. The parser looked
for a composeId key on a dict that never existed. Not double-encoded
either - a second json.loads raises JSONDecodeError.
2. GetViewers was never called. VIEWERS_PATH was defined and unused, so
viewers[0].wprId went out empty. The captures show it populated with
an 84-char token. Kaiser rejects the draft with {"error": 2}.
3. The recipient object carried 12 keys. All three compose captures show
the browser trimming to exactly five: displayName, userId, poolId,
providerId, departmentId. poolId and departmentId are empty in the
browser too, so empty there is correct.
Changes:
- _extract_token accepts bare-string or wrapped token responses, so
neither form can break the chain again.
- _describe_payload reports "type: str" instead of "keys: str". The old
wording read as a key list containing "str" and sent debugging after a
missing field on a non-existent object.
- _post_get_viewers calls GetViewers and picks the isSelf entry. Never
raises - a failure degrades to the previous recipient-row fallback
rather than blocking a send.
- _RECIPIENT_KEYS pins the recipient payload to the browser's five.
Kaiser returns application errors as HTTP 200 with {"error": N}, so
raise_for_status does not catch them. SaveDraft returns error: 0 on
success alongside conversationId. Documented, not yet enforced - we
still key off a missing conversationId rather than error != 0.
messages.md now marks GetComposeId, GetViewers, SaveDraft, and the
recipient key set as verified live, with the byte arithmetic that
explains each. Test mocks use the real shapes.
Live-verified: message sent 2026-07-29 15:10:05 UTC, first successful
phase="result" in the audit log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hugooc
force-pushed
the
fix/send-message-compose-chain
branch
from
July 29, 2026 15:51
da1ea0e to
32262bb
Compare
hugooc
added a commit
that referenced
this pull request
Jul 29, 2026
Follow-up to #6, #7, #8. Closes the loose ends and writes down what this session cost so nobody repeats the dig. PHI removed from the public repo. Two real values had been published: - WISHLIST.md named a real treating provider in a use-case sentence. With the author's name in LICENSE and the ADRs, that discloses a care relationship. - messages.md pasted a real ~85-char document ID out of a HAR, with the line above naming the uploaded file. Both read as harmless illustrative detail, which is the failure mode. Neither survived the 2026-05-10 history rewrite because neither existed yet. Both are gone from the working tree; both remain in history (0def0c8, 6fc9d1b). Clearing history would break the existing fork, so that call is left to a human and the scan reports it separately. scripts/phi-scan.py makes the check repeatable, with the two known leaks named in its docstring so the next reader does not need the backstory. Deliberately not in CI: a publication gate that fails on a false positive gets disabled. Wired into the release checklist instead. Normalized two fixtures the scan caught in test_refill.py — a non-555 phone number and a bare MRN. Synthetic already, but off-convention, and a scanner is only useful when clean means clean. Error codes now enforced. Kaiser answers with HTTP 200 and {"error": N}, so raise_for_status sails past a rejection. SaveDraft returned {"error": 2} for an entire debugging session while the transport looked healthy. _raise_for_kaiser_error checks SaveDraft and Send; error: 0 accompanies success, so only non-zero fails, and endpoints omitting the field are unaffected. Documentation: - docs/postmortems/2026-07-29-send-message-compose-chain.md — full archaeology: three defects, the shared root cause (response shapes inferred from HAR content.size, then mocked in tests), why the error message misdirected, the 21-hour-stale process that ate four debugging cycles, the pytest contamination of the debug log, and a checklist for the next endpoint. - CLAUDE.md gains the verified send-chain facts, the HTTP-200 error rule, a warning that "(inferred)" shapes are guesses, how to verify the server process is newer than the edit, and to gate probes on PYTEST_CURRENT_TEST. Corrects the send_message entry, which still described the chain without GetViewers. - release-checklist.md gains the ongoing PHI scan step and the honest note that the one-time rewrite does not cover new commits. 599 tests pass. ruff, mypy, and the PHI scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
send_messagehad never succeeded once. The commit path failed at the first call withGetComposeId response missing composeId (response keys: str), and~/.openkp/audit.loghad nophase="result"line anywhere in its history.Why the tests didn't catch it
The send chain's response shapes were inferred from HAR
content.sizevalues, never captured. Chrome DevTools' HAR exporter has a small circular buffer and the 690 KBGetConversationListresponse evicted every body in the compose flow —messages.mddocumented this gap honestly. The tests then mocked those inferred shapes, so a fully green suite certified a guess.Three divergences, all verified live 2026-07-29
1. GetComposeId returns a bare JSON string. The whole 130-byte body is
"WP-<128 chars>", soresponse.json()yields astr. The parser looked for acomposeIdkey on a dict that never existed. Not double-encoded either — a secondjson.loadsraisesJSONDecodeError. The HAR sizes (130, 130, 126 across three captures) were consistent with a bare token all along.2. GetViewers was never called.
VIEWERS_PATHwas defined and unused, soviewers[0].wprIdwent out empty. The captures show it populated with an 84-char token. Kaiser rejects the draft with{"error": 2}.3. The recipient object carried 12 keys. All three captures show the browser trimming to exactly five:
displayName,userId,poolId,providerId,departmentId.poolIdanddepartmentIdare empty in the browser too, so empty there is correct and not a failed lookup.Changes
_extract_tokenaccepts bare-string or wrapped token responses, so neither form can break the chain again_describe_payloadreportstype: strinstead ofkeys: str— the old wording read as a key list containing "str" and sent debugging after a missing field on a non-existent object_post_get_viewerscalls GetViewers and picks theisSelfentry. Never raises; a failure degrades to the previous fallback rather than blocking a send_RECIPIENT_KEYSpins the recipient payload to the browser's fiveNote on error codes
Kaiser returns application errors as HTTP 200 with
{"error": N}, soraise_for_status()does not catch them. SaveDraft returnserror: 0on success alongsideconversationId. Documented but deliberately not enforced — we still key off a missingconversationIdrather thanerror != 0. Worth a follow-up.Since both divergences were fixed at once, which one
{"error": 2}was complaining about is not established. Recorded as observational.Verified
596 tests pass. Message sent live 2026-07-29 15:10:05 UTC — the first successful
phase="result"in the audit log.messages.mdnow marks GetComposeId, GetViewers, SaveDraft, and the recipient key set as verified live, with the byte arithmetic behind each.🤖 Generated with Claude Code