Canonical concept file for Affinity, a standalone singleplayer social CRM core
for:
- personal contact management
- solo-business CRM
- hybrid life CRM
- later Ghostpaw integration
This file is the authoritative specification for the product grammar, human direct-code package surface, data model, mechanics, and acceptance bar.
Affinity is not a toy social layer. It is a real standalone CRM whose surface
should feel RPG-native while its internals remain explicit, calculable, and
SQL-grounded.
The design target is:
- emotionally legible like a relationship system in an RPG
- operationally useful like a serious solo CRM
- mechanically explicit in code and SQL
- coherent enough to stand alone without Ghostpaw context
If it only feels like a CRM, it loses its relational magic. If it only feels like a game, it fails as a serious tool. It must do both.
The visible system name is Affinity.
| Technical term | Visible name | Meaning |
|---|---|---|
| system | Affinity |
overall subsystem or product |
contacts |
Contact |
any tracked entity |
links |
Link |
umbrella noun for a relationship row |
relational links |
Social Link |
owner-facing or observed relationship track |
structural links |
Tie |
family, work, membership, hierarchy, affiliation edge |
links.rank |
Rank |
visible progression level |
links.affinity |
Affinity |
hidden progress toward next rank |
links.trust |
Trust |
reliability / confidence axis |
links.bond |
Bond |
current narrative reading of the link |
events |
Journal entry |
ordinary history record |
events.moment_kind |
Moment |
relationship-defining beat |
| derived world graph | Affinity Chart |
relationship map |
| derived maintenance | Radar |
drift, reminders, duplicates, resurfacing |
Natural language should sound like:
- "Add this as a contact."
- "Record that we had dinner."
- "Show my social link with Alice."
- "That moment probably raised the rank."
- "Their bond feels strained right now."
- "Check radar for drift and upcoming birthdays."
Terms to avoid as the main grammar:
Confidantas the universal base nounBondas the name of every link rowRelationshipas the primary visible nounSupportas the universal system nounPatrolas the main maintenance surface
Confidant remains a strong optional earned label for deep personal human
links, but not the universal noun for every tracked entity.
The system must rival the core of serious PRM/CRM tools and the motivating mechanics of relationship-heavy RPGs, excluding integrations.
| Category | Required coverage |
|---|---|
| entity | humans, groups, households, teams, companies, services, bots, pets, owner as first-class contact |
| identity | email, phone, websites, handles, account IDs, aliases, multi-identity, exact and fuzzy resolution |
| relationship | owner-to-contact social links, observed third-party links, structural ties, multiple links between same pair, visible rank, hidden affinity, trust, state, cadence, bond |
| history | conversations, observations, gifts, conflicts, corrections, milestones, activities, transactions, promises, agreements, recurring important dates |
| CRM | tags, custom fields, segmentation, duplicate detection, deterministic merges, reminders, keep-in-touch |
| RPG | visible progression, hidden accumulation, threshold moments, relationship states, preference-sensitive interaction, graph visibility |
The human direct-code API is the primary contract in this pass.
Hard rules:
- store entities, identities, evidence, participation, relationships, and metadata separately
- derive relationship state from evidence plus explicit overrides
- expose intention-shaped writes, not generic CRUD
- keep list reads compact and detail reads explanatory
- allow manual overrides only as explicit exceptional operations
- never require callers to directly write
rank,affinity,trust,moment_kind, drift, readiness, or derived maintenance scores
The system preserves these separations:
- entity is not relationship
- identity is not entity
- event is not participation
- structural is not relational
- evidence is not interpretation
- metadata is not meaning
- maintenance is not source data
- trust is not progress
This API exists so the caller can say what happened in socially legible terms, while the system owns the internal math and consistency.
The only public package surface in this pass is:
import { initAffinityTables, read, types, write } from "@ghostpaw/affinity";| Export | Responsibility |
|---|---|
initAffinityTables(db) |
initialize public schema and required internal support tables |
write.* |
intention-shaped mutations that may atomically touch multiple tables |
read.* |
compact and detailed derived views |
types.* |
stable TypeScript contracts for app code |
The public mental model is:
flowchart TD
initTables[initAffinityTables]
writeNs[write]
readNs[read]
typesNs[types]
writeNs -->|mutates truth| publicTables[PublicTables]
writeNs -->|updates| supportTables[SupportTables]
supportTables --> readNs
publicTables --> readNs
typesNs --> writeNs
typesNs --> readNs
Affinity should mirror the sharp package mechanics of questlog, but without
the LLM-oriented layers in v1.
| Area | Decision |
|---|---|
| runtime | Node 24.14.0, using built-in node:sqlite only |
| package type | ESM package with dual ESM/CJS output |
| runtime dependencies | zero |
| publish surface | single root export only; no subpath exports |
| built files | dist/index.js, dist/index.cjs, dist/index.d.ts, dist/index.d.cts |
| source entry | src/index.ts |
| schema entry | src/init_affinity_tables.ts |
| human API barrels | src/read.ts, src/write.ts, src/types.ts, src/errors.ts |
| build tool | tsup |
| typecheck | tsc --noEmit |
| formatting and lint | biome |
| test runner | node --experimental-strip-types --test |
| packaging rule | package.json files = [\"dist\"] |
The repository should be as small and legible as questlog:
affinity/
.github/
workflows/
ci.yml
publish.yml
docs/
README.md
HUMAN.md
entities/
src/
.gitignore
.node-version
.nvmrc
.tool-versions
biome.json
mise.toml
package-lock.json
package.json
tsconfig.json
tsup.config.ts
README.md
LICENSE
The source tree should stay close to questlog's shape: a small root with a
few package-level files, plus concept folders underneath.
src/
index.ts
index.test.ts
database.ts
init_affinity_tables.ts
init_affinity_tables.test.ts
read.ts
read.test.ts
write.ts
write.test.ts
types.ts
errors.ts
with_transaction.ts
with_transaction.test.ts
resolve_now.ts
resolve_now.test.ts
lib/
contacts/
identities/
links/
events/
attributes/
dates/
merges/
Layout rules:
- package-level root files exist only for cross-cutting public assembly, database/runtime plumbing, shared transactions, and package entrypoints
- feature logic lives in concept folders, not in type-based global bins
- every non-type module has a colocated
.test.ts - concept folders expose their own
index.tsbarrel if they have multiple internal files
Because there is no LLM layer yet, the docs layout should be simpler than
questlog:
docs/
README.md
HUMAN.md
entities/
CONTACTS.md
IDENTITIES.md
LINKS.md
EVENTS.md
ATTRIBUTES.md
Doc roles:
- root
README.md: package pitch, install, quick start, package surface docs/README.md: architecture, invariants, lifecycle, mechanics summarydocs/HUMAN.md: direct-code usage guide with worked examplesdocs/entities/*: concept manuals with exact public API listings for each area
The package should follow the same sharp contract as questlog:
type: "module"- one root
exports["."]entry only - dual
importandrequiretargets main,module, andtypespointing atdistfiles = ["dist"]sideEffects = falseengines.node = ">=24"publishConfig.access = "public"volta.node = "24.14.0"
Scripts:
builddevtestlintlint:fixformattypecheckcheckcleanprepublishOnlyrelease:patchrelease:minorrelease:major
tsconfig.json should match the same runtime assumptions:
target = ES2024module = PreservemoduleResolution = Bundlerlib = ["ES2024"]rootDir = "src"outDir = "dist"noEmit = trueallowImportingTsExtensions = true- strict mode and exact optional semantics enabled
- explicit
.tsimports preserved viaverbatimModuleSyntax
tsup.config.ts should stay minimal:
entry = ["src/index.ts"]format = ["esm", "cjs"]dts = trueclean = truetarget = "node24"splitting = falsesourcemap = falsetreeshake = true
The package should use the same two-workflow pattern as questlog.
ci.yml:
- run on pushes to
mainand pull requests actions/checkoutactions/setup-nodevia.nvmrcnpm cinpm run checknpm run testnpm run buildnpm pack --dry-run- package size guard on
dist
publish.yml:
- run on version tags
v* actions/checkoutactions/setup-nodevia.nvmrcnpm cinpm run checknpm run testnpm run build- verify
package.jsonversion matches tag npm pack --dry-runnpm publish --provenance- create GitHub release notes automatically
The first complete package deliverable set is:
- installable npm package with dual ESM/CJS outputs
- root direct-code API:
initAffinityTables,read,write,types - strict Node 24 + embedded SQLite runtime only
- no runtime dependencies
- full colocated test coverage
- compact human docs set
- CI + publish workflows
Invalid domain operations throw typed errors. Normal absence in reads does not throw.
| Error | Meaning | Examples |
|---|---|---|
AffinityNotFoundError |
referenced thing does not exist | missing contact, link, identity, event, anchor |
AffinityConflictError |
uniqueness or ownership collision | duplicate identity, second owner, conflicting reassignment |
AffinityInvariantError |
attempted state would violate model truth | progression fields on structural tie, multiple owners |
AffinityValidationError |
malformed or out-of-range input | invalid significance, invalid recurrence, unsupported sort |
AffinityMergeError |
merge request is illegal or ambiguous | merge into self, merge merged row, unresolved identity conflict |
AffinityStateError |
forbidden lifecycle or relationship transition | invalid links.state or contacts.lifecycle_state change |
Non-throw rules:
- list reads return arrays, possibly empty
- detail reads return record or
nullwhen absence is normal - throws are for invalid or impossible operations, not for empty result sets
type EntityRef =
| { kind: "contact"; id: number }
| { kind: "identity"; id: number }
| { kind: "link"; id: number }
| { kind: "event"; id: number }
| { kind: "attribute"; id: number };| Record | Purpose |
|---|---|
ContactListItem |
compact portfolio row for listings and search |
ContactProfileRecord |
full contact detail with identities, attributes, top links, rollups |
IdentityRecord |
one identity attached to a contact |
LinkListItem |
compact social or structural link row |
LinkDetailRecord |
full link detail with current mechanics and recent history |
EventRecord |
one Journal entry with participants |
MomentRecord |
one derived key beat |
RadarRecord |
one maintenance candidate row |
UpcomingDateRecord |
one upcoming anchored date |
DuplicateCandidateRecord |
one dedupe candidate pair |
CommitmentRecord |
one unresolved or resolved promise/agreement projection |
MergeHistoryRecord |
one merge lineage entry |
AffinityChartRecord |
graph projection of nodes and weighted edges |
Minimum required fields:
| Record | Required fields |
|---|---|
ContactListItem |
id, name, kind, lifecycleState, isOwner, optional primaryIdentity, optional lastMeaningfulEventAt |
ContactProfileRecord |
contact, identities, attributes, topLinks, activeDates, warnings, optional rollups |
IdentityRecord |
id, contactId, type, value, label, verified |
LinkListItem |
id, fromContactId, toContactId, kind, optional role, optional rank, optional affinity, optional trust, optional state, optional cadenceDays, optional bond |
LinkDetailRecord |
link, counterparty, recentEvents, moments, optional rollups, optional derivation |
EventRecord |
id, type, occurredAt, summary, significance, momentKind, participants |
MomentRecord |
eventId, linkId, momentKind, occurredAt, summary, impactScore |
RadarRecord |
linkId, contactId, driftPriority, recencyScore, normalizedRank, trust, recommendedReason |
UpcomingDateRecord |
anchorEventId, targetRef, recurrenceKind, occursOn, summary, significance |
DuplicateCandidateRecord |
leftContactId, rightContactId, matchReason, matchScore |
CommitmentRecord |
eventId, type, summary, participants, optional dueAt, resolutionState |
MergeHistoryRecord |
winnerContactId, loserContactId, mergedAt, optional reasonSummary, manual |
AffinityChartRecord |
nodes, edges |
All writes return a receipt family with the same top-level shape:
interface MutationReceipt<TPrimary> {
primary: TPrimary;
created: EntityRef[];
updated: EntityRef[];
archived: EntityRef[];
removed: EntityRef[];
affectedLinks: number[];
derivedEffects: DerivedLinkEffect[];
}Receipt families:
ContactMutationReceiptIdentityMutationReceiptLinkMutationReceiptEventMutationReceiptAttributeMutationReceiptMergeReceipt
derivedEffects contains link-level math already computed by the system.
Required receipt behavior:
primaryis always the operator-facing main result of the callcreated,updated,archived, andremoveduse stableEntityRefordering by mutation time inside the transactionaffectedLinksincludes every relational link whose mechanics changedderivedEffectsincludes one row per affected relational link- non-mechanical writes may return
derivedEffects = []
Each write contract below states:
- what the caller provides
- what the caller is forbidden to provide
- which tables may change
- what internal support may change
- what the receipt returns
- which typed errors may be thrown
write.createContact(db, input)
write.reviseContact(db, contactId, patch)
write.setContactLifecycle(db, contactId, lifecycleState, options?)
write.addIdentity(db, contactId, input)
write.reviseIdentity(db, identityId, patch)
write.verifyIdentity(db, identityId, verifiedAt?)
write.removeIdentity(db, identityId, removedAt?)
write.setStructuralTie(db, input)
write.removeStructuralTie(db, tieId, removedAt?)
write.seedSocialLink(db, input)
write.overrideLinkState(db, linkId, state, options?)
write.reviseBond(db, linkId, bond, options?)
write.recordInteraction(db, input)
write.recordObservation(db, input)
write.recordMilestone(db, input)
write.recordTransaction(db, input)
write.recordCommitment(db, input)
write.resolveCommitment(db, commitmentEventId, resolution, options?)
write.addDateAnchor(db, input)
write.reviseDateAnchor(db, anchorEventId, patch)
write.removeDateAnchor(db, anchorEventId, removedAt?)
write.setAttribute(db, target, name, value)
write.unsetAttribute(db, target, name)
write.replaceAttributes(db, target, entries)
write.mergeContacts(db, input)Creates a new contact row and optional intrinsic profile fields.
| Contract | Value |
|---|---|
| caller provides | name, kind, optional intrinsic profile fields |
| caller forbidden | rank, trust, affinity, link data, raw lifecycle other than default bootstrap |
| public tables | contacts |
| support tables | none required |
| returns | ContactMutationReceipt with primary as compact contact projection |
| throws | AffinityValidationError, AffinityConflictError, AffinityInvariantError |
Rules:
is_ownermay only be set during explicit owner bootstrap mode- owner bootstrap may happen exactly once
- intrinsic profile fields must remain contact-intrinsic, never relationship-specific
Revises contact-intrinsic fields only.
| Contract | Value |
|---|---|
| caller provides | patch for intrinsic contact fields |
| caller forbidden | identity changes, ties, mechanics, event history rewrites |
| public tables | contacts |
| support tables | optional contact_rollups refresh |
| returns | ContactMutationReceipt |
| throws | AffinityNotFoundError, AffinityStateError, AffinityValidationError |
Rules:
- may update
name, intrinsic profile fields, and non-derived lifecycle-adjacent metadata - may not edit identities, ties, relationship mechanics, or event history
- merged contacts are read-only and throw
AffinityStateError
Changes contacts.lifecycle_state explicitly.
| Contract | Value |
|---|---|
| caller provides | contactId, target lifecycle state, optional provenance |
| public tables | contacts, possibly links when cascade rules apply |
| support tables | rollups when visibility changes |
| returns | ContactMutationReceipt |
| throws | AffinityNotFoundError, AffinityStateError, AffinityInvariantError |
| From | To | Allowed by | Effect |
|---|---|---|---|
active |
dormant |
manual or long inactivity policy | remains queryable, excluded from some default active views |
dormant |
active |
manual or new evidence | normal visibility resumes |
active / dormant |
merged |
deterministic merge only | contact becomes read-only |
active / dormant |
lost |
manual invalidation | suppressed from default active views |
lost |
active |
manual recovery | re-enabled |
merged is terminal and normally reached only through write.mergeContacts().
Attaches one recognition or routing identity.
| Contract | Value |
|---|---|
| caller provides | type, value, optional label, optional verified |
| caller forbidden | direct normalized-key values, merge lineage writes |
| public tables | identities |
| support tables | normalized identity index |
| returns | IdentityMutationReceipt |
| throws | AffinityNotFoundError, AffinityConflictError, AffinityValidationError |
Rules:
- identity normalization and collision checks happen before insert
- one normalized identity may belong to one live contact only
- collisions throw
AffinityConflictError, not silent reassignment
Updates label, raw value, or type, re-running normalization and collision checks.
| Contract | Value |
|---|---|
| caller provides | patch for type, value, label |
| public tables | identities |
| support tables | normalized identity index |
| returns | IdentityMutationReceipt |
| throws | AffinityNotFoundError, AffinityConflictError, AffinityValidationError |
Marks verified = true. This is provenance only. It does not change trust.
| Contract | Value |
|---|---|
| caller provides | identityId, optional verification timestamp |
| public tables | identities |
| support tables | none required |
| returns | IdentityMutationReceipt |
| throws | AffinityNotFoundError |
Removes one identity from the live routing surface.
Rules:
- removing the last reachable endpoint is allowed
- reads may still show a warning in
ContactProfileRecord - merge lineage must keep historical reassignment truth even after removal
| Contract | Value |
|---|---|
| caller provides | identityId, optional removal timestamp |
| public tables | identities |
| support tables | normalized identity index |
| returns | IdentityMutationReceipt |
| throws | AffinityNotFoundError |
Creates or updates a structural links row.
| Contract | Value |
|---|---|
| caller provides | fromContactId, toContactId, kind, optional role |
| caller forbidden | rank, affinity, trust, state, cadenceDays, bond |
| public tables | links |
| support tables | none required |
| returns | LinkMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Rules:
- structural ties may coexist with relational links between the same pair
- structural ties never carry progression columns
- duplicate structural links of the same
(from, to, kind, role)should resolve to update-or-no-op, not duplicate rows
Archives or removes one structural tie from active surfaces.
| Contract | Value |
|---|---|
| caller provides | tieId, optional removal timestamp |
| public tables | links |
| support tables | none required |
| returns | LinkMutationReceipt |
| throws | AffinityNotFoundError, AffinityInvariantError |
Creates a relational link without recording a new event.
Use only for:
- imports
- manual initial portfolio seeding
- migration from another system
Never use it for ordinary social evidence that should flow through Journal entries.
| Contract | Value |
|---|---|
| caller provides | fromContactId, toContactId, kind, optional initial rank, affinity, trust, state, cadenceDays, bond |
| caller forbidden | direct momentKind, link effect rows |
| public tables | links |
| support tables | optional link_rollups seed |
| returns | LinkMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Rules:
- initial seeded mechanics are allowed only here
- seeded values must obey relational ranges and transition rules
- this does not backfill synthetic Journal entries
Explicitly changes links.state.
Rules:
- valid only for relational links
- override provenance must be recorded
- override changes state, may change maintenance visibility, but does not invent synthetic affinity or trust deltas
| Contract | Value |
|---|---|
| caller provides | linkId, target state, optional provenance |
| public tables | links |
| support tables | link_rollups |
| returns | LinkMutationReceipt |
| throws | AffinityNotFoundError, AffinityStateError, AffinityInvariantError |
Manually rewrites the narrative reading of a relational link.
Rules:
- bond is interpretation, not evidence
- revising bond does not alter trust, rank, affinity, or cadence directly
| Contract | Value |
|---|---|
| caller provides | linkId, new bond text, optional provenance |
| public tables | links |
| support tables | none required |
| returns | LinkMutationReceipt |
| throws | AffinityNotFoundError, AffinityInvariantError |
All evidence writes share one common input family.
interface SocialEventInput {
occurredAt: number;
summary: string;
significance: number; // 1..10
participants: readonly {
contactId: number;
role: "actor" | "recipient" | "subject" | "observer" | "mentioned";
directionality?: "owner_initiated" | "other_initiated" | "mutual" | "observed";
}[];
provenance?: unknown;
}Caller-forbidden fields for all evidence writes:
affinitytrustrankstatemomentKind- direct
link_event_effects
Direct social evidence between owner and one or more other contacts.
| Contract | Value |
|---|---|
| event types | conversation, activity, gift, support, conflict, correction |
| public tables | events, event_participants, possibly links |
| support tables | link_event_effects, link_rollups, contact_rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError, AffinityStateError |
Rules:
- owner + one non-owner affects owner -> contact relational link
- owner + many non-owners affects owner -> each non-owner relational link
- no automatic peer links are inferred between non-owner participants
- if owner and one non-owner co-occur in a direct event and no relational link exists, create one
Third-party or low-directness social evidence.
Rules:
- no owner + two non-owners may create one
observedlink - no owner + three or more non-owners never creates a clique
- only explicitly referenced existing links may be affected in group observation cases
- observational evidence is weaker through lower
directness
| Contract | Value |
|---|---|
| event types | observation |
| public tables | events, event_participants, possibly links |
| support tables | link_event_effects, rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Records milestone events such as anniversaries, breakpoints, promotions, engagements, or notable life changes.
Rules:
- milestone writes still flow through the same event pipeline
- milestone significance may derive
moment_kind = milestone
| Contract | Value |
|---|---|
| event types | milestone |
| public tables | events, event_participants, possibly links |
| support tables | link_event_effects, rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Records commercially or operationally meaningful interactions.
Rules:
- valid for personal, professional, and service links
- may change trust and cadence, but usually weakly affects affinity
| Contract | Value |
|---|---|
| event types | transaction |
| public tables | events, event_participants, possibly links |
| support tables | link_event_effects, rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Records a promise or agreement event and opens unresolved commitment state.
| Contract | Value |
|---|---|
| caller provides | shared social event input plus commitmentType, optional dueAt, optional resolution expectations |
| public tables | events, event_participants |
| support tables | open_commitments, link_event_effects, rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityInvariantError |
Rules:
- public commitment truth is still an event row
- unresolved operational state lives in internal support only
Closes an open commitment as kept, cancelled, or broken.
Rules:
- resolution may optionally record a linked resolving event
- broken commitments affect trust through
violation_factor - resolved commitments disappear from
read.listOpenCommitments()
| Contract | Value |
|---|---|
| caller provides | commitmentEventId, resolution enum, optional linked resolving event data |
| public tables | optional new events, event_participants |
| support tables | open_commitments, link_event_effects, rollups |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityStateError, AffinityValidationError |
Creates an anchored recurring event for birthday, anniversary, renewal, memorial, or custom yearly date.
| Contract | Value |
|---|---|
| caller provides | target contact or link, recurrenceKind, anchorMonth, anchorDay, summary, significance |
| public tables | events, event_participants |
| support tables | upcoming_occurrences |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityConflictError |
Rules:
- anchor rows are
eventswith recurrence metadata, not a separate public table - duplicate yearly anchors of the same kind on the same target should conflict unless explicitly forced
Updates recurrence metadata on an anchored recurring event.
| Contract | Value |
|---|---|
| caller provides | recurrence patch fields and optional summary/significance patch |
| public tables | events |
| support tables | upcoming_occurrences |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError, AffinityConflictError |
Archives or removes one anchored recurring event from the active date surface.
| Contract | Value |
|---|---|
| caller provides | anchor event id, optional removal timestamp |
| public tables | events |
| support tables | upcoming_occurrences |
| returns | EventMutationReceipt |
| throws | AffinityNotFoundError |
Sets one contact-targeted or link-targeted attribute.
| Contract | Value |
|---|---|
| caller provides | target ref, namespaced name, string or NULL value |
| public tables | attributes |
| support tables | none required |
| returns | AttributeMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError |
Removes one attribute.
| Contract | Value |
|---|---|
| caller provides | target ref, namespaced name |
| public tables | attributes |
| support tables | none required |
| returns | AttributeMutationReceipt |
| throws | AffinityNotFoundError |
Replaces the visible attribute set for a target.
Rules:
- exactly one of
contactIdorlinkIdis targeted NULLvalue represents presence/tag semantics- preference-sensitive formulas consume explicit namespaced keys such as
pref.channel.text,pref.activity.walk,pref.link.channel.email
| Contract | Value |
|---|---|
| caller provides | target ref and full replacement entry set |
| public tables | attributes |
| support tables | none required |
| returns | AttributeMutationReceipt |
| throws | AffinityNotFoundError, AffinityValidationError |
Merges one losing contact into one winning contact.
| Contract | Value |
|---|---|
| caller provides | winnerContactId, loserContactId, optional reason summary |
| public tables | contacts, identities, links, events, event_participants, attributes |
| support tables | contact_merges, normalized identity index, rollups |
| returns | MergeReceipt |
| throws | AffinityMergeError, AffinityConflictError, AffinityInvariantError, AffinityNotFoundError |
Rules:
- merge into self is forbidden
- merged contacts cannot be merged again as winners
- owner merge rules must preserve exactly one owner
- identities, participant refs, attributes, and live links must be rewired in one transaction
- merge lineage must always be preserved
Reads return derived truth. A caller should not need to stitch raw contacts,
links, events, event_participants, and support tables into one logical
surface.
List-style reads share these options where applicable:
limitoffsetsortdescendingincludeArchivedincludeDormantincludeObservedsinceuntil
Default rules:
- default
limitis50 - maximum
limitis250 - unsupported sort/filter combinations throw
AffinityValidationError - detail reads do not page
read.getOwnerProfile(db)
read.getContactProfile(db, contactId, options?)
read.listContacts(db, filters?, options?)
read.searchContacts(db, query, options?)
read.getContactJournal(db, contactId, options?)
read.getLinkTimeline(db, linkId, options?)
read.listMoments(db, filters?, options?)
read.getLinkDetail(db, linkId, options?)
read.listOwnerSocialLinks(db, filters?, options?)
read.listObservedLinks(db, filters?, options?)
read.listProgressionReadiness(db, filters?, options?)
read.listRadar(db, filters?, options?)
read.listUpcomingDates(db, filters?, options?)
read.listOpenCommitments(db, filters?, options?)
read.getAffinityChart(db, options?)
read.listDuplicateCandidates(db, filters?, options?)
read.getMergeHistory(db, contactId)| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.getOwnerProfile(db) |
owner contacts row + identities + attributes + rollups |
owner row only | none | ContactProfileRecord | null |
read.getContactProfile(db, contactId, options?) |
one contact + identities + attributes + top links + rollups | exact contactId |
none | ContactProfileRecord | null |
read.listContacts(db, filters?, options?) |
contacts + optional identity snippets + rollups |
kind, lifecycle, owner inclusion, dormant inclusion | name asc |
ContactListItem[] |
read.searchContacts(db, query, options?) |
names + identities + aliases + normalized identity index | query, kind, lifecycle | relevance desc | ContactListItem[] |
Rules:
- list views stay compact
- profile views may embed identity snippets, top links, active dates, and maintenance warnings
| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.getContactJournal(db, contactId, options?) |
events + event_participants where contact participates |
contact id, since/until, event type | occurred_at desc |
EventRecord[] |
read.getLinkTimeline(db, linkId, options?) |
events + event_participants + link_event_effects |
link id, since/until, moment inclusion | occurred_at desc, event_id desc |
EventRecord[] |
read.listMoments(db, filters?, options?) |
events + link_event_effects where moment_kind is not null |
moment kind, contact, link, since/until | occurred_at desc, then impact desc |
MomentRecord[] |
Rules:
- observed third-party timeline is just
read.getLinkTimeline()for an observational link - participant rendering must be explicit and stable
| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.getLinkDetail(db, linkId, options?) |
one links row + rollups + recent Journal effects |
exact linkId |
none | LinkDetailRecord | null |
read.listOwnerSocialLinks(db, filters?, options?) |
owner-origin relational links + rollups | kind, state, archived inclusion, dormant inclusion | normalized_rank desc, then trust desc |
LinkListItem[] |
read.listObservedLinks(db, filters?, options?) |
relational links where kind is observed |
state, archived inclusion | last_meaningful_event_at desc |
LinkListItem[] |
read.listProgressionReadiness(db, filters?, options?) |
active relational links + rollups | owner-only, state=active, kind inclusion |
readiness desc | LinkListItem[] |
progression readiness ranking:
readiness = 0.70 * affinity + 0.20 * trust + 0.10 * normalized_rank
| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.listRadar(db, filters?, options?) |
owner-origin relational links + rollups | state, kind, dormant inclusion, observed inclusion | radar score desc | RadarRecord[] |
read.listUpcomingDates(db, filters?, options?) |
upcoming_occurrences + contacts + optional links |
horizon, recurrence kind, contact kind | occurs_on asc, then significance desc |
UpcomingDateRecord[] |
read.listOpenCommitments(db, filters?, options?) |
internal open_commitments + source events + relevant links |
due horizon, commitment type, contact or link scope | due_at asc, else oldest first |
CommitmentRecord[] |
Radar ranking:
radar_score = 0.45 * drift_priority
+ 0.20 * (1 - recency_score)
+ 0.15 * normalized_rank
+ 0.10 * bridge_score
+ 0.10 * (1 - reciprocity_score)
| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.getAffinityChart(db, options?) |
active contacts + non-archived links | archived inclusion, observed inclusion, contact subset | none | AffinityChartRecord |
Default edge weight:
edge_weight = trust * (0.6 + 0.4 * normalized_rank)
| Function | Source logic | Core filters | Default order | Returns |
|---|---|---|---|---|
read.listDuplicateCandidates(db, filters?, options?) |
normalized identity index + fuzzy name heuristics + merge exclusion | min score, exact-only, contact subset | exact identity hit first, then fuzzy score desc | DuplicateCandidateRecord[] |
read.getMergeHistory(db, contactId) |
contact_merges + referenced contact rows |
exact contactId |
merged_at desc |
MergeHistoryRecord[] |
The public ontology remains six tables.
| Table | Purpose | Answers |
|---|---|---|
contacts |
canonical entity registry | who or what exists? |
identities |
contact recognition and routing | how do I recognize or reach them? |
links |
structural and relational edges | how do they relate? |
events |
canonical evidence and temporal record | what happened? |
event_participants |
explicit multi-party involvement | who was involved and how? |
attributes |
operational metadata and preferences | how do I categorize or operate on this? |
| Value | Meaning |
|---|---|
human |
person |
group |
informal group, household, circle |
company |
company or organization |
team |
formal team or sub-organization |
pet |
cared-for animal or being |
service |
service, vendor, API, bot, account-like entity |
other |
explicit catch-all |
| Value | Meaning |
|---|---|
active |
normal live record |
dormant |
retained but low-current-use record |
merged |
absorbed into another contact |
lost |
invalidated, unreachable, or intentionally retired |
Structural kinds:
works_atmanagesmember_ofmarried_topartner_ofparent_ofchild_ofsibling_offriend_ofclient_ofvendor_ofreports_tobelongs_toother_structural
Relational kinds:
personalfamilyprofessionalromanticcareserviceobservedother_relational
| Value | Meaning |
|---|---|
active |
normal working state |
dormant |
low-motion but not damaged |
strained |
materially tense or unstable |
broken |
actively severed or unusable |
archived |
intentionally frozen from normal progression |
| Value | Meaning |
|---|---|
conversation |
exchange of words/messages |
activity |
shared time / hangout / meeting |
gift |
gift or thoughtful gesture |
support |
help, assistance, care, task-sharing |
milestone |
notable life or relationship milestone |
observation |
observed fact without direct interaction |
conflict |
openly negative interaction |
correction |
repair, clarification, or course correction |
transaction |
commercial or operational interaction |
promise |
stated future commitment |
agreement |
explicit shared commitment |
breakthroughrupturereconciliationmilestoneturning_pointNULL
actorrecipientsubjectobservermentioned
| Table | Key field rules |
|---|---|
contacts |
one owner row only; intrinsic fields only |
identities |
preserve raw value; support normalized matching internally |
links |
structural links must leave progression fields null; relational links must populate them |
events |
moment_kind is system-derived; recurring/date anchors live here |
event_participants |
child rows of events only; directionality optional |
attributes |
exactly one of contact_id or link_id; public value is string-encoded |
Recurring/date anchor fields on events:
recurrence_kindinbirthday,anniversary,renewal,memorial,custom_yearlyanchor_monthin1..12anchor_dayin1..31
- exactly one
contacts.is_owner = true - identities may be reassigned by merge but must preserve lineage
- multiple links between the same pair are allowed when their kinds differ
- events never store participant columns directly
- event participants cannot exist without a parent event
moment_kindis system-derived only
| From | To | Allowed by | Notes |
|---|---|---|---|
active |
dormant |
manual or inactivity policy | still queryable |
dormant |
active |
manual or new evidence | resumes normal visibility |
active / dormant |
merged |
merge only | terminal |
active / dormant |
lost |
manual invalidation | hidden from default active views |
lost |
active |
manual recovery | resumes normal visibility |
| From | To | Trigger |
|---|---|---|
active |
dormant |
manual archive intent or sustained low activity |
dormant |
active |
new meaningful direct event |
active / dormant |
strained |
conflict, repeated trust loss, or manual override |
strained |
active |
repair sequence and trust recovery |
active / dormant / strained |
broken |
severe rupture or explicit severing |
broken |
active |
manual reset plus later positive evidence |
| any non-archived | archived |
manual freeze |
archived |
prior non-archived state | manual restore |
Rules:
- structural links have no progression state
brokensuppresses automatic rank-up until the link is active again- archived links are excluded from normal Radar and progression views
rankis an integer floor at0- automatic progression may increase rank by at most
1per event - automatic progression does not decrease rank
- affinity is clamped to
[0, 1)after carryover - trust is clamped to
[0, 1] - observational-only evidence cannot raise
rankabove1ortrustabove0.35
List reads:
- return final derived metrics only
Detail reads:
- return final metrics plus a
derivationblock for the latest relevant mechanics when applicable
Write receipts:
- return
derivedEffectswith already-computed link consequences
DerivedLinkEffect must expose:
linkIdeventIdbaseWeightintensityvalenceintimacyDepthreciprocitySignaldirectnesspreferenceMatchnoveltyaffinityDeltatrustDeltarankBeforerankAfterstateBeforestateAftercadenceBeforecadenceAftermomentKind
flowchart TD
eventInput[EventInput]
eventResolution[EventToLinkResolution]
featureExtraction[FeatureExtraction]
eventWeight[BaseWeight]
affinityUpdate[AffinityUpdate]
trustUpdate[TrustUpdate]
cadenceUpdate[CadenceUpdate]
momentDerivation[MomentDerivation]
rollupUpdate[RollupUpdate]
readModels[ReadModels]
eventInput --> eventResolution
eventResolution --> featureExtraction
featureExtraction --> eventWeight
eventWeight --> affinityUpdate
eventWeight --> trustUpdate
eventWeight --> cadenceUpdate
affinityUpdate --> momentDerivation
trustUpdate --> momentDerivation
cadenceUpdate --> rollupUpdate
momentDerivation --> rollupUpdate
rollupUpdate --> readModels
| Participant shape | Affected links |
|---|---|
| owner + one non-owner | owner -> contact relational link |
| owner + multiple non-owners | owner -> each non-owner relational link |
| no owner, two non-owners | one third-party observational link if event type is observation |
| no owner, more than two non-owners | only explicitly referenced existing links |
| one contact only | no relational effect unless owner self-anchor or maintenance anchor case |
Auto-creation rules:
- direct owner + one non-owner event with no prior relational link creates one
- auto-created kind is
familyonly if a structural kinship tie already exists - otherwise auto-created kind is
personalforhuman,professionalforcompanyorteam,serviceforservice, elseobserved - third-party auto-created links are always
observed
| Name | Contract |
|---|---|
intensity |
significance / 10 |
valence |
conversation 0.35, activity 0.55, gift 0.65, support 0.7, milestone 0.6, observation 0.15, conflict -0.85, correction 0.2, transaction 0.1, promise 0.25, agreement 0.3 |
intimacyDepth |
conversation 0.35, activity 0.45, gift 0.5, support 0.65, milestone 0.7, observation 0.2, conflict 0.8, correction 0.55, transaction 0.15, promise 0.35, agreement 0.4 |
reciprocitySignal |
1.0 for mutual, 0.75 for other_initiated, 0.65 for owner_initiated, 0.35 for observed, else 0.5 |
directness |
1.0 direct two-way, 0.8 owner-initiated one-way, 0.7 passive incoming, 0.45 observed third-party, 0.25 mention-only |
preferenceMatch |
clamp(1.0 + 0.12 * liked_matches - 0.15 * disliked_matches, 0.75, 1.25) |
novelty |
max(0.2, 1 / (1 + 0.35 * same_day_similar_event_count)) |
| Helper | Contract |
|---|---|
type_weight(eventType) |
conversation 1.0, activity 1.15, gift 1.1, support 1.2, milestone 1.35, observation 0.6, conflict 1.25, correction 0.9, transaction 0.7, promise 1.0, agreement 1.0 |
positive_trust_factor(eventType) |
conversation 0.45, activity 0.55, gift 0.35, support 0.8, milestone 0.5, observation 0.1, conflict 0.0, correction 0.6, transaction 0.3, promise 0.2, agreement 0.4 |
violation_factor(eventType) |
conflict 1.0, promise 0.85, agreement 0.9, correction 0.2, else 0.0 unless provenance marks violation |
damage_multiplier(eventType, provenance) |
0.38 for integrity/betrayal, else 0.26 for conflict, 0.22 for broken promise or agreement, else 0.0 |
warmth_match |
1.0 for gift, support, activity, milestone, positive conversation; 0.5 for neutral transaction; 0.0 for conflict; 0.35 otherwise |
reliability_match |
1.0 when promise or agreement is kept or support delivered; 0.0 when broken; 0.45 default |
normalized_rank |
1 - exp(-rank / 4) |
state_score |
active 1.0, dormant 0.65, strained 0.3, broken 0.05, archived 0.0 |
positive_event_ratio |
positive_meaningful_events / max(total_meaningful_events, 1) over trailing 180 days |
reciprocity_score |
1 - min(1, abs(outbound_count - inbound_count) / max(outbound_count + inbound_count, 1)) over trailing 180 days |
bridge_score |
node betweenness centrality percentile of the counterparty contact |
cadence_floor(kind) |
family 3, personal 5, professional 5, romantic 2, care 3, service 30, observed 30, other_relational 14 |
cadence_ceiling(kind) |
family 60, personal 120, professional 90, romantic 45, care 90, service 365, observed 365, other_relational 180 |
Base event weight:
base_weight = type_weight(event_type)
* (0.35 + 0.65 * intensity)
* directness
* preference_match
* novelty
Affinity:
affinity_gain = base_weight
* max(valence, 0)
* (0.55 + 0.45 * intimacyDepth)
* (0.7 + 0.3 * reciprocitySignal)
* (1 / (1 + 0.22 * rank))
affinity_loss = base_weight * max(-valence, 0) * 0.35
affinity_mass = clamp(affinity + affinity_gain - affinity_loss, 0, 1.5)
If affinity_mass >= 1.0 and link state is active:
rankAfter = rankBefore + 1affinityAfter = min(0.35, affinity_mass - 1.0)momentKind = breakthrough
Else:
rankAfter = rankBeforeaffinityAfter = min(0.99, affinity_mass)
Trust:
trust_gain = base_weight
* positive_trust_factor(event_type)
* (0.6 * warmth_match + 0.4 * reliability_match)
* (1 - trust)
* 0.18
trust_loss = base_weight
* violation_factor(event_type)
* trust
* damage_multiplier(event_type, provenance)
Trust repair:
repair_bonus = min(0.12, consecutive_repair_events * 0.02)
Apply repair only if:
- there is prior negative trust damage
- no new negative event appears in the last
30days - at least
2qualifying repair events occurred in that window
Cadence:
cadence_days = round((cadence_floor(kind) + cadence_ceiling(kind)) / 2)
cadence_days_next = clamp(
0.75 * cadence_days + 0.25 * gap_days,
cadence_floor(kind),
cadence_ceiling(kind)
)
Drift:
drift_ratio = days_since_last_meaningful_event / max(cadence_days, 1)
drift_severity = clamp((drift_ratio - 1.0) / 2.0, 0, 1)
drift_priority = drift_severity
* (0.45 + 0.35 * trust + 0.20 * normalized_rank)
There is no drift before cadence is exceeded. Strong drift begins near 3x
cadence.
Date salience bonus:
date_salience_bonus = 1.0 + min(0.25, significance / 40)
Apply only when the actual event occurs within 7 days before or 2 days after
the anchored date.
Heavy-usage protection:
mass_penalty = 1 / (1 + excess_weekly_mass / 8)
excess_weekly_mass = max(0, weekly_positive_base_weight - 8)
Derivation order:
- rank-up ->
breakthrough - state enters
strainedorbroken->rupture - state returns from
strainedorbrokentoactive->reconciliation - milestone event with significance
>= 7->milestone - otherwise significance
>= 8andabs(affinity_delta) >= 0.45orabs(trust_delta) >= 0.2->turning_point - else
NULL
Date anchors are events with reduced yearly recurrence metadata:
birthdayanniversaryrenewalmemorialcustom_yearly
Upcoming occurrences are generated by taking the next calendar instance of
anchor_month + anchor_day on or after today and materializing it into
upcoming_occurrences.
Open commitments are not a public table. They are an internal support model
backed by unresolved promise and agreement event rows.
- mentions and observations may create contacts and weak observational links
- mentions alone cannot raise rank above
1 - non-direct evidence cannot raise trust above
0.35
The public ontology stays lean. Internal support is explicitly allowed.
| Support | Purpose |
|---|---|
| normalized identity index | exact and fuzzy identity matching |
link_event_effects |
one event's effect on one link |
contact_merges |
deterministic merge lineage |
contact_rollups |
contact-level read acceleration |
link_rollups |
link-level read acceleration |
upcoming_occurrences |
materialized next occurrences for date anchors |
open_commitments |
unresolved promise/agreement support model |
These operations must be transactional:
- all evidence writes
- all commitment writes and resolutions
- all merges
- all lifecycle transitions that cascade link visibility or rewiring
- any write that mutates support tables or rollups with public rows
All mutation receipts must reflect post-transaction truth.
- list reads must be N+1 safe
- one public read should produce one logical surface without caller stitching
- rollups may exist only to accelerate stable public surfaces
- write receipts must reuse already-computed mechanics, not force recomputation in app code
- internal support tables remain invisible to normal callers
| Case | Required behavior |
|---|---|
| second owner creation attempt | throw AffinityConflictError |
| duplicate identity insertion | throw AffinityConflictError |
| identity already attached elsewhere | throw AffinityConflictError unless performed via merge |
| merge into self | throw AffinityMergeError |
| merge merged contact | throw AffinityMergeError |
| merge owner with non-owner | preserve exactly one owner or throw AffinityInvariantError |
| owner + contact direct event with no prior link | auto-create one relational link |
| third-party observation with 2 participants | may auto-create one observed link |
| third-party group observation with 3+ non-owners | never infer a clique |
| same participant repeated in one event | throw AffinityValidationError |
| structural and relational edge coexistence | allowed |
| service contact with relational link | allowed; usually service kind |
positive evidence on broken link |
may affect trust or affinity, but no auto rank-up until state returns active |
new evidence on archived link |
allowed only via explicit unarchive or override policy |
contact marked lost but mentioned again |
caller may reactivate or system may require explicit recovery before normal active handling |
| Feb 29 recurring anchors | materialize on Feb 28 in non-leap years |
| duplicate yearly anchors | throw AffinityConflictError unless explicit force policy exists |
| superseded commitments | later commitment may cancel or replace earlier open one explicitly |
| same-day event spam saturation | novelty and weekly mass penalty apply |
| observational trust ceiling | trust may not exceed 0.35 from non-direct evidence |
| mention-only progression cap | rank may not exceed 1 from mention-only evidence |
| deleting last reachable endpoint | allowed, but profile may surface a routing warning |
| identity conflict during merge | throw AffinityMergeError unless deterministic winner rule resolves it |
The API must be provably adequate for:
- create one contact from a sparse mention
- add one identity
- record one direct conversation
- expected: low trust, low rank, some affinity, no over-claiming
- create client, vendor, and service contacts
- attach account IDs and emails
- record transactions, renewals, and promises
- expected: strong identity routing, date awareness, clean structural ties
- mix family, friends, clients, and services in one graph
- expected: same ontology, different kinds and cadences, no model break
- detect exact and fuzzy matches
- merge without losing identities, participants, or link lineage
- monthly conversations and occasional milestones
- expected: steady rank growth, slower trust growth, moments at sensible thresholds
- record what was seen about other people
- expected: third-party graph truth without hallucinated intimacy
- birthdays, anniversaries, renewals, promises, and agreements all behave deterministically
- repeated same-day low-value events do not explode scores
This pass does not define:
- LLM tool metadata
- skills or soul layers
- generic CRUD over raw tables
- direct writing of derived relationship mechanics
- multi-user collaboration concerns
- sync or integration concerns
- broader audit/event-sourcing beyond evidence plus merge lineage
The system is acceptable only if:
- every public function is intention-shaped
- every write documents inputs, forbidden inputs, touched state, derived effects, receipt shape, and thrown errors
- every read documents source logic, ordering, and result shape
- list, detail, and receipt shapes are consistent across the API
- no caller-authored relationship mechanics remain
- transparent math is explicit and stable in detail reads and write receipts
- all major edge cases have deterministic documented behavior
- the system stays compact and canonical instead of turning into a dump
- it still functions as a serious standalone solo CRM
- it can later be wired back into Ghostpaw without changing ontology