Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ AI: → memory_branch(name="eval_sqlite")

Full API details: [API Reference Skill](skills/api-reference/SKILL.md)

Retry-safe single-memory writes: [Idempotent store protocol](docs/api/idempotent_store.md).

---

## 🔧 Commands
Expand Down
62 changes: 62 additions & 0 deletions docs/api/idempotent_store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Idempotent single-memory writes

`POST /v1/memories/idempotent` accepts the normal single-store body plus a required
`idempotency_key` containing 1–128 ASCII graphic bytes. It uses the same scoped
memory-write permission, owner-only deployment authentication, branch validation,
sensitivity checks and user database routing as ordinary memory writes.

The authenticated owner and resolved storage branch scope the key. An omitted or
blank branch follows the current checkout; specifying that same branch explicitly
replays the same receipt. The operation freezes the routed SQL store and branch
table before embedding, so a concurrent checkout cannot retarget the insert or
conflict lookup. An invalid/deleted branch fails instead of silently moving a keyed
write to another branch. Clients needing a stable scope across separate retries
must specify it explicitly (for example, `"branch": "main"`).

Clients cannot choose another
owner through JSON. Successful retries return the same persisted memory ID.
Concurrent requests use the memory primary key to arbitrate one insertion;
only the insertion winner reports a new memory and schedules entity extraction.
The idempotent path bypasses approximate vector deduplication so two different
session operations cannot acquire each other's record or session metadata.

A key reused with a different content, type, session, trust tier, confidence,
observed time, author, subject or caller metadata fails. The backend preserves a
request fingerprint in reserved `_memoria_store_request_sha256` metadata; callers
cannot set or query that reserved field, and API memory responses hide it while
returning caller metadata unchanged. Payload fingerprints canonicalize nested
JSON object keys. Ordinary vector dedup excludes receipt-bearing rows, so an
ordinary store cannot supersede a receipt or replace its metadata. A retry never
reactivates an inactive receipt. The guarantee depends on retaining the record/tombstone;
physical database or record erasure ends that receipt's lifetime. Account/branch
retention and deletion policies remain authoritative.

Protocol v1 encodes `["memoria.store.v1", owner, resolved_branch, key]` as compact
JSON, hashes it with SHA-256 and uses the first 16 bytes as a lowercase 32-character
hex memory ID. A caller can reject a successful response with an incompatible ID
before treating it as a write receipt. The identity is an operation key, not an
authorization credential. Normal single-store calls may also supply the optional
key; batch store rejects keys because it has no corresponding atomic batch
receipt contract.

The dedicated path allows callers such as Astra's durable session-memory journal
to fail before writing when a deployment uses an old backend. They must retain
source state and retry/reconcile that same frozen payload. They must not fall back
to ordinary store on 404, 405, a malformed receipt or a lost response.

Example (the token remains in the application's credential boundary):

```json
{
"content": "[@episode/summary] Session completed its task",
"memory_type": "episodic",
"session_id": "session-example",
"trust_tier": "T3",
"branch": "main",
"idempotency_key": "session-example:episode:revision-1"
}
```

Astra must deploy this backend endpoint before enabling its matched durable
finalization client. Repeated exit/close, process restart and partial-stage retry
can then reuse receipts without manufacturing duplicate episode or scene nodes.
1 change: 1 addition & 0 deletions memoria/crates/memoria-api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ pub async fn group_main_write_guard(
let branch_aware_body_route = matches!(
path,
"/v1/memories"
| "/v1/memories/idempotent"
| "/v1/memories/batch"
| "/v1/memories/correct"
| "/v1/memories/purge"
Expand Down
5 changes: 5 additions & 0 deletions memoria/crates/memoria-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fn should_mark_metrics_dirty(
if method == "POST" {
return match path {
"/v1/memories"
| "/v1/memories/idempotent"
| "/v1/memories/batch"
| "/v1/memories/correct"
| "/v1/memories/purge"
Expand Down Expand Up @@ -195,6 +196,10 @@ pub fn build_router(state: AppState) -> Router {
// In group mode these are forbidden when checked-out on `main`.
let write_routes = Router::new()
.route("/v1/memories", post(routes::memory::store_memory))
.route(
"/v1/memories/idempotent",
post(routes::memory::store_memory_idempotent),
)
.route("/v1/memories/batch", post(routes::memory::batch_store))
.route(
"/v1/memories/correct",
Expand Down
8 changes: 7 additions & 1 deletion memoria/crates/memoria-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ use std::str::FromStr;

#[derive(Deserialize)]
pub struct StoreRequest {
/// Stable owner/branch-scoped write identity; retries return the same receipt.
#[serde(default)]
pub idempotency_key: Option<String>,
pub content: String,
#[serde(default = "default_memory_type")]
pub memory_type: String,
Expand Down Expand Up @@ -457,7 +460,10 @@ pub struct MemoryResponse {
}

impl From<Memory> for MemoryResponse {
fn from(m: Memory) -> Self {
fn from(mut m: Memory) -> Self {
if let Some(metadata) = m.extra_metadata.as_mut() {
metadata.remove(memoria_core::STORE_REQUEST_HASH_FIELD);
}
Self {
memory_id: m.memory_id,
user_id: m.user_id,
Expand Down
29 changes: 28 additions & 1 deletion memoria/crates/memoria-api/src/routes/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,22 @@ pub async fn fulltext_search_memories(
Ok(Json(memories.into_iter().map(Into::into).collect()))
}

/// Separate route rejects old servers before they can perform a non-idempotent
/// write. All normal authentication, owner scoping and validation still apply.
pub async fn store_memory_idempotent(
state: State<AppState>,
auth: AuthUser,
request: Json<StoreRequest>,
) -> Result<(StatusCode, Json<MemoryResponse>), (StatusCode, String)> {
if request.idempotency_key.is_none() {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"idempotency_key is required".into(),
));
}
store_memory(state, auth, request).await
}

pub async fn store_memory(
State(state): State<AppState>,
auth: AuthUser,
Expand Down Expand Up @@ -267,7 +283,7 @@ pub async fn store_memory(
};
let m = state
.service
.store_memory_with_metadata_on_branch(
.store_memory_with_metadata_and_key_on_branch(
auth.scope_id(),
branch_param(req.branch.as_deref()),
&req.content,
Expand All @@ -279,6 +295,7 @@ pub async fn store_memory(
author,
req.subject_id,
req.extra_metadata,
req.idempotency_key.as_deref(),
)
.await
.map_err(|e| {
Expand All @@ -295,6 +312,16 @@ pub async fn batch_store(
auth: AuthUser,
Json(req): Json<BatchStoreRequest>,
) -> Result<(StatusCode, Json<Vec<MemoryResponse>>), (StatusCode, String)> {
if req
.memories
.iter()
.any(|memory| memory.idempotency_key.is_some())
{
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"idempotency_key requires the single-memory store endpoint".into(),
));
}
if req.memories.len() > 100 {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
Expand Down
Loading
Loading