Build a Single Source of Truth
Several agents write reports into one project and read each other's. Two of them edit the same report at once, one report is produced from another, a fact stops holding, and at the end the corpus has to leave as a file. This tutorial runs that sequence against one project and shows what the record keeps of each step.
Govern Metadata with Schemas covers what a report must say. This tutorial covers what happens to it afterwards. The map of every mechanism is Single Source of Truth.
By the end you will have:
| What | Why it matters |
|---|---|
| A report at version 4 with every earlier state readable | A run can cite the version it read, and a reader can fetch exactly that |
One write refused with 409 VERSION_CONFLICT | The second writer learns it is stale instead of silently overwriting the first |
| A withdrawn document brought back by restoring a version | Removal keeps the history; DELETE is the only permanent operation |
| A retracted memory and the assertion that records who retired it | A fact leaves every read at once and the ledger says why |
A derived_from edge between two reports | A reader can ask what a report was produced from |
| Two NDJSON files | The corpus as a file, in the shape the listings return |
Prerequisites
- SOAT running locally at
http://localhost:5047(Quick Start). - An embedding model configured (
EMBEDDING_PROVIDER,EMBEDDING_MODEL); documents and memories are embedded on write. See Generating Embeddings. jqon yourPATH.- Key Concepts for projects, documents and the admin credential.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in and create a project
Every record in this tutorial belongs to one project: the documents, the memory store and the exports are all scoped to it.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
PROJECT_ID=$(soat create-project --name "Shared Reports" | jq -r '.id')
echo "project: $PROJECT_ID"
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login, error: loginErr } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
if (loginErr) throw new Error(JSON.stringify(loginErr));
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
const { data: project, error: projectErr } =
await adminSoat.projects.createProject({ body: { name: 'Shared Reports' } });
if (projectErr) throw new Error(JSON.stringify(projectErr));
const PROJECT_ID = project.id; // proj_…
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Shared Reports"}' | jq -r '.id')
echo "project: $PROJECT_ID"
Step 2 — File the report and read its version
POST /api/v1/documents files the report at /reports/q1.txt. Version 1 is written on create, and every later write that changes the content or its annotations increments it (Documents — Versioning). Two writers read the document now; both hold version 1.
- CLI
- SDK
- curl
Q1_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q1.txt \
--content "Q1 revenue grew 12% quarter over quarter." | jq -r '.id')
soat get-document --document-id "$Q1_ID" | jq '{id, path, version}'
const { data: q1, error: q1Err } = await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/q1.txt',
content: 'Q1 revenue grew 12% quarter over quarter.',
},
});
if (q1Err) throw new Error(JSON.stringify(q1Err));
const Q1_ID = q1.id; // doc_…
console.log(q1.version); // 1
Q1_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q1.txt\",\"content\":\"Q1 revenue grew 12% quarter over quarter.\"}" \
| jq -r '.id')
curl -s "$SOAT_BASE_URL/api/v1/documents/$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{id, path, version}'
Step 3 — The first writer states the version it read
A write may name the version it is changing with expected_version in the body, or an If-Match header carrying the same number (Concurrent Writes). The document is at version 1, so this write lands and the document moves to version 2.
- CLI
- SDK
- curl
soat update-document \
--document-id "$Q1_ID" \
--expected-version 1 \
--content "Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%." \
| jq '{id, version}'
const { data: first, error: firstErr } =
await adminSoat.documents.updateDocument({
path: { document_id: Q1_ID },
body: {
expected_version: 1,
content: 'Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%.',
},
});
if (firstErr) throw new Error(JSON.stringify(firstErr));
console.log(first.version); // 2
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/documents/$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "If-Match: 1" \
-H "Content-Type: application/json" \
-d '{"content":"Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%."}' \
| jq '{id, version}'
Step 4 — The second writer is refused, re-reads, and lands
The second writer still holds version 1. Its write is refused whole with 409 VERSION_CONFLICT, and error.meta.current_version tells it what the document is at now. It re-reads, states version 2, and lands as version 3. Nothing the first writer wrote is lost.
- CLI
- SDK
- curl
# → 409
soat update-document \
--document-id "$Q1_ID" \
--expected-version 1 \
--content "Q1 revenue grew 12% quarter over quarter. Headcount reached 48."
CURRENT=$(soat get-document --document-id "$Q1_ID" | jq -r '.version')
soat update-document \
--document-id "$Q1_ID" \
--expected-version "$CURRENT" \
--content "Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%. Headcount reached 48." \
| jq '{id, version}'
const { error: stale } = await adminSoat.documents.updateDocument({
path: { document_id: Q1_ID },
body: {
expected_version: 1,
content: 'Q1 revenue grew 12% quarter over quarter. Headcount reached 48.',
},
});
console.log(stale?.error.code, stale?.error.meta); // VERSION_CONFLICT { expected_version: 1, current_version: 2 }
const { data: current } = await adminSoat.documents.getDocument({
path: { document_id: Q1_ID },
});
const { data: second, error: secondErr } =
await adminSoat.documents.updateDocument({
path: { document_id: Q1_ID },
body: {
expected_version: current!.version,
content:
'Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%. Headcount reached 48.',
},
});
if (secondErr) throw new Error(JSON.stringify(secondErr));
console.log(second.version); // 3
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/documents/$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "If-Match: 1" \
-H "Content-Type: application/json" \
-d '{"content":"Q1 revenue grew 12% quarter over quarter. Headcount reached 48."}' \
| jq '.error | {code, meta}'
CURRENT=$(curl -s "$SOAT_BASE_URL/api/v1/documents/$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.version')
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/documents/$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "If-Match: $CURRENT" \
-H "Content-Type: application/json" \
-d '{"content":"Q1 revenue grew 12% quarter over quarter. Churn fell to 2.1%. Headcount reached 48."}' \
| jq '{id, version}'
{
"code": "VERSION_CONFLICT",
"meta": { "expected_version": 1, "current_version": 2 }
}
A write that states no version is still serialized against a concurrent one: the loser of the race gets the same 409. Stating the version adds the case a request-scoped race cannot catch, a writer whose read is minutes old.
Step 5 — Read what a run read, then restore it
GET /api/v1/documents/{document_id}/versions lists the archive newest first, and GET /api/v1/documents/{document_id}/versions/{version} returns one state whole, content included. A run that cited version 1 resolves to the text it read, whatever has been written since.
POST /api/v1/documents/{document_id}/versions/{version}/restore writes version 1's state back as version 4. The counter never rewinds, so a run citing version 2 or 3 still resolves.
- CLI
- SDK
- curl
soat list-document-versions --document-id "$Q1_ID" | jq '[.data[] | .version]'
soat get-document-version --document-id "$Q1_ID" --version 1 | jq '.config.content'
soat restore-document-version --document-id "$Q1_ID" --version 1 | jq '{version, status}'
const { data: versions } = await adminSoat.documents.listDocumentVersions({
path: { document_id: Q1_ID },
});
console.log(versions!.data.map((v) => v.version)); // [3, 2, 1]
const { data: v1 } = await adminSoat.documents.getDocumentVersion({
path: { document_id: Q1_ID, version: 1 },
});
console.log(v1!.config.content); // Q1 revenue grew 12% quarter over quarter.
const { data: restored, error: restoreErr } =
await adminSoat.documents.restoreDocumentVersion({
path: { document_id: Q1_ID, version: 1 },
});
if (restoreErr) throw new Error(JSON.stringify(restoreErr));
console.log(restored.version); // 4
curl -s "$SOAT_BASE_URL/api/v1/documents/$Q1_ID/versions" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | .version]'
curl -s "$SOAT_BASE_URL/api/v1/documents/$Q1_ID/versions/1" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.config.content'
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents/$Q1_ID/versions/1/restore" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{version, status}'
The restore runs through the ordinary update path, so the restored content is re-chunked and re-embedded, and a metadata schema declared since is applied to it like any other write.
Step 6 — Withdraw the report, then bring it back
POST /api/v1/documents/{document_id}/withdraw takes the document out of every default read and archives the withdrawal as a tombstone version (Documents — Withdrawal). It leaves the listing and its chunks leave the index, so knowledge search never returns it. It stays readable by id, and ?include_withdrawn=true shows it in the listing again.
Restoring any content version brings it back. The tombstone itself holds no content and cannot be restored.
- CLI
- SDK
- curl
soat withdraw-document --document-id "$Q1_ID" | jq '{id, status, version}'
soat list-documents --project-id "$PROJECT_ID" --path-prefix /reports | jq '.total'
soat list-documents --project-id "$PROJECT_ID" --path-prefix /reports --include-withdrawn true \
| jq '[.data[] | {path, status}]'
soat restore-document-version --document-id "$Q1_ID" --version 3 | jq '{status, version}'
const { data: withdrawn, error: withdrawErr } =
await adminSoat.documents.withdrawDocument({
path: { document_id: Q1_ID },
body: {},
});
if (withdrawErr) throw new Error(JSON.stringify(withdrawErr));
console.log(withdrawn.status, withdrawn.version); // withdrawn 5
const { data: live } = await adminSoat.documents.listDocuments({
query: { project_id: PROJECT_ID, path_prefix: '/reports' },
});
console.log(live!.total); // 0
const { data: all } = await adminSoat.documents.listDocuments({
query: {
project_id: PROJECT_ID,
path_prefix: '/reports',
include_withdrawn: true,
},
});
console.log(all!.data.map((d) => d.status)); // ['withdrawn']
const { data: back, error: backErr } =
await adminSoat.documents.restoreDocumentVersion({
path: { document_id: Q1_ID, version: 3 },
});
if (backErr) throw new Error(JSON.stringify(backErr));
console.log(back.status, back.version); // ready 6
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents/$Q1_ID/withdraw" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{id, status, version}'
curl -s "$SOAT_BASE_URL/api/v1/documents?project_id=$PROJECT_ID&path_prefix=/reports" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.total'
curl -s "$SOAT_BASE_URL/api/v1/documents?project_id=$PROJECT_ID&path_prefix=/reports&include_withdrawn=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | {path, status}]'
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents/$Q1_ID/versions/3/restore" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{status, version}'
Step 7 — Say what a report was produced from
A second agent writes a summary of the report. POST /api/v1/documents/{document_id}/relations asserts a typed edge from the summary to its source (Documents — Relations). The edge is a write of the summary alone. ?related_to= on the listing finds neighbours on either side of it.
- CLI
- SDK
- curl
SUMMARY_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--path /reports/q1-summary.txt \
--content "Q1: revenue up 12%, churn down, headcount 48." | jq -r '.id')
soat create-document-relation \
--document-id "$SUMMARY_ID" \
--type derived_from \
--to-document-id "$Q1_ID" | jq '{type, from_document_id, to_document_id}'
soat list-documents --project-id "$PROJECT_ID" --related-to "$Q1_ID" | jq '[.data[] | .path]'
soat list-document-relations --document-id "$SUMMARY_ID" | jq '.data'
const { data: summary, error: summaryErr } =
await adminSoat.documents.createDocument({
body: {
project_id: PROJECT_ID,
path: '/reports/q1-summary.txt',
content: 'Q1: revenue up 12%, churn down, headcount 48.',
},
});
if (summaryErr) throw new Error(JSON.stringify(summaryErr));
const SUMMARY_ID = summary.id;
const { error: edgeErr } = await adminSoat.documents.createDocumentRelation({
path: { document_id: SUMMARY_ID },
body: { type: 'derived_from', to_document_id: Q1_ID },
});
if (edgeErr) throw new Error(JSON.stringify(edgeErr));
const { data: neighbours } = await adminSoat.documents.listDocuments({
query: { project_id: PROJECT_ID, related_to: Q1_ID },
});
console.log(neighbours!.data.map((d) => d.path)); // ['/reports/q1-summary.txt']
SUMMARY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"path\":\"/reports/q1-summary.txt\",\"content\":\"Q1: revenue up 12%, churn down, headcount 48.\"}" \
| jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/documents/$SUMMARY_ID/relations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"type\":\"derived_from\",\"to_document_id\":\"$Q1_ID\"}" \
| jq '{type, from_document_id, to_document_id}'
curl -s "$SOAT_BASE_URL/api/v1/documents?project_id=$PROJECT_ID&related_to=$Q1_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | .path]'
Asserting the same edge twice is 409, and both documents have to live in this project.
Step 8 — Retract a fact
Facts live in a memory store. One agent records that the Q1 report is final; a later review finds it is not. POST /api/v1/memories/{memory_id}/retract retires the fact with nothing replacing it (Memories — Retraction): it leaves the listing, dedup and knowledge search at once, invalidated_at is set with superseded_by_memory_id null, and the assertion ledger gains a retracted line naming who withdrew it.
The retraction claims the memory's version like any other write, so it takes expected_version too.
- CLI
- SDK
- curl
STORE_ID=$(soat create-memory-store --project-id "$PROJECT_ID" --name "report-facts" | jq -r '.id')
FACT_ID=$(soat create-memory \
--memory-store-id "$STORE_ID" \
--content "The Q1 report is final and approved for the board." | jq -r '.id')
soat retract-memory --memory-id "$FACT_ID" --expected-version 1 \
| jq '{id, invalidated_at, superseded_by_memory_id, version}'
soat list-memories --memory-store-id "$STORE_ID" | jq '.total'
soat list-memories --memory-store-id "$STORE_ID" --include-invalidated true | jq '.total'
soat list-memory-assertions --memory-id "$FACT_ID" | jq '[.data[] | {outcome, mechanism, principal_type}]'
const { data: store, error: storeErr } =
await adminSoat.memoryStores.createMemoryStore({
body: { project_id: PROJECT_ID, name: 'report-facts' },
});
if (storeErr) throw new Error(JSON.stringify(storeErr));
const STORE_ID = store.id;
const { data: fact, error: factErr } = await adminSoat.memories.createMemory({
body: {
memory_store_id: STORE_ID,
content: 'The Q1 report is final and approved for the board.',
},
});
if (factErr) throw new Error(JSON.stringify(factErr));
const FACT_ID = fact.id;
const { data: retracted, error: retractErr } =
await adminSoat.memories.retractMemory({
path: { memory_id: FACT_ID },
body: { expected_version: 1 },
});
if (retractErr) throw new Error(JSON.stringify(retractErr));
console.log(retracted.invalidated_at, retracted.superseded_by_memory_id); // <timestamp> null
const { data: assertions } = await adminSoat.memories.listMemoryAssertions({
path: { memory_id: FACT_ID },
});
console.log(assertions!.data.map((a) => a.outcome)); // ['created', 'retracted']
STORE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memory-stores" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"report-facts\"}" | jq -r '.id')
FACT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/memories" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"memory_store_id\":\"$STORE_ID\",\"content\":\"The Q1 report is final and approved for the board.\"}" \
| jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/memories/$FACT_ID/retract" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "If-Match: 1" | jq '{id, invalidated_at, superseded_by_memory_id, version}'
curl -s "$SOAT_BASE_URL/api/v1/memories/$FACT_ID/assertions" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '[.data[] | {outcome, mechanism, principal_type}]'
Restating the fact later lands as a new memory: dedup only ever matches a valid one. A memory that is already invalidated answers 409 MEMORY_ALREADY_INVALIDATED.
Step 9 — Export the corpus
GET /api/v1/documents/export and GET /api/v1/memory-stores/{memory_store_id}/export stream newline-delimited JSON, oldest first, one object per line, in the shape the listings return for the same caller. The document export leaves withdrawn documents out; the memory export leaves retired facts out unless include_invalidated=true. Each is its own IAM action, separate from the listing.
- CLI
- SDK
- curl
soat export-documents --project-id "$PROJECT_ID" > reports.ndjson
jq -c '{path, version}' reports.ndjson
soat export-memories --memory-store-id "$STORE_ID" --include-invalidated true > facts.ndjson
jq -c '{content, invalidated_at}' facts.ndjson
const { data: reports, error: reportsErr } =
await adminSoat.documents.exportDocuments({
query: { project_id: PROJECT_ID },
});
if (reportsErr) throw new Error(JSON.stringify(reportsErr));
// `reports` is the raw NDJSON body — one document per line.
console.log(String(reports).trim().split('\n').length); // 2
const { data: facts } = await adminSoat.memoryStores.exportMemories({
path: { memory_store_id: STORE_ID },
query: { include_invalidated: true },
});
console.log(String(facts).trim().split('\n').length); // 1
curl -s "$SOAT_BASE_URL/api/v1/documents/export?project_id=$PROJECT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" > reports.ndjson
jq -c '{path, version}' reports.ndjson
curl -s "$SOAT_BASE_URL/api/v1/memory-stores/$STORE_ID/export?include_invalidated=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" > facts.ndjson
jq -c '{content, invalidated_at}' facts.ndjson
Two rows in reports.ndjson: the report at version 6 and its summary. The tombstone and the archive are not rows; the file holds what the project currently asserts.
What you built
| Rule | Where it held |
|---|---|
| A stale write is refused whole | Step 4: 409 VERSION_CONFLICT with current_version |
| Every past state is readable by version | Step 5: version 1 read back after two later writes |
| Restore appends, never rewinds | Step 5: version 1 came back as version 4 |
| Withdrawal is a version | Step 6: the tombstone, and a restore that brings the document back |
| A fact leaves every read at once | Step 8: one retraction, one invalidated_at, one retracted assertion |
| An edge is owned by the document that asserts it | Step 7: writing the summary's relation left the report untouched |
| The corpus is a file | Step 9: two exports in the listings' shape |
Next steps
- Single Source of Truth — every mechanism a shared record needs, and the module that provides it.
- Concurrent Writes — the precondition in full, including what happens when nobody states one.
- Govern Metadata with Schemas — what a report must say before it is accepted.
- Automate a Flow with Triggers — react to
documents.updated,documents.withdrawnandmemories.retractedas they happen.