Recorded Access Origin (origin)¶
Status: proposed for a 2.5.x pre-release line. Additive to the tool contract —
no tool grows an argument, and no existing response changes shape. It does add
a database column, so it is not rollback-free by the release standard's
definition and takes the pre-release ladder (RELEASE_LIFECYCLE_STANDARD.md
§2.1).
1. The problem: agent_id does not carry origin¶
A stored row answers "whose memory is this" with agent_id, and "who produced
the content" with source. Neither answers "which caller put it here", and the
two are routinely not the same. Four paths in the shipped code produce rows
whose origin is unrecoverable:
- A shared
agent_idby design. More than one operator can be pointed at one agent namespace deliberately — that is what makes their histories legible to each other. Once they are, the only thing distinguishing the writers issource, which the writer declares about itself. - Unscoped writes.
do_storeaccepts an emptyagent_idand writes a row withagent_id = ''. That behavior is a deliberate injected-trust seam, not an accident (bug-137), but the row it leaves behind names nobody. import_memories. Imported rows take the importing side'sagent_idand the exporting side'ssourceverbatim. Who performed the import, and when, is recorded nowhere.- Verified subjects that are dropped.
_oauth_principalfills a principal with theissandsubclaims it verified. Unless the client row opted into per-subject separation (OAUTH_DESIGN.md§12), nothing downstream consumes the subject, and it ends at the end of the request.
The information exists at the moment of the write — acl.current_principal()
returns a Principal(client_id, issuer, subject) inside every handler — and is
then discarded. This document is about keeping it.
2. What this is, and what it must never become¶
It is a measurement, not a claim. The value is written by the server from
what the server resolved. No tool accepts it as an argument. That single rule
is the whole reason the column is worth adding: a field a caller can write is
already source, and a second one would carry no more evidence than the first.
It is not an isolation axis. agent_id, project_id and channel select
whose rows a query reads. origin selects nothing. No recall is filtered by it,
no row becomes reachable or unreachable because of it. Filtering memory by the
identity that wrote it would make memory unreadable across the boundary it
exists to cross — the same rule session.py states for the declared session
key, and for the same reason.
It is not an authorization input. Access decisions belong to the ACL layer, which runs before a handler is reached. A column read after the fact cannot gate anything, and wiring it into a decision would put policy in two places.
It is not a person's identity. What lands in the row is the server-issued
opaque alias, never the raw sub claim. See §5.
3. Why this is not an extension of source¶
source is a JSON column with no schema enforcement, so a sub-key could be
added there with no migration at all. That route was measured and rejected. Two
properties of the existing field defeat it, and both are load-bearing where they
are:
source is caller-written, and the write path preserves unknown keys.
normalize_source exists to fold legacy shapes into the canonical contract
without inventing anything, because fabricating a discriminator would falsify
attribution and defeat the anonymous-source detector. It therefore has no key
whitelist anywhere. The reachable path is not the lenient branch for unknown
shapes — it is the canonical fast path taken by every current producer:
# (1) Already canonical — the fast path used by every 2.5.x producer.
raw_type = source.get("type")
if isinstance(raw_type, str) and raw_type in CANONICAL_SOURCE_TYPES:
return source, False
A caller sending {"type": "Agent", "id": "...", "origin": {...}} has its dict
returned unchanged and serialized as-is by the write seam. Because type is
valid, check_health(invalid_source_type) does not flag it either: a forged
origin would not appear anywhere as an anomaly. Closing that would mean adding a
stripping step, ahead of the preserve-verbatim contract, inside the one function
written to leave the caller's value alone.
source is returned by recall. Every scored row carries it back to the
model. An origin living there would put client identifiers, subject aliases and
session keys into the context window on every recall — paid for in tokens on the
hot path, and read by a model that has no use for them. Keeping it out would
mean stripping the sub-key on the read path.
Those two repairs are the observed/declared split and the admin-only read surface, written in a worse place. A separate column obtains both by construction, and the migration it costs is the one shape this schema's migration ladder has repeatedly done before.
4. The seam¶
One helper resolves the value, called from the write path of store and
archive_episode:
- client —
Principal.client_id. Under stdio and the local principal this is the local client constant; under a provider-issued token it is the issuer-namespaced identifier (oauth:<issuer>:<client_id>) the ACL layer already uses, so a row and a grant row read the same. - subject alias — present only when the principal carries a verified
subject and an alias has been issued for it. Never the raw
sub. - transport — whether the call arrived over stdio or the shared HTTP transport. This is the difference between "one process, one client" and "one process, everyone", and it is what tells a later reader how much the client field is worth.
- declared session key — the key the caller sent, if any.
Resolution failures are not errors. A principal that is absent, a subject with no alias, a transport that cannot be determined: each omits its key. An empty object is a truthful record of a call that carried no resolvable origin, and it is also what every pre-existing row holds, so the two are deliberately indistinguishable — see §7.
5. Observed and declared never share a bag¶
{
"observed": {
"client": "oauth:https://auth.example/:client_abc",
"subject_alias": "u-1a2b3c",
"transport": "http"
},
"declared": { "session_key": "..." },
"at": "2026-01-01T00:00:00+00:00"
}
The nesting is the point. observed holds what the server resolved; for the
OAuth path those fields descend from signed claims that were verified before the
handler ran, which is what entitles a reader to treat them as evidence.
declared holds what the caller said about itself. The declared session key is
compared, never verified — any caller can send any string, including one
belonging to another session — so it is recorded where its trust level is
legible rather than in the same object as a verified subject.
A flat blob would be smaller and would lose exactly the property the column is for. Someone auditing a bad row a year from now reads a field; nothing else in the row tells them which half was measured.
The alias, not the subject. The name a subject's memory lives under is
already an opaque server-issued alias, with the (issuer, subject) → alias map
in a ledger the operator can edit, precisely so that a provider migration or a
switch to pairwise identifiers is one file to repair rather than orphaned data
(OAUTH_DESIGN.md §12). Writing the raw sub into a memory row would reproduce
that orphaning per row, in a column no ledger edit can reach — and would put a
person's provider identifier into a surface that gets exported and recalled.
6. The read surface¶
Phase one exposes origin on the inspection tools only — the per-row admin
reads and the health surface. It is not added to recall or
recall_with_context.
The asymmetry is deliberate. Adding a field to a recall response later is additive; removing one is a contract break. Starting where the cost is zero keeps the choice open, and the operator-facing question this column answers ("which caller wrote this row") is not a question a model answers mid-recall.
For the same reason, no recall filter is proposed. source_id already filters
on declared attribution; an origin-shaped filter is a different feature with
its own decision to make, and it would sit uncomfortably close to the per-subject
boundary, which is restrictive by construction and must not acquire a second,
additive path through it.
7. What this cannot tell you¶
Stated here rather than discovered later:
- Under stdio it is nearly constant. The local principal carries a fixed client id and no subject, so a single-user local install records the same two fields on every row. The column earns its keep on the shared HTTP transport, where one process serves many callers. A deployment that never leaves stdio should expect no information from it.
- It is not retroactive. Every row written before the migration holds
{}, and so does every row written afterwards by a call with no resolvable origin. The column cannot say which — "written before this shipped" and "written by an unidentifiable caller" are the same value by design, because inventing a distinction would mean writing a claim about rows nobody measured. - Import records the importer. An imported row's origin names who performed
the import, not who originally wrote the content — that is unknowable, and the
exporting side's value must not be carried across as though it had been
measured here. Anything else would make the column a forgery surface, which is
the failure mode §3 rejected
sourcefor.
8. Schema and compatibility¶
memories and episodes each gain origin TEXT NOT NULL DEFAULT '{}', added
by the same ALTER TABLE ... ADD COLUMN step every column-adding migration
before it used, and stamped as the next schema version.
Older builds tolerate the column, and this was checked rather than assumed:
- every insert names its columns explicitly, so an added column is never positional to an older writer;
SELECT *does not appear in the package, so no reader receives an unexpected field;- a database stamped newer than the running build logs a warning and continues (bug-138), so a downgrade degrades rather than refusing to boot.
Rolling back therefore leaves the column in place and unread. That makes the change rollback-tolerant, which is not the same as rollback-free: it is still a schema migration, and the release standard sends those through the pre-release ladder regardless of how gentle they look.
9. Tests¶
The claims above are worth only as much as the checks that hold them:
- A caller that sends an
originkey insidesourcedoes not influence the storedorigincolumn — the forgery path §3 measured, asserted directly. - A call with a resolvable principal records the client; a call with none
records
{}. - A verified subject records its alias and never the raw claim value — asserted by matching the alias, and separately by asserting the raw subject string is absent from the serialized row.
recallandrecall_with_contextresponses contain nooriginkey, so §6 is a gate and not a convention.- Import writes the importing caller's origin, not the value in the imported payload.
- A database created before the migration opens, migrates, and serves rows
whose
originis{}.