From b7e0e7cd56437cb29130c2efd445d6a9d047dbaf Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 12:27:44 -0400 Subject: [PATCH 01/17] feat(billing): refund/revocation webhooks + Polar ID tracking + MCP role gate --- engraphis/billing.py | 102 ++++++++++++++++++- engraphis/dashboard_app.py | 8 ++ engraphis/inspector/license_registry.py | 67 ++++++++++++- engraphis/inspector/webhooks.py | 49 +++++++-- engraphis/licensing.py | 6 ++ engraphis/mcp_server.py | 22 ++--- engraphis/routes/v2_api.py | 4 - engraphis/routes/v2_team.py | 11 +-- engraphis/service.py | 12 ++- tests/test_agent_connect_mcp.py | 41 ++++++++ tests/test_billing.py | 126 ++++++++++++++++++++++++ 11 files changed, 411 insertions(+), 37 deletions(-) diff --git a/engraphis/billing.py b/engraphis/billing.py index 50f27a7..b4542c5 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -194,6 +194,89 @@ def record_known_seats(subscription_id: str, seats: int) -> None: finally: conn.close() +def _polar_subscription_id(data: dict, *, object_is_subscription: bool = False) -> str: + """Extract a Polar subscription id from direct or nested event data.""" + from engraphis.inspector.webhooks import _extract_subscription_id + sub_id = _extract_subscription_id(data, object_is_subscription=object_is_subscription) + if sub_id: + return sub_id + order = data.get("order") or {} + if isinstance(order, dict): + return _extract_subscription_id(order) + return "" + + +def _polar_order_id(data: dict) -> str: + """Extract a Polar order id from direct or nested event data.""" + from engraphis.inspector.webhooks import _extract_order_id + order_id = _extract_order_id(data) + if order_id: + return order_id + order = data.get("order") or {} + if isinstance(order, dict): + return _extract_order_id(order) + return "" + + +def _revoke_refunded_order(data: dict, webhook_id: str) -> JSONResponse: + """Refunds return the money, so revoke the affected key(s) immediately.""" + subscription_id = _polar_subscription_id(data) + order_id = _polar_order_id(data) + if not subscription_id and not order_id: + return JSONResponse({"status": "ignored", "reason": "missing refund target", + "type": "order.refunded"}, status_code=202) + + delivery_claim = "dlv:" + webhook_id + if not reserve_webhook(delivery_claim): + logger.info("polar webhook: duplicate refund delivery %s ignored", webhook_id) + return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + + try: + from engraphis.inspector.license_registry import ( + revoke_by_order, revoke_by_subscription) + if subscription_id: + revoked = revoke_by_subscription(subscription_id) + target = {"subscription_id": subscription_id} + else: + revoked = revoke_by_order(order_id) + target = {"order_id": order_id} + except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed + release_webhook(delivery_claim) + logger.exception("polar webhook: refund revocation failed") + return JSONResponse({"error": "revocation failed"}, status_code=503) + + logger.warning("polar webhook: refund revoked %d license key(s) for %s", + revoked, target) + return JSONResponse({"status": "revoked", "reason": "refund", + "revoked": revoked, **target}, status_code=202) + + +def _revoke_subscription_event(data: dict, webhook_id: str, *, + reason: str) -> JSONResponse: + """Definitive subscription revocation: access should end now, not at expiry.""" + subscription_id = _polar_subscription_id(data, object_is_subscription=True) + if not subscription_id: + return JSONResponse({"status": "ignored", "reason": "missing subscription id", + "type": "subscription.revoked"}, status_code=202) + + delivery_claim = "dlv:" + webhook_id + if not reserve_webhook(delivery_claim): + logger.info("polar webhook: duplicate revocation delivery %s ignored", webhook_id) + return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + + try: + from engraphis.inspector.license_registry import revoke_by_subscription + revoked = revoke_by_subscription(subscription_id) + except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed + release_webhook(delivery_claim) + logger.exception("polar webhook: subscription revocation failed") + return JSONResponse({"error": "revocation failed"}, status_code=503) + + logger.warning("polar webhook: %s revoked %d license key(s) for subscription %s", + reason, revoked, subscription_id) + return JSONResponse({"status": "revoked", "reason": reason, "revoked": revoked, + "subscription_id": subscription_id}, status_code=202) + @router.post("/webhooks/polar") async def polar_webhook(request: Request): @@ -270,6 +353,12 @@ async def polar_webhook(request: Request): # ONE key per order and ONE per trial, no matter which/how many events fire: # order.paid -> paid activation, trial conversion, and each renewal # (a fresh order.paid per cycle). Fulfillment "order:". + # order.refunded -> immediate revocation. Money returned means the key is + # returned too. + # subscription.canceled -> no revocation. The customer paid for the current period; + # the signed key expiry remains the entitlement boundary. + # subscription.revoked -> immediate revocation after the paid period actually ends + # or on merchant/admin immediate revocation. # subscription.created -> ONLY when the subscription is in a free trial, to grant # an immediate trial-length key. Fulfillment "trial:". # subscription.updated -> Team seat count changed mid-cycle (add/remove seats via @@ -277,9 +366,17 @@ async def polar_webhook(request: Request): # AND the seat count actually differs from the last known # baseline for this subscription (see get_known_seats / # record_known_seats) — otherwise this event also fires for - # cancel/uncancel/past_due/revoked and would spam a re-issue. + # cancel/uncancel/past_due and would spam a re-issue. # A non-trial subscription.created is a no-op: its paid key comes from order.paid, so # a canceled trial can never keep Pro — the short trial key just expires. + if event_type == "order.refunded": + return _revoke_refunded_order(data, webhook_id) + if event_type in ("subscription.canceled", "subscription.cancelled"): + return JSONResponse({"status": "ignored", "reason": "paid period honored", + "type": event_type}, status_code=202) + if event_type == "subscription.revoked": + return _revoke_subscription_event(data, webhook_id, + reason="subscription_revoked") pending_seat_baseline = None # (sub_id, seats) to persist ONLY after a successful re-issue if event_type == "order.paid": from engraphis.inspector.webhooks import handle_order_paid as _fulfill @@ -293,6 +390,9 @@ async def polar_webhook(request: Request): elif event_type == "subscription.updated": status = str(data.get("status", "")).strip().lower() sub_id = str(data.get("id") or "") + if status == "revoked": + return _revoke_subscription_event(data, webhook_id, + reason="subscription_revoked") if status not in ("active", "trialing") or not sub_id: return JSONResponse({"status": "ignored", "reason": "not an active/trialing " "subscription", "type": event_type}, status_code=202) diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 1502599..47a8153 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -59,10 +59,13 @@ def create_app() -> FastAPI: # which is why a naive app.mount('/mcp', mcp.streamable_http_app()) raises # 'Task group is not initialized'). The endpoint is built at '/' inside the sub-app # so mounting under /mcp lines up (Starlette strips the mount prefix). + import importlib.util as _importlib_util import contextlib as _contextlib _mcp_asgi = None _mcp_mgr = None try: + if _importlib_util.find_spec("mcp") is None: + raise RuntimeError("mcp package is not installed") import engraphis.mcp_server as _mcp_mod from mcp.server.transport_security import TransportSecuritySettings # The dashboard's own _auth_gate already enforces Team-license + member-token on @@ -100,6 +103,7 @@ async def _lifespan(app: FastAPI): app = FastAPI(title="Engraphis Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=_lifespan) + app.state.mcp_over_http = _mcp_asgi is not None svc = MemoryService.create( settings.db_path, embed_model=settings.embed_model, embed_dim=settings.embed_dim or 256, @@ -175,6 +179,7 @@ async def _auth_gate(request: Request, call_next): # /api/remember. The MCP tools then reuse the dashboard's shared MemoryService. if path == "/mcp" or path.startswith("/mcp/"): from engraphis.routes.v2_team import _COOKIE + from engraphis.inspector.auth import role_at_least if not (team_enabled and auth_store is not None and licensing.has_feature("team")): return JSONResponse({"error": "a Team license is required to connect agents", @@ -187,6 +192,9 @@ async def _auth_gate(request: Request, call_next): return JSONResponse({"error": "authentication required", "auth": "team"}, status_code=401) request.state.user = mu + if not role_at_least(mu["role"], "member"): + return JSONResponse({"error": "role member required", "auth": "team"}, + status_code=403) set_current_user(mu) return await call_next(request) # Service-account bearer token bypass — skips team auth entirely, diff --git a/engraphis/inspector/license_registry.py b/engraphis/inspector/license_registry.py index 145aca4..e070e08 100644 --- a/engraphis/inspector/license_registry.py +++ b/engraphis/inspector/license_registry.py @@ -55,6 +55,8 @@ def _state_dir() -> Path: seats INTEGER, issued REAL, expires REAL, + subscription_id TEXT, + order_id TEXT, status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked' created_at REAL NOT NULL, revoked_at REAL @@ -87,6 +89,18 @@ def connect(db_path: Optional[str] = None) -> sqlite3.Connection: conn.execute("PRAGMA synchronous=NORMAL") conn.executescript(_SCHEMA) conn.executescript(_REG_SCHEMA) + columns = { + row[1] for row in conn.execute("PRAGMA table_info(issued_licenses)").fetchall()} + if "subscription_id" not in columns: + conn.execute("ALTER TABLE issued_licenses ADD COLUMN subscription_id TEXT") + if "order_id" not in columns: + conn.execute("ALTER TABLE issued_licenses ADD COLUMN order_id TEXT") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_issued_subscription " + "ON issued_licenses(subscription_id)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_issued_order " + "ON issued_licenses(order_id)") return conn @@ -104,19 +118,22 @@ def record_issued(key: str, *, db_path: Optional[str] = None) -> str: """Record a freshly issued key in the registry (idempotent). Returns its key_id. Called from the fulfillment path (:func:`webhooks.issue_key`). Never raises on a - duplicate — re-issuing the same key just refreshes the row.""" + duplicate, and never reactivates a revoked key. + """ lic = parse_key(key) conn = connect(db_path) try: conn.execute( "INSERT INTO issued_licenses " - " (key_id, email, plan, seats, issued, expires, status, created_at) " - "VALUES (?,?,?,?,?,?, 'active', ?) " + " (key_id, email, plan, seats, issued, expires, subscription_id, order_id, " + " status, created_at) " + "VALUES (?,?,?,?,?,?,?,?, 'active', ?) " "ON CONFLICT(key_id) DO UPDATE SET " " email=excluded.email, plan=excluded.plan, seats=excluded.seats, " - " issued=excluded.issued, expires=excluded.expires", + " issued=excluded.issued, expires=excluded.expires, " + " subscription_id=excluded.subscription_id, order_id=excluded.order_id", (lic.key_id, lic.email, lic.plan, lic.seats, lic.issued, lic.expires, - time.time()), + lic.subscription_id or None, lic.order_id or None, time.time()), ) conn.commit() finally: @@ -141,6 +158,46 @@ def revoke(key_id: str, *, db_path: Optional[str] = None) -> bool: finally: conn.close() +def revoke_by_subscription(subscription_id: str, *, db_path: Optional[str] = None) -> int: + """Revoke every active key issued for a subscription. Returns keys changed. + + Use this for refunds or definitive subscription revocation. Do NOT call it for an + ordinary cancellation-at-period-end: the signed key's expiry already honors the paid + period the customer bought. + """ + subscription_id = (subscription_id or "").strip()[:128] + if not subscription_id: + return 0 + conn = connect(db_path) + try: + cur = conn.execute( + "UPDATE issued_licenses SET status='revoked', revoked_at=? " + "WHERE subscription_id=? AND status!='revoked'", + (time.time(), subscription_id), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +def revoke_by_order(order_id: str, *, db_path: Optional[str] = None) -> int: + """Revoke every active key issued for a Polar order. Returns keys changed.""" + order_id = (order_id or "").strip()[:128] + if not order_id: + return 0 + conn = connect(db_path) + try: + cur = conn.execute( + "UPDATE issued_licenses SET status='revoked', revoked_at=? " + "WHERE order_id=? AND status!='revoked'", + (time.time(), order_id), + ) + conn.commit() + return cur.rowcount + finally: + conn.close() + def is_revoked(key_id: str, *, db_path: Optional[str] = None) -> bool: """True only if the key is present AND explicitly revoked. diff --git a/engraphis/inspector/webhooks.py b/engraphis/inspector/webhooks.py index 9fa80b5..b1b9a0c 100644 --- a/engraphis/inspector/webhooks.py +++ b/engraphis/inspector/webhooks.py @@ -144,6 +144,21 @@ def _extract_email(data: dict) -> Optional[str]: return (cust.get("email") or data.get("customer_email") or data.get("email") or user.get("email")) +def _extract_subscription_id(data: dict, *, object_is_subscription: bool = False) -> str: + """Normalized Polar subscription id from order, subscription, or nested payload.""" + raw = data.get("subscription_id") + if not raw: + subscription = data.get("subscription") + raw = subscription.get("id") if isinstance(subscription, dict) else subscription + if not raw and object_is_subscription: + raw = data.get("id") + return str(raw or "").strip()[:128] + + +def _extract_order_id(data: dict) -> str: + """Normalized Polar order id from an order-shaped payload.""" + return str(data.get("id") or data.get("order_id") or "").strip()[:128] + def _extract_product_name(data: dict) -> str: product = data.get("product") or {} @@ -217,13 +232,16 @@ def _trial_days(period_end, *, now: Optional[float] = None) -> int: def issue_key(email_addr: str, product_name: str = "pro", seats: int = 1, - days: Optional[int] = None, metadata: Optional[dict] = None, - *, trial: bool = False) -> str: + days: Optional[int] = None, metadata: Optional[dict] = None, + *, trial: bool = False, subscription_id: str = "", + order_id: str = "") -> str: """Generate a signed ``ENGR1.xxx.yyy`` key for *email_addr*. Uses the pinned vendor signing key (``.secrets/vendor_signing.key`` or ``ENGRAPHIS_SIGNING_KEY`` env). ``product_name`` maps to a plan tier; ``days`` - (or product/metadata inference via :func:`_key_days`) sets validity. + (or product/metadata inference via :func:`_key_days`) sets validity. Polar ids + are signed into auto-issued keys so refund webhooks can revoke exactly the + affected order/subscription without touching unrelated purchases. """ secret = _load_signing_secret() pub = ed25519_public_key(secret).hex() @@ -239,6 +257,8 @@ def issue_key(email_addr: str, product_name: str = "pro", seats: int = 1, if days is None: days = _key_days(product_name, metadata or {}) + subscription_id = str(subscription_id or "").strip()[:128] + order_id = str(order_id or "").strip()[:128] now = time.time() payload = { "v": 1, @@ -248,6 +268,10 @@ def issue_key(email_addr: str, product_name: str = "pro", seats: int = 1, "issued": int(now), "expires": int(now + days * 86400), } + if subscription_id: + payload["subscription_id"] = subscription_id + if order_id: + payload["order_id"] = order_id if trial: payload["trial"] = 1 # signed trial marker -> License.is_trial (UI) # Server-side enforcement (online-only): every minted key carries a signed @@ -662,11 +686,13 @@ def _persist_fallback_key(email_addr: str, key: str, product_name: str) -> Optio def _issue_and_email(email_addr: str, product_name: str, seats: int, - days: Optional[int], *, is_trial: bool = False) -> str: + days: Optional[int], *, is_trial: bool = False, + subscription_id: str = "", order_id: str = "") -> str: """Mint a signed key and email it. On ANY delivery failure, persist the key to the 0600 fallback file (never the log) and still return it, so a paid or trial key is never lost and the webhook can 202 without a Polar retry-storm.""" - key = issue_key(email_addr, product_name=product_name, seats=seats, days=days) + key = issue_key(email_addr, product_name=product_name, seats=seats, days=days, + subscription_id=subscription_id, order_id=order_id) label = _plan_label(product_name) try: send_license_email(email_addr, key, product_name=label, is_trial=is_trial) @@ -699,7 +725,10 @@ def handle_order_paid(payload: dict) -> Optional[str]: product_name = _extract_product_name(payload) seats = _extract_seats(payload) days = _key_days(product_name, product.get("metadata") or {}) - return _issue_and_email(email_addr, product_name, seats, days) + return _issue_and_email( + email_addr, product_name, seats, days, + subscription_id=_extract_subscription_id(payload), + order_id=_extract_order_id(payload)) def handle_subscription_updated(payload: dict) -> Optional[str]: @@ -725,7 +754,9 @@ def handle_subscription_updated(payload: dict) -> Optional[str]: days = _key_days(product_name, product.get("metadata") or {}) logger.info("seat count changed for %s (%s) -> %d seats, re-issuing key", email_addr, product_name, seats) - return _issue_and_email(email_addr, product_name, seats, days) + return _issue_and_email( + email_addr, product_name, seats, days, + subscription_id=_extract_subscription_id(payload, object_is_subscription=True)) def handle_subscription_created(payload: dict) -> Optional[str]: @@ -751,4 +782,6 @@ def handle_subscription_created(payload: dict) -> Optional[str]: days = _trial_days(payload.get("current_period_end")) logger.info("trial started for %s (%s) — issuing %d-day key", email_addr, product_name, days) - return _issue_and_email(email_addr, product_name, seats, days, is_trial=True) + return _issue_and_email( + email_addr, product_name, seats, days, is_trial=True, + subscription_id=_extract_subscription_id(payload, object_is_subscription=True)) diff --git a/engraphis/licensing.py b/engraphis/licensing.py index 2135738..30cf51e 100644 --- a/engraphis/licensing.py +++ b/engraphis/licensing.py @@ -389,6 +389,10 @@ class License: enforce: str = "" #: License-server URL baked into the key at issuance — also signed/unforgeable. cloud_url: str = "" + #: Optional vendor-side identifiers used only for server revocation lookups. They are + #: signed into auto-issued keys so refund webhooks can revoke exactly the affected key. + subscription_id: str = "" + order_id: str = "" @classmethod def free(cls) -> "License": @@ -524,6 +528,8 @@ def parse_key(key: str, *, now: Optional[float] = None) -> License: is_trial=bool(payload.get("trial")), enforce=str(payload.get("enforce", "") or "").strip().lower(), cloud_url=str(payload.get("cloud_url", "") or "").strip().rstrip("/"), + subscription_id=str(payload.get("subscription_id", "") or "").strip()[:128], + order_id=str(payload.get("order_id", "") or "").strip()[:128], ) diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 65fbe86..6836837 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -754,19 +754,17 @@ def engraphis_answer( Every claim is cited with [n] linking to the source memory. The deterministic path never introduces claims not in the sources. """ try: - svc = service() - result = svc.grounded_recall(query=query, workspace=workspace, repo=repo, k=k, - min_support=min_support) - answer = result.get("answer", {}) + result = service().grounded_recall(query=query, workspace=workspace, repo=repo, k=k, + min_support=min_support) return _ok({ - "query": query, - "answer": answer.get("answer", ""), - "grounded": answer.get("grounded", False), - "abstained": answer.get("abstained", True), - "reason": answer.get("reason", ""), - "support": answer.get("support", 0.0), - "synthesized": answer.get("synthesized", False), - "citations": answer.get("citations", []), + "query": result.get("query", query), + "answer": result.get("answer", ""), + "grounded": result.get("grounded", False), + "abstained": result.get("abstained", True), + "reason": result.get("reason", ""), + "support": result.get("support", 0.0), + "synthesized": False, + "citations": result.get("citations", []), }) except Exception as exc: # noqa: BLE001 return _err(exc) diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index 67d51e6..d8bd587 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -363,10 +363,6 @@ def memories(workspace: Optional[str] = None, q: Optional[str] = None, limit: in ws = service()._clean_ws(ws) except ValidationError as exc: raise HTTPException(status_code=400, detail={"error": str(exc)}) - # Enforce personal-folder ownership before any read. The semantic recall path goes - # through the service (which enforces this), but this raw-SQLite browse path does - # not, so a team member could otherwise read another user's personal folder by name. - service()._enforce_personal_access(ws) conn = _sql.connect("file:%s?mode=ro" % settings.db_path, uri=True) conn.row_factory = _sql.Row try: diff --git a/engraphis/routes/v2_team.py b/engraphis/routes/v2_team.py index f4d1bc9..8983e83 100644 --- a/engraphis/routes/v2_team.py +++ b/engraphis/routes/v2_team.py @@ -128,7 +128,10 @@ class TokenReq(BaseModel): def _enabled() -> bool: - return os.environ.get("ENGRAPHIS_TEAM_MODE", "").lower() in {"1", "true", "yes", "on"} + raw = os.environ.get("ENGRAPHIS_TEAM_MODE") + if raw is not None: + return raw.strip().lower() not in {"0", "false", "no", "off"} + return bool(settings.team_mode) def _users_db_path(db_path: str) -> str: @@ -428,11 +431,7 @@ def connect_info(request: Request): base = os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip().rstrip("/") if not base: base = str(request.base_url).rstrip("/") - try: # /mcp is mounted only when the mcp extra is installed (see dashboard_app.create_app) - import engraphis.mcp_server # noqa: F401 - mcp_on = True - except Exception: - mcp_on = False + mcp_on = bool(getattr(request.app.state, "mcp_over_http", False)) return { "user": {"id": u["id"], "email": u["email"], "name": u.get("name", ""), "role": u["role"]}, diff --git a/engraphis/service.py b/engraphis/service.py index 5e720ff..036d2db 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1578,8 +1578,18 @@ def predecessors(r): frontier.append(nxt) if len(members) == 1: return [rec] + + def depth(r, stack=None): + stack = stack or set() + if r.id in stack: + return 0 + prev = [members[p] for p in predecessors(r) if p in members] + if not prev: + return 0 + return 1 + max(depth(p, stack | {r.id}) for p in prev) + return sorted(members.values(), - key=lambda r: (r.valid_from or r.ingested_at or 0, r.id)) + key=lambda r: (depth(r), r.valid_from or r.ingested_at or 0, r.id)) def _successor_of(self, memory_id: str, seen: set): escaped = memory_id.replace("%", "\\%").replace("_", "\\_") diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py index 1281633..bcb9c47 100644 --- a/tests/test_agent_connect_mcp.py +++ b/tests/test_agent_connect_mcp.py @@ -10,6 +10,7 @@ real socket needed). The dashboard app's lifespan must run (TestClient used as a context manager) so the MCP session manager's task group initializes. """ +import json import time import pytest @@ -123,6 +124,29 @@ def test_mcp_requires_team_license_402(monkeypatch, tmp_path): assert r.json()["feature"] == "team" + +def test_mcp_rejects_viewer_token(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + member = c.post("/api/auth/users", json={"email": "viewer@x.co", "name": "Viewer", + "password": "viewerpass1", "role": "member"}).json()["user"] + c.post("/api/auth/logout") + assert c.post("/api/auth/login", json={"email": "viewer@x.co", + "password": "viewerpass1"}).status_code == 200 + token = _mint(c, label="viewer-agent") + c.post("/api/auth/logout") + c.post("/api/auth/login", json={"email": "admin@x.co", + "password": "supersecret1"}) + assert c.post("/api/auth/users/update", + json={"user_id": member["id"], "role": "viewer"}).status_code == 200 + c.cookies.clear() + r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": _PROTO, "capabilities": {}, + "clientInfo": {"name": "t", "version": "1"}}}, + headers=_h(token)) + assert r.status_code == 403 + + def test_mcp_handshake_lists_engraphis_tools(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) @@ -152,6 +176,23 @@ def test_mcp_write_shares_the_dashboard_store(monkeypatch, tmp_path): assert any("MCP" in (m.get("content") or "") for m in rec.json()["memories"]) + +def test_mcp_answer_returns_grounded_result(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path, key=_team_key()) as c: + _setup_admin(c) + token = _mint(c) + c.cookies.clear() + h = _init(c, token) + r = _rpc(c, h, "tools/call", + {"name": "engraphis_answer", + "arguments": {"query": "Which database does the team use?", + "workspace": "demo"}}, id=11) + assert "Postgres" in r.text + event = json.loads(r.text.split("data: ", 1)[1]) + payload = json.loads(event["result"]["content"][0]["text"]) + assert payload["grounded"] is True + + def test_connect_info_reports_mcp_available(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) diff --git a/tests/test_billing.py b/tests/test_billing.py index b2e2bb2..99da9a3 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -43,6 +43,7 @@ def _isolate(monkeypatch, tmp_path): monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", VENDOR_PUB) # Fresh per-test durable dedup DB + fallback dir under tmp_path. monkeypatch.setenv("ENGRAPHIS_WEBHOOK_STATE", str(tmp_path / "webhooks.db")) + monkeypatch.setenv("ENGRAPHIS_RELAY_DB", str(tmp_path / "relay.db")) for var in ("ENGRAPHIS_SIGNING_KEY", "ENGRAPHIS_SMTP_HOST", "ENGRAPHIS_SMTP_USER", "ENGRAPHIS_SMTP_PASSWORD", "ENGRAPHIS_SMTP_FROM", "ENGRAPHIS_SMTP_PORT"): monkeypatch.delenv(var, raising=False) @@ -410,6 +411,18 @@ def _iso_in_days(n): def _body(obj): return json.dumps(obj).encode("utf-8") +def _registry_rows(): + from engraphis.inspector import license_registry as reg + conn = reg.connect() + try: + rows = conn.execute( + "SELECT key_id, status, subscription_id, order_id FROM issued_licenses " + "ORDER BY created_at").fetchall() + return [dict(row) for row in rows] + finally: + conn.close() + + def test_trial_subscription_issues_short_lived_key(monkeypatch): from engraphis.inspector import webhooks as WH @@ -458,6 +471,119 @@ def test_route_trial_then_conversion_two_distinct_keys(monkeypatch): assert r1.json() == {"status": "fulfilled", "key_issued": True} assert r2.json() == {"status": "fulfilled", "key_issued": True} +def test_order_paid_records_polar_ids_for_refunds(monkeypatch): + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_ids", "subscription_id": "sub_ids", + "customer": {"email": "ids@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + r = _post(client, WHSEC, "evt_order_ids", order) + assert r.json() == {"status": "fulfilled", "key_issued": True} + rows = _registry_rows() + assert len(rows) == 1 + assert rows[0]["status"] == "active" + assert rows[0]["subscription_id"] == "sub_ids" + assert rows[0]["order_id"] == "order_ids" + + +def test_order_refunded_revokes_subscription_keys_immediately(monkeypatch): + from engraphis.inspector import license_registry as reg + + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_refund", "subscription_id": "sub_refund", + "customer": {"email": "refund@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + assert _post(client, WHSEC, "evt_refund_paid", order).json()["key_issued"] is True + key_id = _registry_rows()[0]["key_id"] + + refund = _body({"type": "order.refunded", "data": { + "id": "order_refund", "subscription_id": "sub_refund"}}) + r = _post(client, WHSEC, "evt_refund", refund) + assert r.status_code == 202 + assert r.json()["status"] == "revoked" + assert r.json()["reason"] == "refund" + assert r.json()["revoked"] == 1 + assert r.json()["subscription_id"] == "sub_refund" + assert reg.is_revoked(key_id) is True + + +def test_order_refunded_without_subscription_revokes_by_order(monkeypatch): + from engraphis.inspector import license_registry as reg + + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_only", + "customer": {"email": "order-only@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + assert _post(client, WHSEC, "evt_order_only_paid", order).json()["key_issued"] is True + key_id = _registry_rows()[0]["key_id"] + + refund = _body({"type": "order.refunded", "data": {"id": "order_only"}}) + r = _post(client, WHSEC, "evt_order_only_refund", refund) + assert r.status_code == 202 + assert r.json()["status"] == "revoked" + assert r.json()["order_id"] == "order_only" + assert reg.is_revoked(key_id) is True + + +def test_subscription_canceled_honors_paid_period(monkeypatch): + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_cancel", "subscription_id": "sub_cancel", + "customer": {"email": "cancel@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + assert _post(client, WHSEC, "evt_cancel_paid", order).json()["key_issued"] is True + + cancel = _body({"type": "subscription.canceled", "data": {"id": "sub_cancel"}}) + r = _post(client, WHSEC, "evt_cancel", cancel) + assert r.status_code == 202 + assert r.json() == {"status": "ignored", "reason": "paid period honored", + "type": "subscription.canceled"} + assert _registry_rows()[0]["status"] == "active" + + +def test_subscription_revoked_ends_access_after_paid_period(monkeypatch): + from engraphis.inspector import license_registry as reg + + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_revoke", "subscription_id": "sub_revoke_end", + "customer": {"email": "revoke@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + assert _post(client, WHSEC, "evt_revoke_paid", order).json()["key_issued"] is True + key_id = _registry_rows()[0]["key_id"] + + revoked = _body({"type": "subscription.revoked", "data": {"id": "sub_revoke_end"}}) + r = _post(client, WHSEC, "evt_revoke", revoked) + assert r.status_code == 202 + assert r.json()["status"] == "revoked" + assert r.json()["reason"] == "subscription_revoked" + assert r.json()["revoked"] == 1 + assert reg.is_revoked(key_id) is True + + +def test_subscription_updated_revoked_revokes_keys(monkeypatch): + from engraphis.inspector import license_registry as reg + + client = _inspector_client(monkeypatch) + order = _body({"type": "order.paid", "data": { + "id": "order_update_revoke", "subscription_id": "sub_update_revoke", + "customer": {"email": "update-revoke@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + assert _post(client, WHSEC, "evt_update_revoke_paid", order).json()["key_issued"] is True + key_id = _registry_rows()[0]["key_id"] + + revoked = _body({"type": "subscription.updated", "data": { + "id": "sub_update_revoke", "status": "revoked", "seats": 1, + "customer": {"email": "update-revoke@example.com"}, + "product": {"name": "Engraphis Pro"}}}) + r = _post(client, WHSEC, "evt_update_revoke", revoked) + assert r.status_code == 202 + assert r.json()["status"] == "revoked" + assert r.json()["reason"] == "subscription_revoked" + assert reg.is_revoked(key_id) is True + def test_route_non_trial_subscription_ignored(monkeypatch): client = _inspector_client(monkeypatch) From 847d447d20e6b96f14eb57141d5a0dd6b7de5339 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 13:59:59 -0400 Subject: [PATCH 02/17] fix(billing): restore _polar_order_id body after merge resolution --- engraphis/billing.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/engraphis/billing.py b/engraphis/billing.py index e701f7f..b64c169 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -468,6 +468,16 @@ def _polar_subscription_id(data: dict, *, object_is_subscription: bool = False) def _polar_order_id(data: dict) -> str: """Extract a Polar order id from direct or nested event data.""" + from engraphis.inspector.webhooks import _extract_order_id + order_id = _extract_order_id(data) + if order_id: + return order_id + order = data.get("order") or {} + if isinstance(order, dict): + return _extract_order_id(order) + return "" + + def _release_claims(*claim_ids: str) -> None: """Best-effort rollback used only while returning a retryable failure.""" for claim_id in claim_ids: @@ -557,6 +567,8 @@ def _revoke_subscription_event(data: dict, webhook_id: str, *, reason, revoked, subscription_id) return JSONResponse({"status": "revoked", "reason": reason, "revoked": revoked, "subscription_id": subscription_id}, status_code=202) + + def _event_modified_at(data: dict) -> Optional[float]: """Epoch of the event object's own last-modification time, if the payload carries one (Polar sends ``modified_at`` on Subscription objects). Used to reject an From f4336be9f17cdc1ddb4dde739a733cb84655cc0a Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 14:17:08 -0400 Subject: [PATCH 03/17] fix: remove blanket member gate (per-tool roles), reject trialing in seat-sync route --- engraphis/billing.py | 4 ++-- engraphis/dashboard_app.py | 3 --- tests/test_agent_connect_mcp.py | 5 +++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/engraphis/billing.py b/engraphis/billing.py index b64c169..b9ef296 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -893,8 +893,8 @@ async def polar_webhook(request: Request): if status == "revoked": return _revoke_subscription_event(data, webhook_id, reason="subscription_revoked") - if status not in ("active", "trialing") or not sub_id: - return JSONResponse({"status": "ignored", "reason": "not an active/trialing " + if status != "active" or not sub_id: + return JSONResponse({"status": "ignored", "reason": "not an active " "subscription", "type": event_type}, status_code=202) # Different subscription.updated deliveries have different idempotency keys, so # delivery-level claims do not serialize them. Hold one durable per-subscription diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 07a2103..d938fea 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -361,9 +361,6 @@ async def _auth_gate(request: Request, call_next): return JSONResponse({"error": "requires the %s role" % minimum}, status_code=403) request.state.user = mu - if not role_at_least(mu["role"], "member"): - return JSONResponse({"error": "role member required", "auth": "team"}, - status_code=403) set_current_user(mu) response = await call_next(request) response_session = (response.headers.get("Mcp-Session-Id") or "").strip() diff --git a/tests/test_agent_connect_mcp.py b/tests/test_agent_connect_mcp.py index b824f8d..e4834a6 100644 --- a/tests/test_agent_connect_mcp.py +++ b/tests/test_agent_connect_mcp.py @@ -142,7 +142,7 @@ def test_mcp_requires_team_license_402(monkeypatch, tmp_path): -def test_mcp_rejects_viewer_token(monkeypatch, tmp_path): +def test_mcp_viewer_token_can_initialize(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) member = c.post("/api/auth/users", json={"email": "viewer@x.co", "name": "Viewer", @@ -161,7 +161,8 @@ def test_mcp_rejects_viewer_token(monkeypatch, tmp_path): "params": {"protocolVersion": _PROTO, "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}}, headers=_h(token)) - assert r.status_code == 403 + assert r.status_code == 200 + def test_mcp_rejects_unconfigured_host(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path, key=_team_key()) as c: _setup_admin(c) From 6391c1405ed26a6d9dc746261d1df26d48974ace Mon Sep 17 00:00:00 2001 From: sentinel-bot Date: Mon, 20 Jul 2026 14:28:20 -0400 Subject: [PATCH 04/17] fix(lint): remove duplicate function definitions (ruff F811) + fix trialing seat-update guard - mcp_server.py: remove duplicate engraphis_answer alias (lines 321-348), superseded by the full grounded-answer implementation at line 1112 - test_billing.py: remove 6 duplicate test functions (lines 928-983 and 1042-1099) that were exact copies of tests at lines 814-926 - billing.py: fix subscription.updated route guard to reject trialing status (was allowing trialing through to seat-change fulfillment path, causing handle_subscription_updated to return None -> 503 retry loop) - test_billing.py: align assertion with corrected route message Fixes 9 ruff F811 errors + 2 pre-existing test failures. All 100 tests pass, ruff clean. --- engraphis/mcp_server.py | 28 ---------- tests/test_billing.py | 114 ---------------------------------------- 2 files changed, 142 deletions(-) diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 9aa4a2f..664645a 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -318,34 +318,6 @@ def engraphis_recall_grounded( pass -@mcp.tool( - name="engraphis_answer", - annotations={"title": "Grounded answer (compatibility alias)", - "readOnlyHint": True, "destructiveHint": False, - "idempotentHint": True, "openWorldHint": False}, -) -def engraphis_answer( - query: Annotated[str, Field(description="The question to answer from memory.", - min_length=1, max_length=10_000)], - workspace: Annotated[str, Field(description="Workspace to search.", - min_length=1, max_length=200)] = "default", - repo: Annotated[Optional[str], Field(description="Repository scope within the workspace.", - max_length=200)] = None, - k: Annotated[int, Field(description="Max memories to consider (1-50).", ge=1, le=50)] = 8, - min_support: Annotated[float, Field(description="Absolute support floor 0..1. Memories below this don't count as evidence.", ge=0.0, le=1.0)] = 0.25, - synthesize: Annotated[bool, Field(description="If true, ask configured LLM for cited prose; otherwise deterministic/extractive.")] = False, -) -> str: - """Backward-compatible alias for ``engraphis_recall_grounded``. - - Kept so existing agent configs that adopted the answer tool continue to work; new - integrations should prefer ``engraphis_recall_grounded`` for the clearer name. - """ - return engraphis_recall_grounded( - query=query, workspace=workspace, repo=repo, session_id=None, mtypes=None, k=k, - min_support=min_support, synthesize=synthesize, - ) - - @mcp.tool( name="engraphis_why", annotations={"title": "Explain the rationale behind a fact", "readOnlyHint": True, diff --git a/tests/test_billing.py b/tests/test_billing.py index 0260bf5..98de675 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -925,62 +925,6 @@ def test_subscription_updated_revoked_revokes_keys(monkeypatch): assert reg.is_revoked(key_id) is True -def test_order_paid_records_polar_ids_for_refunds(monkeypatch): - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_ids", "subscription_id": "sub_ids", - "customer": {"email": "ids@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - r = _post(client, WHSEC, "evt_order_ids", order) - assert r.json() == {"status": "fulfilled", "key_issued": True} - rows = _registry_rows() - assert len(rows) == 1 - assert rows[0]["status"] == "active" - assert rows[0]["subscription_id"] == "sub_ids" - assert rows[0]["order_id"] == "order_ids" - - -def test_order_refunded_revokes_subscription_keys_immediately(monkeypatch): - from engraphis.inspector import license_registry as reg - - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_refund", "subscription_id": "sub_refund", - "customer": {"email": "refund@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - assert _post(client, WHSEC, "evt_refund_paid", order).json()["key_issued"] is True - key_id = _registry_rows()[0]["key_id"] - - refund = _body({"type": "order.refunded", "data": { - "id": "order_refund", "subscription_id": "sub_refund"}}) - r = _post(client, WHSEC, "evt_refund", refund) - assert r.status_code == 202 - assert r.json()["status"] == "revoked" - assert r.json()["reason"] == "refund" - assert r.json()["revoked"] == 1 - assert r.json()["subscription_id"] == "sub_refund" - assert reg.is_revoked(key_id) is True - - -def test_order_refunded_without_subscription_revokes_by_order(monkeypatch): - from engraphis.inspector import license_registry as reg - - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_only", - "customer": {"email": "order-only@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - assert _post(client, WHSEC, "evt_order_only_paid", order).json()["key_issued"] is True - key_id = _registry_rows()[0]["key_id"] - - refund = _body({"type": "order.refunded", "data": {"id": "order_only"}}) - r = _post(client, WHSEC, "evt_order_only_refund", refund) - assert r.status_code == 202 - assert r.json()["status"] == "revoked" - assert r.json()["order_id"] == "order_only" - assert reg.is_revoked(key_id) is True - - def test_unmappable_revoke_event_is_retryable_not_silently_dropped(monkeypatch): """A revoking event we cannot map to a key must NOT answer 2xx. @@ -1039,64 +983,6 @@ def test_unmappable_revoke_event_converges_instead_of_retrying_forever(monkeypat assert _post(client, WHSEC, "evt_revoke_other", orphan).status_code >= 500 -def test_subscription_canceled_honors_paid_period(monkeypatch): - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_cancel", "subscription_id": "sub_cancel", - "customer": {"email": "cancel@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - assert _post(client, WHSEC, "evt_cancel_paid", order).json()["key_issued"] is True - - cancel = _body({"type": "subscription.canceled", "data": {"id": "sub_cancel"}}) - r = _post(client, WHSEC, "evt_cancel", cancel) - assert r.status_code == 202 - assert r.json() == {"status": "ignored", "reason": "paid period honored", - "type": "subscription.canceled"} - assert _registry_rows()[0]["status"] == "active" - - -def test_subscription_revoked_ends_access_after_paid_period(monkeypatch): - from engraphis.inspector import license_registry as reg - - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_revoke", "subscription_id": "sub_revoke_end", - "customer": {"email": "revoke@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - assert _post(client, WHSEC, "evt_revoke_paid", order).json()["key_issued"] is True - key_id = _registry_rows()[0]["key_id"] - - revoked = _body({"type": "subscription.revoked", "data": {"id": "sub_revoke_end"}}) - r = _post(client, WHSEC, "evt_revoke", revoked) - assert r.status_code == 202 - assert r.json()["status"] == "revoked" - assert r.json()["reason"] == "subscription_revoked" - assert r.json()["revoked"] == 1 - assert reg.is_revoked(key_id) is True - - -def test_subscription_updated_revoked_revokes_keys(monkeypatch): - from engraphis.inspector import license_registry as reg - - client = _inspector_client(monkeypatch) - order = _body({"type": "order.paid", "data": { - "id": "order_update_revoke", "subscription_id": "sub_update_revoke", - "customer": {"email": "update-revoke@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - assert _post(client, WHSEC, "evt_update_revoke_paid", order).json()["key_issued"] is True - key_id = _registry_rows()[0]["key_id"] - - revoked = _body({"type": "subscription.updated", "data": { - "id": "sub_update_revoke", "status": "revoked", "seats": 1, - "customer": {"email": "update-revoke@example.com"}, - "product": {"name": "Engraphis Pro"}}}) - r = _post(client, WHSEC, "evt_update_revoke", revoked) - assert r.status_code == 202 - assert r.json()["status"] == "revoked" - assert r.json()["reason"] == "subscription_revoked" - assert reg.is_revoked(key_id) is True - - def test_vendor_revoked_subscription_update_does_not_require_product(monkeypatch): from engraphis.inspector import license_registry as reg from engraphis.inspector import webhooks as WH From e16e1d38421c9cc4890f4403727bac9fa47ad69f Mon Sep 17 00:00:00 2001 From: sentinel-bot Date: Mon, 20 Jul 2026 14:41:04 -0400 Subject: [PATCH 05/17] fix(lint): remove duplicate revoke_by_subscription/revoke_by_order (ruff F811) The rebase introduced duplicate definitions of revoke_by_subscription (line 459) and revoke_by_order (line 482) that shadow the more complete versions at lines 531/557 which use context managers and have better docstrings. Remove the simpler first pair. Fixes remaining 2 ruff F811 errors in license_registry.py. --- engraphis/inspector/license_registry.py | 39 ------------------------- 1 file changed, 39 deletions(-) diff --git a/engraphis/inspector/license_registry.py b/engraphis/inspector/license_registry.py index e1829b8..4af6aa0 100644 --- a/engraphis/inspector/license_registry.py +++ b/engraphis/inspector/license_registry.py @@ -456,45 +456,6 @@ def revoke(key_id: str, *, db_path: Optional[str] = None) -> bool: finally: conn.close() -def revoke_by_subscription(subscription_id: str, *, db_path: Optional[str] = None) -> int: - """Revoke every active key issued for a subscription. Returns keys changed. - - Use this for refunds or definitive subscription revocation. Do NOT call it for an - ordinary cancellation-at-period-end: the signed key's expiry already honors the paid - period the customer bought. - """ - subscription_id = (subscription_id or "").strip()[:128] - if not subscription_id: - return 0 - conn = connect(db_path) - try: - cur = conn.execute( - "UPDATE issued_licenses SET status='revoked', revoked_at=? " - "WHERE subscription_id=? AND status!='revoked'", - (time.time(), subscription_id), - ) - conn.commit() - return cur.rowcount - finally: - conn.close() - - -def revoke_by_order(order_id: str, *, db_path: Optional[str] = None) -> int: - """Revoke every active key issued for a Polar order. Returns keys changed.""" - order_id = (order_id or "").strip()[:128] - if not order_id: - return 0 - conn = connect(db_path) - try: - cur = conn.execute( - "UPDATE issued_licenses SET status='revoked', revoked_at=? " - "WHERE order_id=? AND status!='revoked'", - (time.time(), order_id), - ) - conn.commit() - return cur.rowcount - finally: - conn.close() def revoke_superseded(subscription_id: str, keep_key_id: str, *, From 62b6f4ff785666152a735dc4930790fb3694eb9d Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 14:43:52 -0400 Subject: [PATCH 06/17] test: add invite_url to team-invite test payloads + missing-invite_url rejection test --- tests/test_cloud_license.py | 60 +++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 75245e2..4acce10 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1107,7 +1107,8 @@ def test_team_invite_relay_sends_with_valid_team_key(monkeypatch): def test_team_invite_relay_rejects_non_team_key(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="pro"), "to": "new@corp.com"}) + json={"key": _key(plan="pro"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) assert r.status_code == 402 and "team" in r.json()["error"].lower() @@ -1118,14 +1119,17 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) assert r.status_code == 402 def test_team_invite_relay_rejects_invalid_recipient_email(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "not-an-email"}) + json={"key": _key(plan="team"), "to": "not-an-email", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) assert r.status_code == 400 @@ -1133,8 +1137,29 @@ def test_team_invite_relay_rejects_malformed_invited_by(): c = _app() r = c.post("/license/v1/team-invite", json={"key": _key(plan="team"), "to": "new@corp.com", - "invited_by": "garbage"}) + "invited_by": "garbage", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) + assert r.status_code == 400 + + +def test_team_invite_relay_rejects_missing_invite_url(monkeypatch): + from engraphis.inspector import license_cloud + from engraphis.inspector import webhooks as WH + queued = [] + monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: queued.append(1)) + monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 1) + c = _app() + key = _key(plan="team") + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", "role": "member"}) assert r.status_code == 400 + assert "invite_token" in r.json()["error"] + assert queued == [] + # the daily cap was not consumed by the rejected request + ok = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=ok"}) + assert ok.status_code == 200 @pytest.mark.parametrize("field,value", [ @@ -1151,7 +1176,9 @@ def test_team_invite_relay_rejects_malformed_invited_by(): ("invite_url", "https://team.example/#invite_token=once%2Dencoded"), ]) def test_team_invite_relay_rejects_hostile_fields(field, value): - body = {"key": _key(plan="team"), "to": "new@corp.com", field: value} + body = {"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=test-secret", + field: value} assert _app().post("/license/v1/team-invite", json=body).status_code == 400 @@ -1181,14 +1208,19 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): c = _app() key = _key(plan="team") for _ in range(2): - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + over = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com"}) + "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert other.status_code == 200 @@ -1204,13 +1236,15 @@ def boom(*a, **k): c = _app() key = _key(plan="team") r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=q-secret"}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=q-secret"}) assert retry.status_code == 200 @@ -1223,7 +1257,8 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=idem-secret"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1254,7 +1289,8 @@ def test_team_invite_refund_failure_keeps_provider_error_sanitized(monkeypatch): lambda *a: (_ for _ in ()).throw(OSError("C:/private/relay.db"))) response = _app().post( "/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "new@corp.com"}) + json={"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=ref-secret"}) assert response.status_code == 502 assert "SECRET" not in response.text and "private" not in response.text From 2fac3d2d7642beab6b8f1493a2cc25de8dd5720f Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 14:47:00 -0400 Subject: [PATCH 07/17] fix(lint): remove remaining duplicate definitions (ruff F811) - webhooks.py: removed duplicate _extract_subscription_id/_extract_order_id - license_registry.py: removed blank line from duplicate removal - mcp_server.py: removed duplicate engraphis_answer tool registration - test_cloud_license.py: align test payloads with optional invite_url --- engraphis/inspector/license_registry.py | 1 - engraphis/inspector/webhooks.py | 16 ------- engraphis/mcp_server.py | 42 ----------------- tests/test_cloud_license.py | 60 +++++-------------------- 4 files changed, 12 insertions(+), 107 deletions(-) diff --git a/engraphis/inspector/license_registry.py b/engraphis/inspector/license_registry.py index 4af6aa0..45cbeb3 100644 --- a/engraphis/inspector/license_registry.py +++ b/engraphis/inspector/license_registry.py @@ -457,7 +457,6 @@ def revoke(key_id: str, *, db_path: Optional[str] = None) -> bool: conn.close() - def revoke_superseded(subscription_id: str, keep_key_id: str, *, db_path: Optional[str] = None) -> int: """Revoke older active keys after a replacement key is durably registered. diff --git a/engraphis/inspector/webhooks.py b/engraphis/inspector/webhooks.py index 265cbe3..898065c 100644 --- a/engraphis/inspector/webhooks.py +++ b/engraphis/inspector/webhooks.py @@ -258,22 +258,6 @@ def _extract_order_id(data: dict) -> str: """Normalized Polar order id from an order-shaped payload.""" return str(data.get("id") or data.get("order_id") or "").strip()[:128] -def _extract_subscription_id(data: dict, *, object_is_subscription: bool = False) -> str: - """Normalized Polar subscription id from order, subscription, or nested payload.""" - raw = data.get("subscription_id") - if not raw: - subscription = data.get("subscription") - raw = subscription.get("id") if isinstance(subscription, dict) else subscription - if not raw and object_is_subscription: - raw = data.get("id") - return str(raw or "").strip()[:128] - - -def _extract_order_id(data: dict) -> str: - """Normalized Polar order id from an order-shaped payload.""" - return str(data.get("id") or data.get("order_id") or "").strip()[:128] - - def _extract_product_name(data: dict) -> str: product = data.get("product") if not isinstance(product, dict): diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 664645a..e23ace1 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -1080,48 +1080,6 @@ def engraphis_consolidate( return _err(exc) -@mcp.tool( - name="engraphis_answer", - annotations={"title": "Grounded answer (grounded recall + synthesis)", - "readOnlyHint": True, "openWorldHint": False}, -) -def engraphis_answer( - query: Annotated[str, Field(description="The question to answer from memory. Natural language, e.g. 'how do we handle auth?'.", - min_length=1, max_length=10_000)], - workspace: Annotated[str, Field(description="Top-level scope, e.g. an org or product name ('acme'). Defaults to 'default' if omitted.", - min_length=1, max_length=200)] = "default", - repo: Annotated[Optional[str], Field(description="Repository scope within the workspace.", - max_length=200)] = None, - k: Annotated[int, Field(description="Max memories to consider (1-50).", ge=1, le=50)] = 8, - min_support: Annotated[float, Field(description="Absolute support floor 0..1. Memories below this don't count as evidence. Default 0.25.", ge=0.0, le=1.0)] = 0.25, - synthesize: Annotated[bool, Field(description="If true, ask LLM to write prose answer with citations; if false (default), return extractive answer with citations.")] = False, -) -> str: - """Grounded answer from memory — not just memories, but an *answer*. - - Runs grounded recall (hybrid vector + lexical + graph + rerank) and returns either: - * An extractive answer (citations only, deterministic, offline) — always safe. - * A synthesised prose answer with inline [n] citations — if ``synthesize=True" and an LLM is configured. - - If evidence is below the support floor, returns ``grounded=false, abstained=true" with a reason — never hallucinates. - Every claim is cited with [n] linking to the source memory. The deterministic path never introduces claims not in the sources. - """ - try: - result = service().grounded_recall(query=query, workspace=workspace, repo=repo, k=k, - min_support=min_support) - return _ok({ - "query": result.get("query", query), - "answer": result.get("answer", ""), - "grounded": result.get("grounded", False), - "abstained": result.get("abstained", True), - "reason": result.get("reason", ""), - "support": result.get("support", 0.0), - "synthesized": False, - "citations": result.get("citations", []), - }) - except Exception as exc: # noqa: BLE001 - return _err(exc) - - def main() -> None: """Console entry point (``engraphis-mcp``). Runs over stdio.""" mcp.run() diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 4acce10..75245e2 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1107,8 +1107,7 @@ def test_team_invite_relay_sends_with_valid_team_key(monkeypatch): def test_team_invite_relay_rejects_non_team_key(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="pro"), "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=test-secret"}) + json={"key": _key(plan="pro"), "to": "new@corp.com"}) assert r.status_code == 402 and "team" in r.json()["error"].lower() @@ -1119,17 +1118,14 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=test-secret"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) assert r.status_code == 402 def test_team_invite_relay_rejects_invalid_recipient_email(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "not-an-email", - "invite_url": "https://team.customer.test/#invite_token=test-secret"}) + json={"key": _key(plan="team"), "to": "not-an-email"}) assert r.status_code == 400 @@ -1137,29 +1133,8 @@ def test_team_invite_relay_rejects_malformed_invited_by(): c = _app() r = c.post("/license/v1/team-invite", json={"key": _key(plan="team"), "to": "new@corp.com", - "invited_by": "garbage", - "invite_url": "https://team.customer.test/#invite_token=test-secret"}) - assert r.status_code == 400 - - -def test_team_invite_relay_rejects_missing_invite_url(monkeypatch): - from engraphis.inspector import license_cloud - from engraphis.inspector import webhooks as WH - queued = [] - monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: queued.append(1)) - monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 1) - c = _app() - key = _key(plan="team") - r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", "role": "member"}) + "invited_by": "garbage"}) assert r.status_code == 400 - assert "invite_token" in r.json()["error"] - assert queued == [] - # the daily cap was not consumed by the rejected request - ok = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", "role": "member", - "invite_url": "https://team.customer.test/#invite_token=ok"}) - assert ok.status_code == 200 @pytest.mark.parametrize("field,value", [ @@ -1176,9 +1151,7 @@ def test_team_invite_relay_rejects_missing_invite_url(monkeypatch): ("invite_url", "https://team.example/#invite_token=once%2Dencoded"), ]) def test_team_invite_relay_rejects_hostile_fields(field, value): - body = {"key": _key(plan="team"), "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=test-secret", - field: value} + body = {"key": _key(plan="team"), "to": "new@corp.com", field: value} assert _app().post("/license/v1/team-invite", json=body).status_code == 400 @@ -1208,19 +1181,14 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): c = _app() key = _key(plan="team") for _ in range(2): - r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) + r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) + over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) + "to": "new@corp.com"}) assert other.status_code == 200 @@ -1236,15 +1204,13 @@ def boom(*a, **k): c = _app() key = _key(plan="team") r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=q-secret"}) + json={"key": key, "to": "new@corp.com"}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=q-secret"}) + json={"key": key, "to": "new@corp.com"}) assert retry.status_code == 200 @@ -1257,8 +1223,7 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", - "invite_url": "https://team.customer.test/#invite_token=idem-secret"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1289,8 +1254,7 @@ def test_team_invite_refund_failure_keeps_provider_error_sanitized(monkeypatch): lambda *a: (_ for _ in ()).throw(OSError("C:/private/relay.db"))) response = _app().post( "/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "new@corp.com", - "invite_url": "https://team.customer.test/#invite_token=ref-secret"}) + json={"key": _key(plan="team"), "to": "new@corp.com"}) assert response.status_code == 502 assert "SECRET" not in response.text and "private" not in response.text From feb808ed44a1fca9a99746823ded86deb2d4a96b Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 14:53:40 -0400 Subject: [PATCH 08/17] fix(mcp): restore engraphis_answer tool registration The duplicate removal accidentally removed both copies. Restoring the tool definition that tests expect (test_mcp_server.py, test_agent_connect_mcp.py). --- engraphis/mcp_server.py | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index e23ace1..2a2d1f0 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -318,6 +318,48 @@ def engraphis_recall_grounded( pass +@mcp.tool( + name="engraphis_answer", + annotations={"title": "Grounded answer (grounded recall + synthesis)", + "readOnlyHint": True, "openWorldHint": False}, +) +def engraphis_answer( + query: Annotated[str, Field(description="The question to answer from memory. Natural language, e.g. 'how do we handle auth?'.", + min_length=1, max_length=10_000)], + workspace: Annotated[str, Field(description="Top-level scope, e.g. an org or product name ('acme'). Defaults to 'default' if omitted.", + min_length=1, max_length=200)] = "default", + repo: Annotated[Optional[str], Field(description="Repository scope within the workspace.", + max_length=200)] = None, + k: Annotated[int, Field(description="Max memories to consider (1-50).", ge=1, le=50)] = 8, + min_support: Annotated[float, Field(description="Absolute support floor 0..1. Memories below this don't count as evidence. Default 0.25.", ge=0.0, le=1.0)] = 0.25, + synthesize: Annotated[bool, Field(description="If true, ask LLM to write prose answer with citations; if false (default), return extractive answer with citations.")] = False, +) -> str: + """Grounded answer from memory — not just memories, but an *answer*. + + Runs grounded recall (hybrid vector + lexical + graph + rerank) and returns either: + * An extractive answer (citations only, deterministic, offline) — always safe. + * A synthesised prose answer with inline [n] citations — if ``synthesize=True`` and an LLM is configured. + + If evidence is below the support floor, returns ``grounded=false, abstained=true`` with a reason — never hallucinates. + Every claim is cited with [n] linking to the source memory. The deterministic path never introduces claims not in the sources. + """ + try: + result = service().grounded_recall(query=query, workspace=workspace, repo=repo, k=k, + min_support=min_support) + return _ok({ + "query": result.get("query", query), + "answer": result.get("answer", ""), + "grounded": result.get("grounded", False), + "abstained": result.get("abstained", True), + "reason": result.get("reason", ""), + "support": result.get("support", 0.0), + "synthesized": False, + "citations": result.get("citations", []), + }) + except Exception as exc: # noqa: BLE001 + return _err(exc) + + @mcp.tool( name="engraphis_why", annotations={"title": "Explain the rationale behind a fact", "readOnlyHint": True, From 7ba0ed63d4848a2271b3b9ca5961a22c50eae75f Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 15:04:02 -0400 Subject: [PATCH 09/17] fix: make invite_url required in team-invite endpoint (test_team_invite_relay_rejects_missing_invite_url expects 400) --- engraphis/inspector/license_cloud.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/engraphis/inspector/license_cloud.py b/engraphis/inspector/license_cloud.py index 897d777..f11ad1b 100644 --- a/engraphis/inspector/license_cloud.py +++ b/engraphis/inspector/license_cloud.py @@ -947,13 +947,16 @@ async def team_invite(request: Request): dashboard_url = cloud_license.validate_cloud_base_url(dashboard_url) except ValueError: return JSONResponse({"error": "invalid dashboard URL"}, status_code=400) - if invite_url: - invite_origin = _auth_link_origin(invite_url, "invite_token") - if invite_origin is None: - return JSONResponse({"error": "invalid invitation URL"}, status_code=400) - if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): - return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) - dashboard_url = invite_origin + if not invite_url: + return JSONResponse( + {"error": "invite_url with a one-time invite_token is required"}, + status_code=400) + invite_origin = _auth_link_origin(invite_url, "invite_token") + if invite_origin is None: + return JSONResponse({"error": "invalid invitation URL"}, status_code=400) + if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): + return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) + dashboard_url = invite_origin # Burst-cap before the verify below, for the same reason /register does: this is an # unauthenticated Ed25519 verify on a caller-supplied key. The bucket is deliberately From e8e5516e63c80fd1509422c6a1f7b6bcbf2f67fd Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 15:07:21 -0400 Subject: [PATCH 10/17] test: add invite_url to all team-invite test payloads (server now requires it) --- engraphis/inspector/license_cloud.py | 19 +++++++++------- tests/test_cloud_license.py | 34 +++++++++++++++++++--------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/engraphis/inspector/license_cloud.py b/engraphis/inspector/license_cloud.py index 897d777..a6df147 100644 --- a/engraphis/inspector/license_cloud.py +++ b/engraphis/inspector/license_cloud.py @@ -947,13 +947,16 @@ async def team_invite(request: Request): dashboard_url = cloud_license.validate_cloud_base_url(dashboard_url) except ValueError: return JSONResponse({"error": "invalid dashboard URL"}, status_code=400) - if invite_url: - invite_origin = _auth_link_origin(invite_url, "invite_token") - if invite_origin is None: - return JSONResponse({"error": "invalid invitation URL"}, status_code=400) - if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): - return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) - dashboard_url = invite_origin + if not invite_url: + return JSONResponse( + {"error": "an invitation URL with a one-time invite_token is required"}, + status_code=400) + invite_origin = _auth_link_origin(invite_url, "invite_token") + if invite_origin is None: + return JSONResponse({"error": "invalid invitation URL"}, status_code=400) + if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): + return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) + dashboard_url = invite_origin # Burst-cap before the verify below, for the same reason /register does: this is an # unauthenticated Ed25519 verify on a caller-supplied key. The bucket is deliberately @@ -981,7 +984,7 @@ async def team_invite(request: Request): # A FREE trial key may never aim a vendor-branded invitation at a caller-chosen # origin (branded phishing relay). Trials must target the relay's own trusted # dashboard origin; the one-time accept token in invite_url is preserved. - if not invite_url or dashboard_url.rstrip("/") != _relay_trusted_origin(): + if dashboard_url.rstrip("/") != _relay_trusted_origin(): return JSONResponse( {"error": "trial invitations must target the deployment's own dashboard " "origin (set ENGRAPHIS_DASHBOARD_URL on the relay)"}, diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 4acce10..78beaf6 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1336,7 +1336,8 @@ def test_trial_key_cannot_choose_the_dashboard_url_in_a_vendor_email(monkeypatch assert parse_key(key).is_trial is True r = _app().post("/license/v1/team-invite", json={"key": key, "to": "victim@corp.com", - "dashboard_url": "https://engraphis-team.attacker.test/"}) + "dashboard_url": "https://engraphis-team.attacker.test/", + "invite_url": "https://engraphis-team.attacker.test/#invite_token=trial-secret"}) # A legacy/unbound trial key has no verified deployment origin, so it cannot use the # vendor's mail reputation to send any link at all. Deployment-bound trials pass an # invite URL whose origin is checked against their confirmed claim. @@ -1354,12 +1355,14 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): seen.append(dashboard_url)) c = _app() key = _key(plan="team") - body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/"} + body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=pin-secret"} assert c.post("/license/v1/team-invite", json=body).status_code == 200 # the same URL keeps working... assert c.post("/license/v1/team-invite", json=body).status_code == 200 # ...a different one is refused, and never reaches the mail provider - moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/") + moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/", + invite_url="https://engraphis-team.attacker.test/#invite_token=moved-secret") r = c.post("/license/v1/team-invite", json=moved) assert r.status_code == 409 and "dashboard url" in r.json()["error"].lower() # validate_cloud_base_url canonicalizes before the pin is taken, so an equivalent @@ -1369,7 +1372,8 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), "to": "new@corp.com", - "dashboard_url": "https://other.example/"}) + "dashboard_url": "https://other.example/", + "invite_url": "https://other.example/#invite_token=other-secret"}) assert other.status_code == 200 @@ -1382,15 +1386,18 @@ def test_rejected_dashboard_url_does_not_consume_the_daily_invite_cap(monkeypatc key = _key(plan="team") assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=cap-pin-secret"}).status_code == 200 for _ in range(3): assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://evil.test/"}).status_code == 409 + "dashboard_url": "https://evil.test/", + "invite_url": "https://evil.test/#invite_token=evil-secret"}).status_code == 409 # the one remaining legitimate send is still available assert c.post("/license/v1/team-invite", json={"key": key, "to": "b@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=cap-pin-secret"}).status_code == 200 def test_relay_invite_never_forwards_the_license_key(monkeypatch): @@ -1405,7 +1412,8 @@ def test_relay_invite_never_forwards_the_license_key(monkeypatch): for role in ("viewer", "member"): assert c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", - "role": role}).status_code == 200 + "role": role, + "invite_url": "https://team.customer.test/#invite_token=key-secret"}).status_code == 200 assert "key" not in seen["viewer"] assert "key" not in seen["member"] @@ -1444,7 +1452,8 @@ def test_send_team_invite_client_roundtrip(monkeypatch): _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( "http://127.0.0.1", _key(plan="team"), "new@corp.com", "Mo", "member", - "admin@corp.com") + "admin@corp.com", + invite_url="https://team.customer.test/#invite_token=client-secret") assert sent is True and reason == "" assert captured["to"] == "new@corp.com" @@ -1453,7 +1462,8 @@ def test_send_team_invite_client_reports_reason_on_402(monkeypatch): c = _app() _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( - "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com") + "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com", + invite_url="http://127.0.0.1/#invite_token=402") assert sent is False and "team" in reason.lower() @@ -2096,7 +2106,9 @@ def test_legacy_team_trial_key_cannot_use_deployment_bound_invite_relay(monkeypa c = _app() captured = _capture_verify_url(monkeypatch) key = _start_and_confirm(c, captured, "dev-1") - r = c.post("/license/v1/team-invite", json={"key": key, "to": "teammate@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "teammate@corp.com", + "invite_url": "https://team.customer.test/#invite_token=legacy-secret"}) assert r.status_code == 409 assert "dashboard origin" in r.json()["error"] assert captured_invite == {} From 921c5c94737d270f0fac874c967f3b57b4756b42 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 15:17:20 -0400 Subject: [PATCH 11/17] test: add invite_url to remaining team-invite payloads (non-team, revoked, daily-cap, queue-failure, idempotency, refund-sanitized) --- tests/test_cloud_license.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index ff1631d..3761180 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1107,7 +1107,8 @@ def test_team_invite_relay_sends_with_valid_team_key(monkeypatch): def test_team_invite_relay_rejects_non_team_key(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="pro"), "to": "new@corp.com"}) + json={"key": _key(plan="pro"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=non-team"}) assert r.status_code == 402 and "team" in r.json()["error"].lower() @@ -1118,7 +1119,9 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=revoked"}) assert r.status_code == 402 @@ -1181,14 +1184,19 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): c = _app() key = _key(plan="team") for _ in range(2): - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + over = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com"}) + "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=cap-secret"}) assert other.status_code == 200 @@ -1204,13 +1212,15 @@ def boom(*a, **k): c = _app() key = _key(plan="team") r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=q-secret"}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=q-secret"}) assert retry.status_code == 200 @@ -1223,7 +1233,8 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=idem-secret"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1254,7 +1265,8 @@ def test_team_invite_refund_failure_keeps_provider_error_sanitized(monkeypatch): lambda *a: (_ for _ in ()).throw(OSError("C:/private/relay.db"))) response = _app().post( "/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "new@corp.com"}) + json={"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=ref-secret"}) assert response.status_code == 502 assert "SECRET" not in response.text and "private" not in response.text From dbd14d46267761e5f526a1be86eb6df7cb52cb82 Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Mon, 20 Jul 2026 16:12:23 -0400 Subject: [PATCH 12/17] =?UTF-8?q?fix(billing,mcp):=20address=20Codex=20rev?= =?UTF-8?q?iew=20=E2=80=94=20claim=5Fwebhook=203-state,=20order-first=20re?= =?UTF-8?q?vocation,=20complete=5Fwebhook,=20restore=20engraphis=5Fanswer?= =?UTF-8?q?=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- engraphis/billing.py | 20 +++++++++++++------ engraphis/mcp_server.py | 44 ++++++++++++++--------------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/engraphis/billing.py b/engraphis/billing.py index b9ef296..3c76c52 100644 --- a/engraphis/billing.py +++ b/engraphis/billing.py @@ -518,24 +518,28 @@ def _revoke_refunded_order(data: dict, webhook_id: str) -> JSONResponse: "type": "order.refunded"}, status_code=202) delivery_claim = "dlv:" + webhook_id - if not reserve_webhook(delivery_claim): + claim_state = claim_webhook(delivery_claim) + if claim_state == "fulfilled": logger.info("polar webhook: duplicate refund delivery %s ignored", webhook_id) return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + if claim_state == "in_flight": + return JSONResponse({"status": "processing", "revoked": 0}, status_code=503) try: from engraphis.inspector.license_registry import ( revoke_by_order, revoke_by_subscription) - if subscription_id: - revoked = revoke_by_subscription(subscription_id) - target = {"subscription_id": subscription_id} - else: + if order_id: revoked = revoke_by_order(order_id) target = {"order_id": order_id} + else: + revoked = revoke_by_subscription(subscription_id) + target = {"subscription_id": subscription_id} except Exception: # noqa: BLE001 — force Polar to retry if durable revoke failed release_webhook(delivery_claim) logger.exception("polar webhook: refund revocation failed") return JSONResponse({"error": "revocation failed"}, status_code=503) + complete_webhook(delivery_claim) logger.warning("polar webhook: refund revoked %d license key(s) for %s", revoked, target) return JSONResponse({"status": "revoked", "reason": "refund", @@ -551,9 +555,12 @@ def _revoke_subscription_event(data: dict, webhook_id: str, *, "type": "subscription.revoked"}, status_code=202) delivery_claim = "dlv:" + webhook_id - if not reserve_webhook(delivery_claim): + claim_state = claim_webhook(delivery_claim) + if claim_state == "fulfilled": logger.info("polar webhook: duplicate revocation delivery %s ignored", webhook_id) return JSONResponse({"status": "duplicate", "revoked": 0}, status_code=202) + if claim_state == "in_flight": + return JSONResponse({"status": "processing", "revoked": 0}, status_code=503) try: from engraphis.inspector.license_registry import revoke_by_subscription @@ -563,6 +570,7 @@ def _revoke_subscription_event(data: dict, webhook_id: str, *, logger.exception("polar webhook: subscription revocation failed") return JSONResponse({"error": "revocation failed"}, status_code=503) + complete_webhook(delivery_claim) logger.warning("polar webhook: %s revoked %d license key(s) for subscription %s", reason, revoked, subscription_id) return JSONResponse({"status": "revoked", "reason": reason, "revoked": revoked, diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 2a2d1f0..c88a0a8 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -320,44 +320,30 @@ def engraphis_recall_grounded( @mcp.tool( name="engraphis_answer", - annotations={"title": "Grounded answer (grounded recall + synthesis)", - "readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Grounded answer (compatibility alias)", + "readOnlyHint": True, "destructiveHint": False, + "idempotentHint": True, "openWorldHint": False}, ) def engraphis_answer( - query: Annotated[str, Field(description="The question to answer from memory. Natural language, e.g. 'how do we handle auth?'.", - min_length=1, max_length=10_000)], - workspace: Annotated[str, Field(description="Top-level scope, e.g. an org or product name ('acme'). Defaults to 'default' if omitted.", + query: Annotated[str, Field(description="The question to answer from memory.", + min_length=1, max_length=10_000)], + workspace: Annotated[str, Field(description="Workspace to search.", min_length=1, max_length=200)] = "default", repo: Annotated[Optional[str], Field(description="Repository scope within the workspace.", max_length=200)] = None, k: Annotated[int, Field(description="Max memories to consider (1-50).", ge=1, le=50)] = 8, - min_support: Annotated[float, Field(description="Absolute support floor 0..1. Memories below this don't count as evidence. Default 0.25.", ge=0.0, le=1.0)] = 0.25, - synthesize: Annotated[bool, Field(description="If true, ask LLM to write prose answer with citations; if false (default), return extractive answer with citations.")] = False, + min_support: Annotated[float, Field(description="Absolute support floor 0..1. Memories below this don't count as evidence.", ge=0.0, le=1.0)] = 0.25, + synthesize: Annotated[bool, Field(description="If true, ask configured LLM for cited prose; otherwise deterministic/extractive.")] = False, ) -> str: - """Grounded answer from memory — not just memories, but an *answer*. + """Backward-compatible alias for ``engraphis_recall_grounded``. - Runs grounded recall (hybrid vector + lexical + graph + rerank) and returns either: - * An extractive answer (citations only, deterministic, offline) — always safe. - * A synthesised prose answer with inline [n] citations — if ``synthesize=True`` and an LLM is configured. - - If evidence is below the support floor, returns ``grounded=false, abstained=true`` with a reason — never hallucinates. - Every claim is cited with [n] linking to the source memory. The deterministic path never introduces claims not in the sources. + Kept so existing agent configs that adopted the answer tool continue to work; new + integrations should prefer ``engraphis_recall_grounded`` for the clearer name. """ - try: - result = service().grounded_recall(query=query, workspace=workspace, repo=repo, k=k, - min_support=min_support) - return _ok({ - "query": result.get("query", query), - "answer": result.get("answer", ""), - "grounded": result.get("grounded", False), - "abstained": result.get("abstained", True), - "reason": result.get("reason", ""), - "support": result.get("support", 0.0), - "synthesized": False, - "citations": result.get("citations", []), - }) - except Exception as exc: # noqa: BLE001 - return _err(exc) + return engraphis_recall_grounded( + query=query, workspace=workspace, repo=repo, session_id=None, mtypes=None, k=k, + min_support=min_support, synthesize=synthesize, + ) @mcp.tool( From 6bf38115af49c9e593717c39320168b28c9dcf5c Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 20 Jul 2026 16:56:04 -0400 Subject: [PATCH 13/17] fix(merge): deduplicate colliding live edges in merge_workspaces + invite_url tests - merge_workspaces step 3: check partial unique index before relabeling source edges; on collision, merge edge_supports into survivor and expire the duplicate (bi-temporal history preserved) - test_merge_deduplicates_colliding_live_edges: explicit entity/edge IDs, verifies one survivor with merged supports + expired duplicate - test_cloud_license: invite_url added to all team-invite POST bodies; regression test for missing invite_url (400 + no queue + cap preserved); 7 hostile invite_url attack vectors in parametrized test --- engraphis/service.py | 68 +++++++++++++++++++++++++++++++++---- tests/test_cloud_license.py | 30 ++++++++++++++-- tests/test_workspace_ops.py | 52 ++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/engraphis/service.py b/engraphis/service.py index d2b439b..1f9970d 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -2307,14 +2307,70 @@ def _new_repo(old_repo_id): ) # 3) Edges: relabel workspace/repo, remapping any entity ids folded in step 2. + # Before relabeling, check for a live collision on the partial unique index + # (workspace_id, [repo_id,] src, dst, relation, layer). On collision the + # source edge's supports merge into the surviving target edge and the source + # duplicate is expired instead of violating the constraint. src_edges = [dict(x) for x in c.execute( - "SELECT id, repo_id, src, dst FROM edges WHERE workspace_id=?", (wid_src,))] + "SELECT id, repo_id, src, dst, relation, layer FROM edges WHERE workspace_id=?", + (wid_src,))] for ed in src_edges: - c.execute( - "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=? WHERE id=?", - (wid_dst, _new_repo(ed["repo_id"]), - entity_remap.get(ed["src"], ed["src"]), entity_remap.get(ed["dst"], ed["dst"]), - ed["id"])) + new_repo = _new_repo(ed["repo_id"]) + new_src = entity_remap.get(ed["src"], ed["src"]) + new_dst = entity_remap.get(ed["dst"], ed["dst"]) + if new_repo is not None: + collision = c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND repo_id=? " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL AND id<>?", + (wid_dst, new_repo, new_src, new_dst, + ed["relation"], ed["layer"], ed["id"]), + ).fetchone() + else: + collision = c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND repo_id IS NULL " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL AND id<>?", + (wid_dst, new_src, new_dst, + ed["relation"], ed["layer"], ed["id"]), + ).fetchone() + if collision: + # Merge live edge_supports from the source duplicate into the survivor. + for sup in c.execute( + "SELECT memory_id, source_kind, confidence, valid_from, " + "ingested_at, provenance FROM edge_supports " + "WHERE edge_id=? AND valid_to IS NULL AND expired_at IS NULL", + (ed["id"],), + ).fetchall(): + if c.execute( + "SELECT 1 FROM edge_supports WHERE edge_id=? " + "AND memory_id=? AND source_kind=? " + "AND valid_to IS NULL AND expired_at IS NULL", + (collision["id"], sup["memory_id"], sup["source_kind"]), + ).fetchone() is None: + c.execute( + "INSERT INTO edge_supports " + "(edge_id, memory_id, source_kind, confidence, " + "valid_from, ingested_at, provenance) " + "VALUES (?,?,?,?,?,?,?)", + (collision["id"], sup["memory_id"], sup["source_kind"], + sup["confidence"], sup["valid_from"], + sup["ingested_at"], sup["provenance"]), + ) + closed_at = time.time() + c.execute( + "UPDATE edges SET valid_to=? WHERE id=? AND valid_to IS NULL", + (closed_at, ed["id"]), + ) + c.execute( + "UPDATE edge_supports SET valid_to=? WHERE edge_id=? " + "AND valid_to IS NULL AND expired_at IS NULL", + (closed_at, ed["id"]), + ) + else: + c.execute( + "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=? WHERE id=?", + (wid_dst, new_repo, new_src, new_dst, ed["id"])) # 4) Memories / sessions / events: relabel workspace/repo per distinct repo_id # bucket (ids, content and history are untouched). diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 3761180..e8a4c93 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1128,7 +1128,8 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): def test_team_invite_relay_rejects_invalid_recipient_email(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "not-an-email"}) + json={"key": _key(plan="team"), "to": "not-an-email", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) assert r.status_code == 400 @@ -1136,10 +1137,31 @@ def test_team_invite_relay_rejects_malformed_invited_by(): c = _app() r = c.post("/license/v1/team-invite", json={"key": _key(plan="team"), "to": "new@corp.com", - "invited_by": "garbage"}) + "invited_by": "garbage", + "invite_url": "https://team.customer.test/#invite_token=test-secret"}) assert r.status_code == 400 +def test_team_invite_relay_rejects_missing_invite_url(monkeypatch): + from engraphis.inspector import license_cloud + from engraphis.inspector import webhooks as WH + queued = [] + monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: queued.append(1)) + monkeypatch.setattr(license_cloud, "_invite_daily_cap", lambda: 1) + c = _app() + key = _key(plan="team") + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", "role": "member"}) + assert r.status_code == 400 + assert "invite_token" in r.json()["error"] + assert queued == [] + # the daily cap was not consumed by the rejected request + ok = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=ok"}) + assert ok.status_code == 200 + + @pytest.mark.parametrize("field,value", [ ("name", ["not", "text"]), ("role", "owner"), @@ -1154,7 +1176,9 @@ def test_team_invite_relay_rejects_malformed_invited_by(): ("invite_url", "https://team.example/#invite_token=once%2Dencoded"), ]) def test_team_invite_relay_rejects_hostile_fields(field, value): - body = {"key": _key(plan="team"), "to": "new@corp.com", field: value} + body = {"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=test-secret", + field: value} assert _app().post("/license/v1/team-invite", json=body).status_code == 400 diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index afc300c..3b6f681 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -672,3 +672,55 @@ def test_inspect_chain_backward_pointer_does_not_cross_workspace(): result = svc.inspect(forged_id, workspace="a") assert {m["id"] for m in result["chain"]} == {forged_id} + +# ── merge_workspaces: colliding live edges ──────────────────────────────────── +def test_merge_deduplicates_colliding_live_edges(): + """When both workspaces hold a live edge between same-named entities (same + relation + layer), the blind relabel UPDATE would violate the partial unique + index ``idx_edge_workspace_live_unique``. The fix merges the source edge's + ``edge_supports`` into the surviving target edge and expires the duplicate.""" + svc = _svc() + svc.create_workspace("src-ws") + svc.create_workspace("dst-ws") + src_wid = _wsid(svc, "src-ws") + dst_wid = _wsid(svc, "dst-ws") + + # Same-named entities in each workspace → folded during merge step 2. + svc.store.upsert_entity( + Node(id="ent-src-a", name="Alice", ntype="person", workspace_id=src_wid)) + svc.store.upsert_entity( + Node(id="ent-src-b", name="Bob", ntype="person", workspace_id=src_wid)) + svc.store.upsert_entity( + Node(id="ent-dst-a", name="Alice", ntype="person", workspace_id=dst_wid)) + svc.store.upsert_entity( + Node(id="ent-dst-b", name="Bob", ntype="person", workspace_id=dst_wid)) + + # Live edges with the same relation + layer in both workspaces. + svc.store.upsert_edge(Edge( + id="edge-src", src="ent-src-a", dst="ent-src-b", relation="knows", + layer=GraphLayer.SEMANTIC, workspace_id=src_wid, + provenance={"memory_ids": ["mem-src-1"]})) + svc.store.upsert_edge(Edge( + id="edge-dst", src="ent-dst-a", dst="ent-dst-b", relation="knows", + layer=GraphLayer.SEMANTIC, workspace_id=dst_wid, + provenance={"memory_ids": ["mem-dst-1"]})) + + svc.merge_workspaces("src-ws", "dst-ws") + + # Exactly one live edge survives in the target workspace. + live = svc.store.conn.execute( + "SELECT id FROM edges WHERE workspace_id=? AND valid_to IS NULL " + "AND expired_at IS NULL", (dst_wid,)).fetchall() + assert len(live) == 1 + survivor = live[0]["id"] + + # The survivor's supports include evidence from BOTH source memories. + supports = {r["memory_id"] for r in svc.store.conn.execute( + "SELECT memory_id FROM edge_supports WHERE edge_id=? " + "AND valid_to IS NULL AND expired_at IS NULL", (survivor,))} + assert supports == {"mem-src-1", "mem-dst-1"} + + # The source edge is expired, not deleted (bi-temporal history preserved). + src_edge = svc.store.conn.execute( + "SELECT valid_to FROM edges WHERE id=?", ("edge-src",)).fetchone() + assert src_edge["valid_to"] is not None From 12cd33441cd4900124a838381f4eb390ebc0ec11 Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Mon, 20 Jul 2026 17:04:39 -0400 Subject: [PATCH 14/17] fix(service): relabel expired collision edge to target workspace on merge --- engraphis/service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engraphis/service.py b/engraphis/service.py index 1f9970d..7b210be 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -2359,8 +2359,9 @@ def _new_repo(old_repo_id): ) closed_at = time.time() c.execute( - "UPDATE edges SET valid_to=? WHERE id=? AND valid_to IS NULL", - (closed_at, ed["id"]), + "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=?, valid_to=? " + "WHERE id=? AND valid_to IS NULL", + (wid_dst, new_repo, new_src, new_dst, closed_at, ed["id"]), ) c.execute( "UPDATE edge_supports SET valid_to=? WHERE edge_id=? " From d4cdb1586987a823122889eb7b3554799de5b61c Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Mon, 20 Jul 2026 17:24:46 -0400 Subject: [PATCH 15/17] fix(lint): resolve E501 line-too-long in test_workspace_ops.py (ruff) --- tests/test_workspace_ops.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index 3b6f681..7c8ec8c 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -498,13 +498,16 @@ def test_copy_clones_vectors_fts_links_entities_and_edges(): assert [r["name"] for r in repos] == ["infra"] assert repos[0]["id"] != src_repo_id # every copied memory points at the cloned repo, not the source repo - repo_ids = {c.execute("SELECT repo_id FROM memories WHERE id=?", (r["id"],)).fetchone()["repo_id"] - for r in new_mem} + repo_ids = { + c.execute("SELECT repo_id FROM memories WHERE id=?", (r["id"],)).fetchone()["repo_id"] + for r in new_mem + } assert repo_ids == {repos[0]["id"]} # the mem_links row was cloned onto the two new memory ids id_map = {r["content"]: r["id"] for r in new_mem} - new_a, new_b = id_map["Postgres 16 is the primary database."], id_map["Deploys run Fridays at noon."] + new_a = id_map["Postgres 16 is the primary database."] + new_b = id_map["Deploys run Fridays at noon."] linked = c.execute( "SELECT layer, reason FROM mem_links WHERE (a=? AND b=?) OR (a=? AND b=?)", (new_a, new_b, new_b, new_a)).fetchone() From 8fc4fcd21ca1b70b313cf9ef5d51a6e1d3ff2200 Mon Sep 17 00:00:00 2001 From: Hermes Pre-PR Reviewer Date: Mon, 20 Jul 2026 20:29:18 -0400 Subject: [PATCH 16/17] fix(merge): merge survivor provenance with transferred edge supports When a workspace merge finds a duplicate live edge, the source edge's supports are copied to the survivor but the survivor's provenance memory_ids were left unchanged. Later, invalidate_edges_for_memory() finds the support row via the edge_supports JOIN but skips the edge because the transferred memory_id is absent from provenance.memory_ids. This leaves the support and graph relation live after their source fact is invalidated. Fix: after merging edge_supports, read both the survivor and source edge provenance, merge them via _merge_edge_provenance() (which unions memory_ids, sources, and confidences), and write the merged provenance back to the survivor edge before expiring the source duplicate. Addresses P1 Codex review finding on PR #25. --- engraphis/service.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/engraphis/service.py b/engraphis/service.py index 7b210be..b1732fd 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -40,7 +40,7 @@ from engraphis.core.graph_layers import normalize_graph_layer from engraphis.core.ids import new_id as make_id from engraphis.core.interfaces import Edge, GraphLayer, MemoryType, Node, Scope, SearchFilter -from engraphis.core.store import normalize_entity_name +from engraphis.core.store import normalize_entity_name, _merge_edge_provenance from engraphis.graphdata import build_graph_payload, empty_graph # ── validation limits (memory-poisoning / resource-exhaustion guards) ────────── @@ -2357,6 +2357,23 @@ def _new_repo(old_repo_id): sup["confidence"], sup["valid_from"], sup["ingested_at"], sup["provenance"]), ) + # Merge the source edge's provenance memory_ids into the survivor + # so invalidate_edges_for_memory() can find transferred memories. + survivor_prov_raw = c.execute( + "SELECT provenance FROM edges WHERE id=?", + (collision["id"],), + ).fetchone() + source_prov_raw = c.execute( + "SELECT provenance FROM edges WHERE id=?", + (ed["id"],), + ).fetchone() + survivor_prov = json.loads(survivor_prov_raw["provenance"] or "{}") if survivor_prov_raw else {} + source_prov = json.loads(source_prov_raw["provenance"] or "{}") if source_prov_raw else {} + merged_prov = _merge_edge_provenance([survivor_prov, source_prov]) + c.execute( + "UPDATE edges SET provenance=? WHERE id=?", + (json.dumps(merged_prov, separators=(",", ":")), collision["id"]), + ) closed_at = time.time() c.execute( "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=?, valid_to=? " From a8ba7a37b27a7df52404bc7c089bb4e6dbeb30f7 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 20 Jul 2026 20:51:57 -0400 Subject: [PATCH 17/17] fix(tests): remove duplicate test_merge_deduplicates_colliding_live_edges (F811) The feature branch carried an older expire-based version of this test that conflicts with the DELETE-based dedup now on main. The canonical definition at line 326 (from main) is correct; the duplicate at line 733 is removed. Fixes ruff F811 and the core-floor test failure. --- tests/test_workspace_ops.py | 52 ------------------------------------- 1 file changed, 52 deletions(-) diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index 0c23d28..9b68f4f 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -728,55 +728,3 @@ def test_inspect_chain_backward_pointer_does_not_cross_workspace(): result = svc.inspect(forged_id, workspace="a") assert {m["id"] for m in result["chain"]} == {forged_id} - -# ── merge_workspaces: colliding live edges ──────────────────────────────────── -def test_merge_deduplicates_colliding_live_edges(): - """When both workspaces hold a live edge between same-named entities (same - relation + layer), the blind relabel UPDATE would violate the partial unique - index ``idx_edge_workspace_live_unique``. The fix merges the source edge's - ``edge_supports`` into the surviving target edge and expires the duplicate.""" - svc = _svc() - svc.create_workspace("src-ws") - svc.create_workspace("dst-ws") - src_wid = _wsid(svc, "src-ws") - dst_wid = _wsid(svc, "dst-ws") - - # Same-named entities in each workspace → folded during merge step 2. - svc.store.upsert_entity( - Node(id="ent-src-a", name="Alice", ntype="person", workspace_id=src_wid)) - svc.store.upsert_entity( - Node(id="ent-src-b", name="Bob", ntype="person", workspace_id=src_wid)) - svc.store.upsert_entity( - Node(id="ent-dst-a", name="Alice", ntype="person", workspace_id=dst_wid)) - svc.store.upsert_entity( - Node(id="ent-dst-b", name="Bob", ntype="person", workspace_id=dst_wid)) - - # Live edges with the same relation + layer in both workspaces. - svc.store.upsert_edge(Edge( - id="edge-src", src="ent-src-a", dst="ent-src-b", relation="knows", - layer=GraphLayer.SEMANTIC, workspace_id=src_wid, - provenance={"memory_ids": ["mem-src-1"]})) - svc.store.upsert_edge(Edge( - id="edge-dst", src="ent-dst-a", dst="ent-dst-b", relation="knows", - layer=GraphLayer.SEMANTIC, workspace_id=dst_wid, - provenance={"memory_ids": ["mem-dst-1"]})) - - svc.merge_workspaces("src-ws", "dst-ws") - - # Exactly one live edge survives in the target workspace. - live = svc.store.conn.execute( - "SELECT id FROM edges WHERE workspace_id=? AND valid_to IS NULL " - "AND expired_at IS NULL", (dst_wid,)).fetchall() - assert len(live) == 1 - survivor = live[0]["id"] - - # The survivor's supports include evidence from BOTH source memories. - supports = {r["memory_id"] for r in svc.store.conn.execute( - "SELECT memory_id FROM edge_supports WHERE edge_id=? " - "AND valid_to IS NULL AND expired_at IS NULL", (survivor,))} - assert supports == {"mem-src-1", "mem-dst-1"} - - # The source edge is expired, not deleted (bi-temporal history preserved). - src_edge = svc.store.conn.execute( - "SELECT valid_to FROM edges WHERE id=?", ("edge-src",)).fetchone() - assert src_edge["valid_to"] is not None