← Knowledge Store

Lifecycle

knowledge-store · workzone

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.

Curation Pass — the graph-curation orchestrator

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:

An orchestrator, not a separate module. Curation Pass isn't a new service but a Knowledge Store background run that launches a set of lifecycle operations over the already-persisted three projections. Each step is self-contained and idempotent: a repeat run does no harm, and a partial failure doesn't break the rest.
Edge materialization: from claims to graph

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.

Entity resolution: fuzzy cross-source reconciliation

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.

Identity resolution
Unresolved source_principals and identities under different emails are matched fuzzily by the run, which sets identity_idv2, 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.
Merging nodes in the graph
Two entities recognized as one object collapse into a single node; their edges are moved over, and the duplicate is marked 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.

Staleness & trust decay

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.

Trust inputs
  • source authority — the base weight from the tier (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
  • age — how long ago source_updated_at was last edited in the source; the old is lowered by a decay function
  • access frequency — what's never queried is lowered; what's in demand keeps its weight. The signal is driven by Query Engine (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
Output
trust_score
a ranking weight, not an access filter — the stale stays visible, just lower
The function's shape and two invariants. 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:
  • absolute recompute, not increment — each run computes trust_score anew from the current inputs, so a repeat run is idempotent: the same result, with no accumulated drift
  • degradation on a missing signal — while an entity has no row in 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.

Retention policyv2

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.

Keep
Active and in demand — stays in hot results with no time limit.
Archive
Old but valuable for audit — leaves hot results, kept physically. Akin to a soft-delete, but by policy, not by disappearance from a source.
Delete
Expired by period or policy — purged physically along with its child chunks by CASCADE.
Embedding refresh — re-embedding on a model change

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.

Refresh rebuilds the index, it doesn't edit in place. A bulk 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.

Control-plane: 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.

Curation run Curation Pass run journal.
curation_runs curation run journal
platform-wide · not per-source one active
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
One active platform-wide run ↓ Lane coordination
The lock mirrors sync_runs, but platform-wide, not per-source: a partial unique index on a constant discriminator 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.
Per-step stats — in steps, not in columns
A run's steps are heterogeneous (edge materialization, resolution, decay, retention, re-embedding), and the set may change, so their totals live in a flexible JSONB rather than fixed columns: which step ran and with what stats. trigger answers why the run was raised, stepswhat 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.
Lane coordination: delivery ∥ curation → Harvester · Sync

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.

Delivery Harvester · per-source
Curation Curation · platform-wide merge · retention
they intersect only at the lock — while it holds, a source's sync waits in the queue and starts right after
Runs in parallel — almost everything
Edge materialization, decay and re-embedding only add and recompute, breaking nothing. They comfortably survive a concurrent Harvester write — they need no lock: search works, ingestion doesn't stall.
Yields the road — only the destructive
Merging duplicates and retention v2 move edges and delete records. Have Harvester rewrite the same entity at that moment and you get a mess. So a lock goes up on them, and delivery of the affected sources waits and continues right after (a merge is cross-source by nature — the window spans more than one source).
The lock self-clears, one run per platform. While a destructive step holds the lock, a new source run queues as 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 & restore

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.

Backup config and journal What to copy where and what's already taken.
backup_settings backup settings
singleton · CHECK (id = 1)
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
backup_snapshots snapshot journal
append-only · mirrors sync_runs
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
One active snapshot · a stale heartbeat_at releases the lock
Taking a dump (Postgres + vectors) is a long I/O operation, and the pod can die mid-way, leaving a snapshot in 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.
Restore is maintenance, rotation is by count
Restore overwrites the database wholesale with a snapshot, so it runs under maintenance: ingestion and search paused, the platform in maintenance mode. Snapshots rotate by 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.
Single-database consistency

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.