Skip to content

fix(mcp): an omitted contact field is kept, not deleted (product#4046) - #198

Merged
milstan merged 2 commits into
mainfrom
milstan/update-contact-full-body
Sep 2, 2026
Merged

fix(mcp): an omitted contact field is kept, not deleted (product#4046)#198
milstan merged 2 commits into
mainfrom
milstan/update-contact-full-body

Conversation

@milstan

@milstan milstan commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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.reachable to contacts.candidates, and the lead stopped being contactable. updated: true either 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:

Route forceUpdateIfNullOrEmpty A field absent from the body
/contacts/{id}/update true written as null — erased
/contacts/{id}/merge false skipped — kept

OrgContactRoutes.kt:110,152OrgContactsDaoImpl.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:

  • Nothing being erased → /merge. Every field the caller omitted survives. This is the common path and it costs nothing.
  • Any field passed as null/update, which rewrites the record. That call must therefore carry all four optional fields; if it does not, it is refused with CONTACT_CLEAR_NEEDS_FULL_RECORD rather than deleting what nobody mentioned.
  • The result carries mode (merge / replace), preserved and cleared, 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

POST /contacts/{id}/merge   {first_name, last_name, job_title:"CEO"}
  → job_title CEO,  email merge.probe@example.test,  phone +15550001111   KEPT

POST /contacts/{id}/update  {first_name, last_name, job_title:"CTO"}
  → job_title CTO,  email absent,  phone absent                          ERASED

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 test 2,224 passing, pnpm -r typecheck clean.

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

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T00:13:30.030002Z bf4eeb4 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog

## 0.33.2 — 2026-09-02 — Correcting a contact no longer wipes their email

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +68 to +69
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@milstan milstan changed the title fix(mcp): tell the agent that an omitted field deletes it (product#4046) fix(mcp): an omitted contact field is kept, not deleted (product#4046) Sep 2, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +126 to +128
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +138 to +141
const contact = await client.request<UpdatedContact>(
"POST",
`/contacts/${params.contact_id}/merge`,
body,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /updatewith the whole record supplied (product#4046)", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@milstan
milstan merged commit 46aa0f8 into main Sep 2, 2026
2 checks passed
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