Skip to content
Back to Blog
memory-systemsprovenancearchitecturepythontypescript

A Context Object Should Carry Its Receipt

Daniel Anthony Romitelli Jr. · August 15, 2026

A capsule leaves my memory service with four things attached: the content, confidence values, reasoning metadata, and a proof value. Three of those are what anyone would expect. The fourth is the one worth arguing about.

Reuse is a decision somebody made. A stored fact got compared against a threshold, cleared it, and went into the answer. That is evidence. Evidence found afterwards in a log is weak evidence, because by then the message has gone out and nobody can say why the detail was allowed back in.

So the admission record travels with the material it admitted.

1. Keep the outside surface small

This is the pattern I used in Holographic, Law-Bound Memory (HLM), a stand-alone memory brain outside application code. The README describes public Application Programming Interface (API) routes under /api/brain/*, with internal /api/v1/* services behind that layer.

Every table in the schema carries a tenant id, including the decisions and the outbox rows. Isolation is a property of the data model instead of a filter each caller remembers to add.

The outside shape is intentionally thin. Register an agent, write a fact, build a capsule. The Python Software Development Kit (SDK) in sdks/python/hlm_sdk/client.py shows that boundary, with no table names or policy code crossing it:

import httpx

class HLMClient:
 def __init__(self, base_url: str, token: str | None = None):
 self.base_url = base_url.rstrip("/")
 self._client = httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"} if token else None)

 async def register_agent(self, name: str):
 r = await self._client.post(f"{self.base_url}/api/brain/agents/register", json={"name": name})
 r.raise_for_status
 return r.json

 async def write_fact(self, text: str, tags: list[str] | None = None, selectors: list[str] | None = None):
 r = await self._client.post(f"{self.base_url}/api/brain/memory/facts",
 json={"text": text, "tags": tags or [], "selectors": selectors or []})
 r.raise_for_status
 return r.json

 async def build_capsule(self, query: str, budget_tokens: int = 2048):
 r = await self._client.post(f"{self.base_url}/api/brain/context/capsule",
 json={"query": query, "budget_tokens": budget_tokens})
 r.raise_for_status
 return r.json

The TypeScript client exposes the same three calls under camel-case names. Two clients means a compatibility surface at the gateway, and I accept that cost, because admission policy copied into every consumer becomes drift. One caller skips a selector, another copies an old threshold, a third treats a nearby match as enough. Centralizing the decision gives the service a place to say yes or rebuild before the application acts.

2. Write facts with handles the service can check

A plain text memory is easy to save. It gives retrieval very little to inspect later. HLM writes each fact with tags, selectors, and an optional tenant field so the service has decision axes before a query shows up.

The write model in services/memory/app/main.py is small:

from typing import List
from pydantic import BaseModel, Field

class FactIn(BaseModel):
 text: str
 tags: List[str] = Field(default_factory=list)
 selectors: List[str] = Field(default_factory=list)
 tenant_id: str | None = None

That class feeds create_fact. The row itself lands in brain_facts. Two more writes follow against the same fact id, one for facets and one for predicates. The response returns the new id with both sets attached.

generate_facets has two paths in the current scaffold. A known selector value produces a specific facet row. Anything empty or unmatched falls back to a general facet, built from the first 256 characters of the text with the token count capped at 64. Those are constants in the code rather than performance claims. What they show is the shape: retrieval sees more than a blob of prose.

The fact row has a vector column beside the text, sized at 1536, with an ivfflat index over cosine distance and a hundred lists. Similarity search and the facet handles are meant to work together. Vectors find candidates, and the tags, selectors and predicates decide whether a candidate is allowed through. Neither half is sufficient alone. A nearest neighbour with no admission axes is the failure this whole service is built against, and admission axes with no similarity search leave you scanning.

generate_predicates turns selector strings into one predicate joined with AND, swapping the first colon for =. That is rough. It is also enough, because the fact now leaves the write path carrying handles a machine can check. The tradeoff lands on the writer. A caller that sends empty selectors can still store text, but later selection has fewer axes to test.

3. Decide admission before capsule assembly

The reuse service is Conformal-Causal Reuse (CCR). Its request model carries four things: a cache key, an artifact type, the selectors, and optional numeric controls.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="HLM CCR", version="0.1.0")

class CCRRequest(BaseModel):
 tenant_id: str | None = None
 cache_key: str
 artifact_type: str = "resume_kit"
 selectors: list[str] = Field(default_factory=list)
 similarity: float | None = None
 tau: float | None = None

The rule in reuse_or_rebuild is direct: a hit requires similarity > tau and the required selector kinds stakeholder, time, and channel to be present. Missing one of those kinds makes the service return rebuild. When the request omits values, the code uses 0.9 for similarity and 0.8 for tau. Those numbers are defaults in the function. They are not measured latency, quality, or production calibration.

The response includes decision, tau, similarity, and causal_ok. All four travel with the answer. Accepted material can name the rule that admitted it, and a rejection arrives as a rebuild decision instead of a silent empty match.

One field in that response deserves more than it usually gets. causal_ok is the causal half of Conformal-Causal Reuse, and the schema shows what it is meant to lean on. Predicate rows carry a continuation_ids array of other fact ids, so a stored fact can point at what followed from it. A reuse check that consults those is asking whether the chain still holds, rather than whether two strings look alike. The current rule does not walk that array yet. The column is there and the edges are unwritten.

Calibration stays beside the same service. It takes a selector, a similarity, and a span error. From those it computes a rounded tau and clamps the result between 0.5 and 0.95.

I kept that next to the decision endpoint on purpose. Threshold repair that lives away from the admission rule is one more place for the two to drift apart. The cost is coupling: CCR now owns the current decision and the local adjustment path both.

4. The decision gets its own table

The argument at the top of this post is a claim about where evidence lives. sql/001_core.sql is where I committed to it.

CREATE TABLE IF NOT EXISTS ccr_decisions (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  packet_id UUID REFERENCES brain_packets(id) ON DELETE SET NULL,
  decision TEXT CHECK (decision IN ('hit','rebuild')),
  tau NUMERIC(4,2),
  similarity NUMERIC(4,2),
  causal_ok BOOLEAN,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Those are the same four fields the CCR endpoint returns. The wire format and the stored format agree, so an answer a caller received can be matched against the row that authorised it.

The check constraint is doing real work. A decision is a hit or a rebuild and the database will not accept a third thing. That keeps a null or an empty string from quietly becoming a category of its own later on.

The deletion behaviour is the part I would defend hardest. Facets and predicates are declared with cascade: they belong to a fact, and when the fact goes they go. The decision row uses set null instead. Delete the packet and the decision survives, orphaned but readable, still carrying its tau and its similarity and the time it was taken.

That asymmetry is the whole design in one line of schema. A facet is part of the material. A decision is evidence about an event, and evidence that disappears when the artifact disappears was never evidence.

brain_packets carries a merkle_receipt column beside the payload and the snapshot version. voit_runs hangs off the same packet id with tier, model, budget in cents, and a quality score, and it uses set null for the same reason.

Now the honest part. I grepped the services for those table names before writing this section. ccr_decisions appears zero times. So do brain_packets, brain_outbox, and voit_runs. Of the seven tables in that file the running code touches one, and it touches it once.

The schema is a long way ahead of the services. I would rather report that than describe the design as though it were deployed. What the file does show is which shape I committed to before writing the code, and a decisions table with a foreign key is a harder thing to walk back than a logging call.

5. Build the capsule with provenance attached

The orchestrator joins the pieces. In services/orchestrator/app/main.py, /api/v1/capsule derives selectors from the query and posts them to CCR. What comes back shapes the capsule: content, confidence values, reasoning metadata, and a proof value.

The intended object is signed context rather than an anonymous bag of nearest neighbors.

The current branch is still a scaffold, and the receipt is where that shows. Episode writes in the memory service return the literal string merkle:demo. The orchestrator repeats that same demo value in its local capsule response. Neither one hashes anything. The slot is real and the hardening is unfinished, so the caveat stays in the design.

The four-step path is the engineering pattern:

services/gateway/app/main.py contains merkle_root(items). It hashes the item strings and folds pairs until one hash is left, duplicating the last leaf when the count is odd. The result gets a merkle: prefix. The gateway is the right home for it, because the external object is formed there. Downstream code should receive a single object holding the selected material, the CCR decision fields, and a provenance value it can store or compare later.

The architecture document names this Proof-of-Context (PoC): Merkle roots over snapshot, version, tau, model, and ids. Those five are not abstractions. Packets carry a snapshot version column, episodes carry their own version counter, tau and similarity sit on the decision row, the model and its tier are recorded per run with the budget it spent, and every one of those tables is keyed by id. The receipt is meant to be a hash over state the database already holds, so a stored receipt and a recomputed one can disagree out loud.

Writes are versioned for the same reason. The architecture notes specify MVCC through an ETag or an expected version, so two agents editing the same memory produce a conflict a caller can see rather than a last-write-wins overwrite nobody notices.

The label matters less than the placement. If applications learn to consume loose context first, provenance turns into a retrofit. Retrofitted evidence is usually optional. Optional evidence disappears under deadline pressure.

6. Preserve the same object across streamed updates

Memory work does not always end at the first capsule. The orchestrator has a local loop that yields Server-Sent Events (SSE) through a StreamingResponse. Each packet carries a fresh UUID as its packet_id, a summary, a list of next actions, and a UTC timestamp. Then it sleeps two seconds and sends another.

Read that loop and the scaffold is obvious. The next actions are the literal strings "Do X" and "Do Y". The gateway is franker still: its resume object comes back as packet_id: None beside the receipt, so the field that should carry lineage across packets is present and empty.

The larger path is not missing, though. It is built, in the schema. brain_outbox has a status enum covering pending, leased, processed, failed and dead, plus columns for the holder of the lease, its expiry, an attempt count, the next attempt time, and the last error. There is even a partial index over the next attempt time, restricted to pending rows already due. That is the index a worker polling for available work would ask for.

No service references that table either. Leases, backoff and the dead-letter state exist as a data model with nothing writing to them, the same as the decisions table two sections up.

What the current code does establish is the contract shape, and the shape is the part I care about. Lineage has to move with every later packet as well as the first capsule. That costs more than returning an array from a nearest-neighbor endpoint. A resumed update without the original admission data is just another loose event. Avoiding exactly that is why the design exists.

7. Own the memory lifecycle

This design buys safer reuse by moving work into the memory service. Writers must send useful selectors. The gateway becomes stricter. The service has to keep admission metadata and provenance beside the content from write, through CCR, into capsule creation and streaming. I prefer that pressure inside HLM over spreading half-copied rules through applications, because systems that remember should expose the conditions under which memory became usable.