Skip to content

Guides

Propagate identity into memory layers

Verify application, agent, and end-user identity, then enforce a private memory partition on every operation.

Alter’s private memory boundary is verified application + current acting agent + represented end user. Different agents serving the same user have different partitions. A verified headless agent can explicitly use an application + agent partition, which is structurally separate from every user partition. An application-only identity cannot open private agent memory.

The backend establishes the relationship using authenticated agent credentials and a verified user token, or its authorized consent/delegation resolution path. Supplying an arbitrary app_user_id does not establish that relationship. Credential permission and memory access remain separate: an identity assertion identifies the caller; your trusted service decides whether to offer memory access.

Conversation, task, run, thread, project, group, and profile labels do not establish ownership. Developers own additional context and sharing policies. Subdivisions can only append to the verified boundary. Prior actors in an assertion’s delegation history do not grant memory access.

Run the integration inside a trusted service. Keep the raw store and its credentials out of agent tools and untrusted execution environments. An agent with unrestricted datastore access can bypass any SDK wrapper. A namespace string, serialized IdentityContext, or serialized MemoryScope is not an authorization credential. Process-local provenance checks guard against accidental misuse; they do not sandbox arbitrary application code.

ScopedMemoryStore supports the async namespace-store contract exercised against LangGraph InMemoryStore. It constructs namespaces for get, put, search, delete, and namespace listing, and rejects results outside the requested partition. It does not expose raw batch operations or the underlying store. Search pagination defaults to 10 results; namespace listing defaults to 100. Supply a positive limit and nonnegative offset to paginate. A developer path may have at most 16 segments of at most 255 UTF-8 bytes each, and a key at most 512 characters: these are identifiers, not content.

from alter_sdk import Agent, ScopedMemoryStore
from langgraph.store.memory import InMemoryStore
# user_jwt must come from the authenticated request, never model arguments.
user_jwt = "VERIFIED_REQUEST_USER_JWT"
async with Agent(api_key="YOUR_AGENT_API_KEY") as agent:
async def resolve_current():
return await agent.resolve_identity(user_token=user_jwt)
memory = ScopedMemoryStore(
InMemoryStore(), await resolve_current(), refresh_identity=resolve_current,
)
# The path is developer-owned and always appended under the verified boundary.
await memory.put(["conversation", "conversation-123"], "preference", {"language": "en"})
item = await memory.get(["conversation", "conversation-123"], "preference")
items = await memory.search()
namespaces = await memory.list_namespaces()
await memory.delete(["conversation", "conversation-123"], "preference")
import { Agent, ScopedMemoryStore, type NamespaceStore } from "@alter-ai/alter-sdk";
// Supply your LangGraph store in the trusted service.
declare const rawStore: NamespaceStore;
const agent = new Agent({ apiKey: "YOUR_AGENT_API_KEY" });
try {
const userJwt = "VERIFIED_REQUEST_USER_JWT";
const resolveCurrent = () => agent.resolveIdentity({ userToken: userJwt });
const memory = new ScopedMemoryStore(rawStore, await resolveCurrent(), {
refreshIdentity: resolveCurrent,
});
await memory.put(["conversation", "conversation-123"], "preference", { language: "en" });
const item = await memory.get(["conversation", "conversation-123"], "preference");
const items = await memory.search();
const namespaces = await memory.listNamespaces();
await memory.delete(["conversation", "conversation-123"], "preference");
} finally {
await agent.close();
}

The resolver must call Alter on every invocation, without returning a cached context: the store proves the identity it returns was minted after the operation started, and a memoized or re-used result is rejected with memory_refresh_not_live. A resolver error fails the operation; there is no offline fallback. The refreshed application, agent, and user must match the original binding (memory_identity_changed otherwise), and the user must be active. Create a new store binding when the principal changes. Authorization occurs at operation start; a lifecycle change does not cancel an operation already in flight.

For a headless agent, omit user inputs and pass agent_only=True / { agentOnly: true }. This flag cannot drop a user from an existing user identity. Missing identity, suspended users, app-only callers, and forged contexts raise MemoryIsolationError; branch on its stable code (the full table is in Errors), never on the message. A malformed path, key, value, query, filter, pagination argument or option is a programming error and raises AlterValueError instead, so a MemoryIsolationError handler only ever sees an authorization outcome:

from alter_sdk import MemoryIsolationError
try:
await memory.put(["conversation", "conversation-123"], "preference", {"language": "en"})
except MemoryIsolationError as e:
if e.code == "memory_user_not_active":
deny_and_notify_user()
else:
deny()
import { MemoryIsolationError } from "@alter-ai/alter-sdk";
try {
await memory.put(["conversation", "conversation-123"], "preference", { language: "en" });
} catch (e) {
if (e instanceof MemoryIsolationError && e.code === "memory_user_not_active") {
denyAndNotifyUser();
} else if (e instanceof MemoryIsolationError) {
deny();
} else {
throw e;
}
}

Verify assertions at a downstream boundary

Section titled “Verify assertions at a downstream boundary”

A remote caller sends the signed token, not identity JSON. Configure issuer, audience, expected application, and trusted JWKS on the receiving service. The issuer is the Alter host the application is configured against, pinned in the verifier’s own configuration and never read from the token; the comparison is an exact string match, so pin it without a trailing slash. Fetch and refresh keys from that issuer’s /.well-known/alter-identity-jwks.json; never select the key endpoint from token claims or headers. Cache these public keys and refresh on rotation; an unknown key fails closed until trusted keys are refreshed. A key set that carries private key material (a d parameter) is a misconfiguration and is rejected outright.

Python requires pip install 'alter-sdk[identity]'; without the extra, verify_identity_assertion raises ImportError naming it. TypeScript includes its JWT verifier dependency. The TypeScript jwks option is typed by the SDK’s own JsonWebKeySet, so a parsed JWKS response is assignable without any third-party type.

An assertion opens a private partition only when it was minted by an agent (Agent.assert_identity / agent.assertIdentity), because the partition is application + agent + user. An assertion minted through an App carries no acting agent and the store refuses it with memory_agent_required.

from alter_sdk import ScopedMemoryStore, verify_identity_assertion
# assertion_token, trusted_jwks, and raw_store are supplied by your service.
identity = verify_identity_assertion(
assertion_token,
issuer="https://YOUR_ALTER_ISSUER", # exact match, no trailing slash
audience="https://memory.example.com",
jwks=trusted_jwks,
expected_app_id="YOUR_APP_ID",
)
memory = ScopedMemoryStore(raw_store, identity)
await memory.put([], "preference", {"language": "en"})
import {
ScopedMemoryStore, verifyIdentityAssertion,
type JsonWebKeySet, type NamespaceStore,
} from "@alter-ai/alter-sdk";
declare const assertionToken: string;
declare const trustedJwks: JsonWebKeySet;
declare const rawStore: NamespaceStore;
const identity = await verifyIdentityAssertion(assertionToken, {
issuer: "https://YOUR_ALTER_ISSUER", // exact match, no trailing slash
audience: "https://memory.example.com",
jwks: trustedJwks,
expectedAppId: "YOUR_APP_ID",
});
const memory = new ScopedMemoryStore(rawStore, identity);
await memory.put([], "preference", { language: "en" });

The verifier returns the ownership fields (application, current agent, represented user, and active-at-issuance status). Profile, group, and trace claims are not reconstructed into the returned context.

Verification pins ES256 and typ=alter-identity+jwt, validates issuer/audience, signature, key ID, required claims, application binding, current actor, and assertion lifetime. Optionally pin expected_agent_id / expectedAgentId. When the assertion names an acting agent, the agent_auth claim recording how Alter authenticated that agent must be present; its value set is open, so a new attestation tier never breaks a deployed verifier. Assertions have a server lifetime of 10–300 seconds (default 120), and the verifier enforces the same 300-second ceiling. exp is strict; iat tolerates at most 5 seconds of skew, so a verifier running slightly behind the issuer still accepts a fresh assertion. Keep server clocks synchronized beyond that. A missing or malformed verifier argument raises AlterValueError; a rejected token raises MemoryIsolationError with code memory_assertion_invalid and a coarse details["reason"] / details.reason of malformed, header, key, signature, lifetime, claims or binding, never any token, claim or key material.

assert_identity / assertIdentity responses also establish local provenance through the authenticated SDK connection. A constructed or deserialized IdentityContext does not. Only assertion-backed identities can authorize offline store operations; ordinary resolve responses are snapshots requiring live refresh.

EventLive modeAssertion mode
User suspended/deprovisioned or agent/key no longer eligibleNext operation’s resolution/active check denies accessExisting assertion can remain usable until expiry
Resolver unavailableDeny; no cache fallbackNo live resolver call
Assertion expiresResolve current identity if using live modeDeny; obtain and verify a new assertion
Credential consent revokedReevaluate the actual identity resolution pathNo immediate invalidation of an issued assertion

Revoking one credential consent does not universally revoke identity or memory access: a verified user-token path or another valid authority path may remain. This feature does not create a separate memory-grant system or sharing policy.

An assertion is a bearer identity statement, not a one-time-use credential: presenting the same assertion twice within its lifetime yields the same verified identity and no additional capability, so the verifier validates jti and exp but does not track them, and the returned IdentityContext carries neither. A boundary that needs a single-use guarantee (a non-idempotent side effect keyed on the assertion itself) reads jti and exp from the already-verified token with its JWT library and keeps a short jti cache bounded by exp, which is at most 300 seconds away.

Each identifier is encoded independently using unpadded UTF-8 base64url, with no Unicode normalization:

User: ("alter", "v2", "agent-user", encode(app), encode(agent), encode(user))
Headless: ("alter", "v2", "agent", encode(app), encode(agent))

identity.memory_scope() / identity.memoryScope() returns metadata for the verified snapshot. partition_key / partitionKey joins the namespace with / and adds a trailing /. namespace_for(path) / namespaceFor(path) appends encoded developer segments. Empty segments, invalid Unicode, more than 16 segments, and a segment over 255 UTF-8 bytes are rejected with AlterValueError. A slash inside a segment is encoded, so ['a/b'] differs from ['a', 'b'].

user_key, agent_key, and app_key (camelCase in TypeScript) remain correlation labels, not sufficient private partitions on their own. There is no run key: trace identifiers remain available under identity.trace for diagnostics and developer-chosen subdivisions, and never select memory.

The SDK wrapper enforces the namespace-store contract; it does not automatically secure every vendor or checkpoint API. For Mem0, Zep, Honcho, and other integrations, a trusted gateway must bind the full partition to every supported operation and verify the provider’s filter/ACL semantics. Do not treat a user_id, thread ID, metadata filter, or matching key format as proof of authorization. LangGraph checkpointers and LangSmith thread APIs require their own thread authorization; this store wrapper does not intercept them.

Shared memories and additional business policies belong to the developer. Keep their authorization explicit rather than deriving access from a task label, group string, previous actor, or shortened private prefix.

The old namespace ('alter', app, user) omitted the agent; the old headless prefix ('alter', app) could search user namespaces. The v2 wrapper never falls back to either format.

  1. Inventory old namespaces and take a restorable snapshot. Identify every writer, reader, search/list/delete path, and checkpoint integration before cutover.
  2. Build a developer-reviewed mapping of each record to its verified application, agent, and user. Legacy records lacking agent provenance cannot be assigned automatically. Keep ambiguous records inaccessible until an owner decides; never copy them into every agent’s partition.
  3. Dry-run migration and report unmapped records, collisions, destination namespaces, record counts, and content hashes. Do not treat IDs found inside memory content as identity proof.
  4. Pause or coordinate writers. Copy with a migration ledger keyed by source namespace/key and destination namespace/key. Compare content hashes before overwriting so retries are idempotent and conflicts require resolution.
  5. Verify destination counts/hashes and run cross-user, cross-agent, headless-prefix, search, listing, and deletion canary checks. Switch all access paths together.
  6. Retain the snapshot and ledger for rollback. If verification fails, stop writes and restore the previous deployment/data under controlled access; do not reenable an unscoped reader as an automatic fallback. Remove legacy access after successful cutover under your retention policy.

Use alter sdk-passthrough resolve-identity to inspect the verified identity and derived partition; use --agent-only explicitly for headless agents. When no partition can be derived, memory_scope is null and memory_scope_error carries the same { "code": ..., "message": ... } a script would branch on in the SDK. CLI JSON is diagnostic metadata, not portable proof.

The boundary follows LangSmith store authorization: authenticate the principal and enforce namespaces on operations. RFC 8693 §4.1 distinguishes the current actor from prior actor history. RFC 8725 supplies the JWT issuer, audience, algorithm, and token-type validation requirements.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.