refactor(mem0-agent): stop requiring org/project in client config

An API key is already bound to one (org_id, project_id) server-side, so storing a
copy locally was redundant state that can go stale. Verified live that add,
get_all and feedback all succeed with no ids in the body at all.

- both ids are now purely an OVERRIDE, for pointing one key at a different
  project in the same org; half an override is ignored rather than half-applied
- identity resolution no longer persists scope as a side effect
- the project-config endpoints (the only ones needing ids in the URL) resolve
  them from /v1/ping/ and cache the result
- corrects an over-generalized finding: feedback does not require the ids; it
  404'd earlier only because that memory lived outside the key's project
- onboarding now recommends a key issued for a dedicated project over an override

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-29 17:13:52 -07:00
co-authored by Claude Fable 5
parent dda5f21332
commit f4949c55af
6 changed files with 120 additions and 45 deletions
+16 -8
View File
@@ -6,19 +6,27 @@ each disagreement is marked and was reproduced.
## Four rules that apply to every call ## Four rules that apply to every call
### 1. Pin the project in the request BODY ### 1. Scope is the API key's job; body ids are only an override
An API key is bound to exactly one `(org_id, project_id)` pair server-side. Send nothing and
every call resolves correctly — verified live: `add`, `get_all` and `feedback` all return 200
with no ids anywhere. **Do not require them in client config**; that is redundant state that
can go stale.
They exist for one purpose: pointing a key at a *different* project in the same org. When you
do that, both ids must travel in the **body** — as query params they are silently ignored:
```python ```python
body = {..., "project_id": "proj_...", "org_id": "org_..."} # routes correctly body = {..., "project_id": "proj_...", "org_id": "org_..."} # overrides the key
params = {"project_id": "proj_..."} # SILENTLY IGNORED params = {"project_id": "proj_..."} # SILENTLY IGNORED
``` ```
The API key carries a default project. Passing `project_id` as a query param does not Two probe writes, one routed each way, landed in two different projects. Half an override
override it — the call lands in the key's default project with no error. Two identical (one id without the other) is treated as no override.
probe writes, one routed each way, landed in two different projects.
This is almost certainly how v1's benchmark corpora (39% of the production project) got The only endpoints that genuinely need the ids are the project-configuration ones, which
there. `Api._pin()` applies this automatically; `strict=True` raises if it cannot. carry both in the URL path. They resolve them from `GET /v1/ping/` and cache the result, so
nothing has to be configured locally.
### 2. Every read passes `latest_only=True` ### 2. Every read passes `latest_only=True`
@@ -107,7 +115,7 @@ Identity comes from `GET /v1/ping/` → `user_email`, `org_id`, `project_id`.
|---|---| |---|---|
| `DELETE /v1/memories/` | Takes **query params**; a body returns 400 "at least one filter required" | | `DELETE /v1/memories/` | Takes **query params**; a body returns 400 "at least one filter required" |
| `GET .../projects/<id>/` | `fields` must be **repeated** params (`?fields=a&fields=b`), not comma-joined | | `GET .../projects/<id>/` | `fields` must be **repeated** params (`?fields=a&fields=b`), not comma-joined |
| `POST /v1/feedback/` | **404s without the project pin** in the body | | `POST /v1/feedback/` | 404s when the memory is in a project the key is not bound to and no override is sent; fine unpinned otherwise |
| `POST /v3/memories/` | This is `get_all`; `page`/`page_size` are query params, filters go in the body | | `POST /v3/memories/` | This is `get_all`; `page`/`page_size` are query params, filters go in the body |
## Metadata schema ## Metadata schema
+39 -8
View File
@@ -45,17 +45,39 @@ class Api:
self.strict = strict self.strict = strict
self._opener = opener or urllib.request.urlopen self._opener = opener or urllib.request.urlopen
self.last_error: str | None = None self.last_error: str | None = None
self._scope: tuple[str | None, str | None] | None = None
# ---------- plumbing ---------- # ---------- plumbing ----------
def _pin(self, project_id: str | None = None) -> dict[str, str]: def _pin(self, project_id: str | None = None) -> dict[str, str]:
"""Body-level scope OVERRIDE, deliberately empty by default.
An API key is already bound to one (org, project) server-side, so sending nothing
is the normal case and the backend resolves it. These ids are only for pointing a
key at a different project in the same org, and they must travel in the BODY --
as query params they are silently ignored.
"""
pid = project_id or self.project_id pid = project_id or self.project_id
oid = self.org_id oid = self.org_id
if not pid or not oid: if not pid or not oid:
if self.strict:
raise ContractError("project_id and org_id must be set; unpinned calls leak into the key's default project")
return {} return {}
return {"project_id": pid, "org_id": oid} return {"project_id": pid, "org_id": oid}
def resolve_scope(self) -> tuple[str | None, str | None]:
"""The (org_id, project_id) this key acts on, asked of the API and cached.
Needed only by the project-configuration endpoints, which carry both ids in the
URL path. Memory operations never need this.
"""
if self._scope is None:
status, body = self.ping()
if status == 200 and isinstance(body, dict):
self._scope = (body.get("org_id"), body.get("project_id"))
else:
self._scope = (None, None)
org = self.org_id or self._scope[0]
project = self.project_id or self._scope[1]
return org, project
def call(self, method: str, path: str, body: dict | None = None, def call(self, method: str, path: str, body: dict | None = None,
params: dict | None = None, timeout: float = READ_TIMEOUT) -> tuple[int, Any]: params: dict | None = None, timeout: float = READ_TIMEOUT) -> tuple[int, Any]:
"""Returns (status, parsed_body). Never raises on HTTP or network error.""" """Returns (status, parsed_body). Never raises on HTTP or network error."""
@@ -146,18 +168,27 @@ class Api:
# ---------- project config ---------- # ---------- project config ----------
def project_get(self, *, project_id: str | None = None, def project_get(self, *, project_id: str | None = None,
fields: list[str] | None = None) -> tuple[int, Any]: fields: list[str] | None = None) -> tuple[int, Any]:
pid = project_id or self.project_id org, pid = self.resolve_scope()
params = {"fields": fields} if fields else None # rule 4: repeated params pid = project_id or pid
return self.call("GET", f"/api/v1/orgs/organizations/{self.org_id}/projects/{pid}/", if not org or not pid:
return 0, {"error": "could not resolve org/project from the API key"}
params = {"fields": fields} if fields else None # repeated params, not comma-joined
return self.call("GET", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
None, params=params) None, params=params)
def project_update(self, *, project_id: str | None = None, **kw) -> tuple[int, Any]: def project_update(self, *, project_id: str | None = None, **kw) -> tuple[int, Any]:
pid = project_id or self.project_id org, pid = self.resolve_scope()
return self.call("PATCH", f"/api/v1/orgs/organizations/{self.org_id}/projects/{pid}/", pid = project_id or pid
if not org or not pid:
return 0, {"error": "could not resolve org/project from the API key"}
return self.call("PATCH", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
kw, timeout=WRITE_TIMEOUT) kw, timeout=WRITE_TIMEOUT)
def project_create(self, name: str, description: str = "") -> tuple[int, Any]: def project_create(self, name: str, description: str = "") -> tuple[int, Any]:
return self.call("POST", f"/api/v1/orgs/organizations/{self.org_id}/projects/", org, _ = self.resolve_scope()
if not org:
return 0, {"error": "could not resolve org from the API key"}
return self.call("POST", f"/api/v1/orgs/organizations/{org}/projects/",
{"name": name, "description": description}, timeout=WRITE_TIMEOUT) {"name": name, "description": description}, timeout=WRITE_TIMEOUT)
+12 -14
View File
@@ -62,24 +62,22 @@ def build(session_id: str | None = None, cwd: str | None = None, *, strict: bool
if not key: if not key:
return Ctx(None, settings, state, "", "", session_id, None, False, "no API key") return Ctx(None, settings, state, "", "", session_id, None, False, "no API key")
org = settings.get("org_id") or os.environ.get("MEM0_ORG_ID") # An API key is already bound to one (org, project) on the backend, so neither id is
project = settings.get("memory_project_id") or settings.get("default_project_id") \ # required here. They are sent only to OVERRIDE that binding -- i.e. to point one key at
or os.environ.get("MEM0_PLATFORM_PROJECT_ID") # a different project in the same org. Left unset, every call resolves server-side.
org = settings.get("org_id") or os.environ.get("MEM0_AGENT_ORG_ID")
project = settings.get("memory_project_id") or os.environ.get("MEM0_AGENT_PROJECT_ID")
api = Api(key, org_id=org, project_id=project, api = Api(key, org_id=org, project_id=project,
breaker=Breaker(state.breaker_path), strict=strict) breaker=Breaker(state.breaker_path), strict=strict)
# First run: learn identity and the key's home project from the API itself. # An override needs both halves; one alone would be ignored and quietly mislead.
if not org or not project: if bool(org) != bool(project):
status, body = api.ping() api.org_id = api.project_id = None
if status == 200 and isinstance(body, dict): settings_warning = "project override ignored: set both org_id and memory_project_id"
api.org_id = org = body.get("org_id") else:
api.project_id = project = settings.get("memory_project_id") or body.get("project_id") settings_warning = ""
settings.set("org_id", org)
settings.set("default_project_id", body.get("project_id"))
else:
return Ctx(None, settings, state, "", "", session_id, None, False, "identity unavailable")
user_id = resolve_user_id(api, settings) user_id = resolve_user_id(api, settings)
app_id = resolve_app_id(cwd, settings) app_id = resolve_app_id(cwd, settings)
branch = resolve_branch(cwd) branch = resolve_branch(cwd)
return Ctx(api, settings, state, user_id, app_id, session_id, branch, True) return Ctx(api, settings, state, user_id, app_id, session_id, branch, True, settings_warning)
@@ -191,9 +191,11 @@ def run_onboard(interactive: bool = True, **overrides: Any) -> dict:
detail = f"{size} memories" if size is not None else "size unknown" detail = f"{size} memories" if size is not None else "size unknown"
if busy or size is None: if busy or size is None:
report["warnings"].append( report["warnings"].append(
f"Using the API key's default project {name or ctx.api.project_id} ({detail}). " f"Using the API key's project {name or 'unknown'} ({detail}). "
"If it also serves a production app, create a dedicated coding-memory project and " "If it also serves a production app, the cleanest fix is a separate API key "
"set `memory_project_id` in " + str(settings.path) + "." "issued for a dedicated coding-memory project -- the key carries the scope, so "
"nothing needs configuring here. As an escape hatch you can override with "
"org_id + memory_project_id in " + str(settings.path) + "."
) )
# 4. The memory-mode decision. Default follows what the repo already does. # 4. The memory-mode decision. Default follows what the repo already does.
@@ -134,11 +134,9 @@ def resolve_user_id(api, settings: Settings | None = None) -> str:
email = body.get("user_email") or "" email = body.get("user_email") or ""
uid = email.split("@")[0] if email else "" uid = email.split("@")[0] if email else ""
if uid: if uid:
# Cache the identity only. Scope is deliberately NOT persisted: the API key
# already carries it, and storing a copy here would be state that can go stale.
settings.set("user_id", uid) settings.set("user_id", uid)
if body.get("org_id"):
settings.set("org_id", body["org_id"])
if body.get("project_id"):
settings.set("default_project_id", body["project_id"])
return uid return uid
return os.environ.get("USER", "default") return os.environ.get("USER", "default")
+46 -8
View File
@@ -54,8 +54,9 @@ def make_api(**kw):
return api, rec return api, rec
# --- Rule 1: the project pin travels in the body, never as a query param --- # --- Rule 1: a scope OVERRIDE travels in the body, never as a query param.
def test_writes_pin_project_in_body(): # Omitting it is normal: the API key is already bound to one (org, project). ---
def test_writes_send_the_override_in_the_body():
api, rec = make_api() api, rec = make_api()
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True) api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
assert rec.last["body"]["project_id"] == "proj_Y" assert rec.last["body"]["project_id"] == "proj_Y"
@@ -63,24 +64,61 @@ def test_writes_pin_project_in_body():
assert "project_id=" not in rec.last["url"] assert "project_id=" not in rec.last["url"]
def test_reads_pin_project_in_body(): def test_reads_send_the_override_in_the_body():
api, rec = make_api() api, rec = make_api()
api.get_all(F.all_in_scope("u", "app")) api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y" assert rec.last["body"]["project_id"] == "proj_Y"
assert "project_id=" not in rec.last["url"] assert "project_id=" not in rec.last["url"]
def test_feedback_carries_the_pin_or_it_404s_in_production(): def test_feedback_carries_the_override_when_one_is_configured():
"""Feedback 404s only when the memory lives in a project the key is not bound to and
no override is sent -- not, as first assumed, on every unpinned call."""
api, rec = make_api() api, rec = make_api()
api.feedback("mem-1", "POSITIVE", "referenced in session") api.feedback("mem-1", "POSITIVE", "referenced in session")
assert rec.last["body"]["project_id"] == "proj_Y" assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X" assert rec.last["body"]["org_id"] == "org_X"
def test_strict_mode_refuses_unpinned_calls(): def test_unpinned_calls_send_no_scope_and_let_the_key_decide():
api = Api("key", strict=True) """An API key is already bound to one (org, project) server-side, so the ids are an
with pytest.raises(ContractError): override, not a requirement. Verified live: add, get_all and feedback all return 200
api._pin() with nothing in the body."""
api, rec = make_api()
api.org_id = api.project_id = None
api.add([{"role": "user", "content": "hi"}], user_id="u", infer=True)
body = rec.last["body"]
assert "project_id" not in body and "org_id" not in body
api.get_all(F.all_in_scope("u", "app"))
assert "project_id" not in rec.last["body"]
api.feedback("mem-1", "POSITIVE")
assert "project_id" not in rec.last["body"]
def test_override_travels_in_the_body_when_both_ids_are_set():
api, rec = make_api()
api.get_all(F.all_in_scope("u", "app"))
assert rec.last["body"]["project_id"] == "proj_Y"
assert rec.last["body"]["org_id"] == "org_X"
assert "project_id=" not in rec.last["url"], "query params are silently ignored"
def test_half_an_override_is_no_override():
"""One id alone would be ignored by the backend; sending it would only mislead."""
api, _ = make_api()
api.org_id = None
assert api._pin() == {}
def test_project_endpoints_resolve_scope_from_the_key():
"""Only the project-config endpoints need the ids, and they ask the API for them."""
api, rec = make_api(recorder_kw={"body": {"org_id": "org_ping", "project_id": "proj_ping"}})
api.org_id = api.project_id = None
api.project_get(fields=["decay"])
assert "/organizations/org_ping/projects/proj_ping/" in rec.last["url"]
assert rec.calls[0]["url"].endswith("/v1/ping/"), "scope comes from ping, not from config"
# --- Rule 2: latest_only on every read, or superseded facts resurface --- # --- Rule 2: latest_only on every read, or superseded facts resurface ---