Skip to main content

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:

WhatWhy it matters
A report at version 4 with every earlier state readableA run can cite the version it read, and a reader can fetch exactly that
One write refused with 409 VERSION_CONFLICTThe second writer learns it is stale instead of silently overwriting the first
A withdrawn document brought back by restoring a versionRemoval keeps the history; DELETE is the only permanent operation
A retracted memory and the assertion that records who retired itA fact leaves every read at once and the ledger says why
A derived_from edge between two reportsA reader can ask what a report was produced from
Two NDJSON filesThe 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.
  • jq on your PATH.
  • Key Concepts for projects, documents and the admin credential.
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.

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"

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.

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}'

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.

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}'

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.

# → 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}'
{
"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.

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}'

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.

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}'

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.

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'

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.

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}]'

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.

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

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​

RuleWhere it held
A stale write is refused wholeStep 4: 409 VERSION_CONFLICT with current_version
Every past state is readable by versionStep 5: version 1 read back after two later writes
Restore appends, never rewindsStep 5: version 1 came back as version 4
Withdrawal is a versionStep 6: the tombstone, and a restore that brings the document back
A fact leaves every read at onceStep 8: one retraction, one invalidated_at, one retracted assertion
An edge is owned by the document that asserts itStep 7: writing the summary's relation left the report untouched
The corpus is a fileStep 9: two exports in the listings' shape

Next steps​