The Knowledge Store data lifecycle revolves around Curation Pass — a periodic background run over the graph already assembled, not over the source. It's an orchestrator on top of a set of lifecycle operations: edge materialization, fuzzy resolution and demoting the stale run as one chain on a schedule, while retention v2 adjoins it as the next step; re-embedding stands apart — triggered by a model-change event. Run visibility is held by the control-plane table curation_runs; mutual exclusion of concurrent runs against Harvester delivery is held by lane coordination, and backup and single-database consistency by backup and consistency.
Harvester delivers data and repairs each source individually; Curation Pass curates the graph across all sources at once. It's the mirror of Harvester · Reconciliation: Reconciliation reads the source API and brings the per-source snapshot into agreement with it; Curation Pass reads the Knowledge Store database itself and reconciles what's visible only when you look at all sources together. The boundary is exactly the one at the boundary with the producer: "certain within a source → Harvester, a guess across sources → Knowledge Store".
The schedule is a single platform one, not per-source: the run covers the whole graph, and there's no point splitting it by source. The cadence is set by → Admin · Knowledge Store. Under one orchestrator — three scheduled steps in v1 (retention v2 being the fourth) plus one by event:
entity_ref claims →
edges, once both nodes are in the database
→
2
Entity resolution
fuzzy cross-source resolution of
duplicates and identities
→
3
Staleness decay
age and access demote
trust_score on top of source weight
→
4
Retentionv2
archival and deletion by type and
policy
A deterministic step, not a guess. During capture Harvester leaves
explicit references as claims in
entity_ref,
when the target node isn't in the database yet (another source, or a
forward within its own). The run walks the unresolved claims, looks up
the target by its natural identifier and, on a match, writes an edge to
entity_edge
(origin=curation) and clears the claim; if the target isn't
found, the claim waits for the next run. So the graph is eventually
complete: a relation appears the moment both of its ends are in the
database. This separates the deterministic assembly of explicit
relations from the
fuzzy resolution
below.
This is deep entity resolution — what Harvester's exact-match didn't catch at load time. Within one source, duplicates are collapsed by Reconciliation's full scan; across sources there's no exact key, and resolution becomes a guess — a job for Curation Pass. Jira's "Ivan Petrov" and Slack's "ipetrov" are one person; two pages about the same release in different spaces are one topic.
source_principals and identities under
different emails are matched fuzzily by the run, which sets
identity_id — v2, a
Curation Pass step. In v1 the identity ↔ users bridge
only links up by matching email; when addresses differ, the identity
goes to manual review (below). The model and exact resolution are on
ACL & identity.
duplicate_of. A graph node is
the entities record itself, so the merge is an operation on the relational body, not on a
separate node table.
The run doesn't discard the unresolved: whatever couldn't be linked fuzzily stays for manual review in → Admin · Account linking — so automation and manual review work off one queue.
Data goes stale and isn't equal in reliability: a ticket closed a year
ago and yesterday's release, an official policy and a chat aside — all
carry different weight. Curation Pass maintains the ranking
entities.trust_score
— no deletion, only a lower priority in results. The base is set by
source authority, with age and demand
modulating it: trust = source weight, lowered by
staleness and disuse.
low · normal · high):
policy bases and wikis higher, tickets mid, chat and bots lower.
The tier is set by the admin on the source card
(Harvester.sources.authority_tier, read via JOIN on source_id); decay expands the
tier into a numeric multiplier — the
tier → weight mapping lives in code in one place
source_updated_at was last edited in the source; the
old is lowered by a decay function
access_counter.hits · last_accessed_at,
read via JOIN on the entity id during recompute — the same trick
as authority_tier); long-unqueried data decays toward
neutral
trust_score ↓
trust_score is a product of normalized multipliers:
authority × freshness × demand. Freshness is an exponential decay with a
configurable half-life; the authority-tier multipliers and the axis
weights live in code/config in one place, not as magic numbers. A simple,
explainable function on purpose: add complexity after measuring on real
results, not ahead of it. Two invariants keep the run predictable:
trust_score anew from the current inputs, so a
repeat run is idempotent: the same result, with no accumulated drift
access_counter, the demand multiplier is
neutral
(decay rides on authority and age); the signal plugs in without
changing the formula or the schema
Decay is neither a status nor a soft-delete: the
status and is_deleted axes are driven by
ingestion, while trust_score is a continuous weight the run
recomputes. A record's disappearance from a source is recorded by
Reconciliation, not by staleness.
What to keep forever, what to archive, what to delete physically — the closing step of the chain, deferred to v2: in the first version data simply accumulates (the source purges only what it marked deleted itself — via Reconciliation), and our own retention policy comes later. The policy depends on the entity type and the customer's requirements (regulatory periods, volume, sensitivity), so it's set not in code but in → Admin · Knowledge Store alongside the run schedule.
This step stands outside the scheduled chain: it's
triggered not by a timer but by an event — a change of
embedding model. Vectors computed by the old model are incompatible with
the new one: their proximity in a shared space loses meaning, and search
breaks while the database is mixed. So on a model change Curation Pass
regenerates
chunks.embedding
and updates chunks.embedding_model — a bulk run that is
itself inference
AI.
A subtlety of dimensionality: at equal dimensionality old and new
embeddings coexist row by row while the run proceeds (told apart by
embedding_model). Changing the dimensionality N itself is
not a row-by-row refresh but a schema operation:
chunks.embedding is typed by its dimensionality
(halfvec(N)) and can't hold two Ns at once, so a new size
requires reindexing the column.
UPDATE of the embedding column on a live
HNSW is total graph churn (each re-embedded row = deleting and inserting
a node under locks), the worst regime for maintaining the index. So the
run proceeds as a rebuild without an exclusive write lock: a new column
→ CREATE INDEX CONCURRENTLY → an atomic swap, or
REINDEX INDEX CONCURRENTLY — at the cost of run time and the
space for two indexes at once. The same trick builds the index on a
source's initial bulk import: chunks are loaded first, HNSW in a single
pass afterward rather than incrementally during the load.
Cancellation is safe — the swap is the only commit point. Since the new generation is assembled in a separate column while the old index stays authoritative until the swap, aborting the run simply drops the half-built column: no mixed row-by-row state visible to search arises, and the platform stays on the previous model. The cost isn't in integrity but in the inference spent — which is why in Admin cancelling a re-embedding goes through a confirmation, and restarting is a repeat model change.
curation_runs
→ Cache & Workers
The mirror of Harvester's run journal
(sync_runs) on the Knowledge Store side: one row per run, visibility of progress
and outcome. The UI computes nothing — it reads
curation_runs and draws progress, status and per-step stats.
| id | BigInteger | PK | — |
| trigger | Text | NOT NULLCHECK | what raised the run · schedule (timer) · model_change (embedding-model change) · manual (manual start from Admin) |
| state | Text | NOT NULLDEFAULTCHECK | queued · running · succeeded · failed · cancelled |
| started_at | DateTime(tz) | NULLIDX | NULL while queued · duration = finished − started |
| finished_at | DateTime(tz) | NULL | NULL while running · terminal time |
| heartbeat_at | DateTime(tz) | NULL | liveness of the run worker · staleness releases the lock |
| steps | JSONB | NULL | which steps ran + stats · edges materialized · duplicates resolved · entities demoted |
| error | Text | NULL | short failure reason |
| created_at | DateTime(tz) | DEFAULT | now() · no updated_at — run progress is carried by started_at / finished_at / heartbeat_at, a separate edit stamp is redundant |
UNIQUE ((true)) WHERE state IN ('queued','running')
— at most one unfinished curation run across the whole platform.
A second request (a timer coinciding with a running one, a manual
trigger over a scheduled one) queues as queued or is
rejected, rather than spawning a parallel curation. The worker
beats heartbeat_at ~every 30s; silence longer than
~90s (three misses, as with
sync_runs
and agent_runs)
— the watchdog releases the lock of a zombie that never closed
its run; cancelled terminalizes — the next one
starts fresh. Coordination with Harvester delivery (what
runs in parallel, what's mutually exclusive) is held by a
separate section.
steps, not in columns
JSONB
rather than fixed columns: which step ran and with what stats.
trigger answers why the run was raised,
steps — what was done inside: an
event-driven re-embedding is a row with
trigger = model_change and a single re-embedding
step in steps. The exact contents of
steps are indicative — settled during
implementation.
Two background lanes write to one graph: delivery — Harvester pulling sources, and curation — Curation Pass tidying the already-assembled graph. There's one rule: normally they run side by side, without getting in each other's way, and only for a short destructive curation window does source sync yield the road to it.
queued and starts right after — it doesn't fail. Should the
curation worker crash without closing its run, the lock releases itself,
and no stuck locks remain. There's one curation run per platform
(curation_runs), a
second one queues. Re-embedding isn't part of this: it only
adds vectors and runs without a lock.
UI. The buttons simply reflect this state, computing nothing. A source under a run — "Sync" is disabled; a destructive curation window is in progress — the start is marked "queued · graph curation in progress". The overall activity indicator is on → Admin · Knowledge Store, once the screen leaves stub state.
Backup runs automatically on a schedule, separately from graph curation. The destination (external storage / S3), frequency and the backups' own retention are set in → Admin · Backups; restore is launched from there too. One Postgres database under the whole module means the snapshot is consistent by construction: body, vectors, graph and permissions land in the backup at one consistent point, with no assembly from several DBMSs.
| id | BigInteger | PKCHECK | always 1 · one row per instance |
| destination_url | Text | NULL | S3-compatible destination (s3://…) · NULL = not configured |
| destination_creds_enc | Text | NULL |
storage access key · write-only, encrypted by the crypto core
(like api_key_enc), not returned in UI or data export ·
NULL = the instance's ambient IAM role (inside AWS)
|
| frequency | Text | NOT NULLDEFAULTCHECK | dump cadence · DEFAULT 'daily' · daily · weekly |
| weekday | Integer | NULLCHECK | 0–6 · only for weekly · NULL for daily |
| time | Text | NOT NULLDEFAULT | 'HH:MM' local time · DEFAULT '02:00' |
| retention_count | Integer | NOT NULLDEFAULTCHECK | how many snapshots to keep · DEFAULT 14 · old ones rotate out |
| created_at | DateTime(tz) | DEFAULT | now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · edited in place |
| id | BigInteger | PK | — |
| state | Text | NOT NULLDEFAULTCHECK | running · succeeded · failed · UI "ready" chip = succeeded |
| started_at | DateTime(tz) | NOT NULLIDX | when taken · journal sort |
| finished_at | DateTime(tz) | NULL | NULL while running |
| heartbeat_at | DateTime(tz) | NULL | liveness of the dump worker · staleness releases the lock |
| size_bytes | BigInteger | NULL | dump size · shown in the journal (12.4 GB) |
| location | Text | NULL | snapshot path in storage · for restore |
| error | Text | NULL | short failure reason |
| created_at | DateTime(tz) | DEFAULT | now() · no updated_at — progress is carried by started_at / finished_at / heartbeat_at |
heartbeat_at releases the lock
running forever. The same pattern as
curation_runs:
a single-flight lock — a partial unique index
UNIQUE ((true)) WHERE state = 'running', one active
backup per platform; the next one on schedule, coinciding with an
unclosed one, is rejected rather than spawning a parallel dump.
The worker beats heartbeat_at ~every 30s; silence
longer than ~90s (three misses) — the watchdog
reaps the zombie into failed and releases the lock,
and the schedule moves on.
retention_count: once the count
exceeds the threshold, the oldest is deleted from both the
journal and storage. Storage credentials are write-only — set on
the
backups screen,
never shown back.
The relational body, pgvector and the graph live in one Postgres, so the projections and the ACL change transactionally — no cross-database synchronization, no window where permissions and content have diverged across DBMSs. The source of truth is the relational body; vectors and edges are its projections.
Synchronous loading (one transaction, a fixed projection order) is held by the data model. Curation, by contrast, is asynchronous: Curation Pass reconciles cross-source edges and fuzzy relations in a separate run, on top of the already-consistent body. So the graph is eventually complete — an edge is visible at once when both nodes are already in the database; a reference to a node that hasn't arrived yet (as with cross-source) is reconciled by the run — while the relational body is always correct.