DOCS

Start with the local service, add memory, recall with source quotes, and verify governed-action receipts with a pinned public key.

HTTP API Reference

Machine-readable contract: this API is also published as an OpenAPI 3.1 spec, served by every deployment at GET /openapi.json (public, no auth) and hand-authored at openapi.yaml in the repo root. CI mechanically diffs the spec against the service router in both directions, so it cannot drift from the routes this page describes.

The Shomei Governed Memory service exposes HTTP endpoints for memory ingestion, recall, governed deletion/restriction, subject-access export, and provable governance receipts. All endpoints except GET /v1/healthz, GET /v1/readyz, GET /openapi.json, POST /v1/signup, and POST /v1/signup/verify require bearer authentication via the Authorization: Bearer <api-key> header, which maps to a tenant. (POST /v1/billing/stripe-webhook is the one other non-bearer route: it is authenticated by the Stripe webhook signature instead.)

The web layer is the Python standard-library http.server — there is no framework. Trailing slashes are tolerated. The request body cap is 1 MiB; chunked (Transfer-Encoding) bodies are rejected with 400.

Authentication and scopes

A bearer key resolves to a tenant and carries a scope:

  • admin — full data plane plus destructive and control-plane routes.
  • data — data-plane reads and writes only. A data-scoped key calling an admin route gets 403 {"error":"this action requires an admin-scoped key"}.

Keys come from two sources, checked in order with constant-time comparison: the static SHOMEI_API_KEYS map (env keys are always admin-scoped) and the dynamic key registry (shm_-prefixed runtime keys, which carry their issued scope). A missing bearer returns 401 {"error":"missing bearer token"}; an unknown key returns 401 {"error":"invalid api key"}.

Routes that require an admin scope are marked (admin) below.

Configuration

The service is configured via environment variables (canonical prefix SHOMEI_; the legacy MOCHI_ prefix is honored as a fallback, SHOMEI_ wins):

Env Purpose Default
SHOMEI_BIND_HOST Bind address 127.0.0.1
SHOMEI_PORT Port 8088
SHOMEI_DATA_DIR Per-tenant DBs + KEK material ./mochi-data (cwd-relative)
SHOMEI_API_KEYS "key1:tenantA,key2:tenantB" REQUIRED
SHOMEI_KEK_PROVIDER file | inmemory | aws-kms file
SHOMEI_COMMIT_SECRET Deployment commitment secret (≥16 chars in prod) dev-only default
SHOMEI_TLS_CERT PEM cert path (set with SHOMEI_TLS_KEY to enable HTTPS)
SHOMEI_TLS_KEY PEM key path
SHOMEI_EMBEDDER hash | bedrock | ollama hash
SHOMEI_EMBED_DIMS Embedding dimension (must match model) 256
SHOMEI_SELF_SERVE_SIGNUP 1 enables /v1/signup* off
SHOMEI_GRAPH_ANSWERS_API 1 enables the unreleased Temporal Answers API route off

TLS: the scheme is http unless both SHOMEI_TLS_CERT and SHOMEI_TLS_KEY are set, which wraps the socket as https on whatever SHOMEI_PORT is. There is no separate TLS port.

Secrets resolution: SHOMEI_API_KEYS_SSM and SHOMEI_COMMIT_SECRET_SSM (AWS SSM SecureString, KMS-decrypted at boot) take precedence over the plain env var. At rest, payload and embedding cells are sealed via per-row DEK envelope encryption — not SQLCipher page encryption; each tenant holds its own workspace KEK.

The signed-receipt envelope

Governance receipts are returned inside a signed_receipt envelope with exactly these fields:

{
  "schema": "shomei.vector_delete.v1",
  "receipt_hash": "sha256:<hex over the canonical body>",
  "signer_id": "shomei-receipt:<tenant>",
  "public_key_hex": "<ed25519 public key, hex>",
  "signature_hex": "<ed25519 signature, hex>",
  "body": { "...content-free receipt body..." }
}

The ed25519 signature is over the RFC-8785 (JCS) canonical bytes of body, and receipt_hash is the SHA-256 over those same bytes. Timestamps live inside body (e.g. applied_at, erased_at, exported_at); there is no top-level signed_at. Receipts are content-free: they carry hashes/commitments (tenant_id_hash, subject_commitment, memory_id), never raw names or content. They prove authorship of the governance action, not that copies or backups elsewhere were erased.

Pin the tenant's signer public key (GET /v1/account/signer-key) and verify receipts offline with the open shomei_memory_verify tool.

Public Endpoints (no auth)

GET /v1/healthz

Coarse liveness check.

Response 200:

{ "status": "ok" }

Authenticated operators should use GET /v1/admin/health for operational detail.


GET /v1/readyz

Coarse readiness check: ready/unready only, backed by cached real probes (custody, DB write, disk, embedder). Which probe failed is operational metadata and stays behind auth on GET /v1/admin/health (its readiness field).

Response 200:

{ "status": "ready" }

Response 503:

{ "status": "unready" }

Data-Plane Endpoints (any valid bearer)

POST /v1/memories

Add a governed memory.

Request:

{
  "messages": ["text message or array of message objects"],
  "semantic_type": "GENERIC",        // optional; default "GENERIC"
  "user_id": "user@example.com",      // optional; subject identifier
  "infer": false,                     // optional; default false
  "infer_mode": "async",              // optional; default "async"
  "consistency_mode": "session",      // optional; default "session"
  "idempotency_key": "key-123",       // optional; also via the Idempotency-Key header
  "inference_profile": null,          // optional
  "source_kind": "app",               // optional
  "actor_kind": "user",               // optional
  "actor_id": "user123",              // optional
  "scope": "default",                 // optional
  "return_context_pack": false        // optional; honored only when infer=true
}

messages is required (400 if absent or empty).

Response 200 (non-infer):

{
  "results": [
    {
      "id": "lm_1a2b3c...",
      "memory": "<stored content>",
      "event": "ADD",
      "receipt": { "...content-free body..." },
      "receipt_hash": "sha256:...",
      "signed_receipt": { "...envelope..." }   // present when a signer is configured
    }
  ]
}

When infer=true the response is the inference-pipeline shape (handle/source ids, claim/event/state ids, and an optional context_pack) rather than the {"results":[{...ADD...}]} shape above. It carries a top-level recall_readiness honesty label:

  • pending_semantic_index — the memory is durably stored, governed, receipted, and deletable; it has not yet been added to the semantic search index (the async default posture).
  • semantic_ready — indexed (sync/strong writes, or after the async worker drains).

The write-time graph-edge proposer runs only on this infer=true source-and-job lane. The default direct governed add still records a caller-attested source for governance closure, but intentionally does not create claims, edge proposals, or graph edges. See Entity Memory Graph demo seeding for the deterministic zero-model seed path and the strict stub-vs-Bedrock parity boundary.

Error codes:

  • 400 — missing messages, invalid input
  • 409 — idempotency conflict
  • 413 — content exceeds the per-memory limit

POST /v1/memories/bulk

Bulk import: up to 2,000 items per request through the identical governed single-add path (chunked transactions server-side; no bulk-only write shortcut, so every item is durable, governed, receipted, and deletable the moment its chunk commits). Ships dark behind SHOMEI_BULK_IMPORT_ENABLED (404 when disabled). For larger sets, the SDK (RemoteMemory.bulk) pages transparently.

Bulk is always async: every item is forced infer=true, infer_mode=async, consistency_mode=session and returns recall_readiness: "pending_semantic_index" until the shared index worker drains it (bulk jobs drain at low priority with a weighted 4:1 interactive-first share, so a backfill can never starve interactive adds). An explicit consistency_mode: "strong" in any item fails the request with 400 strong_unsupported_in_bulk.

This route's request-body cap is 8 MiB (all other routes keep the global 1 MiB cap). The per-memory content cap applies per item unchanged (over-cap items are rejected per-item with content_too_large). One bulk request may be in flight per tenant (429 bulk_concurrency_exceeded). Metering counts only committed items, never the caller-declared count.

Request:

{
  "items": [
    {
      "messages": "text or message array",   // required per item; same coercion as single add
      "user_id": "u_123",                     // optional; all single-add provenance fields accepted:
      "occurred_at": 1751900000.0,            // source_kind, actor_kind, actor_id, scope,
      "idempotency_key": "cust-42-7",         // consent_basis, inference_profile, semantic_type
      "source_kind": "chat_turn"
    }
  ],
  "import_id": "backfill-2026-07"             // optional caller label, echoed + bound into the batch receipt
}

Request-level idempotency via the Idempotency-Key header: it derives a stable batch_id and per-item keys {key}:{index} for items without their own key, so a network retry of the whole request converges instead of double-ingesting. A replay reports current state; it never re-vouches old outcomes.

Response 200 (partial failure is normal and index-aligned — never a 4xx):

{
  "batch_id": "blk_...",
  "import_id": "backfill-2026-07",
  "accepted_count": 1994,
  "rejected_count": 6,
  "recall_readiness": "pending_semantic_index",
  "items": [
    {"index": 0, "status": "accepted", "id": "lm_...", "job_id": "job_...",
     "receipt_hash": "sha256:...", "recall_readiness": "pending_semantic_index"},
    {"index": 7, "status": "rejected",
     "error": {"code": "content_too_large", "message": "..."}}
  ],
  "batch_receipt": {
    "receipt": { "schema": "shomei.bulk_import.v1", "...": "content-free" },
    "receipt_hash": "sha256:...",
    "item_receipt_hashes": ["sha256:...", "..."],
    "signed_receipt": { "...envelope..." }
  },
  "quota": {"metered_items": 1994}
}

items has exactly N entries, index-aligned with the request. Accepted entries carry the per-item add-receipt hash (receipt_hash is the receipt identifier; the full signed per-item receipt is returned inline as signed_receipt when a signer is configured). The batch receipt is a new content-free receipt kind (shomei.bulk_import.v1) binding batch id, import label, tenant, counts, and the ORDERED hash-of-hashes over the per-item receipt hashes (RFC 8785 JCS) — it proves "these N receipted writes were one import" without carrying content or memory ids, and verifies offline with the standard open verifier.

Per-item rejection codes: invalid_item, content_too_large, idempotency_conflict, idempotency_replay_blocked, quota_exceeded, quota_metering_unavailable, internal_error (an unexpected error rolls back only its chunk; prior chunks stand; later chunks proceed).

Error codes (request-level):

  • 400 — missing/oversized items, infer=false, or any item with consistency_mode=strong (strong_unsupported_in_bulk)
  • 404 — bulk import not enabled on this deployment
  • 429bulk_concurrency_exceeded (one in-flight bulk per tenant)

POST /v1/search

Semantic search (recall) over ingested memories.

Request:

{
  "query": "what did the user say about...",   // required
  "limit": 10,                                  // optional; default 10, clamped 1..1000
  "user_id": "user@example.com",                // optional; filter by subject
  "min_score": 0.5                              // optional; minimum similarity score
}

Response 200:

{
  "results": [
    {
      "id": "lm_1a2b3c...",
      "memory": "<text>",
      "user_id": "user@example.com",
      "score": 0.1234,
      "rank_score": 0.42,                 // only when a reranker is applied
      "rank_explanation": { "...": "..." } // only when a reranker is applied
    }
  ],
  "recall_label": "...",
  "degraded": false,
  "reranked": false,
  "rerank_label": "not_configured",
  "rerank_degraded": false,
  "index_lag": {"pending_jobs": 3, "oldest_pending_seconds": 41.2}  // only when >0 jobs pending
}

A memory that is pending_semantic_index is excluded, and disclosed, never partial: it cannot appear in semantic results at any similarity and no lexical stand-in is fabricated. index_lag is the disclosure — present only when at least one committed write is not yet visible to semantic search (counts and ages only; content-free).


POST /v1/graph/answers (UNRELEASED, flag-gated)

Deterministic Temporal Answers API over admitted temporal graph edges. This route is dark by default and exists only when SHOMEI_GRAPH_ANSWERS_API=1; when the flag is off it returns the same 404 {"error":"no such route","path":"/v1/graph/answers"} shape as an unknown route. It is read-only and idempotent: no model, embedder, reranker, or LLM is called on the serve/ranking path.

Request:

{
  "subject": "user@example.com",
  "predicate": "declares_codeword",
  "user_id": "user@example.com",
  "as_of": 1750000000,
  "include_history": false,
  "limit": 50,
  "cursor": null
}
  • subject, predicate, and user_id are optional string scopes.
  • as_of is optional and must be numeric and finite. It is anchored only to source occurred_at / assertion time; the API does not parse natural-language dates from the query and does not re-anchor to in-text stated dates.
  • include_history includes closed intervals for the returned groups.
  • limit is clamped to 1..200; cursor is an opaque cursor that encodes the deterministic group sort key.
  • Restricted, held, pending-erase, consent-invalid, and deleted sources are out of the default serve view.

Response 200:

{
  "schema": "shomei.graph_answers_response.v1",
  "as_of": 1750000000.0,
  "groups": [
    {
      "group": {
        "subject": "user@example.com",
        "predicate": "declares codeword",
        "temporal_policy": "functional"
      },
      "answer_status": "single_unconfirmed",
      "current_answer": {
        "value": "ce alpha 7",
        "valid_from": 1750000000.0,
        "valid_until": null,
        "end_bound_class": "unknown_silence",
        "end_bound_status": "unknown",
        "evidence_age_seconds": 0.0,
        "newest_support_occurred_at": 1750000000.0,
        "evidence": {
          "quote": "CE-ALPHA-7",
          "span": [16, 26],
          "source_id": "lm_...",
          "occurred_at": 1750000000.0
        }
      },
      "candidates": [
        {
          "rank": 0,
          "value": "ce alpha 7",
          "end_bound_class": "unknown_silence",
          "start_bound_class": "unknown",
          "recency_score": 1.0,
          "support_count": 1,
          "evidence": {
            "quote": "CE-ALPHA-7",
            "span": [16, 26],
            "source_id": "lm_...",
            "occurred_at": 1750000000.0
          }
        }
      ],
      "total_candidates": 1,
      "trace": {
        "tier_order": [
          "end_bound_class",
          "start_bound_class",
          "recency",
          "capped_support",
          "canonical_tie_break"
        ],
        "losers": []
      },
      "function_versions": {
        "ranked_view_schema": "shomei.edge_current_answer.v1",
        "ranking_policy": "shomei.edge_ranking_policy.v2",
        "ranking_policy_hash": "sha256:...",
        "ranking_policy_overridden": false,
        "consumes_record_schema": "shomei.edge_temporal_record.v1"
      },
      "history": []
    }
  ],
  "next_cursor": null,
  "function_versions": {
    "ranked_view_schema": "shomei.edge_current_answer.v1",
    "ranking_policy": "shomei.edge_ranking_policy.v2",
    "ranking_policy_hash": "sha256:...",
    "ranking_policy_overridden": false,
    "consumes_record_schema": "shomei.edge_temporal_record.v1"
  },
  "engine_flags": {
    "aspect_kind": false,
    "temporal_dsl": false,
    "predicate_normalization": false
  }
}

Statuses include single_supported for one affirmatively grounded selected current answer, single_unconfirmed for one selected current answer whose currency rests only on unknown_silence (no stated end and no ongoing cue), multiple_supported for multi-valued live facts, live_conflict for near-tied functional competitors, stale_best_effort when the best current support is stale by policy, and no_temporal_support when no current interval is supported.

Every served value resolves to a non-empty literal source quote/span/source id. If the live source span cannot be opened or the quote is empty, the answer is omitted. Delete, restrict, legal hold, hold release, and POST /v1/forget recompute the same graph projection used by this route. Subject matching is exact after graph normalization; there is no fuzzy entity merge.

Error codes:

  • 400 — invalid JSON/body type, non-string scope, invalid cursor, non-numeric or non-finite as_of
  • 401 — missing or invalid bearer
  • 403 — enabled route but current managed-tier plan lacks graph_answers when quota enforcement is on
  • 404 — route flag off, or no such route

POST /v1/context

Render governed context: retrieve, rank, and assemble memories into a bounded token context for an LLM or agent.

Request:

{
  "query": "What did the user say about...",   // required
  "user_id": "user@example.com",                // optional; filter to one subject
  "scope": "default",                           // optional
  "as_of": 1234567890,                          // optional; must be numeric and finite
  "consistency_mode": "eventual",               // optional; default "eventual"
  "context_mode": "auto",                       // optional; default "auto"
  "token_budget": 800,                          // optional; default 800, clamped 64..8000
  "candidate_k": 12,                            // optional; default 12, clamped 1..100
  "rerank_window": null,                        // optional
  "include_raw_items": false,                   // optional; default false
  "include_sources": false                      // optional; default false
}

Response 200: A publicized context pack. query is required (400 if absent); a non-numeric or non-finite as_of is 400.

When the tenant has a receipt signer, the context pack includes a signed trace_receipt envelope:

{
  "context_text": "...",
  "items": [],
  "omitted_summary": { "...": "..." },
  "trace_id": "trace_...",
  "trace_receipt": {
    "schema": "shomei.context_trace.v1",
    "receipt_hash": "sha256:...",
    "signer_id": "shomei-receipt:...",
    "public_key_hex": "...",
    "signature_hex": "...",
    "body": {
      "schema": "shomei.context_trace.v1",
      "tenant_id_hash": "hmac-sha256:...",
      "subject_pseudonym": "hmac-sha256:...",
      "query_commitment": "hmac-sha256:...",
      "served_set_witness": "sha256:...",
      "omissions_commitment": "sha256:...",
      "created_at": 1234567890.0,
      "nonce": "...",
      "proof_scope": "integrity_of_served_and_withheld_set_as_of_query_not_retrieval_optimality"
    }
  }
}

The trace receipt is content-free: it binds a keyed subject pseudonym, a keyed query commitment, the served-id witness, and the omissions commitment, never the raw user_id, query, or context text. Before signing, every candidate artifact is accounted for as served or omitted. Offline verification with shomei_memory_verify.verify_context_trace_receipt(...) checks the ed25519 signature and recomputes the served-set witness from caller-supplied served opaque ids; if omission details are supplied, it also checks the omissions commitment. Honest scope: the signature attests integrity of the served/withheld set as of the query, not retrieval optimality or answer correctness.

The query_commitment shown above belongs to the returned signed receipt; the durable context-trace row does not retain it or a caller-derived scope commitment. It stores a content-free unretained_<trace_id> marker (or an erased_<rowid> tombstone) instead.


GET /v1/memories

List memories (optionally filtered by subject).

Query params:

  • limit — page size (default 100, clamped 1..1000)
  • user_id — narrow to one subject

Response 200:

{
  "results": [
    {
      "id": "lm_1a2b3c...",
      "memory": "<text>",
      "user_id": "user@example.com",
      "metadata": { "semantic_type": "GENERIC" }
    }
  ]
}

GET /v1/memories/{id}

Retrieve a single memory.

Response 200: the _public shape (id, memory, user_id, metadata.semantic_type), as above, plus recall_readiness (semantic_ready for a directly-served governed row).

For an inference-backed id whose content is not directly servable (its semantic-index job is pending, blocked, or failed), the response is the content-free readiness view instead of a silent 404:

{
  "id": "lm_1a2b3c...",
  "recall_readiness": "pending_semantic_index",   // or semantic_ready | index_blocked | index_failed
  "semantic_recall_index": {"job_id": "job_...", "worker_status": "queued",
                            "worker_substatus": "state_views_index_queued", "error_code": null}
}

index_blocked means a governance verb (delete/erase) won the race before indexing — honest, expected, terminal; it reveals no content and the same fact is visible via the memory's own lifecycle surfaces. index_failed surfaces a failed (or partially-indexed) job with its error code, so a silent embed failure is visible instead of quietly never-recallable.

Response 404 {"error":"not found"} — erased, restricted, or missing. Art. 18-restricted ids stay 404-indistinguishable on the readiness lane too (it is never a restriction-state oracle).


GET /v1/memories/{id}/verify

Operator-reported governance status for a memory (Tier-2: operator-verifiable, not an independent proof).

Response 200:

{
  "valid": true,
  "retrieval_safe": true,
  "embedding_retired": false,
  "physically_purged": false,
  "failures": []
}

For a hidden or absent memory: {"valid": null, "reason": "not_found"}.


GET /v1/memories/{id}/history

Lifecycle history rows for a memory.

Response 200:

{ "results": [ { "...history rows..." } ] }

A hidden, non-erased memory returns {"results": []}.


GET /v1/memories/{id}/lineage (alias GET /v1/memories/{id}/verify_lineage)

Content-free lineage verification.

Response 200 (when supported):

{
  "lineage_root_hash": "...",
  "lineage_status": "...",
  "source_count": 3,
  "replay_verified": true,
  "evidence_tier": "..."
}

Response 501 (when the engine reports lineage unavailable):

{
  "error": "lineage verification unavailable",
  "reason": "...",
  "replay_verified": false
}

GET /v1/memories/{id}/lineage/neighbors

Content-free immediate provenance neighbors.

Query params:

  • directionout (default) | in | both (other values → 400)
  • limit — default 50, clamped 0..100

Response 200: content-free neighbor list. Response 501 when lineage is unsupported.


GET /v1/memories/{id}/lineage/graph

Content-free immediate provenance nodes + edges.

Query params: same direction / limit as lineage/neighbors.

Response 200: content-free nodes + edges. Response 501 when lineage is unsupported.


GET /v1/export

Subject Access Request / Art. 15 portable evidence bundle for one subject: LIVE governed memories, LIVE inference-derived records, the subject's content-free derived-artifact lineage set, and content-free erasure evidence for already-forgotten memories.

Query params:

  • user_id — subject identifier (required, 400 if absent)
  • include_content — include message text (default false; true requires an admin key, else 403)
  • include_restricted — Art.15 subject-access mode (default false; requires an admin key when true)
  • subject_access — alias for include_restricted

Response 200:

{
  "subject": "user@example.com",
  "count": 3,
  "content_included": false,
  "restricted_content_included": false,
  "memories": [
    {
      "id": "lm_1a2b3c...",
      "semantic_type": "GENERIC",
      "held": false,
      "restricted": false,
      "recallable": true,
      "consent_basis": "...",
      "verify": { "...": "..." },
      "memory": "<text>"            // present only when include_content=true and caller is admin
    }
  ],
  "erased_count": 1,
  "erased": [ { "...content-free erasure evidence..." } ],
  "inference": { "sources": [], "states": [] },
  "inference_count": 0,
  "inference_restricted_retained": [],
  "inference_restricted_retained_count": 0,
  "derived": [
    { "id": "artifact:<hex>", "status": "active", "recallable": true }
  ],
  "derived_count": 1,
  "live_count": 5,
  "note": "evidence export: ...",
  "receipt": { "...SubjectExportReceipt body..." },   // when a commitment key is present
  "receipt_hash": "sha256:...",
  "signed_receipt": { "...envelope..." }              // when a signer is present
}

The derived-artifact disclosure is the Art. 15 mirror of the Art. 17 deletion cascade. derived is a list of content-free opaque artifact:<hex> ids; recallable is honest per node (true only when at least one supporting source is still served — a node whose sources are all restricted stays in the set but is not recallable). live_count includes the derived ids. The signed witness binds the derived ids into the export commitment under set-schema shomei.subject_export_set.v2; the receipt body schema remains shomei.subject_export.v1.

Art.15 access mode: default export is content-free evidence. include_content=true adds content for admin-scoped callers. include_restricted=true (or subject_access=true) is the subject-access override: with include_content=true, it returns restricted-but-retained content while the same rows remain restricted: true and recallable: false. A data-scoped key gets 403 for content or access mode. Crypto-erased content stays unreturnable.

The witness proves the delivered set is untampered as of exported_at. It does not prove that an operator's enumeration was complete against a dishonest operator — operator-independent completeness is the measured-enclave (Tier-3) roadmap, not shipped today.


Subject-Level Governance

POST /v1/forget (admin)

Server-side Data Subject Request / Art. 17 erasure. Before the first target is changed, Shomei durably snapshots the subject's governed and inference target ids. Local execution is quiesced; if the process dies between targets, workspace reopen replays the remaining journal before the facade is returned. This is a crash-resumable saga across stores, not literal cross-provider ACID. Held memories are not erased now — they are Art. 18-restricted and carry a durable obligation that auto-erases on hold release.

Request:

{
  "user_id": "user@example.com",   // required
  "render": false                   // optional; also return a markdown RTBF certificate
}

Response 200 (selected fields; the full object carries per-kind counts and lists):

{
  "subject": "user@example.com",
  "erased_count": 41,
  "deferred_count": 1,
  "external_delete_pending_count": 0,
  "external_delete_pending": [],
  "source_local_crypto_erased_count": 4,
  "source_external_delete_pending_count": 0,
  "source_external_delete_pending": [],
  "source_external_vector_delete_confirmed": true,
  "erased": ["lm_..."],
  "deferred_held": ["lm_..."],
  "receipt_hashes": [ { "id": "lm_...", "receipt_hash": "sha256:..." } ],
  "signed_receipts": [ { "id": "lm_...", "signed_receipt": { "...envelope..." } } ],
  "restriction_receipts": [ { "id": "lm_...", "signed_receipt": { "...envelope..." } } ],
  "closure_receipt": { "...SubjectClosureReceipt body, including nonce..." },
  "closure_receipt_hash": "sha256:...",
  "signed_closure_receipt": {
    "schema": "shomei.subject_closure.v2",
    "receipt_hash": "sha256:...",
    "signer_id": "shomei-receipt:...",
    "public_key_hex": "...",
    "signature_hex": "...",
    "body": { "...": "..." }
  },
  "human_readable_certificate": "## Right to Be Forgotten\n..."  // when render=true and a closure was minted
}

The closure block is present only when both a signer and a commitment key are configured. Per-memory proofs live in signed_receipts (a list); the subject-level proof is signed_closure_receipt. SubjectClosureReceipt carries a per-forget() nonce so repeated forget attempts have distinct signed bodies. There is no top-level erased boolean.

Response 503 — external deletion pending: the body has the same ForgetResponse shape, with external_delete_pending_count and/or source_external_delete_pending_count greater than zero. Each pending item carries an opaque id, transition_id, local_crypto_erased: true, and retryable: true. The local per-row encryption keys are already destroyed, but Shomei has not confirmed exact provider absence, so this is not a successful global erasure and no complete disposition is implied. Preserve the returned retry identifiers and retry the indicated delete transition.

An external upsert that was in flight when a process died or returned an ambiguous transport error also keeps the erase pending. Adapters without provider-side operation status/versioning may require operator/provider reconciliation; a current point-read absence is not promoted to success while the older upsert might still land. Likewise, an upsert enqueue acknowledgement is accepted only after an exact point read observes the row present.


Memory Governance (Hold, Restrict, Erase, Update)

POST /v1/memories/{id}/hold (admin)

Place a legal hold (blocks deletion; preserve-but-allow-use).

Response 200:

{ "id": "lm_1a2b3c...", "held": true, "changed": true }

For an inference handle the response also carries handle_id, handle_kind, and source_ids.


DELETE /v1/memories/{id}/hold (admin)

Release a legal hold.

Response 200:

{ "id": "lm_1a2b3c...", "held": false, "changed": true, "obligations_fired": [] }

obligations_fired is present when releasing the hold fires deferred erasure obligations.


POST /v1/memories/{id}/restrict (admin)

Art. 18 restriction (out of recall, preserved, reversible).

Request:

{ "reason": "art18_request" }

reason must be one of the controlled restriction reason codes (400 otherwise).

Response 200:

{
  "id": "lm_1a2b3c...",
  "restricted": true,
  "changed": true,
  "receipt": { "...": "..." },           // present when changed
  "receipt_hash": "sha256:...",          // present when changed
  "signed_receipt": { "...envelope..." } // present when changed and a signer is configured
}

DELETE /v1/memories/{id}/restrict (admin)

Lift an Art. 18 restriction (the engine records reason="art18_lifted"; any caller-supplied reason is ignored).

Response 200: as above with restricted: false. When the restriction cannot be lifted, the response carries changed: false and a reason such as consent_invalid or pending_erasure_obligation, plus a note.


POST /v1/memories/{id}/forget (admin)

Governed erasure of a single inference handle (source + derived items).

Response 200: the inference response shape. Response 404 when the handle status is rejected.


Inference-evidence controls (admin)

These operate on an inference handle, each returning the inference response shape and 404 when the handle status is rejected:

  • POST /v1/memories/{id}/confirm — append tool-confirmed evidence.
  • POST /v1/memories/{id}/correct — body correction_text (falls back to text), optional user_id.
  • POST /v1/memories/{id}/dispute — body reason (default user_dispute).
  • POST /v1/memories/{id}/mark_irrelevant — body reason (default irrelevant).
  • POST /v1/memories/{id}/change_scope — body scope.
  • POST /v1/memories/{id}/pin — ranking control.

PUT /v1/memories/{id} (admin)

Governed update (revoke-and-recreate; the result carries a new id).

Request:

{
  "messages": [ "..." ],   // or "data"; one is required
  "semantic_type": "GENERIC"  // optional
}

Response 200: the update result, including receipt and signed_receipt.

Error codes:

  • 400 — both messages and data absent, or other invalid input
  • 404 — unknown or already-erased memory
  • 409 — cannot be updated in its current state (e.g. Art. 18 restriction)
  • 423 — legal hold prevents update

DELETE /v1/memories/{id} (admin)

Governed crypto-erase of a single memory (idempotent).

Response 200 (erased):

{
  "message": "memory deleted (governed crypto-erasure)",
  "deleted": true,
  "receipt": { "...VectorDeleteReceipt body..." },
  "receipt_hash": "sha256:...",
  "key_custody": "...",
  "retirement_hashes": [ "..." ],
  "signed_receipt": { "...envelope..." }   // present when a signer is configured
}

Response 423 (legal hold):

{
  "message": "memory under legal hold; delete blocked",
  "deleted": false,
  "held": true
}

Response 404:

{ "message": "...not found", "deleted": false }

Receipt Rendering

POST /v1/render

Render a signed receipt into a human-readable GC/DPO-facing statement.

Request:

{
  "signed_receipt": { "...envelope (object, required)..." },
  "format": "both"   // optional; markdown | html | both (default both)
}

Response 200:

{
  "pinned_public_key_hex": "...",   // null if the tenant is erased (renders UNPINNED)
  "note": "Produced by the operator service... For an INDEPENDENT attestation an auditor re-runs the open verifier.",
  "statement_markdown": "## ...",   // when format is markdown or both
  "statement_html": "<h2>...</h2>"  // when format is html or both
}

This is the operator's own rendering, which pins its own published signer key. For an independent attestation, an auditor re-runs the open verifier: python -m shomei_memory_verify receipt.json --pin <key> --render.


Account

GET /v1/account/signer-key

This tenant's receipt-signer public key (pin it for offline verification).

Response 200:

{ "tenant": "acme", "signer_public_key_hex": "..." }

Response 410 when the tenant has been erased.


Control Plane (admin)

POST /v1/admin/erase-tenant

Crypto-erase an entire tenant (all data + KEK); returns a durable closure receipt. Idempotent — re-erasing returns the persisted receipt.

Response 200:

{
  "erased": true,
  "restore_immune": false,
  "signed_receipt": {
    "schema": "shomei.tenant_closure.v1",
    "receipt_hash": "sha256:...",
    "signer_id": "shomei-receipt:...",
    "public_key_hex": "...",
    "signature_hex": "...",
    "body": {
      "tenant_id_hash": "...",
      "custody_tier": "file|aws-kms|inmemory",
      "root_ref": "...",
      "erased_at": 1234567890,
      "statement": "..."
    }
  }
}

restore_immune is true when a restore-immune (S3 Object-Lock) ledger recorded the event. An already-erased tenant surfaces 410 from other routes.


GET /v1/admin/health

Authenticated operational health.

Response 200:

{
  "status": "ok",
  "tenants_loaded": 5,
  "warm_load_failure_count": 0,
  "readiness": { "ready": true, "checks": { "...": "..." }, "age_seconds": 1.2, "ttl_seconds": 10 },
  "decay_scheduler": { "enabled": false, "retired": true },
  "entity_graph_seed": { "edge_source": "off", "zero_model_ready": false }
}

readiness carries the per-check probe detail backing GET /v1/readyz (null when no probe is wired, e.g. the library/test path). entity_graph_seed is a content-free, model-id-free preflight for the deterministic demo seed harness. zero_model_ready is true only for the startup-latched stub edge proposer, hash embedder, and deterministic extractor.


GET /v1/admin/keys

This tenant's key metadata (never secret/hash).

Response 200:

{ "keys": [ { "lookup_id": "...", "label": "...", "scope": "admin|data", "status": "...", "...": "..." } ] }

Response 404 {"error":"key registry not enabled"}; 410 if the tenant is tombstoned.


POST /v1/admin/keys/issue

Mint an additional key for this tenant.

Request:

{
  "scope": "data",        // optional; admin | data (default data)
  "label": "mobile-app",  // optional; default "manual-<scope>"
  "ttl_seconds": 2592000  // optional; seconds until expiry
}

Response 200:

{
  "api_key": "shm_...",
  "lookup_id": "shm_abc123...",
  "scope": "data",
  "note": "Store this key now; it is shown only once."
}

Errors: 400 invalid ttl_seconds/scope; 404 registry not enabled; 410 tombstoned.


POST /v1/admin/keys/rotate

Mint a fresh key and retire the old one (with a migration grace window).

Request:

{
  "lookup_id": "shm_abc123...",
  "grace_seconds": 3600,   // optional; default 0
  "ttl_seconds": 2592000   // optional
}

Response 200:

{
  "api_key": "shm_...",
  "lookup_id": "shm_def456...",
  "old_lookup_id": "shm_abc123...",
  "grace_seconds": 3600,
  "note": "Store this key now; it is shown only once."
}

Errors: 400 invalid grace_seconds/ttl_seconds; 404 registry off / no manageable key; 409 not rotatable; 410 tombstoned.


POST /v1/admin/keys/revoke

Terminally revoke a dynamic key (effective on the next request, no restart).

Request:

{ "lookup_id": "shm_abc123..." }

Response 200:

{ "revoked": true, "lookup_id": "shm_abc123..." }

Errors: 404 registry off / no manageable key; 410 tombstoned.


GET /v1/context/{trace_id}

Debug trace for a context render (admin).

Response 200: a publicized (content-free) trace. Response 404 {"error":"trace not found"}.


Self-Serve Signup (opt-in: SHOMEI_SELF_SERVE_SIGNUP=1)

POST /v1/signup

Begin a self-serve signup (proof-of-work-gated, rate-limited, capped). Returns a PoW challenge.

Response 200: the PoW challenge object.

Response 404 {"error":"self-serve signup is not enabled"} when signup is off.

Response 429 when rate-limited or the global cap is reached.


POST /v1/signup/verify

Verify the proof-of-work and issue the new tenant's first API key (shown once).

Request:

{ "signup_id": "sup_abc123...", "nonce": "..." }

Response 200:

{
  "tenant_id": "new_acme",
  "api_key": "shm_...",
  "lookup_id": "shm_abc123...",
  "signer_public_key_hex": "...",   // best-effort; omitted on a concurrent tenant erase
  "tier": "test",
  "note": "Store this key now; it is shown only once..."
}

The first key's scope is the registry's issue default — it is not asserted as admin-scoped.

Response 404 when signup is off; 500 {"error":"key registry unavailable"} if the registry is missing.


Retired Endpoints

These remain only to fail old clients honestly.

Route Verb Response
POST /v1/capabilities POST 400 {"error":"PCLD capability auth has been retired"}
POST /v1/admin/decay-tick (admin) POST 200 {"transitioned":0,"retired":true,"reason":"graded_decay_retired"}
GET /v1/memories/{id}/verify_decay GET 200 {"counterfactual_verified":null,"supported":false,"reason":"graded_decay_retired"}
GET /v1/memories/{id}/audit GET 200 {"supported":false,"reason":"graded_decay_retired"}
POST /v1/memories/{id}/make_temporary (admin) POST 200 {"status":"rejected","retired":true,"reason":"temporary_ttl_retired"}

Error Responses

Every error body carries BOTH the legacy human-readable string and a stable machine-readable envelope:

{
  "error": "tenant has been erased",
  "error_detail": {
    "code": "gone_erased",
    "message": "tenant has been erased",
    "request_id": "0f3c9a2e6b1d4c58a7e2d90f4b6c8a13"
  }
}
  • error (string): the legacy field, byte-for-byte what it was before the envelope shipped. It stays for at least one deprecation cycle, so existing clients keep working with no changes. It always equals error_detail.message.
  • error_detail.code: the stable contract. Branch on this, never on message text (messages may be reworded; codes are never renamed or reused). The full taxonomy is the table below and the ErrorDetail schema in the machine-readable contract (openapi.yaml / GET /openapi.json).
  • error_detail.request_id: the request's correlation id (see Request ids below).

A future major version will move the error_detail object into error itself; until then both fields are always present. Route-specific context fields still appear alongside (for example path on unknown-route 404s, detail on 413s, and reason/quota_enforced/used/limit on quota rejections).

Internal errors return 500 with error = the exception class name ONLY (no stack traces or internals leaked) and error_detail.code = internal; quote the request_id when reporting one; it points the operator at the exact server-side log lines. Every response is screened for leaks on egress; a detected content leak fails closed as 500 {"error":"ResponseLeak"}.

Request ids

Every response (success and error, on every route) carries an X-Request-Id header. The id is a fresh uuid4 hex per request; if you send an X-Request-Id request header it is honored after sanitizing to [a-zA-Z0-9-] (max 64 chars; an id that sanitizes to empty is replaced with a fresh one). The same id appears in error_detail.request_id and in the service's own log lines as rid=<id>, so one id correlates a client-side failure report with the server-side trace. Pass your own id to stitch Shomei calls into an existing distributed trace.

Error Codes

error_detail.code HTTP When
bad_request 400 Invalid input: missing required field, body over the 1 MiB cap, chunked body, malformed JSON, route-specific validation
unauthorized 401 Missing or unknown bearer token
forbidden_scope 403 Insufficient scope (a data key attempting an admin action, or include_content=true without admin)
not_found 404 Resource not found, already erased, or unknown route
conflict_governance_state 409 Conflict: idempotency conflict, memory/key not updatable or rotatable in its current governance state, binding already exists
gone_erased 410 Tenant has been crypto-erased (any route)
payload_too_large 413 Content exceeds the per-memory limit
locked_legal_hold 423 Legal hold prevents deletion/update (governance preserved the memory)
rate_limit_exceeded 429 Per-minute rate limit exceeded (quota enforcement on); carries Retry-After
quota_exceeded 403 Monthly API-call, memories-live, or cost cap exceeded (quota envelope with reason/used/limit)
plan_feature_required 403 The current plan does not include this feature (body carries feature and plan)
not_implemented 501 This build/store does not support the capability (e.g. lineage on a non-lineage store)
service_unavailable 503 Embedder outage (strict mode), quota/plan metering unavailable (fail-closed), control-plane dependency down
internal 500 Internal error; error is the exception class name only
bulk_concurrency_exceeded 429 Another bulk import is already in flight for this tenant (one at a time)
strong_unsupported_in_bulk 400 A bulk item declared consistency_mode=strong; write that item singly

The enum only grows: new codes may be added, but existing codes are never renamed or reused, so treat an unknown code as its HTTP status class.

Retry Guidance

  • 429 always carries a Retry-After header (seconds). Wait at least that long, then retry.
  • 503 does not carry Retry-After today. Retry with exponential backoff and jitter; these are transient by construction (fail-closed metering, strict-mode embedder outage).
  • Other 4xx are not retryable as-is: fix the request (bad_request, payload_too_large), the key (unauthorized, forbidden_scope), or the plan (quota_exceeded, plan_feature_required). gone_erased and locked_legal_hold are governance outcomes, not transient failures.

Governance Guarantees and Honest Boundaries

  • Crypto-erasure: DELETE /v1/memories/{id} and POST /v1/forget wrap every deletion in an ed25519-signed, content-free receipt, pinnable by customers.
  • No resurrection: Erased tenants (POST /v1/admin/erase-tenant) can be recorded in a restore-immune ledger (opt-in S3 Object-Lock) so a whole-volume restore cannot roll back the tombstone.
  • Content-free proof: Receipts and audit rows carry commitments and hashes (tenant_id_hash, subject_commitment, memory_id), never raw tenant/user names or content. A receipt proves the governance action was authored by the tenant's signer; it does not prove that copies or backups elsewhere were erased.
  • Legal hold: Held memories cannot be deleted or updated (423); a forget defers them via Art. 18 restriction and auto-erases on hold release.
  • Art. 18 restriction: Memories can be restricted (out of recall, preserved, reversible) without deletion.
  • Tier-2, operator-verifiable today: /verify and the rendered statements are operator-reported. The signed witness in an export proves the delivered set is untampered as of exported_at, but operator-independent completeness against a dishonest operator is the measured-enclave (Tier-3) roadmap — not shipped today.
  • Key custody is the proof boundary: Receipt signers and commitments are HKDF-derived per tenant from the tenant's own KEK; dev custody (file/inmemory providers) is dev-only and is blocked in production unless explicitly opted in.

See Also