PRD¶
| Owner | Agus |
| Status | Draft, pending review |
| Created | 2026-09-01 |
| Updated | 2026-09-03 (domain and scenarios split out, D28; v0 review: E6-E7, M1, milestones; D22-D27) |
| Supporting docs | Architecture, Module Interfaces, Decision log, R1 Context Embedding Report, Excalidraw board a8d17080-29dd-47c2-98d7-b1fcaee9c0ab |
This document states what Indra must do and why, with testable requirements. How it does it (concepts, storage model, invariants, matching design) is the architecture doc; the contracts modules build against are the interfaces doc; the rulings behind both are the decision log.
1. Problem¶
Helmi's products see the same people and organizations through many disconnected windows: emails, meeting recordings, calendar events, documents, notes. Nothing today can say "these five mentions are one person" with evidence attached. Every product that needs identity re-derives it locally and badly, and no correction made in one place propagates anywhere else.
Opportunity. One standalone service that owns the problem: callers push sources, Indra composes them into events, extracts partial entities with claims, normalizes the queryable claims, and (last, once real data exists) matches and resolves them into canonical entities, every merge carrying the evidence that drove it.
2. Goals and success metrics¶
| Goal | Metric | Target |
|---|---|---|
| One identity across windows | Given real mentions of one person across two or more source types, Indra returns one entity with links to every mention | Passes on real Helmi org data |
| Evidence attached, mistakes reversible | Every member link records its driving claim; a wrong merge is undone by superseding links, with no data loss | 100% of links; an undo touches links only |
| Human review stays minimal | Human workload per resolved entity at fixed precision: queued review decisions plus wrong-merge corrections | Target parameterized from the M1 report |
| Matching designed on data, not guesses | The extraction metrics (requirement M1) exist as a report before any matching code | Report produced from a real ingested corpus |
| Context-rich corpus from day one | Sources and events carry versioned situating summaries | 100% of ingested sources and events |
The two costs in the review-workload metric are not equal. A queued case, prepared with its context bundle, costs one accept/deny decision. A wrong merge that escapes to production costs an investigation: find it, read the context, hunt the correct match, fix it. So precision outweighs queue-minimization: when in doubt, queue (D10).
Progress is measured by milestone completion (section 8), never by time.
3. Non-goals and deferred work¶
Non-goals, permanent scope boundaries:
- Product UX. Products own UX; Indra owns truth, behind an API. Dev-only inspection UI (seeing the data and pipeline state during development and testing) is in scope and expected.
Deferred beyond v1, wanted but not now:
- Soft-claim processing, claim truthfulness, profile generation. Soft claims are stored and otherwise untouched until an entity reaches canonical.
- Semi-hard (dynamic) claim types. Adds complexity, not required for v1.
- Event context grouping (relating a meeting to the email thread after it): v2, do not build against it.
- Right-to-erasure. v1 cannot erase a person; accepted risk (D14).
4. Users and context¶
Callers are Helmi's products; keshi is the first integration. The service is API-first and
multi-tenant: every row is scoped by organization_id from the first migration.
The audience of this PRD is the implementing team: two engineers building v1, with Benve on forward research. The work is decomposed into five modules (ingestion, extraction, matching, entity store + applier, bootstrap), each with a contract in the interfaces doc. Five modules do not mean five owners (D15): the two engineers take the first wave (ingestion and extraction), and the remaining modules are unassigned until their block starts. The contract rule holds regardless of who builds what: a module is built against the contracts, never against another module's implementation.
5. Proposed solution¶
A pipeline of three stages. Ingestion: sources compose into events, and events are summarized and parsed. Extraction: an LLM extracts partial entities carrying claims, and hard claims are normalized into an indexed projection. Matching: partials are resolved into entities. These are the only stages. Summarization and extraction are the pipeline's only LLM calls; everything else is deterministic and testable without a model.
The design rests on one storage idea: entities are never rows that accumulate edits, they are views over an append-only set of links to partial entities. Merging is creating a link. Unmerging is superseding one. Data is never destroyed, only composed.
The human-capability ceiling. Indra auto-resolves only where a human could disambiguate the reference from the same context. That sets the design focus: capture the context a human would use (the situating summaries, D5) and put it in front of the model, so the model's resolution reasoning approximates the human's. What still cannot be resolved after the deterministic, heuristic, and model layers queues for human review, and the queue must stay small (goal table): a review backlog that outruns its users kills adoption of the whole service.
Matching and resolution are deliberately built last, against the distribution of data extraction actually produces. Match confidence bands and the critical-mass model are parameterized from measured data (M1), never hand-tuned in advance. The vocabulary and the ten invariants are in the domain doc; the pipeline and the directional matching design are in the architecture doc; worked cases are in scenarios.
6. Requirements¶
P0 blocks v1. P1 ships after the P0 core works, parameterized by M1's numbers.
Ingestion and composition (module 1)¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| I1 | Source ingestion API with external_id/external_system/content_hash dedup; identical re-ingest is a no-op, changed content mints a new row |
P0 | The same payload ingested twice yields one source row; a changed payload yields a second |
| I2 | Sources compose into events | P0 | A meeting recording plus its calendar event yield one event holding both sources |
| I3 | Source and event summaries generated eagerly, keyed by id plus summarizer_version, small model via OpenRouter |
P0 | Every source and event has a summary row; re-running under a bumped version adds rows and replaces none |
| I4 | A changed source (same external_id/external_system, new content) recomposes into the SAME event and forces re-extraction; reconciliation is diff-based: unchanged output is kept, changed output supersedes the stale claims and links |
P0 | A rescheduled calendar event yields one event, re-extracted; the removed attendee's stale claims are superseded and no duplicate entity members appear |
The driving case for I4 is calendars: events mutate constantly (reschedules, attendee
changes), and corrected emails and edited documents follow the same path. Claims from a
superseded source version stop contributing to critical mass (interacts with S4). The
reconciliation mechanics, including how they relate to extractor_version re-runs (E1),
are owed by the architecture doc (D11).
Extraction and normalization (module 2)¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| E1 | LLM extraction of partial entities with claims from events, versioned and re-runnable | P0 | Re-extracting an event under a new extractor_version leaves prior output intact |
| E2 | Every claim is typed against the shared registry, carrying both classification dimensions (queryability with identifying class; temporality with cardinality) | P0 | A claim with an unregistered type is rejected at the contract boundary |
| E3 | A partial entity carries at least one hard claim | P0 | Extractor output violating this is rejected, not stored |
| E4 | Hard claims normalize into an indexed, org-scoped projection | P0 | A normalized email query returns its partial entity; a cross-org query returns nothing |
| E5 | Relation claims: the extractor emits claims with target_partial_id referencing a partial of the same event, typed against the relation-type registry with per-type attributes (design/relationships.md, D16-D19) |
P0 | An event stating an employment yields a typed relation claim between the two partials; an unregistered relation type or a cross-event target is rejected at the contract boundary |
| E6 | Identifying claims are grounded: the value appears literally in the event's source text, modulo the claim type's canonicalization (D23) | P0 | An extractor output containing an identifying claim whose value is absent from the source text is rejected, not stored |
| E7 | Partial entities are typed: kind from the registry (v0: person, organization), with identifying class keyed by (claim type, entity kind) where it differs (D22) |
P0 | A domain claim resolves as identifying on an organization partial and non-identifying on a person partial; an unregistered kind is rejected at the contract boundary |
Measurement (deliverable, not overhead)¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| M1 | Extraction metrics report: hard-claim yield per source type, share of partials carrying an identifying claim, alias collision rate, candidate-set size per claim type, relation-claim yield and precision per relation type (including other label distribution, D18/D21), alignment stability under re-extraction, identifying-claim error rate per source type (D25), and review-driver analysis (which claim patterns produce ambiguity a human would have to resolve) |
P0 | Report computed from a real ingested corpus against the labeled eval set (D25); matching design review and the review-workload target (goal table) are set against it |
Entity store and resolution (modules 3, 4, 5; designed directionally, built last)¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| S1 | Entity store on the EntityLink model: member/evidence kinds, supersede instead of delete, driving_claim_id on every link |
P1 | A superseded link remains queryable; no code path deletes a link |
| S2 | Matching with tier routing: identifying exact hit merges, non-identifying hit attaches evidence, no hit creates an unresolved entity | P1 | Each of the three routes produces its link kind or entity, per the architecture design |
| S3 | Per-org FIFO applier | P1 | Two concurrent resolutions naming the same unknown person yield one unresolved entity, not two |
| S4 | Promotion at both levels: evidence to member on critical mass; unresolved to canonical only on clear no-match plus identifying claim from a trusted source or critical mass | P1 | Promotion transactions are atomic; a failed search never enables promotion |
| S5 | Resolution judge in shadow mode until it clears an eval dataset | P1 | Verdicts are logged with model and prompt version, not applied, until the gate clears |
| S6 | Bootstrap import of canonical entities from authoritative systems, through the applier | P1 | Imported entities carry member links with driving claims, like resolved ones |
Human review and auditability¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| R1 | Cases no resolution layer can decide (including judge inconclusive verdicts) land in a review queue exposed via API; each case carries the context bundle needed to decide it |
P1 | A queued case is decidable without opening source systems; deciding it is one accept/deny API action |
| R2 | Every mutating action (merge, link create/supersede, promotion, bootstrap import, review verdict) is recorded in an action log with actor, timestamp, and inputs; humans and the system write the same log, distinguished by actor | P1 | Any entity's current state is explainable from its action history alone |
| R3 | Resolution actions (merge, link create/supersede, promotion, review verdict) expose one-step undo; an undo is itself a logged action. Ingestion and bootstrap import are re-runnable, not undoable | P1 | Undoing a merge restores prior query results, appends an action, and deletes nothing |
Observability and cost (cross-cutting)¶
Tool choices (PostHog, Langfuse, BetterStack from the start) are a stack decision (D9); this section states only what must be observable and why.
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| O1 | Pipeline traceability: a source is traceable end to end through ingestion, extraction, and matching, with stage-level failure visibility | P0 | Given a source id, an operator answers "where did it stop and why" without reading code |
| O2 | Every LLM call records prompt version, model, tokens, cost, and latency; extraction and judge calls are linkable to their eval datasets. Traces carry real org data: tracing destinations sit inside the production data boundary | P0 | A month of extraction spend is attributable per stage and per prompt version; S5 shadow verdicts are auditable |
| O3 | Goal metrics emitted continuously, not only in the M1 report: review rate, merge rate, undo rate (the production precision proxy), unresolved backlog | P1 | Each metric queryable over time; regressions visible without a manual report |
| C1 | Per-stage LLM cost metrics with caps and alerts | P0 | A runaway stage triggers an alert before it exhausts budget |
| C2 | All LLM calls go through one interface that hides both the provider (OpenRouter or a specific vendor) and the call mode; the interface is batch-shaped, so sequential calls (testing) and batch calls (production) are a configuration switch, not a code change | P0 | Swapping provider or call mode changes no caller |
| C3 | Batch-mode processing for summarization and extraction in production | P1 | No full-corpus run starts on sequential calls |
Backup, security, and access¶
| # | Requirement | Pri | Acceptance criterion |
|---|---|---|---|
| B1 | Backups with RPO 24h and RTO of half a working day. Sources are re-ingestable from external systems, but derived LLM output costs real money to re-create: the database is the asset | P0 | A restore drill has actually run before v1 is declared done |
| X1 | Per-product service credentials; every call org-scoped | P0 | No credential can query across organizations |
| X2 | PII posture stated and enforced: Indra is Helmi's most PII-dense service by design (emails, names, employment, relationships, cross-referenced). Encryption at rest; access logging on entity and claim reads | P0 | Reads of entity data are attributable to a caller |
Erasure is explicitly not supported in v1 (section 3, D14).
7. Milestones¶
These are system gates, not work assignments: each marks a point where one of the three stages of section 5 joins the one before it and something end to end becomes true (D15). Modules carry their own internal milestones, owned with the module; this section tracks only the joins. Sequential, each done when its criterion holds, no time estimates.
The first wave is the ingestion and extraction stages. The entity store, applier, bootstrap, and matching are deferred as one block: matching needs entities in the graph to match against, and its thresholds are parameterized from M1. Observability and cost requirements (O1, O2, C1, C2) land with the gate that introduces their stage, not at the end.
- Contracts ratified. The interfaces doc is agreed, the registry package exists at v0, and the modules of the first wave have owners. Done when a module can be started from the interfaces doc alone.
- Corpus ingested and summarized (I1-I3). Done when a real corpus of emails plus calendar events is ingested with source and event summaries, behind the C2 LLM interface.
- Corpus extracted (E1-E7, and I4 at the claim level). Done when partial entities from that corpus are queryable by hard claim, a changed-source re-ingest reconciles claims correctly, and the M1 eval-set plan (size, sampling, who labels) exists before the measured corpus is extracted (D25). Link supersession is out of scope here: links do not exist yet.
- Measurement (M1). Done when the report exists, the matching design has been reviewed against it, and the review-workload target is set from it.
- Matching and resolution (S1-S6, R1-R3, O3), parameterized by M1's numbers. Internal order: entity store and applier, then bootstrap import (organizations first, D26), then matching. Done when the goal-table identity test passes on real Helmi org data and I4 is re-verified with links present.
Gate 5 holds nine requirements across three modules, far more than any other. It is decomposed into its own milestones when the block starts, against M1's numbers; splitting it earlier would mean guessing the parameters that decomposition depends on.
8. Decisions¶
Decisions live in the decision log: append-only, one entry per decision, each carrying a status line that records supersession and why. Do not re-litigate a decision without naming its entry and saying what changed.
9. Appendix¶
- Domain: concepts and the ten invariants.
- Scenarios: worked inputs and expected outcomes.
- Architecture: pipeline, reconciliation, module map, matching design, stack rationale.
- Relationships proposal, frozen: relations as claims with a target (D16-D21).
- v0 review proposal, frozen: the pre-milestone-3 fixes (D22-D25), orgs-first bootstrap (D26), the four deferred bottlenecks (D27).
- Module Interfaces: the contracts, ownership, change rules.
- Decision log.
- Context Embedding Research Brief and R1 Report (TTL 90 days from 2026-09-01).
- Excalidraw board
a8d17080-29dd-47c2-98d7-b1fcaee9c0ab(snapshot: helmi-context-service.excalidraw): full schemas, flow diagrams, module map. - README: naming and scope rationale.