e9-person-id
Skill1 starsChanged 48 days agoApache-2.0View on GitHub
Explains engine9 person identity: how a quality person_id is chosen from many inputs (email, phone, remote_person_id, delegate), the lookup tables (person_id_* compact vs deprecated person_identifier), attribute tables (person, person_email, person_phone, person_remote), assignPersonIds / loadPeople / processPeople, and first-wins dedupe. Covers the old person_metadata / person_id_int / timeline_v3 / transaction_metadata model that did not support multiple emails or phones per person. Use when working with person_id, person_identifier, person_id_email_hash_v1, person_id_phone_hash_v1, person_id_remote_person_id, email_hash_v1, phone_hash_v1, remote_person_id, loadPeople, assignPersonIds, identity, dedupe, or duplicate people — not source_code_id or other non-person ids.
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 21
Use this skill to understand identity stores, trace the inbound people pipeline, or diagnose deduplication and duplicate-person behavior.
- Very specific: line 70
Do not assume person_identifier is populated.
- Very specific: line 150
Within-batch person_id: if any row already has person_id (caller-supplied), copy it onto other rows that share an identifier.
- Very specific: line 200
Prefer attribute tables (plaintext) over compact BLOBs.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
engine9 person identity (person_id)
The engine9 person identity framework selects a durable person_id from
inbound email, phone, remote, delegate, hash, and existing-person identifiers.
Use this skill to understand identity stores, trace the inbound people pipeline,
or diagnose deduplication and duplicate-person behavior. Downstream tables such
as timeline, transaction, and segments consume this identity rather than
creating their own.
Quick reference
| Need | Start here |
| Understand canonical identity and match keys | Concepts |
| Inspect lookup and attribute tables | File format |
| Follow assignment and upsert behavior | Workflow |
| Avoid legacy/current identity mistakes | Rules |
| Diagnose missing or duplicate identity | Troubleshooting |
Concepts
person.id is person_id: a numeric autoincrement on the person table. Every other person-related table stores that integer as person_id.
Names (given_name, family_name) and addresses are attributes, not match keys. Two people can share a name. Identity matching uses only the identifier types below.
Rule: One person may have many emails and many phones. Do not apply the legacy one-email-equals-one-person assumption to current identity.
Rule: This skill is exclusively about person_id; do not apply these rules to
source_code_id, message IDs, timeline entry IDs, or other identifiers.
File format
Two layers: a lookup from match key → person_id, and attribute rows hanging off that person_id.
Identity lookup (match key → person_id)
This is the core lookup. The current optimization is one compact table per identifier type:
| Table | id_type | Role |
person_id_email_hash_v1 | email_hash_v1 | Email hash → person_id |
person_id_phone_hash_v1 | phone_hash_v1 | Phone hash → person_id |
person_id_remote_person_id | remote_person_id | Plugin-scoped remote id → person_id |
person_id_delegate | delegate | Delegate unid → person_id |
person_id_<id_type> | any safe id_type | Same shape for additional types |
Compact schema: value (16-byte hash, primary key) + person_id. No plaintext, no source_input_id. First insert wins (INSERT OR IGNORE / INSERT IGNORE). Tables are created on demand when that id_type is first written.
value is not email_hash_v1 itself. Extract already hashed the email/phone to a hex id_value; the compact store hashes that string again: first 16 bytes of SHA-256(id_value UTF-8). id_type is the table name, not mixed into the hash. Rare collisions are accepted. For remote_person_id the id_value is the plugin-prefixed string, then the same 16-byte truncate.
person_identifier is fundamentally similar but lower performing, so it is being deprecated, but can still be used. Same semantics (id_type + id_value string → person_id), plus source_input_id. MySQL accounts still default to it until migrated. SQLite / D1 default to compact person_id_* and leave person_identifier empty.
Do not assume person_identifier is populated. Check identifier_store_kind (core plugin setting) or which tables have rows. See Identifier stores.
Person attributes (many per person)
Written after person_id is assigned. Unique keys allow multiple emails/phones/remotes on one person.
| Table | Unique-ish key | Role |
person | id | Canonical row: given_name, family_name, created_at |
person_email | (email, person_id) | Emails, subscription/confirmation, email_hash_v1 |
person_phone | (phone, person_id) | Phones, SMS/call status, phone_hash_v1 |
person_remote | (source_input_id, remote_person_id, person_id) | Per-plugin CRM / vendor person id — when written / how exports filter: e9-person-remote |
person_address | (not an identity key) | Postal address; upserted in the same pipeline, never used to match |
person_hash_email / person_hash_phone (optional @engine9/schemas/person_hash) store hashes without plaintext. They are not in the default inbound chain.
Downstream consumers
timeline, transaction, person_segment, and similar tables store person_id after the people pipeline has run. They are not identity sources.
Identifier types
Inbound rows do not match on raw email/phone strings in the lookup tables. Extract transforms push { type, value, path } onto row.identifiers. assignPersonIds matches those.
id_type | id_value | Extracted from | Notes |
email_hash_v1 | SHA-256 hex of trimmed lowercase email | email, or inbound email_hash_v1 | Skip if email shorter than 5 chars. Refuse the SHA-256 of ''. |
phone_hash_v1 | SHA-256 hex of normalized phone | phone, or cell / mobile / mobile_phone, or inbound phone_hash_v1 | Digits only; US 10-digit → +1…; 11-digit starting 1 → +1…; already-+ kept. Min 8 digits. |
remote_person_id | {pluginId}.{remote_person_id} lowercased | remote_person_id | If the value already starts with a UUID + ., it is used as-is (lowercased). Requires pluginId. |
delegate | trimmed lowercase unid | delegate_id | Core PersonWorker.extractDelegateIdentifiers; not a schema plugin. |
appendPersonId lowercases and strips accents on every identifier value before lookup (NFD + combining marks). Matching is case- and accent-insensitive.
Rule: Never store the blank SHA-256
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Extract skips it, and assignPersonIds throws if it appears.
Code: @engine9/schemas/person_email|person_phone|person_remote/transforms/inbound/extract_identifiers.js, @engine9/core/lib/id/index.js.
Workflow
Inbound pipeline
Shared by server PersonWorker.loadPeople (streams/files) and core PersonWorker.processPeople (in-memory). Woven by buildInboundTransforms in @engine9/core/lib/peoplePipeline/getInboundTransforms.js from the plugins installed in the account — core has no list of person plugins. Each people schema plugin declares metadata.inbound = { slot: [transformKey] }; the snapshot is stored on plugin.transforms at install.
Slots, in order (assign is core-only):
beforeAll extra
normalize person:normalizeFieldNames # lowercase keys, strip punctuation
id person_email / person_phone / person_remote :id # identifiers[]; person_hash:id when installed
id core person.extractDelegateIdentifiers # core client only
assign core appendInputId → appendPersonId → appendEntryTypeId → source code steps
upsert person / person_address / person_email / person_phone / person_remote :upsert
(+ person_custom per table_prefix, person_hash when installed)
afterAll extraWoven steps in a slot run in path order, then any extraTransforms for that slot. omitTransforms drops a path for one job. A limited-pii account has no person_email / person_phone / person_address rows, so those steps do not exist there.
See the chain for an account: personWorker.getInboundTransforms({ pluginId, describe: true }) prints one slot source path line per step (also logged at debug by loadPeople / processPeople).
doNotUpsert / do_not_upsert: run extracts + lookup only. Existing people resolve; unknown rows stay without person_id; no inserts.
Identity assignment is blocking (lookups and inserts must serialize across threads). InputWorker.id calls loadPeople so Timeline ID files already have person_id before they hit timeline.
Inspect store mode:
SELECT value FROM setting
WHERE plugin_id = '00000000-0000-4000-a000-000000000001'
AND name = 'identifier_store_kind';
-- compact | legacy | missing (then SQLite/D1 → compact, MySQL → person_identifier)How assignPersonIds works
@engine9/core/lib/id/index.js. One batch, one store.
- Refuse blank SHA-256 identifier values.
- Within-batch person_id: if any row already has
person_id(caller-supplied), copy it onto other rows that share an identifier. - Within-batch temp_id: rows that share any identifier get the same
temp_id, so they become one person even if the store has never seen them. - Store lookup of every
{ id_type, id_value }. - Apply hits without overwriting:
remote_person_idmatches first, then other types. Ifperson_idis already set, a later hit is ignored. First assignment wins for that row. - Insert
personrows for unmatchedtemp_ids (unlessdoNotUpsert). Optionalcreated_atfromdate_created/remote_date_created/frakture_date_created/created_at. - Insert identifier mappings that the store did not already have. Existing keys are skipped (first-wins, never remapped).
Consequences:
- One inbound row with email + phone + remote id attaches all three keys to the same
person_id. A later row matching any of those keys resolves to that person (unless a higher-priority already-setperson_id/remote_person_idwon first). - Rule: Identifier mappings are immutable and first-wins. A second person
created via a new
remote_person_iddoes not steal an email or phone key that already points at someone else.person_emailuniqueness is(email, person_id), so plaintext email can appear on more than one person; the lookup key still points at the firstperson_id. - Caller-supplied
person_idis respected and seeds the store for new keys.
After assignment, upserts write attributes. Email upsert looks up existing person_email by address; same person+email updates (keeps original source_input_id); new pair inserts. Default new subscription is Subscribed; explicit unsubscribe (or EMAIL_UNSUBSCRIBE) updates all person_email rows with that address. Phone upsert is analogous (min 8 digits; default SMS Not Subscribed).
Select an identifier store
createDefaultIdentifierStore (@engine9/core/lib/id/storeKind.js):
- Durable Object binding (
worker.personIds/PERSON_IDS) always wins (Cloudflare). - Else setting
identifier_store_kindon the core plugin (00000000-0000-4000-a000-000000000001):compactorlegacy(person_identifieraccepted aslegacy). - Else dialect default: SQLite / D1 → compact; MySQL →
person_identifier.
findPersonIdentifierHashCollisions scans the legacy table for 16-byte hash collisions before migrate. PersonWorker.populatePersonRemoteFromIdentifiers backfills person_remote from person_identifier rows with id_type = 'remote_person_id' (strips the {pluginId}. prefix into person_remote.remote_person_id).
Migrate: PersonWorker.migratePersonIdentifiersToCompact pages person_identifier, writes compact tables (first-wins), renames person_identifier to person_identifier_legacy_<timestamp>, sets identifier_store_kind=compact. dry_run counts only.
Rules
Old identity model (person_metadata, person_id_int, timeline_v3, transaction_metadata)
person_metadata, person_id_int, transaction_metadata, and timeline_v3 all use an OLD identity model that did not account for multiple emails or phones per person.
In that model the person key was a single integer person_id_int, and person_metadata.person_id was often the email string (legacy loaders filter meta.person_id like '%@%'). person_metadata generates person_id_int from that person_id string. person_id_int is not current person.id — do not join them. One email ≈ one person. Phones were not first-class match keys. timeline_v3 joined person_metadata using (person_id_int). Inspect that log through timeline_v3_summary, whose type column is entry_type_label (not current entry_type). transaction_metadata sat in the same integer-person space.
PersonWorker.unwindPersonMetadata is the bridge: copy person_metadata.person_id_int into person.id, then delete those legacy metadata rows. LocalDatabasePersonWorker.importFromTimelineV3 still reads timeline_v3 joined to person_metadata and feeds emails into the current people pipeline.
Rule: When you see person_id_int or person_metadata.person_id as an email,
you are in the old model. Do not join it to current person_email as if it were
person.id; resolve through the current lookup (person_id_* or
person_identifier) and attribute tables.
Legacy source_code_summary.origin_* rollups were computed in that old identity world. Current models write {prefix}_* tables with bigint person_id — e9-model.
Troubleshooting
Prefer attribute tables (plaintext) over compact BLOBs. DESCRIBE first; filter to one example; LIMIT.
-- Email → person_id (current model)
SELECT person_id, email, subscription_status, email_hash_v1
FROM person_email
WHERE LOWER(email) = 'alice@example.com'
LIMIT 20;
-- Phone → person_id (phones stored normalized, often +1…)
SELECT person_id, phone, phone_type, sms_status, phone_hash_v1
FROM person_phone
WHERE phone IN ('+12025550143', '2025550143')
LIMIT 20;
-- Remote CRM id → person_id (value is usually NOT plugin-prefixed in this table)
SELECT pr.person_id, pr.remote_person_id, pr.source_input_id, i.plugin_id
FROM person_remote pr
JOIN input i ON i.id = pr.source_input_id
WHERE LOWER(pr.remote_person_id) = '123425385'
LIMIT 20;
-- Canonical person
SELECT id, given_name, family_name, created_at FROM person WHERE id = ?;On legacy (person_identifier) accounts:
SELECT id_type, id_value, person_id, source_input_id
FROM person_identifier
WHERE id_type IN ('email_hash_v1', 'phone_hash_v1', 'remote_person_id', 'delegate')
AND person_id = ?
LIMIT 50;On compact accounts, person_identifier may be empty or renamed. Lookup tables are person_id_email_hash_v1, person_id_phone_hash_v1, person_id_remote_person_id. The value column is a 16-byte hash of the identifier id_value, not the email string — use person_email / person_phone / person_remote for human debugging.
Duplicate-people symptoms: same email on two person_ids (lookup first-wins; attribute table allows both), missing remote_person_id extract (pluginId required), short phone dropped (< 8 digits), blank hash refused, or still reading person_metadata / timeline_v3 as if they were current.
Related documentation
| Topic | Documentation |
| Worked email, phone, remote ID, and dedupe examples | Examples |
Person activity log that consumes person_id | e9-timeline |
| Plugin-scoped remote attributes and exports | e9-person-remote |
| Source-code identifiers, which are not person identity | e9-source-code |
| Current model output and legacy origin context | e9-model |
In this repository24 other files it gives its agents.
- .cursor/rules/no-client-names.mdcCursor rule
- .cursor/rules/skill-writing-standard.mdcCursor rule
- create-engine9-pluginSkill
- e9-api-keySkill
- e9-cliSkill
- e9-custom-fieldsSkill
- e9-delegate-domainSkill
- e9-dev-tasksSkill
- e9-eqlSkill
- e9-exportSkill
- e9-funnelSkill
- e9-global-messageSkill
- e9-inputSkill
- e9-inventorySkill
- e9-mcpSkill
- e9-modelSkill
- e9-person-remoteSkill
- e9-pii-freeSkill
- e9-pluginSkill
- e9-reportsSkill
- e9-source-codeSkill
- e9-tasks-apiSkill
- e9-timelineSkill
- e9-upgradeSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

