Narrow by default, widened on purpose

people-context

A local-first MCP server holding memory about the people in your life, where a four-level sensitivity decides what an ordinary read may disclose and every other level needs an explicit opt-in, imported candidates are staged for review and commit only by the ids a person names, and a committed eval suite scores rubrics that include what an answer must not say.

Carries 3 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

people-context is memory about people rather than about code: "who someone is, how you know them, what you last agreed, and how they like to be talked to", held in one local SQLite file with "[n]o account, no cloud, no network calls". MIT, version 1.2.1, 238 commits since 16 July 2026, 33,465 lines of Python in src against 52,271 lines of tests, plus a committed eval suite, an Obsidian plugin and an OpenClaw plugin.

The subject matter is the first thing to say about it. This is a store of personal data about third parties who did not consent to being in it, and the project knows: sensitivity is a first-class field, CLI error paths are annotated with warnings that a rejected input echoes submitted values "which may be personal", and a validation error is noted as "not automatically safe to print". That care shows up as mechanism rather than as a policy document.

Disclosure is narrow by default and widened on purpose. ORDINARY_SENSITIVITIES is (PUBLIC, PERSONAL), documented as "[l]evels an ordinary read may disclose, in the shared order used by every other read path", with ALL_SENSITIVITIES kept for "the explicit local opt-in". A fact defaults to PERSONAL. So a record marked SENSITIVE or RESTRICTED is simply absent from a timeline or an upcoming-reminder read until a caller asks for it — the shape this atlas looks for in a scope key, applied here to confidentiality rather than to tenancy.

Imports stage, review, then commit by name. The import module's opening line states the intent: "[t]he lifecycle keeps its review gate on purpose. A staged batch is durable review state." pctx import review lists the staged candidates; pctx import commit takes the ids a person selected, and "only ids the batch actually staged are selectable, and a typo refuses the whole selection rather than silently committing the part that happened to parse." Refusing the whole selection on a typo is the conservative choice and the uncommon one.

The staging model's docstring is the best piece of writing in the repository and is worth reading for the principle rather than the code. It explains which validations belong at the staging boundary and which do not, and the rule it lands on for a restore is subtle and right: re-check a bound only when refusing could not reject this installation's own data. A trait's evidence budget and its stated_by attribution are re-checked because they were unconditional at the input boundary and their breach would be carried forward; an observation's text and a fact's value keep their released shape, because narrowing those "would refuse rows this installation legitimately stored."

The eval suite scores what an answer must not say. evals/suite/suite.json carries a fixture world and weighted rubrics; the identity-disambiguation task seeds two contacts sharing a first name and scores names-the-right-priya and states-employer-and-role beside does-not-attribute-the-other-priya.

Two limits to state plainly. The store is one person's local file, so sensitivity is a discipline about what surfaces show, not a boundary between principals — there is no second principal to keep out. And a fact carries a validity period and a recording time, which the domain calls "bitemporal-lite"; no read path found here gates a query against a past instant, so the data supports a point-in-time question that the interface does not yet ask.

Three marks: scope_enforced, human_review, negative_eval.

2. Mental Model

A person is the subject. Around them: facts (predicate, value, validity period, recorded time, confidence, sensitivity, provenance), observations, interactions, traits with typed evidence links, relationships from a vocabulary, groups, organizations, preferences and reminders.

A sensitivity is one of four levels. Ordinary reads see two.

A staged candidate is an import in review: not the shape it arrived in, not the shape it will be stored as, but a third thing with batch-local references rewritten to canonical ids.

Diagram — imports stage into durable review state and commit only by named id; ordinary reads disclose two of four sensitivity levels and the rest need an explicit opt-in
Diagram source
%% caption: imports stage into durable review state and commit only by named id; ordinary reads disclose two of four sensitivity levels and the rest need an explicit opt-in
flowchart TB
    SRC["transcript / source"] --> EXTRACT["extraction at a bounded boundary<br/>byte budgets apply here only"]
    EXTRACT --> STAGE[("import_staging<br/>refs rewritten to canonical ids<br/>extra = forbid")]
    STAGE --> REVIEW["pctx import review &lt;batch&gt;<br/>every staged candidate shown"]
    REVIEW --> PERSON{"person selects ids"}
    PERSON -->|"typo in an id"| REFUSE["whole selection refused"]
    PERSON -->|"valid ids"| COMMIT["pctx import commit --ids ..."]
    COMMIT --> STORE[("SQLite: people, facts,<br/>observations, interactions,<br/>traits + evidence links,<br/>relationships, reminders")]
    FACT["fact: predicate, value,<br/>validity period, recorded_at,<br/>confidence, sensitivity, provenance"] --> STORE
    READ["timeline / upcoming / insights"] --> SENS{"sensitivity"}
    STORE --> SENS
    SENS -->|"PUBLIC, PERSONAL"| SHOW["disclosed by an ordinary read"]
    SENS -->|"SENSITIVE, RESTRICTED"| HIDE["absent unless the caller<br/>opts in explicitly"]
    EVAL["evals/suite: fixture world + rubrics"] --> MUSTNOT["scores must-say and must-not-say<br/>e.g. does-not-attribute-the-other-priya"]

3. Architecture

Area Role
src/people_context/domain/ The model — one file per concept, including staged_candidate.py and trait_evidence.py
src/people_context/app/ Use cases, including insights/timeline.py and insights/upcoming.py where the sensitivity bound lives
src/people_context/adapters/, ports/ The SQLite adapter behind ports
src/people_context/cli/ pctx — people, groups, imports, insights, portability, onboarding, maintenance
evals/ A harness, a suite with a fixture world, and recorded results
obsidian-plugin/, openclaw-plugin/, mcpb/, skills/ Integrations

4. Essential Implementation Paths

  • src/people_context/app/insights/timeline.py:66-74 — the two sensitivity tuples and the comment that makes the default explicit.
  • src/people_context/cli/imports.py:1-9, 200-260 — the review gate and the all-or-nothing selection.
  • src/people_context/domain/staged_candidate.py — the boundary reasoning.
  • src/people_context/domain/fact.py:10-22 — the "bitemporal-lite" fact.
  • evals/suite/suite.json — the rubrics, including the must-not item.

5. Memory Data Model

One domain file per concept keeps the model readable, and two details stand out. A trait's evidence link carries the record type as well as the id, because "ids are opaque and unique only within their own table, so a restored store may hold an observation and an interaction sharing one id" — a restore-safety concern most systems discover later. And extra="forbid" on the staged shape is justified as "staging is where extraction output stops being prose, so a key nothing here declares is unexplained text that review would display and every later bundle would carry."

6. Retrieval Mechanics

Resolve the person first — the eval suite's system prompt instructs exactly that, and says "never guess an identity you could look up" — then read their stored context. Timelines and upcoming reminders are bounded by the ordinary sensitivity levels.

7. Write Mechanics

Direct writes through the CLI and MCP tools; imports through extraction, staging, review and a named commit. Error paths are written with the awareness that echoing a rejected value may print personal data.

8. Agent Integration

An MCP server plus pctx, with an Obsidian plugin, an OpenClaw plugin, an mcpb bundle and skills. The pitch — "[y]our agent already remembers your codebase. Now it can remember your people" — is a fair description of the gap it fills in this corpus, where almost every subject is about code or tasks.

9. Reliability, Safety, and Trust

The care about personal data is real and mechanical: a default sensitivity of PERSONAL, ordinary reads bounded to two levels, error paths annotated for what they may echo, and a review gate before anything extracted becomes durable.

What the design cannot do is consent. The people in this store did not agree to be in it, and no software decision changes that; the honest framing is that the project reduces accidental disclosure rather than resolving the underlying question. Anyone deploying it should read the sensitivity levels as a tool for their own discipline.

The single-user local model is also why scope_enforced here means something narrower than it does for a multi-tenant service: the key is stored and the read path honours it, but the party being kept out is the surface, not another principal.

10. Tests, Evals, and Benchmarks

52,271 lines of tests against 33,465 of source, organised to mirror the package, plus evals/ with a harness, a committed fixture world, a suite and recorded results. The eval design is the part to copy: a small hand-built world with deliberately confusable contacts, tasks with weighted rubric items, and rubric kinds that include both answer_contains_all and a must-not pattern.

11. For Your Own Build

Steal

  • Make the narrow set the default and name the wide one. Two constants — ORDINARY_SENSITIVITIES and ALL_SENSITIVITIES — with a comment saying which one an ordinary read uses, is clearer than a boolean parameter.
  • Refuse the whole selection on a bad id. Committing the part that parsed is how a review gate quietly becomes a formality.
  • Decide which validations belong at which boundary, and write the rule down. Re-check a bound on restore only when refusing could not reject your own stored data.
  • Put the record type in an evidence citation. Ids unique only within a table collide after a restore, and a bare id renders two records as one.
  • Seed an eval world with confusable entities and score what the answer must not say.

Avoid

  • Printing a rejected value without thinking about what it contains. The CLI's annotations exist because the obvious error message leaks the thing the store is careful about.

Fit

Reach for this if you want an agent to hold relationship context locally with disclosure bounded by default. Look elsewhere if you need multi-principal scoping, or point-in-time reads over the validity periods it already stores.

12. Open Questions

  • The fact carries a validity period and a recording time. Is an as-of read planned, and would it use both axes or only validity?
  • Sensitivity bounds ordinary reads. Does the MCP surface expose the opt-in, and if so what stops an agent taking it by default?
  • Trait evidence links name the record type. Is there a check that a cited record still exists after an erasure?

Appendix: File Index

Path What to read it for
app/insights/timeline.py The sensitivity bound and its comment
cli/imports.py The stage-review-commit gate and its refusal rule
domain/staged_candidate.py Which validations belong where, and why
domain/fact.py The "bitemporal-lite" assertion
evals/suite/suite.json Rubrics including a must-not item

History

2026-09-16e4afd375… — first reading, at a commit dated 14 September 2026. Screened before opening, from a shallow clone: twenty files, three auto-run surfaces (a .claude-plugin/ directory, an .mcp.json and an MCP server manifest), two build-time execution points, two unpinned surfaces, nine dependency files inside the cooldown, and AGENTS.md read as data. Nothing was installed, built or run.