tkgs, tkg_nodes, tkg_edges. The same steps apply to any team with a similar layout.
Only the graph moves. Users, billing, FactBlock bodies (claims, predictions), canvas coordinates, and folders stay in Supabase.
Real users are on the service, so the order is dual write, then cut reads over one at a time, then compare, then remove the old path. Nothing switches at once.
What goes where
Grouping without tables
In Supabase you would reach for a new table when a new kind of data shows up. tckg has no tables to create. Four fields do that job, and every read takes them as arguments (Concepts).
Two rules follow from this. Filtering on fields inside
payload is not indexed, so if a screen needs “all nodes where payload.stance is BELIEVE”, ask for a filter argument instead of working around it (that is how category was added). And a change of stance is not an update to payload.stance: it is a new node plus a SUPERSEDES edge, because the old stance is the history you came here to keep.
Node ids
A node id is unique within a tenant. The same FactBlock sits in many TKGs, so usingfactblock_id as the id collides.
Use tkg_nodes.id (a different uuid per TKG) as is. The way back to the FactBlock is payload.factblock_id.
Nodes
asserted_at is when this user took this stance, not when the FactBlock came into the world. Hence tkg_nodes.added_at, not claims.created_at.
Edges
Step 1. One-time import (backfill)
Existing rows were created atadded_at or created_at, but tckg learns them today. Inserted plainly, every past as_of would show nothing.
So declare a backfill. One request is one batch, and a batch has one declared instant.
Recommended granularity: one request per TKG per calendar day, with declared_captured_at set to that day’s max(added_at).
One batch per row is exact but turns rows into requests. Per day loses only the ordering inside a day.
warned: already_remembered and nothing is written.
An edge that arrives before its nodes is refused with missing_endpoint. Put the nodes in the same request or send them first.
Check
Pick one migrated TKG and read it as of two instants.Step 2. Dual write
Wherever the app writestkg_nodes or tkg_edges (the tkgbuilder/capture route, for example), also call POST /v1/memories. No backfill, asserted_at = now.
Keep the Supabase write. All reads still come from Supabase.
- Await the response, but a tckg failure must not fail the user’s request. Queue the failed body and resend; resends are safe through
already_remembered. - Where an
upsertused to be, justPOST. Same content gives a warning, different content gives a new row. A changed stance (BELIEVE to DISBELIEVE) is a new node id plus aSUPERSEDESedge. Nothing is overwritten.
Step 3. Cut reads over one at a time
Pick one screen or API route. Produce the old answer and the tckg answer from the same input and compare. Show the old path until the diff is zero.
For “now”, pass the call’s own timestamp as
as_of. There is no default; omitting it is a 400.
Deletes
tkg_nodes has DELETE. tckg does not, and answers 501.
During dual write, delete in Supabase and do nothing in tckg. The node stays in tckg, and “this person believed this at that time” remains true as a record.
Making something invisible is for v1.x invalidate. Until then, keep the delete button’s reads on Supabase.
What stays and what goes
Keys: one per app
The two apps call the same address with different keys, and the key is the tenant. factagora.ai keeps the token it already has. app.factagora.com gets its own key (handed out through the password manager). Rows written with one key never appear in reads with the other: same URL, same database, but the server stamps every row with the key’s tenant and every read is scoped to it. app.factagora.com holds other people’s data, which is why the two are apart from day one. Inside an app, keep usingspace for users, agents and datasets. Do not ask for a key per user.
Natural-language writes
POST /v1/memories also takes messages. Instead of the nodes and edges the app builds, send the text and when it was said; the server extracts the blocks (claim, prediction), the causal links the speaker argued between them, and the entities they mention (a company, an asset, a person). Gemini does the extraction; a message takes a few seconds and runs inside the request.
observed_atis when the words were said. Every block from the message is asserted at that instant, and the extraction knows nothing after it.- For past material, add
backfillas well, so the blocks are also known then. Without it, a 2024 transcript ingested today is asserted in 2024 but known today, andas_of=2024-06-01reads hide it. - The response’s
episodeslists everything the message produced. The same statement sent again later resolves onto the existing block; no duplicate.
/mcp/factagora/tckg:<id> and can reach nothing else. For an end user’s own client, the app shows that address: a tckg:<uuid> space opens with no credential on the hosted deployment (the address is the secret), and the app key never leaves the server. See Connect an agent.
For factagora.ai this is the tkgbuilder/ingest route: the transcript it fetches can go straight in as one message per video, observed_at = the video’s publish date. The capture route keeps sending structure, since a human has already reviewed those cards. See Write memories for what comes out of a message.
Not yet
- SDK. Plain HTTP. A TypeScript SDK comes after.