Observe evidence
Evidence enters Titen as an immutable, content-hashed observation, filed under the trust its credential is allowed to assert.
An observation answers one question: what happened? It is not a conclusion, not a summary, and not an instruction. It is the record that a claim will later have to cite, which is why Titen refuses to edit it.
Append one
import { TitenClient } from 'titen-memory';
const titen = new TitenClient({
url: 'http://127.0.0.1:8787',
key: process.env.TITEN_API_KEY!,
});
const obs = await titen.observe({
subject_id: 'user_rama',
kind: 'tool_result',
content: 'Deploy smoke returned 200 for checkout-service.',
source: { type: 'tool', ref: 'deploy_789#smoke' },
trust: 'verified',
});
// obs.observation_id → "obs_ed91a2d0f66143fbba66c932494d5e64"
The same call as the raw route, which is all the SDK does:
curl -X POST http://127.0.0.1:8787/v1/observations \
-H "authorization: Bearer $TITEN_API_KEY" \
-H 'content-type: application/json' \
-d '{"subject_id":"user_rama","kind":"tool_result",
"content":"Deploy smoke returned 200 for checkout-service.",
"source":{"type":"tool","ref":"deploy_789#smoke"},"trust":"verified"}'
{ "data": { "observation_id": "obs_ed91a2d0f66143fbba66c932494d5e64",
"subject_id": "user_rama", "project_id": null, "agent_id": null, "run_id": null,
"kind": "tool_result", "trust": "verified", "visibility": "team",
"content_hash": "bba5cf65e8aee81fe32d89ba9a5e46518fc17644d488b462a50924d6e1926516",
"occurred_at": null, "ingested_at": "2026-07-30T10:38:43.062Z" },
"meta": { "request_id": "req_…", "replayed": false } }
What you send
subject_id who or what the evidence is about. Required, up to 200 characters.
kind one of user_statement, tool_result, imported_source, decision, system_event. Required.
content the evidence itself, up to 32000 characters. Required.
source an object with a type and an optional ref. Required — evidence with no origin is not evidence.
trust unverified, asserted, verified or policy_approved. Defaults to asserted.
visibility private, team or organization. Defaults to team.
occurred_at ISO-8601 time of the source event, when you know it.
project_id, agent_id, run_id optional scope dimensions.
There is no organization_id field. Tenant authority comes from the credential and a
request body can never widen it.
The content hash is not a deduplication key
content_hash is SHA-256 over content, and it exists so a record can be checked against
the bytes it was accepted with. It does not decide identity. Two smoke runs that print the
same line are two events, so appending identical content twice produces two observations
that share one hash — verified live, obs_ed91a2d0… and obs_c98879c3… both carry
bba5cf65e8aee81f….
Retries are idempotent when you ask
Send an Idempotency-Key header and a retry returns the first result rather than a second
record. The replay marker is in meta, not in data, so the body you parse is identical
either way:
{ "data": { "observation_id": "obs_c98879c3adc44f6a9b2227b821a033bd", "…": "…" },
"meta": { "request_id": "req_7fad51e28bee4229b193bb4ddf64392e", "replayed": true } }
A replayed write also returns 200 instead of 201. Reusing one key with a different body
is a mistake rather than a second write, and Titen says so:
{ "error": { "code": "CONFLICT",
"message": "Idempotency-Key was reused with a different request body." },
"meta": { "request_id": "req_0e44fb86ed814eaa84efaefb656e6c7a" } }
The replay record is inserted in the same atomic batch as the observation, so a duplicate key cannot half-commit.
Trust belongs to the credential
trust is a claim about authority, so Titen checks it against the ceiling stored on your
key before anything is written:
{ "error": { "code": "FORBIDDEN",
"message": "This credential may not assert \"policy_approved\" trust." },
"meta": { "request_id": "req_7a53014ab8484e32b8f12d7beaa02014" } }
Levels are ordered unverified < asserted < verified < policy_approved. Give a low-trust
agent a low ceiling and it cannot promote its own output into verified fact — not by trying
harder, not by writing more often. That ordering also caps every claim built on the
observation, which is where the ceiling does most of its work.
occurred_at is yours, ingested_at is Titen’s
occurred_at describes the source event. ingested_at describes when Titen learned about it.
Titen assigns the second and never infers the first, which is why the capture above shows
occurred_at: null — the request did not supply one. Backfilling a month of logs gives old
occurred_at values and today’s ingested_at values; collapsing the two would make that
import indistinguishable from a month of live traffic.
private is enforced against the creating principal. Retrieval currently admits a record
when visibility <> 'private' OR actor_id = you, so team and organization behave
identically inside one organization — the membership model that separates them is not
wired into the retrieval predicate yet. Write the value you mean; it is recorded
faithfully and the predicate is what will tighten.
Why there is no update path
One write commits the observation, its history row, its FTS projection, an index-outbox row and a metadata-only domain event, in a single transaction. None of those is an update.
A claim that cites an observation must still be explainable a year later. If content could be edited, every claim built on it would silently become a claim about something else.
Correction is additive. Append the new evidence and let the claim lifecycle retire the old conclusion.
Forgetting is an authorized lifecycle action, not a model deciding that evidence stopped mattering.
Embedding and vector work is queued to the outbox, never held inside the transaction. The request succeeds when canonical SQL succeeds, so an unreachable embedding endpoint cannot discard evidence you already accepted.