# Observe evidence

> Evidence enters Titen as an immutable, content-hashed observation, filed under the trust its credential is allowed to assert.

Section: The memory loop · Source: https://titen.dev/docs/observe
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/architecture/memory-model.md

---
An observation answers one question: *what happened?* It is the record a claim will later
have to cite, so Titen never edits it.

## Append one

<figure class="code"><figcaption>observe.ts<b>typescript</b></figcaption>

```ts
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"
```

</figure>

The same call as the raw route, which is all the SDK does over `fetch`:

```sh
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"}'
```

```json
{ "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

<div class="kv">
<strong>REQUEST FIELDS</strong>

<p><code>subject_id</code> who or what the evidence is about. Required, up to 200 characters.</p>
<p><code>kind</code> one of <code>user_statement</code>, <code>tool_result</code>, <code>imported_source</code>, <code>decision</code>, <code>system_event</code>. Required.</p>
<p><code>content</code> the evidence itself, up to 32000 characters. Required.</p>
<p><code>source</code> an object with a <code>type</code> and an optional <code>ref</code>. Required.</p>
<p><code>trust</code> <code>unverified</code>, <code>asserted</code>, <code>verified</code> or <code>policy_approved</code>. Defaults to <code>asserted</code>.</p>
<p><code>visibility</code> <code>private</code>, <code>team</code> or <code>organization</code>. Defaults to <code>team</code>.</p>
<p><code>occurred_at</code> ISO-8601 time of the source event, when you know it.</p>
<p><code>project_id</code>, <code>agent_id</code>, <code>run_id</code> optional scope dimensions.</p>
</div>

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`, 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: `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:

```json
{ "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 an error, not a second write:

```json
{ "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

Titen checks `trust` against the ceiling stored on your key before anything is written:

```json
{ "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. That ordering
also caps every claim built on the observation.

## 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 look like a month of live traffic.

<div class="callout">
<strong>Visibility today</strong>

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

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

<div class="rules">

<p>A claim that cites an observation must still be explainable a year later. If content could be edited, every claim built on it would become a claim about something else.</p>
<p>Correction is additive. Append the new evidence and let the <a href="/docs/claim-lifecycle">claim lifecycle</a> retire the old conclusion.</p>
<p>Forgetting is an authorized lifecycle action, recorded with who decided and why.</p>

</div>

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.

## Next

<div class="cards">

<a href="/docs/consolidate"><strong>Consolidate claims</strong><span>Turn observations into citable, disputable claims.</span></a>
<a href="/docs/keys-scopes"><strong>Keys &amp; scopes</strong><span>Where the trust ceiling on a credential comes from.</span></a>
<a href="/docs/api"><strong>HTTP API</strong><span>All 58 routes, their scopes and their error codes.</span></a>

</div>