Correcting and superseding
Stored facts change for two distinct reasons:
- A mistake was made: A typo, misparsed text, or bad record needs to be overwritten.
- Reality changed: An address moved, a contract was renewed, or a team changed. The old fact was true for a period of time and should remain preserved in history.
Jennah separates these two operations into correction (overwrite in place) and supersession (close the prior validity window and open a new one).
Correcting: overwrite in place
Committing an ID that already exists overwrites that record in place. This applies to vector chunks, graph nodes, and graph edges through memory:commit:
{
"vectors": [{
"chunkId": "cust-4471-address",
"rawContent": "Acme Corp, 100 Main St, Des Moines IA"
}]
}
The previous rawContent and metadata are discarded immediately. Use this when the previous data was erroneous and has no historical value.
Superseding: close the old, write the new
Supersession tracks facts that change over time. It closes the validity window on an existing record and writes a replacement record in a single atomic operation:
POST /v1/agents/{agentInstanceId}/vectors/chunks:supersede
{
"priorChunkId": "cust-4471-address",
"newChunk": {
"chunkId": "cust-4471-address-2026-08",
"rawContent": "Acme Corp, 55 Sakura Ave, Tokyo",
"validAt": "2026-08-01T00:00:00Z"
}
}
The validAt timestamp defines the boundary: the prior chunk is closed at validAt, and the replacement chunk becomes valid from validAt onward. newChunk.validAt is required, and a value in the future returns INVALID_ARGUMENT. The replacement may also carry its own invalidAt when it has already stopped holding.
The replacement chunk requires a new, unused chunkId. Reusing priorChunkId as the replacement's ID returns INVALID_ARGUMENT; naming another ID that already exists returns ALREADY_EXISTS.
Graph edges follow the same pattern at POST /v1/agents/{agentInstanceId}/graph/edges:supersede using priorEdgeId and newEdge, on the same terms: newEdge.validAt is required and must not be in the future.
Every route on this page also exists under /v1/scopes/{scopeId}/... for subject scopes, for example POST /v1/scopes/{scopeId}/vectors/chunks:supersede.
Supersession is explicit
Jennah does not detect contradictions automatically. If you commit a new address under a different ID without superseding the old one, both chunks remain active and rankable in search results.
The exception is memory:form, which infers contradictions automatically and issues supersessions during commit.
Superseding as part of a commit
The two routes above are the single-item form. When a supersession needs to land in the same transaction as other writes, send it as the supersessions section of memory:commit:
POST /v1/agents/{agentInstanceId}/memory:commit
{
"log": {
"stepId": "step-8817",
"thoughtProcess": "customer confirmed the new address"
},
"vectors": [
{ "chunkId": "cust-4471-note", "rawContent": "Confirmed by phone." }
],
"supersessions": {
"chunks": [
{
"priorChunkId": "cust-4471-address",
"newChunk": {
"chunkId": "cust-4471-address-2026-08",
"rawContent": "Acme Corp, 55 Sakura Ave, Tokyo",
"validAt": "2026-08-01T00:00:00Z"
}
}
],
"edges": [
{
"priorEdgeId": "cust-4471-located-in-osaka",
"newEdge": {
"edgeId": "cust-4471-located-in-tokyo",
"sourceNodeId": "cust-4471",
"targetNodeId": "tokyo",
"relationshipType": "LOCATED_IN",
"validAt": "2026-08-01T00:00:00Z"
}
}
]
}
}
Everything in that request is one transaction at one commit timestamp: either the facts are retired and their consequences recorded together, or nothing is written. Sending the supersession and the other writes as separate calls leaves an instant in which the old fact is retired and nothing has replaced it yet, which a concurrent reader can land on.
The receipt reports supersessions separately, as operations rather than rows:
{
"commitTimestamp": "2026-08-25T04:11:52.117Z",
"executionLogRows": "1",
"vectorRows": "1",
"chunkSupersessions": "1",
"edgeSupersessions": "1"
}
vectorRows and graphEdgeRows count what the vectors and graph sections wrote. A supersession touches two rows in different ways, closing one window and inserting one replacement, so it is counted on its own and those numbers keep meaning what they always meant.
Neither form is preferred and neither is deprecated. The standalone routes construct exactly this commit, so the two are the same operation reached two ways: use the route when retiring one fact is the whole job, and the section when it is part of something larger.
The section inserts, while vectors and graph replace
A write in the vectors or graph section is create-or-replace: re-sending an ID overwrites that row, which is how you correct a record. A supersession's replacement is inserted, so a newChunk.chunkId or newEdge.edgeId that already exists returns ALREADY_EXISTS and aborts the whole commit rather than overwriting the row holding it. That difference is the mechanism that keeps a supersession from silently becoming a correction.
Two other refusals abort the whole commit for the same reason. A priorChunkId or priorEdgeId that does not exist returns NOT_FOUND: a supersession names a specific record to retire, so if it is not there, the writes you sent alongside it were reasoned from a premise that does not hold. And naming one ID in both a write section and the supersessions section returns INVALID_ARGUMENT, because a request that both replaces a record's content and retires that record has not said which it means.
From the command line
Both operations have CLI equivalents in jnh. See CLI scripting and automation for output, exit code, and dry-run conventions.
Because corrections use standard commits (which can span vectors, graph elements, and log steps in one transaction), they are supplied as a file:
$ cat correction.yaml
vectors:
- chunk_id: cust-4471-address
raw_content: Acme Corp, 100 Main St, Des Moines IA
$ jnh agents memory commit my.agent --from-file correction.yaml
Superseding operates on a single record at a time and is driven by flags:
$ jnh agents memory supersede-chunk my.agent \
--prior cust-4471-address \
--new-id cust-4471-address-2026-08 \
--content "Acme Corp, 55 Sakura Ave, Tokyo" \
--valid-at 2026-08-01
Superseded at 2026-08-17T05:09:07.829644Z
closed cust-4471-address
replacement cust-4471-address-2026-08
valid from 2026-08-01 00:00:00 JST (2026-07-31T15:00:00Z)
cust-4471-address is still readable as history; a read as of an instant before
the boundary returns it.
jnh agents memory supersede-edge takes --prior, --new-id, and --valid-at, along with the edge's --source, --target, and --type. Its --properties flag takes a JSON object for typed property values.
Both supersessions take --invalid-at for a replacement that has itself already stopped holding.
Both supersessions take --metadata key=value, repeated per pair, carrying the replacement's whole tag set. See Metadata. --properties and --metadata are separate bags and are spelled differently on purpose: properties are the relationship's own typed attributes, metadata is attribution about the record and is what tag filters read.
To append a single execution-log step without a file:
$ jnh agents memory add-step my.agent --step-id s-1 \
--tool crm.lookup --thought "checking the registered address"
--valid-at is required, and a bare date opens the day
The boundary is never defaulted to the current time: a change recorded today rarely took effect today, and an inaccurate boundary corrupts historical queries. A bare date like 2026-08-01 resolves to the start of that day in your local time zone. The command output prints the resolved boundary in both local time and UTC.
The CLI cannot check ID existence in advance
Committing an ID that already exists replaces the row without keeping history. Because the read surface does not support ad-hoc ID lookups, the CLI cannot warn you if a commit will overwrite an existing record. --dry-run validates format and prints the request payload without contacting the server. When updating a fact that changed over time, use supersession instead.
Querying historical state
By default, semantic search matches only currently valid chunks (invalidAt is absent). Any chunk with a recorded invalidAt, including one in the future, is excluded from default search. Once superseded or retired, a chunk drops out of default search ranking immediately.
To query what was valid at a specific point in time, pass asOfValid in the query section:
{
"semantic": {
"queryText": "where is Acme located",
"limit": 5,
"asOfValid": "2026-03-01T00:00:00Z"
}
}
This returns chunks whose validity window covered that timestamp (validAt <= asOfValid < invalidAt).
Matches report their validity window in validAt and invalidAt. validAt is null when the chunk has no recorded start, and invalidAt is null while the chunk is currently valid, so in default search it is always null. Queries using asOfValid return invalidAt when matching chunks retired after the requested timestamp:
{
"semantic": {
"matches": [
{
"chunkId": "acme-hq",
"rawContent": "Acme is headquartered in Osaka",
"distance": 0.21,
"validAt": "2025-06-01T00:00:00Z",
"invalidAt": "2026-04-15T00:00:00Z"
}
]
}
}
Consistent queries across sections
Pass asOfValid across semantic search and graph traversal to query a consistent point in time:
{
"semantic": {
"queryText": "Acme",
"limit": 5,
"asOfValid": "2026-03-01T00:00:00Z"
},
"graph": {
"start": {
"filters": [{ "key": "NodeId", "value": "cust-4471" }]
},
"steps": [
{ "direction": "GRAPH_DIRECTION_OUTGOING" }
],
"asOfValid": "2026-03-01T00:00:00Z"
}
}
Valid time vs transaction time
memory:query supports both valid-time filters and transaction-time snapshots:
| Parameter | Type | Scope | Retention limit |
|---|---|---|---|
asOfValid |
Valid time (business time) | Logical validity filter across stored timestamp columns | Unlimited (any historical date) |
asOf |
Transaction time (read snapshot) | Physical database read snapshot for repeatable reads | Approximately 1 hour (bounded by database retention) |
asOfTx |
Transaction time (filter) | Filters records by when they were committed (assertedAt <= asOfTx < expiredAt) |
Unlimited (must be paired with asOfValid) |
- Use
asOfValidto ask: "What was true on this date?" - Use
asOfto ask: "What did the database look like during this specific read operation?" - Use
asOfTxalongsideasOfValidto ask: "What did our system believe was true on date X, based only on data committed before date Y?"
From the CLI
jnh agents memory query (and jnh scopes memory query) takes the same bounds as flags. --as-of-valid applies to the semantic search and to a --graph-json traversal together, and the table shows each match's validity window:
$ jnh agents memory query my.agent --text "where is Acme located" \
--as-of-valid 2026-03-01 --fields chunkId,validAt,invalidAt
CHUNK VALID FROM VALID TO
acme-hq 2025-06-01T00:00:00Z 2026-04-15T00:00:00Z
as of valid 2026-03-01T14:59:59.999999999Z
A match with a VALID TO has been retired since the instant you asked about. A bare date means the end of that day in your local time zone, and the last line shows the exact instant that was read. --as-of-tx adds the transaction-time bound and requires --as-of-valid.
--since bounds the execution log from an instant on, inclusive. A bare date means the start of that day:
The CLI refuses a bound it cannot apply rather than dropping it: --as-of-tx without --as-of-valid, --as-of-valid without --text or --graph-json (the log has no valid time), --since without --log-limit, and --as-of-valid beside a --graph-json document that sets its own asOfValid or asOfTx.
Validity support by memory type
Validity windows apply to state-based records that evolve over time:
| Memory type | Validity support | Description |
|---|---|---|
| Vector chunks | Yes | Text passages with time-bounded validity |
| Graph edges | Yes | Entity relationships with time-bounded validity |
| Graph nodes | No | Entity identifiers and node definitions |
| Execution-log steps | No | Append-only event history |
Execution-log steps represent immutable events. An action taken at 10:00 cannot become invalid at 11:00, so asOfValid does not apply to the log section. To inspect execution history at a past database snapshot, use the top-level asOf parameter within the database retention window.
Valid time for formed memory
Memory written by memory:form uses the request's observedAt
timestamp as the valid-time start for new records and as the boundary timestamp
for supersessions.
Set this field when forming memory from historical conversations. Without
observedAt, formed chunks carry no valid-time start (valid for all past time)
and formed edges start at the commit time, so asOfValid queries cannot
accurately reconstruct past state.
observedAt also enforces chronological supersession: formation will not
retire a memory that became valid after the conversation occurred, preventing
historical statements from superseding newer facts. See
Forming memory.
Semantic search matches include the chunk's valid-time start in validAt,
allowing callers to evaluate validity boundaries directly from query results.
Seeing what a workspace holds
memory:inspect lists all stored records in a workspace, including superseded chunks and edges and their validity ranges:
{
"vectors": {
"chunks": [{
"chunkId": "cust-4471-address",
"rawContent": "Acme Corp, 100 Main St, Des Moines IA",
"validAt": "2025-11-01T00:00:00Z",
"invalidAt": "2026-08-01T00:00:00Z",
"assertedAt": "2025-11-01T09:14:02.113Z",
"updatedAt": "2026-08-01T11:20:31.006Z"
}]
},
"graph": {
"edges": [{
"edgeId": "cust-4471-hq",
"sourceNodeId": "acme",
"targetNodeId": "des-moines",
"relationshipType": "HEADQUARTERED_IN",
"validAt": "2025-11-01T00:00:00Z",
"invalidAt": "2026-08-01T00:00:00Z",
"updatedAt": "2026-08-01T11:20:31.006Z"
}]
}
}
Unlike search queries and traversals (which filter to active records by default), memory:inspect returns all stored records. Inspected edges with an invalidAt timestamp represent superseded relationships.
Record timestamp fields:
validAt/invalidAt: The logical validity window (when this fact was true in the real world). IfinvalidAtis null, the record is currently valid. IfvalidAtis null, the record is valid across all past time. Both chunks and edges carry these fields.assertedAt/expiredAt: The transaction time window (when this record was active in the database). IfexpiredAtis null, the record is active. Applies to chunks only; inspected edges do not report transaction time.updatedAt: The timestamp of the record's last write, including an in-place correction and the supersession that closed it.