Why Wend never silently updates a fact
When a new fact contradicts one already in your graph, Wend flags it instead of overwriting. Here is how conflicts are surfaced and resolved.
Ansh Vasani
Co-Founder and CEO of Wend ·
The first time Wend saw a contradiction, it almost embarrassed us into shipping the wrong thing.
A user’s meeting notes from March said “Kevin is President of the Asia Society Policy Institute.” An email from him in April said “I’m wrapping up at ASPI and starting at Asia Society next month.” The naïve flow our first prototype shipped did what most CRMs do: it overwrote the old fact with the new one. Three days later the user told Wend to draft a follow-up to Kevin and the draft addressed him by the wrong title.
The bug wasn’t the AI. The bug was that we’d built our knowledge graph to trust the latest input. That’s a reasonable default for, say, a contact-management tool where the user is the one typing every fact. It’s a terrible default for a system where most facts come from probabilistic extractors.
So we ripped the auto-update logic out and built the conflicts page instead. This post is the design of that page: the schema, the four-way resolution model, and the reasoning behind treating silent updates as a feature you don’t want.
The schema
Conflicts get their own table. This is the shipped DDL, copied out of the migration:
create table public.conflicts (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
target_kind text not null
check (target_kind in ('node_detail', 'link_detail')),
target_id uuid not null, -- the existing detail row
existing_value jsonb not null, -- snapshotted at conflict time
existing_source_id uuid not null references public.sources (id)
on delete cascade,
incoming_value jsonb not null, -- the new claim
incoming_source_id uuid not null references public.sources (id)
on delete cascade,
status text not null default 'pending'
check (status in ('pending', 'resolved', 'ignored')),
resolution text check (resolution in (
'kept_existing', 'replaced_with_incoming', 'merged',
'both_valid_temporal'
)),
created_at timestamptz not null default now(),
resolved_at timestamptz
);
create index conflicts_user_status_idx
on public.conflicts (user_id, status, created_at desc);The first thing to notice: we snapshot both values into the conflict row. We could have stored just pointers to the rows in question, but values mutate (the existing row could be resolved and re-conflicted later). By snapshotting at conflict-creation time, the user always sees what we saw when we asked them to weigh in.
The second thing: existing_source_id and incoming_source_id are both not null. A conflict in Wend is never “these two strings differ.” It is always “this email says one thing, that page says another, here are both receipts.” You cannot open a conflict without being able to answer where each side came from, because the resolution is only a real decision if the user can weigh the sources.
There is also no DELETE policy on this table. Resolved and ignored conflicts stay, which means the history of every disagreement your graph ever had is still there to read.
The three resolutions
When the user opens /app/conflicts, every pending conflict shows up as a card. The card has three buttons:
Kevin Rudd · current role
Already on file
President, Asia Society Policy Institute
Source: meeting note, Mar 12 2026
Incoming
President, Asia Society
Source: email from him, Apr 09 2026
/app/conflicts. The user picks one of three; Wend never picks.The three resolutions, in detail:
- Keep existing. The existing fact wins. The incoming fact is dropped, with the rejection written to the audit log so we can tell later if the AI keeps proposing the same incorrect change.
- Use new. The incoming fact wins. The detail row is updated to the incoming value and its
source_idis repointed to the incoming source, so provenance follows the fact rather than lagging behind it. The audit log gets aconflict_resolvedrow naming the choice. - Ignore. Neither fact wins yet. The conflict moves out of the pending queue without a decision and stops nagging the user. Useful when the user needs more information before resolving, or when the conflict turns out to reflect something more nuanced that’s better edited directly on the person’s page.
The schema actually supports more resolution shapes than the UI surfaces today. The resolution column on conflicts has four possible values (the three above plus “both valid, temporally” for cases where the existing fact is true for one period and the new fact is true for another). The three-button UI is a deliberate simplification while we watch how users handle the common cases. Adding the time-bounded resolution to the UI is a small change once it’s clear there’s a real audience for it.
One mechanic matters more than the button labels. Both decisive resolutions set user_confirmed = true on the underlying detail row. That flag is the lock: once a human has ruled on a fact, later automated passes read it and leave the value alone rather than re-proposing the same change on the next ingest. Without it, the conflicts page becomes a treadmill where the extractor keeps relitigating a decision you already made, which is its own kind of disrespect.
The reason a human picks at all is that the right answer depends on the world, not on the data. Two contradictory job titles might be a job change, a misread of the new email, or a stale fact that needs overwriting. No heuristic gets this right consistently.
Why we never auto-resolve
The obvious objection: can’t you use confidence scores to auto-resolve high-confidence cases? In theory yes; in practice the cost of being wrong is asymmetric.
When Wend silently overwrites a fact, two bad things happen:
- The user doesn’t know it happened. Every silent change is a debt the system owes the user. At some point they’ll notice the graph is wrong and have no way to trace why.
- Downstream drafts run on the wrong fact. The Kevin example wasn’t hypothetical. We generated a follow-up that addressed him by the wrong title, and the user’s response was “wait, how does Wend think he’s at the new org?” That’s a question we couldn’t answer without digging through logs.
When Wend flags a conflict instead of resolving it, only one bad thing happens: the user has to click. The click is annoying. The wrong draft is much worse. So we pay the annoyance and skip the disaster.
How we detect conflicts in the first place
The original rule was blunt: any new value on a detail that already had one raised a conflict. Same key, different string after normalization, into the queue.
That rule was wrong, and the queue told us so. It filled with pairs that were not contradictions at all. “Volunteer at SNIPSA” and “Volunteer at Special Olympics” are both true. So is a second email address, a second language, a third concurrent role. We were asking the user to arbitrate between two facts when the honest answer was “yes, and.” One founder’s queue hit four figures, most of it this.
So in July 2026 the model changed. Every detail field holds multiple values. Adding a fact never conflicts with an existing one; each distinct value gets its own row, deduped case- and whitespace-insensitively, and the first one is primary. Replacing a value became an explicit act instead of a side effect: the agent has to call proposeEditNode, which renders a before-and-after preview for the user to approve.
One narrow exception keeps geography sane. Location fields collapse by specificity, so a graph never carries both “United States” and “Fremont, California, United States” for the same person. The vaguer value loses, no conflict raised, because that is a precision difference rather than a disagreement.
What still raises a conflict, today:
- Web enrichment disagreeing with the graph. A public source says a person’s title is X, the graph says Y, and both cite a source. That is a genuine contradiction between two claims about the same slot.
- The agent proposing a replacement. When extraction concludes a fact is not additional but corrective, the proposal is an edit, and an edit against a user-confirmed value is a conflict rather than a commit.
- Ingest, as we wire it in. Email and calendar facts route through the same helper. Every producer that wants to disagree with the graph goes through one function, so there is exactly one place where a contradiction can be born.
// The only way to create a conflict: one library-callable helper.
export async function flagDetailConflict(supabase, userId, input) {
// Re-running enrichment must not pile up duplicate rows for the
// same target, so an existing pending conflict short-circuits.
const { data: existing } = await supabase
.from("conflicts")
.select("id")
.eq("user_id", userId)
.eq("target_kind", input.target_kind)
.eq("target_id", input.target_id)
.eq("status", "pending")
.maybeSingle();
if (existing) return { ok: true, conflict_id: existing.id };
const { data, error } = await supabase
.from("conflicts")
.insert({
user_id: userId,
target_kind: input.target_kind,
target_id: input.target_id,
existing_value: input.existing_value,
existing_source_id: input.existing_source_id,
incoming_value: input.incoming_value,
incoming_source_id: input.incoming_source_id,
status: "pending",
})
.select("id")
.single();
if (error) return { ok: false, error: error.message };
return { ok: true, conflict_id: data.id };
}One pattern flag: detection is value-based, not confidence-based. If the extractor is wildly confident the new fact is right, we still flag. The model’s confidence isn’t evidence; it’s self-report. The user is the ground truth.
The lesson we would generalize from the multi-value rewrite: a conflict queue is a bill you send the user. Every row in it should be a decision only a human can make. The moment it fills with rows the system could have reasoned through, people stop reading the queue, and a governance mechanism nobody reads is worse than none, because now you can claim you asked.
What this design costs
Three costs worth noting, because we’ve had this conversation a few times:
- Inbox burden. Under the old any-difference rule, active users accumulated a few conflicts a week and a pile after a big ingest pass. The multi-value change cut that sharply, since most of what used to land here was never a contradiction. What remains still has to be quick to triage, which is why the card UI has three buttons and not a form.
- Draft latency. If a draft references a fact that’s currently in conflict, we use the existing value (the user-confirmed one) and surface a small note that says “there’s a pending conflict on this contact, you may want to review before sending.”
- UX surface. Conflicts is a route and a badge in the side nav. We considered hiding it behind “advanced” and decided against. Conflict review is the privacy posture itself, and the privacy posture belongs at the top level.
Why this got more important, not less
When this post was first written, the only writer was our own extraction pipeline. Wend now runs an MCP server, so Claude, ChatGPT, Gemini, and Cursor can read a user’s graph and propose to it. Several models, from several companies, writing into one store of who somebody knows.
Multiply the writers and every argument above gets stronger. “Trust the latest fact” becomes “trust whichever agent ran most recently,” which is not a data model, it is a race condition with feelings attached. So the boundary is enforced in the tool surface rather than in a prompt: the agent capability list contains propose operations and no confirm operation. There is nothing an agent can call to approve its own write. A model that hallucinates a job change can put that claim in front of you; it cannot put it in your memory.
Verify this in the source. The conflicts table quoted above, the provenance it depends on, and the commit path that honors it are open source under AGPLv3 in wend-core. Specifically: schema/20260504_004_graph_instance_layer.sql for the conflicts DDL and the sources table it references, schema/20260504_005_graph_rls.sql for the row-level security policies (note the missing DELETE policy), src/core/apply.ts for the multi-value and location-collapse logic described here, and src/core/tool-definitions.ts for the agent tool surface with no confirm capability in it. The resolution server actions and the conflicts UI are part of the hosted product and not in the repo.
Closing
Most products that touch knowledge graphs lean on confidence scoring to dodge this design. Confidence scoring is real and useful, but it’s a way of ranking which conflicts to surface first, not a license to skip user resolution. Anyone who builds against probabilistic extractors will eventually hit the moment where their data is “90% confident” and wrong, and the user has no recourse. The conflicts page is the recourse.
For us this was the line between Wend feeling like a tool the user controls and Wend feeling like an AI that rewrites their memory. We’d rather take the click.
Adjacent posts: we open-sourced the engine · auditing every AI inference · why our knowledge graph lives in Postgres