Add durable person profiles over identity clusters - #531
Conversation
Participant clusters have no stable identity: the canonical ID is derived from component membership and re-points on link/unlink, and importer-driven merges delete participant rows. Curated data needs an identifier that survives that churn. - persons table (stable ID, reserved unique vCard UID, display-name override, revision counter for If-Match) plus person_participants bindings, dual SQLite/PostgreSQL schema - linking two clusters bound to different persons conflicts instead of silently merging curated data; importer merges repoint bindings in the same transaction; Beeper import skips conflicting merges with a warning - promote/get/list/update API under /api/v1/persons with generated clients, and a person CLI command group
roborev: Combined Review (
|
- the promote CLI treated the API's 201 Created as an error; add an expected-status variant to the daemon client and cover the 201 path - participant-binding inserts, merge repoints, and removals now bump the affected person's revision and updated_at in the same transaction, so stale If-Match values are rejected after binding changes
|
Both addressed in 9d910c5: the promote CLI now accepts the 201 response (new expected-status variant on the daemon client, with coverage), and any participant-binding insert/repoint/removal bumps the affected person's revision and updated_at in the same transaction so stale If-Match values are rejected. |
roborev: Combined Review (
|
…copy - PATCH display_name is required and nullable so generated clients can express clearing the override; regenerate clients - person reads hydrate profile and bindings from one transaction, and create/update commit only after hydration succeeds - CopySubset preserves persons and bindings referenced by retained participants
|
All three addressed in 3c1a3f3: display_name is now required and nullable on the PATCH body (clients regenerated, null clears the override); person reads hydrate the profile and its bindings from a single transaction, with create/update committing only after hydration succeeds; and CopySubset now carries persons and bindings for retained participants, with a round-trip test. |
roborev: Combined Review (
|
person set-display-name gains a --clear flag that PATCHes a null display_name, mutually exclusive with providing a name
roborev: Combined Review (
|
|
looking |
|
I'm going to do some work on this |
Close the operational gaps in the durable person layer before UI work
builds on it:
- DeletePerson (store, DELETE /api/v1/persons/{id} with required
If-Match, msgvault person delete): binding conflicts are no longer
dead ends — delete one profile, link, re-promote. Deletion is
permanent and retires the vCard UID; persons.id now uses
AUTOINCREMENT/IDENTITY so a deleted person's ID is never recycled.
- LinkParticipants auto-binds: when exactly one person covers the two
clusters being joined, the combined cluster's unbound members are
bound to it (revision bump), so person membership never drifts
behind cluster membership. MergeParticipants fills the same way.
- PersonForParticipants lookup, surfaced as a profile block (id,
display-name override, revision) on GET /api/v1/people/{id}, so
clients can resolve participant -> person without listing persons.
- Promote returns 200 for an idempotent re-promotion, 201 only on
creation; the CLI accepts both.
- Contracts documented in both schemas: bindings are the source of
truth for membership; UIDs are never reused and the survivor keeps
its UID on a future person-merge; deletes have no tombstones.
- Hygiene: persons added to the PG exclusive-lock table set (merge
bumps person revisions on the import path), 409 message casing
unified, If-Match strictness and unpaginated list documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed fab8e51, which closes the operational gaps found in review so the person layer is safe to build UI on:
One deliberate semantic to be aware of: auto-bind plus "unlink never unbinds" means a briefly linked participant stays a person member after the link is retracted; the remedy today is delete + re-promote. That trade-off, along with web UI exposure, read-side integration (display-name override in analytics/search, messages-for-person), person-merge, and Beeper skip counts in import stats, is tracked in #532. Verified: build, vet, golangci-lint clean; full test suite passes with the |
roborev: Combined Review (
|
…rsion Address review findings on fab8e51: - pkg/client: add a hand-written CreatePerson wrapper (mirroring AddAccount/StageDeletion) that accepts both documented success statuses; the generated convenience method treats the 200 returned by an idempotent re-promotion as an error. - PostgreSQL: BeginExclusive now takes the identity-mutation row lock before LOCK TABLE. Identity mutations write person tables before participants/messages — the opposite of the LOCK TABLE order — so a serialized source removal racing an importer-driven merge could deadlock (verified: without the row lock the new regression test fails with SQLSTATE 40P01). Sharing the row lock serializes the two paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both findings confirmed and fixed in 8c8055b:
|
roborev: Combined Review (
|
CopySubset selected participants only by message involvement and never copied participant_links, so a linked cluster whose members straddle the subset arrived as disconnected identities — while the person bindings this PR copies made the inconsistency visible (bindings silently filtered to message-bearing members). Pull each copied participant's full link component in first (cluster-mates via recursive CTE over src.participant_links), then copy every edge inside those components, so destination clusters and person bindings match the source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Confirmed and fixed in 973b040. The fix pulls each copied participant's full link component in first — cluster-mates resolved via a recursive CTE over
|
roborev: Combined Review (
|
…order Address review findings on 973b040: - create-subset: the participant boundary is message-derived by default again — no participant row, identifier, link edge, or person binding is copied for identities without selected messages (subsets are documented for sharing). A new --include-identity flag opts in to the full identity closure, expanded through BOTH participant_links and shared person bindings, so included clusters and profiles arrive complete even when a person spans clusters disconnected by unlink. - Persons are copied only when every binding falls inside the subset; a partial binding set under the original revision would misrepresent the curated profile. With --include-identity the closure makes every touched profile complete, so nothing is skipped. - MigrateLegacyIdentityConfig now takes the identity-mutation row lock at the start of its transaction, before writing account_identities, matching every other identity-revision writer and the BeginExclusive ordering contract (verified: without it the new PG regression test can deadlock with SQLSTATE 40P01). The blocked-cancellation PG test now pins the up-front lock statement instead of the late bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three confirmed and fixed in c21c252. The two subset findings are resolved with one consistency policy rather than two patches:
Full SQLite and PostgreSQL suites plus lint are green. |
roborev: Combined Review (
|
* origin/main: Fix deduplicate plan timeout: index + batch RFC822 duplicate-group lookup (kenn-io#512) Index relationship analytics for fast, memory-bounded queries (kenn-io#528)
The build-cache test fixtures define their own minimal schema including participant_links; LinkParticipants now maintains person bindings, so tests that link participants on those fixtures failed with 'no such table: person_participants' once merged with main's cache-staleness tests. Mirror the persons and person_participants definitions into both fixture schemas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CI failures on c21c252 had a single root cause: the four failing jobs run the PR merged with latest main, and main's new cache-staleness tests (#528) link participants on the hand-rolled fixture schemas in |
roborev: Combined Review (
|
Taking the identity-mutation row lock unconditionally at the start of the migration transaction turned every store open into a write transaction: an UPDATE row version plus WAL commit on one archive_metadata row per open, and a serialization point between the daemon and CLI subprocesses sharing a database. Locally that cost ~20% on the PG cmd test package; on loaded CI runners it blew the package past the 10-minute timeout (main: 72s). Check the applied_migrations marker first without locks, and only when the migration will actually write take the identity-mutation row lock — still before any account_identities write, preserving the BeginExclusive ordering contract — then re-check the marker under the lock for a concurrent open that applied it while we waited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
|
CI is green on 0cf7f01 (all jobs, including the four that failed and test-pgvector). Two fixes were needed:
|
SetParticipantIdentifier bumps the identity revision when the written identifier is owner evidence, but wrote participant_identifiers first — inverting BeginExclusive's row-then-LOCK-TABLE order, so a concurrent serialized source removal could deadlock (verified: SQLSTATE 40P01 without the fix under the new PG regression test). The no-op fast path (importer re-runs) stays read-only; the lock is taken only on the write path, with the no-op re-checked under it. Audited every other identity-revision writer: link, unlink, merge, add/remove account identity, the legacy identity migration, and person mutations all lock first. The phone-unique migration's mergeParticipant acquired it mid-transaction via rewriteLinksForMerge; it now locks at entry for the same ordering. The contract is documented on lockIdentityMutationTx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Confirmed and fixed in 52bcec1. Also completed the requested audit of every identity-revision writer: link, unlink, merge, add/remove account identity, the legacy identity migration, and person mutations all lock first. One more late acquirer surfaced — the one-shot phone-unique migration's Full SQLite and PostgreSQL suites plus lint green; the PG |
roborev: Combined Review (
|
Adds a curated person layer over the identity-cluster substrate from #492.
A cluster's canonical ID is derived from component membership, so it re-points whenever links change elsewhere in the component, and importer-driven merges delete participant rows outright — there is nothing stable to hang curated data on. This introduces that stable identity:
personstable — stable surrogate ID (AUTOINCREMENT/IDENTITY, so a deleted person's ID is never recycled), reserved unique vCard UID, editable display-name override, and arevisioncounter drivingIf-Matchoptimistic concurrency — plusperson_participantsbindings, in both SQLite and PostgreSQL schemas. The design contracts (bindings are the source of truth for membership; UIDs are never reused, the survivor keeps its UID on a future person-merge, deletes retire UIDs with no tombstones) are documented in the schema.POST /api/v1/personspromotes a participant's cluster into a person (201 on creation, 200 on idempotent re-promotion);GET/list/PATCH(display name, with stale-revision conflicts) andDELETE(permanent,If-Match-guarded) round it out.GET /api/v1/people/{id}now carries aprofileblock (person ID, display-name override, revision) when the cluster is promoted, so clients can resolve participant → person without listing persons. OpenAPI document and generated clients updated.msgvault person promote|get|list|set-display-name|delete.Usage: promote any participant of a cluster (
msgvault person promote <participant-id>), then address that person by its stable ID regardless of later identity linking, unlinking, or importer merges. Profiles are only created by explicit promotion — no automatic promotion of observed participants. A binding conflict is resolved by deleting one of the profiles, linking, and re-promoting.Web UI exposure and read-side integration are tracked in #532.