> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tckg.factagora.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Concepts

> Blocks, the two clocks, spaces and tenants, backfill, the certificate, declared facts, and why there is no delete.

## 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

| Field | Meaning | Who sets it |
| - | - | - |
| `asserted_at` | When the statement **was made**: the conversation time, the article date | Caller |
| `valid_from`, `valid_to` | The interval the content **holds for**. Open from `asserted_at` if omitted | Caller |
| `captured_at` | When tckg **learned** it | **Server.** Refused if sent |

A read asks two separate questions.

* **`as_of`** (required): "show only what we **knew** at this instant", so `captured_at <= as_of`. This is what makes backtests and post-mortems honest.
* **`valid_at`** (optional): "show only what **held** at this instant", so `asserted_at <= valid_at` and the `valid` interval contains `valid_at`.

They are independent. "Everything we know now that held in June 2024" is `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>`, or `tckg:<id>` is recommended. It is a partition, not the author of a statement (the author is a block attribute, in `payload`). It was called `owner` before 0.3.2.
  Reads and searches are scoped by `space`. Omit it to read the whole tenant.

A node `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](/migrate-from-supabase#node-ids).

## Grouping data

There are no tables to create. Data is grouped by four fields that every read understands, from coarse to fine.

| Level | Field | What it separates | Hard wall? |
| - | - | - | - |
| Customer | `tenant` | Hard isolation. Never mixes. Chosen by the API key, one key per app | yes |
| Namespace | `space` | Whose memory: a user, an agent, a worldview, a project. Reads and searches take `space`; omit it for the whole tenant | yes, when given |
| Record type | `kind` | What a block is: `claim`, `prediction`, `entity`, `factor`, `timeseries`, `episode`, or your own value (preserved). Reads take `kind` | filter only |
| Topic | `category` | A free label on a block: `macro`, `equities`, `product-x`. Reads, search candidates, and export take `category` | **no**: search candidates are narrowed, but causal expansion may cross topics |

**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.

<Note>
  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.
</Note>

## Backfill

Data you created in the past and insert today gets `captured_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_at` is 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.**

If the data you move has several real capture times, declare several batches. One request is one batch.

## The certificate

A small JSON object on every read response: "what was done to produce this answer".

```json theme={null}
{"as_of": "...", "tx_as_of": "...",
 "masked": {"node": 2, "edge": 1},
 "backfill": {"batches": 1, "rows": 3},
 "rules_as_of": "..."}
```

| Key | Meaning |
| - | - |
| `as_of` | The instant actually applied (after widening a date) |
| `tx_as_of` | When the server answered |
| `masked` | Rows **hidden** because of `as_of`, by kind. Omitted when nothing was hidden |
| `backfill` | If any visible row used a declared instant: how many batches and rows |
| `rules_as_of` | Only on `resolve`, when declarations were applied as of a different instant |

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" is `up` in March and `down` in September. tckg **does not let a model pick** which one to return.

1. `POST /v1/facts` declares **what counts as one fact** (`fact_key`, `key_fields`) and **how to choose** (`policy`).
2. Nodes carry `fact_key` and `fact_value`.
3. `POST /v1/resolve {fact, as_of}` picks **one** value among the candidates known at that instant, by the declared policy.

When it cannot choose (two values starting at the same instant) it returns `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

| family | types | in search expansion |
| - | - | - |
| causal | `CAUSES`, `CONTRIBUTING_FACTOR`, `TRIGGERS`, `PREVENTS` | by default |
| temporal | `SUPERSEDES`, `CONCURRENT_SIGNAL`, `RESTATES` | by default |
| argumentative | `SUPPORTS`, `CONTRADICTS`, `QUALIFIES` | when listed in `family` |
| general | `DEPENDS_ON`, `DERIVED_FROM`, `MENTIONS` | when listed in `family` |

`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`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.