save
Save with exact-text dedup. Parallel chunk extraction mints the same fact as two propositions with identical (contextId, text) but different ids; a bare MERGE-by-id persists both, leaving duplicate rows. The stripe lock is held across the transaction COMMIT — the find-then-insert runs inside txTemplate, which commits before the lock is released — so a concurrent sibling on the same stripe cannot read pre-commit and slip a duplicate past the existence check.
Propositions with blank text have nothing to dedup on and are persisted directly.
An update to an id already stored always writes to that id — it never redirects to a same-text sibling. If a foreign sibling turns out to be ACTIVE too (only reachable without the (contextId, text) constraint), that's a live duplicate this method won't silently create by redirecting, but won't collapse either — it logs a WARN naming both ids for the dedup sweep.
Two transaction shapes, because recovery from a uniqueness race needs a transaction the race did not already poison:
A caller's transaction is active: this joins it and stays inside it. A uniqueness violation marks that transaction rollback-only, and no write of ours could commit in it afterwards, so the violation propagates to the caller untouched — the same behaviour as before evidence union existed. Recovery is the caller's to retry. Opening a nested transaction to sneak a write out would break the atomicity the caller asked for.
No caller transaction:
SUPPORTSkeeps the proxy from opening one, so txTemplate owns the attempted insert, and after that transaction rolls back the recovery runs in a fresh one and can commit.