PPact
CRM

Contact detail

The per-person record view: identity, account context, timeline, consent, buyer-lens, tags, and AI panels — backed by the people projection.

Contact detail

The contact detail view at /contacts/{id} is the per-person record: who they are, which account they belong to, everything that has happened with them, and their consent posture. It is served by GET /v1/people/{canonical_id} (api/routes/people.py).

Two contact models — this page is the people projection

Pact has two parallel "contact" data models. This detail view reads the people projection: each row in the companies table carries up to nine named executives in denormalized columns (ceo_name, ceo_corporate_email, ceo_title, cfo_*, cto_*, …), and the people route explodes those wide rows into per-person records. The separate contacts table (real per-person rows used by sequence enrollment and buyer-lens) is a different surface. Know which one you are looking at before "fixing missing contacts."

How a person is addressed

The canonical_id in the URL is opaque but encodes c{company_id}-{role} — for example c42-ceo. The route parses it, loads the company row, and returns the exploded person plus a slice of account context (name, website, industry, HQ) so the page can render company stats without a second round-trip. An id that doesn't resolve to a real named executive returns 404.

What the record shows

The header renders the person's name, normalized title (role defaults fill in when a title is blank), and account. Contact actions — call, compose email, add note, schedule meeting, enrich — sit alongside. Below, a tabbed timeline aggregates activity:

TabContents
AllEvery timeline event for the person's account.
EmailEmail engagement events.
SignalsBehavioral / tracking signals.
NotesNotes logged against the record.
MeetingsCalendar meetings.
CommentsCollaborative comments (with live presence).
ConsentThe contact's per-channel consent state.
Buyer LensBuyer-intelligence view for the person.

Side panels layer on relationship strength, sentiment trend, engagement, custom fields, tags, territory visibility, and AI panels (action suggestions, "what's happening"). Comments carry real-time presence via a collaboration provider.

Identifiers and email

Each exploded person exposes a primary_email, primary_linkedin, and primary_phone drawn from the company's {role}_corporate_email / {role}_linkedin / {role}_corporate_phone columns, packaged as typed identifiers with confidence and source.

Every person carries a consent_state, read from the consent ledger (consent_records / consent_events) through the person's email address and phone number (core/consent_state.py). A list page reads consent for all its people in one query, never one per row.

The badge on a list row answers one question: may this person be sent email marketing? It picks the record the send gate would pick, a channel-wide record first and then the email record, so the badge and the gate cannot disagree. Its tooltip says which question it answers, when the answer was recorded and where from. For example: Email · marketing, recorded Sep 12, 2026 via an import.

Badgeconsent_stateMeaning
ConsentedgrantedThe latest recorded event granted consent, and it has not expired.
WithdrawnwithdrawnThe latest recorded event withdrew consent (an opt-out, an unsubscribe, an erasure).
ExpiredexpiredConsent was granted and has lapsed. It needs refreshing, which is different from a refusal.
PendingpendingA double opt-in was sent and has not been confirmed.
No recordunknownNothing is recorded for this person. This is not permission, and marketing sends are blocked.
UnavailableunavailableThe ledger could not be read. The badge says so instead of reading as "No record", because a failed read and an empty result are different answers.

The payload's consent block gives the detail behind the badge: channel and purpose (the cell answered), basis (ledger, no_record, no_email or read_failed), as_of, source and event_id. On the detail route, consent.cells lists every recorded channel and purpose for the person (email, SMS, calls, tracking; marketing, transactional and so on), each with its own state, lawful basis and expiry. Suppression (bounces and complaints) is a separate list that the send gate also checks. It is not consent and does not change this badge.

Roadmap: a real people table

The people route is explicitly a read-side projection on top of the existing schema. The docstring notes that when a proper people / identity_records table lands, the routes flip to query it without changing the contract. Until then, "contacts" here are a computed view of company executive columns.

Editing

POST /v1/people creates a person and the record supports inline edits, note-adding, and enrichment. Because the underlying store is the wide companies row, edits write back to that role's columns.