Skip to content

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.

The replacement chunk requires a new, unused chunkId. Reusing priorChunkId or another existing ID returns ALREADY_EXISTS.

Graph edges follow the same pattern at POST /v1/agents/{agentInstanceId}/graph/edges:supersede using priorEdgeId and newEdge.

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.

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 newChunkId or newEdgeId 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 --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 only matches currently valid chunks (where invalidAt is absent or in the future). Once superseded, 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).

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 asOfValid to ask: "What was true on this date?"
  • Use asOf to ask: "What did the database look like during this specific read operation?"
  • Use asOfTx alongside asOfValid to ask: "What did our system believe was true on date X, based only on data committed before date Y?"

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.

Seeing what a workspace holds

memory:inspect lists all stored records in a workspace, including superseded chunks 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"
    }]
  }
}

Unlike search queries (which filter to active records by default), memory:inspect returns every stored record.

Record timestamp fields:

  • validAt / invalidAt: The logical validity window (when this fact was true in the real world). If invalidAt is absent, the record is currently valid. If validAt is absent, the record is valid across all past time.
  • assertedAt / expiredAt: The transaction time window (when this record was active in the database). If expiredAt is absent, the record is active.
  • updatedAt: The timestamp when the record was last modified via in-place correction.