Blocks
The unit tckg stores. Nodes (entity, claim, prediction, factor, timeseries, episode) and the edges between them.
A block never changes once written. When a belief changes, you write a new block and connect it with a SUPERSEDES edge. “What did we believe then” is always still there.
Two clocks
A read asks two separate questions.
as_of(required): “show only what we knew at this instant”, socaptured_at <= as_of. This is what makes backtests and post-mortems honest.valid_at(optional): “show only what held at this instant”, soasserted_at <= valid_atand thevalidinterval containsvalid_at.
as_of=now, valid_at=2024-06-01.
A date-only as_of is widened to the end of that day (UTC 23:59:59.999999). A full timestamp is used as is.
Space and tenant
- tenant: a customer, or an app that holds other people’s data. Data never mixes across tenants. The API key decides the tenant: every write carries it, every read is scoped to it, and a request cannot name another.
- space: whose memory inside a tenant: a user, an agent, a worldview, a dataset. A free-form string; a prefix like
user:<id>,agent:<id>, ortckg:<id>is recommended. It is a partition, not the author of a statement (the author is a block attribute, inpayload). It was calledownerbefore 0.3.2. Reads and searches are scoped byspace. Omit it to read the whole tenant.
id is unique within a tenant (the same id may reappear with a non-overlapping valid interval). If two spaces each remember the same fact, give the two rows different ids.
The id rule for a Supabase migration is here.
Grouping data
There are no tables to create. Data is grouped by four fields that every read understands, from coarse to fine.
If you come from Graphiti. Its
group_id is one partition axis with no fixed meaning, and it is a hard wall: search and edges stay inside a group. tckg splits that axis in two. Used group_id per user or agent? That is space. Used it per topic or domain? That is category, which is deliberately not a wall: “rates up, so housing demand falls” crosses macro and housing, and cutting the chain at a topic boundary would hide the cause. If you need a topic to be a wall, put the topic in space (topic:macro is a valid space); if you need both a user axis and a topic wall, ask for it.
Below those, payload holds anything else: a human-readable name, a source_description, a document_type, a schema version. It is yours; the engine only stores and returns it.
An ingestion event (a conversation turn, a document, an import run) is a node of kind episode. Its statement is the human-readable name (Graphiti’s name), its payload.source says where it came from (source_description), and the blocks extracted from it point back with DERIVED_FROM or MENTIONS edges. That is Graphiti’s episode, written as a block with its own three clocks.
Filtering on fields inside
payload is not indexed. If you find yourself needing “all blocks where payload.region is KR”, that is a request for a new filter on the API, not something to solve with a new table.Backfill
Data you created in the past and insert today getscaptured_at = today. Every past as_of then sees nothing.
So you can declare “we actually knew this since this instant”: backfill: {declared_captured_at, reason} on the write request.
- The declared instant is what masking uses (
known_at). captured_atis still stamped by the server with the real instant. Both are kept.- Every response that shows this data carries
backfill: {batches, rows}in its certificate. It is not hidden.
The certificate
A small JSON object on every read response: “what was done to produce this answer”.
Do not read a response with
masked as “there is nothing”. It means “we did not know yet”. A single-block read also says this as reason: not_yet.
Facts and resolve
The same fact accumulates several values. “NVDA direction” isup in March and down in September. tckg does not let a model pick which one to return.
POST /v1/factsdeclares what counts as one fact (fact_key,key_fields) and how to choose (policy).- Nodes carry
fact_keyandfact_value. POST /v1/resolve {fact, as_of}picks one value among the candidates known at that instant, by the declared policy.
status: no_answer, reason: unresolved_conflict with every candidate attached. It never picks one quietly.
Policies: latest_valid (default: the latest validity start), latest_observed, source_priority (needs source_order), strict (refuses when there are two candidates).
Declarations are time-aware too. A fact declared yesterday, asked with a 2024 as_of, is undeclared_fact. To apply today’s rule to past knowledge, send rules_as_of: now. It is recorded in the certificate.
There is no delete
DELETE answers 501. The way to correct a mistake is a new block plus SUPERSEDES. Hiding something from view (who, when, why) arrives in v1.x as invalidate.
That too is append-only: an as_of before the hiding still sees the block. Post-mortems do not break.
Edge types
CONCURRENT_SIGNAL is co-occurrence, not causation. It is never scored as a cause. RESTATES is the same statement said again, a row with its own asserted_at.