What happened?
POST /api/lead silently replaces an existing contact when another contact has the same company name and city. It also clears previously populated company/enrichment fields that were omitted from the new request.
Reproduced through the public API with synthetic records and no enrichment-provider calls. Both create requests returned the same lead ID. After adding Person Two, Person One's name/email were replaced, and website, phone, and specialization became empty strings.
This affects a company-first workflow: enrich a company, then discover multiple people at that company. Adding a person can erase the company information already collected, and adding a second person replaces the first.
Expected: omitted fields preserve existing values. A distinct person's identity should not silently replace another contact. If Leads intentionally represent one company with one contact slot, the API should reject/report the collision or require an explicit update, and document the supported path for multiple people per company. This report does not prescribe a broader schema redesign.
Steps to reproduce
Use a disposable workspace and an authenticated editor/admin session. Set TOKEN to an existing access token and WORKSPACE_ID to that workspace's ID. Requires curl and jq. The script uses a unique synthetic company and leaves the resulting test record for inspection.
set -eu
BASE_URL=http://localhost:3010
: "${TOKEN:?Set an existing API access token}"
: "${WORKSPACE_ID:?Set a disposable workspace ID}"
COMPANY="Overwrite repro $(date +%s)-$$"
api() {
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-Id: $WORKSPACE_ID" \
-H 'Content-Type: application/json' "$@"
}
FIRST=$(api -X POST "$BASE_URL/api/lead" --data \
"$(jq -n --arg company "$COMPANY" '{company:$company, city:"Repro City", website:"https://example.invalid", contact_person:"Person One", email:"one@example.invalid"}')")
LEAD_ID=$(printf '%s' "$FIRST" | jq -r '.id')
printf 'First create: %s\n' "$FIRST"
api -X PUT "$BASE_URL/api/lead/$LEAD_ID" \
--data '{"specialization":"B2B SaaS","phone":"+15555550100"}'
api "$BASE_URL/api/lead/$LEAD_ID" | jq \
'{id,company,city,website,contact_person,email,phone,specialization}'
SECOND=$(api -X POST "$BASE_URL/api/lead" --data \
"$(jq -n --arg company "$COMPANY" '{company:$company, city:"Repro City", contact_person:"Person Two", email:"two@example.invalid"}')")
printf 'Second create: %s\n' "$SECOND"
api "$BASE_URL/api/lead/$LEAD_ID" | jq \
'{id,company,city,website,contact_person,email,phone,specialization}'
The second request deliberately omits website, phone, and specialization; it does not request that they be cleared. The PUT simulates values already saved by an enrichment step without calling a provider.
How are you running OpenGTM?
Docker Compose (bundled Postgres), macOS ARM64 with Colima. The API reports PG_LEAD_STORE=True, and use_pg_store() returns True.
Version / commit
- Running image:
ghcr.io/debpalash/opengtm:3.0.0
- Image digest:
sha256:43cd3ae23422135cac37cc52f7cc942c191d8ce586ce40e573095dcbb415d362
- Source inspection:
9fe0b57689230335aceceac691de42f620365b0f; behavior reproduced on the published 3.0.0 image.
Relevant logs
Observed API responses and selected readback fields:
First create: {"ok":true,"id":1}
Second create: {"ok":true,"id":1}
field before after
contact_person Person One Person Two
email one@example.invalid two@example.invalid
website https://example.invalid ""
phone +15555550100 ""
specialization B2B SaaS ""
No API error was returned. All data above is synthetic. The local reproduction record was deleted after capturing readback.
Likely cause and regression coverage
AddLeadRequest / add_lead defaults omitted fields to empty strings and passes body.model_dump() to the store. PgLeadStore.upsert_lead matches workspace + company + city, then assigns every payload field except created_at to the existing row. Contact identity is not part of that match.
A regression test through the API should establish that omitted values survive a repeated import and that submitting a different person does not silently replace the existing contact. Explicit clearing should remain distinguishable from omission. Runtime reproduction here covers the Postgres path; SQLite behavior was not tested.
What happened?
POST /api/leadsilently replaces an existing contact when another contact has the same company name and city. It also clears previously populated company/enrichment fields that were omitted from the new request.Reproduced through the public API with synthetic records and no enrichment-provider calls. Both create requests returned the same lead ID. After adding Person Two, Person One's name/email were replaced, and
website,phone, andspecializationbecame empty strings.This affects a company-first workflow: enrich a company, then discover multiple people at that company. Adding a person can erase the company information already collected, and adding a second person replaces the first.
Expected: omitted fields preserve existing values. A distinct person's identity should not silently replace another contact. If Leads intentionally represent one company with one contact slot, the API should reject/report the collision or require an explicit update, and document the supported path for multiple people per company. This report does not prescribe a broader schema redesign.
Steps to reproduce
Use a disposable workspace and an authenticated editor/admin session. Set
TOKENto an existing access token andWORKSPACE_IDto that workspace's ID. Requirescurlandjq. The script uses a unique synthetic company and leaves the resulting test record for inspection.The second request deliberately omits
website,phone, andspecialization; it does not request that they be cleared. The PUT simulates values already saved by an enrichment step without calling a provider.How are you running OpenGTM?
Docker Compose (bundled Postgres), macOS ARM64 with Colima. The API reports
PG_LEAD_STORE=True, anduse_pg_store()returnsTrue.Version / commit
ghcr.io/debpalash/opengtm:3.0.0sha256:43cd3ae23422135cac37cc52f7cc942c191d8ce586ce40e573095dcbb415d3629fe0b57689230335aceceac691de42f620365b0f; behavior reproduced on the published 3.0.0 image.Relevant logs
Observed API responses and selected readback fields:
No API error was returned. All data above is synthetic. The local reproduction record was deleted after capturing readback.
Likely cause and regression coverage
AddLeadRequest/add_leaddefaults omitted fields to empty strings and passesbody.model_dump()to the store.PgLeadStore.upsert_leadmatches workspace + company + city, then assigns every payload field exceptcreated_atto the existing row. Contact identity is not part of that match.A regression test through the API should establish that omitted values survive a repeated import and that submitting a different person does not silently replace the existing contact. Explicit clearing should remain distinguishable from omission. Runtime reproduction here covers the Postgres path; SQLite behavior was not tested.