fix(mcp): an omitted contact field is kept, not deleted (product#4046) - #198
Conversation
POST /contacts/{contact_id}/update REPLACES the contact; it does not patch it.
Any field absent from the body is erased, and the call still returns
updated: true.
Reproduced on production while verifying #194: sending a contact's own current
first_name + last_name + job_title, changing nothing, deleted that contact's
email. The contact moved from contacts.reachable to contacts.candidates and
_meta.has_reachable_contact flipped to false, so the lead stopped being
contactable. Restored with a second call; both are in the lead's activity
timeline.
This became reachable only yesterday. Until #194 the tool returned 404 on 100%
of calls, so it destroyed nothing. A tool that always failed now succeeds and
deletes data, which is strictly worse than the bug that was fixed.
Instructional fix, per Milan — the agent must send every field:
- The description leads with the mechanism, the production evidence, and a
three-step procedure: read the contact, send all six fields, pass null only
when removal was actually asked for.
- Each optional field's schema description says omitting it deletes it. The old
text ("Pass null to clear it") implied omission was the safe way to leave a
field alone, which is precisely the mistake.
- short_description and prefer_when carry the warning too, so it survives hosts
that truncate tool descriptions.
- New test pins the warning AND pins null-means-clear beside it, so the
guidance cannot be trimmed as verbose without failing.
No behaviour change: the tool sends what it is given, as before.
pnpm -r test 2224 passed. pnpm -r typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Solid instructional fix — schema descriptions, tool description, and the new pinning test all line up, and execute() is untouched (confirmed no behavior change). Two documentation inconsistencies worth fixing before merge: WORKFLOWS.md still encodes the old unsafe pattern as the expected/correct behavior, and the root CHANGELOG title overstates what actually changed.
| @@ -1,5 +1,13 @@ | |||
| # Changelog | |||
|
|
|||
| ## 0.33.2 — 2026-09-02 — Correcting a contact no longer wipes their email | |||
There was a problem hiding this comment.
This title ("no longer wipes their email") reads as if the bug is fixed, but the PR body and packages/mcp/CHANGELOG.md are explicit that this is instructional-only with no behavior change — an agent that omits a field still deletes it. Worth softening so this user-facing note doesn't over-promise.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 05d000e991
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| So, every time, even to change one field: | ||
|
|
||
| 1. Read the contact first with `leadbay_research_lead_by_id` (or `leadbay_campaign_call_sheet`). |
There was a problem hiding this comment.
Read exact fields from a lossless contact source
Do not present leadbay_campaign_call_sheet as an interchangeable source for this read-before-write flow. Its contact mapping exposes no source field and converts the stored linkedin_page into linkedin_url, which can be a synthesized LinkedIn search URL when the stored value is null (campaign-call-sheet.ts lines 274–285). For campaign contacts, the agent therefore cannot verify that the ID is an editable source:"org" contact and may copy a constructed URL back as the real LinkedIn value. Use leadbay_research_lead_by_id here, or expose the raw source and fields in the call-sheet result.
Useful? React with 👍 / 👎.
| 2. Send **all** of `first_name`, `last_name`, `job_title`, `email`, `phone_number`, `linkedin_page` — current value for the ones you are not changing, new value for the one you are. | ||
| 3. Only pass `null` for a field when the user actually asked you to remove it. |
There was a problem hiding this comment.
Allow null when preserving an already-empty field
Permit null when the value returned by the preceding read is already null, not only when the user requested removal. For a contact whose email, phone, title, or LinkedIn value is currently null, step 2 requires sending that current value while step 3 prohibits doing so, leaving the agent unable to follow the advertised safe procedure and encouraging it to omit or invent a value. The instruction should distinguish preserving an existing null from deliberately clearing a populated field.
Useful? React with 👍 / 👎.
Editing one field on a contact erased the others. Reproduced on production while verifying #194: sending a contact's own current first_name + last_name + job_title, changing nothing, deleted that contact's email, and the lead stopped being contactable. The backend already had the safe behaviour and we were choosing the other one. Both routes take the same payload (OrgContactRoutes.kt:110,152 → OrgContactsDaoImpl.kt:265-272): /contacts/{id}/update forceUpdateIfNullOrEmpty=true absent field -> erased /contacts/{id}/merge forceUpdateIfNullOrEmpty=false absent field -> kept So this needs no read-modify-write, no extra round-trip and no race window. The tool now routes by intent: - Nothing being erased -> /merge. Every omitted field survives. - Any field passed as null -> /update, which rewrites the record, so that call must carry all four optional fields. Otherwise it is refused with CONTACT_CLEAR_NEEDS_FULL_RECORD rather than deleting what was not mentioned. The destructive path costs more effort than the safe one; that is the point. - The result carries mode, preserved and cleared, so a wrong edit is visible. Verified live on staging, same body to each route: /merge kept the email and phone, /update deleted both. Two existing test files changed rather than added, because both asserted the contract this fixes. update-contact-null-clear.test.ts had encoded the data loss as correct — asserting that unmentioned fields are not sent, while posting to the route that deletes them. pnpm -r test 2224 passed. pnpm -r typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf4eeb4af2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"] as const; | ||
| const asked = (f: (typeof OPTIONAL)[number]) => params[f] !== undefined; | ||
| const clearing = OPTIONAL.filter((f) => params[f] === null); |
There was a problem hiding this comment.
Route empty name updates through a clearing path
When correcting a contact to a mononym or removing an incorrectly populated name part, callers must send first_name/last_name as strings, so an empty string is the only representable cleared value. Because clearing only examines the four optional fields, this request is sent to /merge; the documented backend semantics for that route skip empty values, leaving the old name in place while the tool reports updated: true. Include empty name fields in the destructive-path decision or otherwise provide a safe way to clear them.
Useful? React with 👍 / 👎.
| const contact = await client.request<UpdatedContact>( | ||
| "POST", | ||
| `/contacts/${params.contact_id}/merge`, | ||
| body, |
There was a problem hiding this comment.
Update the 400 test to stub the merge route
For the no-null input used by update-contact.test.ts's “propagates a 400” case, this branch now calls /merge, but that test still registers /contacts/c-2/update. The mock harness therefore rejects with mockHttp: no script matched, and the broad .rejects.toThrow() assertion accepts that unrelated error, so the test passes without exercising or verifying propagation of a backend 400. Stub /merge and assert the mapped backend error rather than any thrown value.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Follow-up review of bf4eeb4 (the fix for the previous review's concern). The merge-vs-erase routing logic in update-contact.ts is correct and well covered by the new update-contact-merge-vs-replace.test.ts (merge path, erase-with-full-record path, refusal path, and description assertions all check out against the actual template/generated-file text). One process note below.
|
|
||
| describe("leadbay_update_contact — null clears", () => { | ||
| it("forwards null to clear a field (email/title) — backend accepts it", async () => { | ||
| it("clearing a field forwards null on /update — with the whole record supplied (product#4046)", async () => { |
There was a problem hiding this comment.
Per CLAUDE.md Review guidelines, new tests should go in new files — existing test files shouldn't be modified. This PR edits update-contact.test.ts and this file in place (and deletes update-contact-omitted-field-warning.test.ts) rather than adding new files. The CHANGELOG entry argues these tests encoded the bug as correct behavior, which is a fair rationale, but the same effect could have been achieved by removing the stale test file and adding a fresh one instead of editing in place.
Closes leadbay/product#4046.
The bug
Editing one field on a contact erased the others. Reproduced on production while verifying #194 — I sent the contact's own current values, changing nothing:
{ "contact_id": "6837c37e-…", "first_name": "BARBE", "last_name": "", "job_title": "BARBE" }The email was deleted, the contact moved from
contacts.reachabletocontacts.candidates, and the lead stopped being contactable.updated: trueeither way. Restored with a second call.This became reachable the day before. Until #194 the tool returned 404 on 100% of calls, so it destroyed nothing. A tool that always failed now succeeded and deleted data — strictly worse than the bug #194 fixed, and on the default surface for every hosted user.
The finding
The backend already has the safe behaviour. We were calling the other one. Both routes take the same payload:
forceUpdateIfNullOrEmpty/contacts/{id}/updatetrue/contacts/{id}/mergefalseOrgContactRoutes.kt:110,152→OrgContactsDaoImpl.kt:265-272.So this needs no read-modify-write, no extra round-trip, no race window — the design I originally proposed in #4046 and the parameter Milan considered both turn out to be unnecessary.
The fix
Route by intent:
/merge. Every field the caller omitted survives. This is the common path and it costs nothing.null→/update, which rewrites the record. That call must therefore carry all four optional fields; if it does not, it is refused withCONTACT_CLEAR_NEEDS_FULL_RECORDrather than deleting what nobody mentioned.mode(merge/replace),preservedandcleared, so a wrong edit is visible instead of silent.Both intentions stay expressible, and the destructive one costs more effort than the safe one — which is the asymmetry that was inverted before.
Verified live on staging, same body to each route
The bug and the fix, two calls, on a real server.
Tests
New:
update-contact-merge-vs-replace.test.ts— pins the route each intent takes, that a merge does not invent values for omitted fields, and that an underspecified erase makes zero HTTP calls rather than a destructive one.Two existing files changed rather than added, which the repo normally forbids. Both asserted the contract this fixes:
update-contact.test.ts— happy path now expects/merge.update-contact-null-clear.test.ts— the clear case now supplies the whole record. This one had encoded the data loss as correct: it asserted that unmentioned fields are not sent, while posting to the route that deletes them.pnpm -r test2,224 passing,pnpm -r typecheckclean.Note on the two commits
The first commit was the instructional mitigation — tell the agent to send every field. Milan then asked for the real design. The second commit supersedes it: omission is now safe, so that warning would have been false. Kept as two commits so the reasoning is legible.
🤖 Generated with Claude Code