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:
| Tab | Contents |
|---|---|
| All | Every timeline event for the person's account. |
| Email engagement events. | |
| Signals | Behavioral / tracking signals. |
| Notes | Notes logged against the record. |
| Meetings | Calendar meetings. |
| Comments | Collaborative comments (with live presence). |
| Consent | The contact's per-channel consent state. |
| Buyer Lens | Buyer-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.
Consent state
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.
| Badge | consent_state | Meaning |
|---|---|---|
| Consented | granted | The latest recorded event granted consent, and it has not expired. |
| Withdrawn | withdrawn | The latest recorded event withdrew consent (an opt-out, an unsubscribe, an erasure). |
| Expired | expired | Consent was granted and has lapsed. It needs refreshing, which is different from a refusal. |
| Pending | pending | A double opt-in was sent and has not been confirmed. |
| No record | unknown | Nothing is recorded for this person. This is not permission, and marketing sends are blocked. |
| Unavailable | unavailable | The 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.