memgres 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {memgres-0.9.0 → memgres-0.10.0}/PKG-INFO +1 -1
  2. {memgres-0.9.0 → memgres-0.10.0}/memgres/_version.py +1 -1
  3. {memgres-0.9.0 → memgres-0.10.0}/memgres/admin.py +108 -6
  4. {memgres-0.9.0 → memgres-0.10.0}/memgres/identity.py +191 -13
  5. {memgres-0.9.0 → memgres-0.10.0}/memgres/mcp_server.py +104 -6
  6. memgres-0.10.0/memgres/migrations/0022_user_disabled.sql +12 -0
  7. {memgres-0.9.0 → memgres-0.10.0}/memgres/schema.py +1 -1
  8. {memgres-0.9.0 → memgres-0.10.0}/memgres/server.py +31 -1
  9. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/PKG-INFO +1 -1
  10. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/SOURCES.txt +2 -0
  11. memgres-0.10.0/tests/test_admin_two_way.py +402 -0
  12. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_admin_tools.py +6 -0
  13. {memgres-0.9.0 → memgres-0.10.0}/LICENSE +0 -0
  14. {memgres-0.9.0 → memgres-0.10.0}/README.md +0 -0
  15. {memgres-0.9.0 → memgres-0.10.0}/memgres/__init__.py +0 -0
  16. {memgres-0.9.0 → memgres-0.10.0}/memgres/admin_cli.py +0 -0
  17. {memgres-0.9.0 → memgres-0.10.0}/memgres/blame.py +0 -0
  18. {memgres-0.9.0 → memgres-0.10.0}/memgres/bootstrap.py +0 -0
  19. {memgres-0.9.0 → memgres-0.10.0}/memgres/config.py +0 -0
  20. {memgres-0.9.0 → memgres-0.10.0}/memgres/delimiters.py +0 -0
  21. {memgres-0.9.0 → memgres-0.10.0}/memgres/diffing.py +0 -0
  22. {memgres-0.9.0 → memgres-0.10.0}/memgres/embed_worker.py +0 -0
  23. {memgres-0.9.0 → memgres-0.10.0}/memgres/embeddings.py +0 -0
  24. {memgres-0.9.0 → memgres-0.10.0}/memgres/healthcheck.py +0 -0
  25. {memgres-0.9.0 → memgres-0.10.0}/memgres/indexing.py +0 -0
  26. {memgres-0.9.0 → memgres-0.10.0}/memgres/info.py +0 -0
  27. {memgres-0.9.0 → memgres-0.10.0}/memgres/lines.py +0 -0
  28. {memgres-0.9.0 → memgres-0.10.0}/memgres/links.py +0 -0
  29. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0001_core.sql +0 -0
  30. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0002_identity.sql +0 -0
  31. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0003_history_author.sql +0 -0
  32. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0004_title.sql +0 -0
  33. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  34. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  35. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  36. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0008_service_roles.sql +0 -0
  37. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  38. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  39. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  40. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0012_user_profile.sql +0 -0
  41. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0013_hash_version.sql +0 -0
  42. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  43. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  44. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0016_valid_at.sql +0 -0
  45. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0017_memory_link.sql +0 -0
  46. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0018_links_built.sql +0 -0
  47. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  48. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  49. {memgres-0.9.0 → memgres-0.10.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
  50. {memgres-0.9.0 → memgres-0.10.0}/memgres/periodic.py +0 -0
  51. {memgres-0.9.0 → memgres-0.10.0}/memgres/reembed.py +0 -0
  52. {memgres-0.9.0 → memgres-0.10.0}/memgres/relink.py +0 -0
  53. {memgres-0.9.0 → memgres-0.10.0}/memgres/search.py +0 -0
  54. {memgres-0.9.0 → memgres-0.10.0}/memgres/segments.py +0 -0
  55. {memgres-0.9.0 → memgres-0.10.0}/memgres/store.py +0 -0
  56. {memgres-0.9.0 → memgres-0.10.0}/memgres/tags.py +0 -0
  57. {memgres-0.9.0 → memgres-0.10.0}/memgres/token_cli.py +0 -0
  58. {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/__init__.py +0 -0
  59. {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/base.py +0 -0
  60. {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/pgvector.py +0 -0
  61. {memgres-0.9.0 → memgres-0.10.0}/memgres/vector/qdrant.py +0 -0
  62. {memgres-0.9.0 → memgres-0.10.0}/memgres/worker.py +0 -0
  63. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/dependency_links.txt +0 -0
  64. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/entry_points.txt +0 -0
  65. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/requires.txt +0 -0
  66. {memgres-0.9.0 → memgres-0.10.0}/memgres.egg-info/top_level.txt +0 -0
  67. {memgres-0.9.0 → memgres-0.10.0}/pyproject.toml +0 -0
  68. {memgres-0.9.0 → memgres-0.10.0}/setup.cfg +0 -0
  69. {memgres-0.9.0 → memgres-0.10.0}/tests/test_blame_integration.py +0 -0
  70. {memgres-0.9.0 → memgres-0.10.0}/tests/test_chunk_index.py +0 -0
  71. {memgres-0.9.0 → memgres-0.10.0}/tests/test_claim_and_reembed.py +0 -0
  72. {memgres-0.9.0 → memgres-0.10.0}/tests/test_config.py +0 -0
  73. {memgres-0.9.0 → memgres-0.10.0}/tests/test_diffing.py +0 -0
  74. {memgres-0.9.0 → memgres-0.10.0}/tests/test_embed_worker.py +0 -0
  75. {memgres-0.9.0 → memgres-0.10.0}/tests/test_embeddings.py +0 -0
  76. {memgres-0.9.0 → memgres-0.10.0}/tests/test_enrollment.py +0 -0
  77. {memgres-0.9.0 → memgres-0.10.0}/tests/test_healthcheck.py +0 -0
  78. {memgres-0.9.0 → memgres-0.10.0}/tests/test_identity_integration.py +0 -0
  79. {memgres-0.9.0 → memgres-0.10.0}/tests/test_lexical_match.py +0 -0
  80. {memgres-0.9.0 → memgres-0.10.0}/tests/test_limits.py +0 -0
  81. {memgres-0.9.0 → memgres-0.10.0}/tests/test_links.py +0 -0
  82. {memgres-0.9.0 → memgres-0.10.0}/tests/test_list.py +0 -0
  83. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_http_transport.py +0 -0
  84. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_instructions.py +0 -0
  85. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_recall_schema.py +0 -0
  86. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility.py +0 -0
  87. {memgres-0.9.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility_http.py +0 -0
  88. {memgres-0.9.0 → memgres-0.10.0}/tests/test_migration_upgrade.py +0 -0
  89. {memgres-0.9.0 → memgres-0.10.0}/tests/test_multi_space_search.py +0 -0
  90. {memgres-0.9.0 → memgres-0.10.0}/tests/test_path_addressing.py +0 -0
  91. {memgres-0.9.0 → memgres-0.10.0}/tests/test_qdrant_ca.py +0 -0
  92. {memgres-0.9.0 → memgres-0.10.0}/tests/test_qdrant_integration.py +0 -0
  93. {memgres-0.9.0 → memgres-0.10.0}/tests/test_replace_build.py +0 -0
  94. {memgres-0.9.0 → memgres-0.10.0}/tests/test_require_title.py +0 -0
  95. {memgres-0.9.0 → memgres-0.10.0}/tests/test_retention.py +0 -0
  96. {memgres-0.9.0 → memgres-0.10.0}/tests/test_roles_bootstrap.py +0 -0
  97. {memgres-0.9.0 → memgres-0.10.0}/tests/test_search_integration.py +0 -0
  98. {memgres-0.9.0 → memgres-0.10.0}/tests/test_security_integration.py +0 -0
  99. {memgres-0.9.0 → memgres-0.10.0}/tests/test_segments.py +0 -0
  100. {memgres-0.9.0 → memgres-0.10.0}/tests/test_segments_store.py +0 -0
  101. {memgres-0.9.0 → memgres-0.10.0}/tests/test_server_info.py +0 -0
  102. {memgres-0.9.0 → memgres-0.10.0}/tests/test_server_integration.py +0 -0
  103. {memgres-0.9.0 → memgres-0.10.0}/tests/test_snippets.py +0 -0
  104. {memgres-0.9.0 → memgres-0.10.0}/tests/test_store_integration.py +0 -0
  105. {memgres-0.9.0 → memgres-0.10.0}/tests/test_tags.py +0 -0
  106. {memgres-0.9.0 → memgres-0.10.0}/tests/test_token_sink.py +0 -0
  107. {memgres-0.9.0 → memgres-0.10.0}/tests/test_usage.py +0 -0
  108. {memgres-0.9.0 → memgres-0.10.0}/tests/test_valid_at.py +0 -0
  109. {memgres-0.9.0 → memgres-0.10.0}/tests/test_write_ergonomics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.9.0"
11
+ __version__ = "0.10.0"
@@ -353,7 +353,8 @@ def create_namespace(conn, p: Principal, *, owner_user_id: str, name: str,
353
353
 
354
354
  def edit_namespace(conn, p: Principal, *, namespace_id: str,
355
355
  description: Optional[str] = None,
356
- instruction: Optional[str] = None) -> dict:
356
+ instruction: Optional[str] = None,
357
+ name: Optional[str] = None) -> dict:
357
358
  """Amend a namespace's description or routing instruction.
358
359
 
359
360
  `create_namespace` is an idempotent upsert that ignores conflicts, so
@@ -363,19 +364,92 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
363
364
  """
364
365
  require_namespace_admin(conn, p, namespace_id)
365
366
  identity.edit_namespace(conn, namespace_id, description=description,
366
- instruction=instruction)
367
- return {"namespace_id": namespace_id}
367
+ instruction=instruction, name=name)
368
+ out = {"namespace_id": namespace_id}
369
+ if name is not None:
370
+ out["name"] = name
371
+ out["warning"] = ("renamed — anyone addressing it by the old name now "
372
+ "gets 'no such namespace'. Ids and aliases still work")
373
+ return out
374
+
375
+
376
+ def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
377
+ """Owner-or-superadmin, a tier narrower than `require_namespace_admin`.
378
+
379
+ For the acts that dispose of the namespace itself rather than work inside
380
+ it. An admin MEMBER was given authority over the contents; giving away
381
+ somebody else's namespace is not part of that, and the difference only
382
+ matters here — which is why this is its own check rather than a flag.
383
+ """
384
+ require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
385
+ if p.is_admin:
386
+ return
387
+ if identity.namespace_owner(conn, namespace_id) != p.user_id:
388
+ raise Forbidden("only the owner (or a superadmin) may do this")
368
389
 
369
390
 
370
391
  def add_member(conn, p: Principal, *, namespace_id: str, user_id: str,
371
392
  permission: str = "read") -> dict:
372
- """Share a namespace with another user — cross-tenant, so superadmin only."""
373
- require_superadmin(p)
393
+ """Share a namespace with another user.
394
+
395
+ Authorized per-NAMESPACE, not deployment-wide. It used to demand superadmin,
396
+ on the reasoning that sharing reaches across tenants — but the thing being
397
+ shared is the caller's OWN namespace, and requiring the deployment's root
398
+ for that made "let a colleague into my cabinet" an operator ticket. What it
399
+ still demands is an admin-ceiling credential for that namespace, so a
400
+ read-only or differently-scoped token cannot hand out access.
401
+ """
402
+ require_namespace_admin(conn, p, namespace_id)
374
403
  identity.add_member(conn, namespace_id, user_id, permission)
375
404
  return {"namespace_id": namespace_id, "user_id": user_id,
376
405
  "permission": permission}
377
406
 
378
407
 
408
+ def remove_member(conn, p: Principal, *, namespace_id: str, user_id: str) -> dict:
409
+ """Un-share a namespace. `removed: false` means they were not a member.
410
+
411
+ The other half of `add_member`, and its absence was the sharpest gap in this
412
+ control plane: access could be granted and never taken back except by
413
+ revoking every token the person held, which cuts them off from everything
414
+ rather than from this.
415
+ """
416
+ require_namespace_admin(conn, p, namespace_id)
417
+ return {"namespace_id": namespace_id, "user_id": user_id,
418
+ "removed": identity.remove_member(conn, namespace_id, user_id)}
419
+
420
+
421
+ def transfer_namespace(conn, p: Principal, *, namespace_id: str,
422
+ new_owner_user_id: str,
423
+ keep_previous_owner: Optional[str] = "admin") -> dict:
424
+ """Hand a namespace to another account — owner or superadmin.
425
+
426
+ The outgoing owner stays behind as an `admin` member unless
427
+ `keep_previous_owner` is null. Defaulting to keeping them is the safer
428
+ footing: the alternative is a single call that removes the caller from a
429
+ namespace whose contents they may be the only one who knows.
430
+ """
431
+ require_namespace_owner(conn, p, namespace_id)
432
+ return identity.transfer_namespace(
433
+ conn, namespace_id, new_owner_user_id,
434
+ keep_previous_owner=keep_previous_owner)
435
+
436
+
437
+ def set_disabled(conn, p: Principal, *, user_id: str, disabled: bool) -> dict:
438
+ """Switch an account off, or back on. Every token it holds stops at once.
439
+
440
+ Offboarding as ONE act. Doing it by revoking tokens one at a time is a loop
441
+ that has to be complete to be correct, and nothing stops a new token being
442
+ issued afterwards. Reversible and destructive of nothing — authorship,
443
+ namespaces and memberships all survive, which is what makes it usable for
444
+ "gone for now" as well as "gone".
445
+ """
446
+ require_manage_users(p)
447
+ _require_target_is_plain_user(conn, p, user_id,
448
+ "disabling an account" if disabled
449
+ else "re-enabling an account")
450
+ return identity.set_disabled(conn, user_id, disabled)
451
+
452
+
379
453
  def list_namespaces(conn, p: Principal, *, owner_user_id: Optional[str] = None,
380
454
  limit: Optional[int] = None, offset: int = 0) -> List[dict]:
381
455
  """Deployment-wide namespace inventory.
@@ -407,6 +481,27 @@ def list_spaces(conn, p: Principal) -> List[dict]:
407
481
 
408
482
  # ─── tokens ──────────────────────────────────────────────────────────────────
409
483
 
484
+ def _unreachable_warning(conn, user_id: str,
485
+ namespace_id: Optional[str]) -> Optional[str]:
486
+ """Warn when a credential is about to be scoped to a namespace its owner
487
+ cannot reach.
488
+
489
+ Scoping is not granting: reach comes from ownership or membership, and a
490
+ token pinned to a namespace the account is not in is perfectly valid and
491
+ reaches nothing. The person enrols, everything answers "no namespace", and
492
+ it reads as a broken server rather than an unfinished provisioning. A
493
+ warning rather than a refusal, because issuing the credential first and
494
+ adding the membership after is a legitimate order.
495
+ """
496
+ if not namespace_id:
497
+ return None
498
+ if identity.reaches(conn, user_id, namespace_id) is not None:
499
+ return None
500
+ return ("that user cannot reach this namespace yet, so the token will be "
501
+ "valid and see NOTHING — add them with memory_admin_add_member "
502
+ "(or hand the namespace over) before they try to use it")
503
+
504
+
410
505
  def issue_token(conn, p: Principal, *, user_id: str,
411
506
  namespace_id: Optional[str] = None, permission: str = "write",
412
507
  label: str = "", expires_days: Optional[int] = None,
@@ -426,10 +521,14 @@ def issue_token(conn, p: Principal, *, user_id: str,
426
521
  expires_at = None
427
522
  if expires_days:
428
523
  expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
524
+ warning = _unreachable_warning(conn, user_id, namespace_id)
429
525
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
430
526
  permission=permission, label=label,
431
527
  expires_at=expires_at)
432
- return deliver_secret(secret, tid, sink_dir)
528
+ out = deliver_secret(secret, tid, sink_dir)
529
+ if warning:
530
+ out["warning"] = warning
531
+ return out
433
532
 
434
533
 
435
534
  def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
@@ -472,6 +571,9 @@ def create_enrollment(conn, p: Principal, *, user_id: str,
472
571
  "for a meeting link — it is single-use and short-lived. They "
473
572
  "generate their own token, put it in their client's config, "
474
573
  "and call memory_enroll with this key.")
574
+ warning = _unreachable_warning(conn, user_id, namespace_id)
575
+ if warning:
576
+ out["warning"] = warning
475
577
  return out
476
578
 
477
579
 
@@ -24,6 +24,7 @@ resolve a space before every operation.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import datetime as dt
27
28
  import hashlib
28
29
  import hmac
29
30
  import os
@@ -189,16 +190,22 @@ def resolve(conn, cfg, secret: Optional[str], *, touch: bool = True) -> Principa
189
190
  "SELECT t.id, t.user_id, t.namespace_id, t.permission, "
190
191
  " (t.revoked_at IS NOT NULL) AS revoked, "
191
192
  " (t.expires_at IS NOT NULL AND t.expires_at <= now()) AS expired, "
192
- " u.role "
193
+ " u.role, (u.disabled_at IS NOT NULL) AS disabled "
193
194
  "FROM token t JOIN app_user u ON u.id = t.user_id "
194
195
  "WHERE t.token_hash=%s", (h,))
195
196
  row = cur.fetchone()
196
197
  if row is not None:
197
- tid, uid, nsid, perm, revoked, expired, role = row
198
+ tid, uid, nsid, perm, revoked, expired, role, disabled = row
198
199
  if revoked:
199
200
  raise AuthError("token revoked")
200
201
  if expired:
201
202
  raise AuthError("token expired")
203
+ if disabled:
204
+ # Checked HERE, on the credential, rather than at each door:
205
+ # an account that is off has to be off for every token it
206
+ # holds and every one it could still be issued, and the only
207
+ # place all of those meet is authentication.
208
+ raise AuthError("this account is disabled")
202
209
  if touch:
203
210
  cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s",
204
211
  (tid,))
@@ -742,7 +749,8 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
742
749
  if role is not None and role not in SERVICE_ROLES:
743
750
  raise ValueError(f"bad role: {role}")
744
751
  sql = ("SELECT id, name, description, role, can_create_namespace, "
745
- "created_at, email, full_name, department, position FROM app_user")
752
+ "created_at, email, full_name, department, position, "
753
+ "disabled_at FROM app_user")
746
754
  args: list = []
747
755
  if role is not None:
748
756
  sql += " WHERE role=%s" # backed by app_user_role_idx
@@ -755,13 +763,17 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
755
763
  sql += " OFFSET %s"
756
764
  args.append(offset)
757
765
  cols = ["id", "name", "description", "role", "can_create_namespace",
758
- "created_at", *PROFILE_FIELDS]
766
+ "created_at", *PROFILE_FIELDS, "disabled_at"]
759
767
  with conn.cursor() as cur:
760
768
  cur.execute(sql, args)
761
769
  out = []
762
770
  for r in cur.fetchall():
763
771
  d = dict(zip(cols, r))
764
772
  d["id"] = str(d["id"])
773
+ # Both, deliberately: `disabled` is what a caller branches on, and
774
+ # `disabled_at` answers "since when", which is the question that
775
+ # follows every time the first one is true.
776
+ d["disabled"] = d["disabled_at"] is not None
765
777
  out.append(d)
766
778
  return out
767
779
 
@@ -822,12 +834,81 @@ def revoke_superadmin(conn, user_id: str, *, demote_to: str = "user") -> None:
822
834
  cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (demote_to, user_id))
823
835
 
824
836
 
837
+ def set_disabled(conn, user_id: str, disabled: bool) -> dict:
838
+ """Turn an account off (or back on). Reversible, and destroys nothing.
839
+
840
+ Anti-lockout, in the same spirit as `revoke_superadmin`: the last ACTIVE
841
+ superadmin cannot be switched off, because nobody would be left who could
842
+ switch it back on. Recovery from such a lockout would mean editing the
843
+ database by hand.
844
+ """
845
+ with conn.cursor() as cur:
846
+ cur.execute("SELECT role, disabled_at IS NOT NULL FROM app_user WHERE id=%s",
847
+ (_as_uuid(user_id),))
848
+ row = cur.fetchone()
849
+ if row is None:
850
+ raise SpaceNotFound(f"no such user {user_id}")
851
+ role, already = row
852
+ if bool(already) == bool(disabled):
853
+ return {"user_id": str(user_id), "disabled": bool(disabled),
854
+ "unchanged": True}
855
+ if disabled and role == "superadmin":
856
+ cur.execute("SELECT count(*) FROM app_user WHERE role='superadmin' "
857
+ "AND disabled_at IS NULL")
858
+ if int(cur.fetchone()[0]) <= 1:
859
+ raise AuthError("cannot disable the last active superadmin")
860
+ cur.execute("UPDATE app_user SET disabled_at = %s WHERE id=%s",
861
+ (None if not disabled else dt.datetime.now(dt.timezone.utc),
862
+ _as_uuid(user_id)))
863
+ return {"user_id": str(user_id), "disabled": bool(disabled)}
864
+
865
+
825
866
  # How many namespaces one account may own. Not a business rule — a bound, so a
826
867
  # self-service deployment cannot be turned into an INSERT loop by anyone holding
827
868
  # a well-formed token.
828
869
  MAX_NAMESPACES_PER_USER = 50
829
870
 
830
871
 
872
+ def _require_name_free(cur, owner_user_id: str, name: str, *,
873
+ except_namespace_id: Optional[str] = None,
874
+ upsert: bool = False) -> None:
875
+ """Refuse a name that would be unaddressable for `owner_user_id`.
876
+
877
+ Two ways that happens, and they are the same failure seen from two sides: an
878
+ ALIAS of theirs already means something else (so the namespace could not be
879
+ reached by its own name, and their existing calls would silently keep
880
+ resolving elsewhere), or they already OWN a namespace by that name.
881
+
882
+ Extracted from `create_namespace` because renaming and transferring hit the
883
+ identical wall — and a check that lives at only one of three doors is the
884
+ kind of duplication this codebase keeps paying for.
885
+ """
886
+ cur.execute("SELECT namespace_id FROM namespace_alias "
887
+ "WHERE user_id=%s AND alias=%s", (owner_user_id, name))
888
+ if cur.fetchone() is not None:
889
+ raise SpaceAmbiguous(
890
+ f"'{name}' is already one of that user's aliases — the namespace "
891
+ "could not be addressed by its own name, and their existing calls "
892
+ "using it would keep resolving elsewhere. Drop the alias or choose "
893
+ "another name")
894
+ if upsert:
895
+ # `create_namespace` is an idempotent upsert BY DESIGN: calling it again
896
+ # with the same name returns the existing id rather than failing, which
897
+ # is what makes provisioning scripts re-runnable. Only renaming and
898
+ # transferring must refuse the collision, because for them there is no
899
+ # "the one you meant" to return.
900
+ return
901
+ sql = "SELECT id FROM namespace WHERE owner_user_id=%s AND name=%s"
902
+ args = [owner_user_id, name]
903
+ if except_namespace_id is not None:
904
+ sql += " AND id <> %s"
905
+ args.append(_as_uuid(except_namespace_id))
906
+ cur.execute(sql, args)
907
+ if cur.fetchone() is not None:
908
+ raise SpaceAmbiguous(
909
+ f"that account already owns a namespace called '{name}'")
910
+
911
+
831
912
  def create_namespace(conn, owner_user_id: str, name: str, *,
832
913
  description: str = "", instruction: str = "") -> str:
833
914
  """Create (or return the existing) namespace ``name`` owned by the user.
@@ -841,14 +922,7 @@ def create_namespace(conn, owner_user_id: str, name: str, *,
841
922
  codebase keeps paying for.
842
923
  """
843
924
  with conn.cursor() as cur:
844
- cur.execute("SELECT namespace_id FROM namespace_alias "
845
- "WHERE user_id=%s AND alias=%s", (owner_user_id, name))
846
- if cur.fetchone() is not None:
847
- raise SpaceAmbiguous(
848
- f"'{name}' is already one of that user's aliases — the new "
849
- "namespace could not be addressed by its own name, and their "
850
- "existing calls using it would keep resolving elsewhere. Drop "
851
- "the alias or choose another name")
925
+ _require_name_free(cur, owner_user_id, name, upsert=True)
852
926
  cur.execute("SELECT count(*) FROM namespace WHERE owner_user_id=%s",
853
927
  (owner_user_id,))
854
928
  if cur.fetchone()[0] >= MAX_NAMESPACES_PER_USER:
@@ -966,8 +1040,25 @@ def add_member(conn, namespace_id: str, user_id: str, permission: str = "read")
966
1040
 
967
1041
 
968
1042
  def edit_namespace(conn, namespace_id: str, *, description: Optional[str] = None,
969
- instruction: Optional[str] = None) -> None:
1043
+ instruction: Optional[str] = None,
1044
+ name: Optional[str] = None) -> None:
1045
+ """Amend a namespace. `name` renames it — checked against the OWNER's other
1046
+ namespaces and aliases, since that is who the name has to stay addressable
1047
+ for. Anyone addressing it by the old name gets "no such namespace" from then
1048
+ on; ids and aliases are unaffected."""
970
1049
  sets, params = [], []
1050
+ if name is not None:
1051
+ if not name.strip():
1052
+ raise ValueError("a namespace name cannot be blank")
1053
+ with conn.cursor() as cur:
1054
+ cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
1055
+ (_as_uuid(namespace_id),))
1056
+ row = cur.fetchone()
1057
+ if row is None:
1058
+ raise SpaceNotFound(f"no such namespace {namespace_id}")
1059
+ _require_name_free(cur, str(row[0]), name,
1060
+ except_namespace_id=namespace_id)
1061
+ sets.append("name=%s"); params.append(name)
971
1062
  if description is not None:
972
1063
  sets.append("description=%s"); params.append(description)
973
1064
  if instruction is not None:
@@ -979,6 +1070,93 @@ def edit_namespace(conn, namespace_id: str, *, description: Optional[str] = None
979
1070
  cur.execute(f"UPDATE namespace SET {', '.join(sets)} WHERE id=%s", params)
980
1071
 
981
1072
 
1073
+ def remove_member(conn, namespace_id: str, user_id: str) -> bool:
1074
+ """Take a shared namespace away again. False if they were not a member.
1075
+
1076
+ Takes effect on the next call: reach is computed per request from the member
1077
+ table, never cached. A token that was SCOPED to this namespace is not
1078
+ revoked — it simply stops reaching anything, which is the same state as a
1079
+ token scoped to a namespace that was never shared. Revoking it as well is
1080
+ the caller's decision, not a side effect of un-sharing.
1081
+ """
1082
+ with conn.cursor() as cur:
1083
+ cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
1084
+ (_as_uuid(namespace_id),))
1085
+ row = cur.fetchone()
1086
+ if row is None:
1087
+ raise SpaceNotFound(f"no such namespace {namespace_id}")
1088
+ if str(row[0]) == str(user_id):
1089
+ # Ownership is not a membership row, so deleting one would report
1090
+ # success and change nothing — the owner would keep full access and
1091
+ # whoever asked would believe they had removed it.
1092
+ raise AuthError(
1093
+ "that user OWNS this namespace — ownership is not a membership "
1094
+ "and cannot be removed. Transfer the namespace instead")
1095
+ cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s "
1096
+ "AND user_id=%s RETURNING user_id",
1097
+ (_as_uuid(namespace_id), _as_uuid(user_id)))
1098
+ return cur.fetchone() is not None
1099
+
1100
+
1101
+ def namespace_owner(conn, namespace_id: str) -> Optional[str]:
1102
+ with conn.cursor() as cur:
1103
+ cur.execute("SELECT owner_user_id FROM namespace WHERE id=%s",
1104
+ (_as_uuid(namespace_id),))
1105
+ row = cur.fetchone()
1106
+ return str(row[0]) if row else None
1107
+
1108
+
1109
+ def transfer_namespace(conn, namespace_id: str, new_owner_user_id: str, *,
1110
+ keep_previous_owner: Optional[str] = "admin") -> dict:
1111
+ """Hand a namespace to somebody else.
1112
+
1113
+ `keep_previous_owner` leaves the outgoing owner behind as a member at that
1114
+ permission (default `admin`), and defaults to doing so for a reason: without
1115
+ it, one call removes the caller from a namespace they may be the only person
1116
+ who knows the contents of, and nothing in the reply would have warned them.
1117
+ Pass None for a clean hand-off.
1118
+
1119
+ The new owner's stale membership row, if any, is dropped — ownership already
1120
+ grants more than any membership can, and leaving a `read` row behind means a
1121
+ later transfer silently demotes them.
1122
+ """
1123
+ if keep_previous_owner is not None and keep_previous_owner not in _RANK:
1124
+ raise ValueError(f"bad permission: {keep_previous_owner}")
1125
+ with conn.cursor() as cur:
1126
+ cur.execute("SELECT owner_user_id, name FROM namespace WHERE id=%s "
1127
+ "FOR UPDATE", (_as_uuid(namespace_id),))
1128
+ row = cur.fetchone()
1129
+ if row is None:
1130
+ raise SpaceNotFound(f"no such namespace {namespace_id}")
1131
+ old_owner, name = str(row[0]), row[1]
1132
+ if old_owner == str(new_owner_user_id):
1133
+ return {"namespace_id": str(namespace_id), "owner_user_id": old_owner,
1134
+ "unchanged": True}
1135
+ cur.execute("SELECT 1 FROM app_user WHERE id=%s",
1136
+ (_as_uuid(new_owner_user_id),))
1137
+ if cur.fetchone() is None:
1138
+ raise SpaceNotFound(f"no such user {new_owner_user_id}")
1139
+ # The receiving account has its own names and aliases, and (owner, name)
1140
+ # is unique: without this the transfer dies on a constraint violation
1141
+ # rather than on an explanation.
1142
+ _require_name_free(cur, str(new_owner_user_id), name,
1143
+ except_namespace_id=namespace_id)
1144
+ cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s AND "
1145
+ "user_id=%s", (_as_uuid(namespace_id), _as_uuid(new_owner_user_id)))
1146
+ cur.execute("UPDATE namespace SET owner_user_id=%s WHERE id=%s",
1147
+ (_as_uuid(new_owner_user_id), _as_uuid(namespace_id)))
1148
+ if keep_previous_owner is not None:
1149
+ cur.execute(
1150
+ "INSERT INTO namespace_member (namespace_id, user_id, permission) "
1151
+ "VALUES (%s, %s, %s) ON CONFLICT (namespace_id, user_id) "
1152
+ "DO UPDATE SET permission=EXCLUDED.permission",
1153
+ (_as_uuid(namespace_id), _as_uuid(old_owner), keep_previous_owner))
1154
+ return {"namespace_id": str(namespace_id),
1155
+ "owner_user_id": str(new_owner_user_id),
1156
+ "previous_owner_user_id": old_owner,
1157
+ "previous_owner_permission": keep_previous_owner}
1158
+
1159
+
982
1160
  def list_spaces(conn, user_id: str) -> List[dict]:
983
1161
  """Every namespace the user can reach: owned + shared, with flags."""
984
1162
  aliases = list_aliases(conn, user_id)
@@ -160,7 +160,7 @@ TOOL_VISIBILITY = {
160
160
  "memory_admin_list_tokens": ("identity", "can_manage_users"),
161
161
  "memory_admin_revoke_token": ("identity", "can_manage_users"),
162
162
  "memory_admin_set_role": ("identity", "can_administer_deployment"),
163
- "memory_admin_add_member": ("identity", "can_administer_deployment"),
163
+ "memory_admin_set_disabled": ("identity", "can_manage_users"),
164
164
  "memory_admin_count_orphans": ("identity", "can_administer_deployment"),
165
165
  "memory_admin_adopt_orphans": ("identity", "can_administer_deployment"),
166
166
  # per-namespace admin: any namespace OWNER qualifies, so no deployment-wide
@@ -168,6 +168,13 @@ TOOL_VISIBILITY = {
168
168
  # ceiling, since the effective permission is membership ∧ ceiling
169
169
  "memory_admin_edit_namespace": ("identity", "has_admin_ceiling"),
170
170
  "memory_admin_list_members": ("identity", "has_admin_ceiling"),
171
+ "memory_admin_add_member": ("identity", "has_admin_ceiling"),
172
+ "memory_admin_remove_member": ("identity", "has_admin_ceiling"),
173
+ "memory_admin_transfer_namespace": ("identity", "has_admin_ceiling"),
174
+ "memory_admin_list_requests": ("identity", "has_admin_ceiling"),
175
+ "memory_admin_decide_access": ("identity", "has_admin_ceiling"),
176
+ # asking is not administering — anyone with an account may
177
+ "memory_request_access": ("identity",),
171
178
  }
172
179
 
173
180
 
@@ -782,6 +789,21 @@ def build_server(cfg: Optional[Config] = None):
782
789
  out.update({"permission": permission, "namespace_id": nsid})
783
790
  return out
784
791
 
792
+ @mcp.tool()
793
+ def memory_request_access(space_id: str, permission: str = "read",
794
+ ctx: Context = None) -> dict:
795
+ """Ask the owner of a namespace to let you in.
796
+
797
+ The push side of sharing needs somebody to know your user id; this is
798
+ the pull side, for when you know a namespace exists and want in. You
799
+ need its id — the answer deliberately cannot tell a namespace you cannot
800
+ reach from one that does not exist, so ids come from a person, not from
801
+ browsing. `already_reachable` means you are in it already."""
802
+ with pool.connection() as conn, conn.transaction():
803
+ return admin.request_access(conn, _principal(conn, _token(ctx)),
804
+ namespace_id=space_id,
805
+ permission=permission)
806
+
785
807
  @mcp.tool()
786
808
  def memory_list_tokens(ctx: Context = None) -> List[dict]:
787
809
  """List your tokens (metadata only — never the secret)."""
@@ -922,15 +944,20 @@ def build_server(cfg: Optional[Config] = None):
922
944
  def memory_admin_edit_namespace(space_id: str,
923
945
  description: Optional[str] = None,
924
946
  instruction: Optional[str] = None,
947
+ name: Optional[str] = None,
925
948
  ctx: Context = None) -> dict:
926
- """Amend a namespace's description or routing instruction. Creating
927
- it again will not: that call ignores conflicts, so this is the only
928
- way to correct the text agents route by."""
949
+ """Amend a namespace's description, routing instruction, or NAME.
950
+ Creating it again will not: that call ignores conflicts, so this is
951
+ the only way to correct the text agents route by.
952
+
953
+ Renaming is addressed by id and checked against the owner's other
954
+ namespaces and aliases. Callers using the old name get "no such
955
+ namespace" from then on; ids and aliases keep working."""
929
956
  with pool.connection() as conn, conn.transaction():
930
957
  return admin.edit_namespace(
931
958
  conn, _principal(conn, _token(ctx)),
932
959
  namespace_id=space_id, description=description,
933
- instruction=instruction)
960
+ instruction=instruction, name=name)
934
961
 
935
962
  @mcp.tool()
936
963
  def memory_admin_count_orphans(ctx: Context = None) -> dict:
@@ -959,12 +986,83 @@ def build_server(cfg: Optional[Config] = None):
959
986
  permission: str = "read",
960
987
  ctx: Context = None) -> dict:
961
988
  """Share a namespace with another user at read | write | admin.
962
- Superadmin only — it grants access across tenants."""
989
+ Yours to give: the owner of a namespace (or a superadmin) may share
990
+ it, with an admin-ceiling credential for it. Re-adding an existing
991
+ member changes their permission."""
963
992
  with pool.connection() as conn, conn.transaction():
964
993
  return admin.add_member(conn, _principal(conn, _token(ctx)),
965
994
  namespace_id=space_id, user_id=user_id,
966
995
  permission=permission)
967
996
 
997
+ @mcp.tool()
998
+ def memory_admin_remove_member(space_id: str, user_id: str,
999
+ ctx: Context = None) -> dict:
1000
+ """Take a shared namespace away again. `removed: false` means they
1001
+ were not a member.
1002
+
1003
+ Effective immediately — reach is recomputed on every call. A token
1004
+ they hold that was SCOPED to this namespace keeps existing and stops
1005
+ reaching anything; revoke it separately if that is what you meant.
1006
+ The OWNER cannot be removed this way: ownership is not a membership,
1007
+ so hand the namespace over instead."""
1008
+ with pool.connection() as conn, conn.transaction():
1009
+ return admin.remove_member(conn, _principal(conn, _token(ctx)),
1010
+ namespace_id=space_id, user_id=user_id)
1011
+
1012
+ @mcp.tool()
1013
+ def memory_admin_transfer_namespace(
1014
+ space_id: str, new_owner_user_id: str,
1015
+ keep_previous_owner: Optional[str] = "admin",
1016
+ ctx: Context = None) -> dict:
1017
+ """Hand a namespace to another account — owner or superadmin only.
1018
+
1019
+ The outgoing owner stays behind as a member at
1020
+ `keep_previous_owner` (default `admin`); pass null for a clean
1021
+ hand-off, knowing that this removes them from a namespace whose
1022
+ contents they may be the only one who knows. Refused if the new
1023
+ owner already owns — or aliases — a namespace by the same name,
1024
+ since the name has to stay addressable for whoever holds it."""
1025
+ with pool.connection() as conn, conn.transaction():
1026
+ return admin.transfer_namespace(
1027
+ conn, _principal(conn, _token(ctx)), namespace_id=space_id,
1028
+ new_owner_user_id=new_owner_user_id,
1029
+ keep_previous_owner=keep_previous_owner)
1030
+
1031
+ @mcp.tool()
1032
+ def memory_admin_list_requests(space_id: str,
1033
+ ctx: Context = None) -> List[dict]:
1034
+ """Pending requests to join a namespace you administer — who asked,
1035
+ for what permission, when."""
1036
+ with pool.connection() as conn:
1037
+ out = admin.list_requests(conn, _principal(conn, _token(ctx)),
1038
+ namespace_id=space_id)
1039
+ return _iso(out, "created_at")
1040
+
1041
+ @mcp.tool()
1042
+ def memory_admin_decide_access(request_id: str, approve: bool = True,
1043
+ ctx: Context = None) -> dict:
1044
+ """Approve or deny a request to join. Approving adds them as a
1045
+ member at the permission they asked for — the same act as
1046
+ `memory_admin_add_member`, reached from their side."""
1047
+ with pool.connection() as conn, conn.transaction():
1048
+ admin.decide_access(conn, _principal(conn, _token(ctx)),
1049
+ request_id=request_id, approve=approve)
1050
+ return {"request_id": request_id, "approved": approve}
1051
+
1052
+ @mcp.tool()
1053
+ def memory_admin_set_disabled(user_id: str, disabled: bool = True,
1054
+ ctx: Context = None) -> dict:
1055
+ """Switch an account off, or back on.
1056
+
1057
+ Offboarding in one act: every token it holds stops authenticating at
1058
+ once, and so does any token issued to it later. Nothing is
1059
+ destroyed — authorship, namespaces and memberships all survive, and
1060
+ re-enabling restores the account as it was. The last ACTIVE
1061
+ superadmin cannot be switched off."""
1062
+ with pool.connection() as conn, conn.transaction():
1063
+ return admin.set_disabled(conn, _principal(conn, _token(ctx)),
1064
+ user_id=user_id, disabled=disabled)
1065
+
968
1066
  @mcp.tool()
969
1067
  def memory_admin_list_members(space_id: str,
970
1068
  ctx: Context = None) -> List[dict]:
@@ -0,0 +1,12 @@
1
+ -- Turning an account off without destroying it (schema v23).
2
+ --
3
+ -- Offboarding had no operation. The only way to stop someone was to revoke
4
+ -- their tokens one at a time — a loop over a list that has to be complete to be
5
+ -- correct, with nothing preventing a fresh token being issued a minute later.
6
+ -- A person leaving is not a person being deleted: their authorship stays, their
7
+ -- namespaces stay, and coming back should not mean being re-provisioned.
8
+ --
9
+ -- A timestamp rather than a boolean: "when did this account stop working" is
10
+ -- the question actually asked afterwards, and a bool cannot answer it. NULL =
11
+ -- active, which makes every existing row correct without a backfill.
12
+ ALTER TABLE app_user ADD COLUMN IF NOT EXISTS disabled_at timestamptz;
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
  from .config import Config
19
19
 
20
20
  # The version this build migrates the database TO (the latest migration it carries).
21
- SCHEMA_VERSION = 22
21
+ SCHEMA_VERSION = 23
22
22
 
23
23
  # The compatibility FLOOR: the schema version of the most recent backward-
24
24
  # INCOMPATIBLE migration — one that changed the shape/semantics old code relied on