feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+147 -2
View File
@@ -24,7 +24,17 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -103,4 +113,139 @@ class OrgSetupDraftRow(Base):
)
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
class DefaultModelGrantRow(Base):
"""The platform owner's default grant set, copied into every new organization.
Kept in a table of its own (never a column on ``ai_models``) so the model
catalogue stays a pure, owner-managed list.
"""
__tablename__ = "default_model_grants"
__table_args__ = (UniqueConstraint("ai_model_id", name="uq_default_model_grant"),)
id: Mapped[str] = pk()
ai_model_id: Mapped[str] = mapped_column(
ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False
)
daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = created()
class OrgPolicyRow(Base):
"""Per-organization policy the owner sets: limits and automation switches."""
__tablename__ = "org_policies"
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
)
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
#: MCP access for this organization (Phase 5); the owner may switch it off.
mcp_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class OrgMemoryRow(Base):
"""One thing the organization remembers about itself.
A memory is *small* by design (≤ 1,500 characters): longer material is a
document and belongs in the Knowledge Base. ``content_hash`` is what makes
"teach the same thing twice" a correction instead of a duplicate, and
``key`` gives a rule a stable address so the engine can update it.
"""
__tablename__ = "org_memories"
__table_args__ = (
Index("ix_org_memories_org_kind", "organization_id", "kind"),
Index("ix_org_memories_org_key", "organization_id", "key"),
)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
#: Optional stable handle, unique in spirit within one org.
key: Mapped[str | None] = mapped_column(String(80), nullable=True)
content: Mapped[str] = mapped_column(String(1500), nullable=False)
#: A digest of the normalised content — the dedupe key the domain defines.
content_hash: Mapped[str] = mapped_column(String(32), nullable=False, default="")
source: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
evidence_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confidence: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
hits: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
#: Soft delete: a memory an admin removed stays in the audit story.
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class McpTokenRow(Base):
"""A scoped token an external AI agent uses to call the MCP gateway.
The token itself is shown exactly once, at creation; only its hash and a
six-character prefix (for display) are stored. Every call is attributed to
that prefix in the audit trail, so a leaked token can be identified and
revoked long before anyone can reuse it.
"""
__tablename__ = "mcp_tokens"
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
#: First six characters, for display only. Never enough to authenticate.
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
#: Comma-separated scopes; an empty string means "no scopes", never "all".
scopes: Mapped[str] = mapped_column(String(255), default="", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = created()
__all__ = [
"DefaultModelGrantRow",
"McpTokenRow",
"OrgMemoryRow",
"OrgPolicyRow",
"OrgSetupDraftRow",
"PlatformInviteRow",
]
@@ -0,0 +1,281 @@
"""Repositories for org memory (Agent 1).
One table, three access shapes:
* reads are always scoped to one ``organization_id`` — there is no "list all
memories" call, because a memory from another tenant must be unreachable by
construction rather than by a filter someone remembers to add;
* dedupe asks the database for the entry it suspects is the same one (by hash,
by key), so "teach it twice" becomes an update;
* eviction asks for the oldest unpinned lessons, which is the only thing this
table is allowed to throw away.
"""
from __future__ import annotations
from sqlalchemy import String, cast, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.memory.models import Memory, MemoryKind, MemorySource
from app.infrastructure.db.models_org import OrgMemoryRow
def _to_domain(row: OrgMemoryRow) -> Memory:
return Memory(
id=row.id,
organization_id=row.organization_id,
kind=MemoryKind(row.kind),
key=row.key,
content=row.content,
source=MemorySource(row.source),
evidence_task_id=row.evidence_task_id,
pinned=bool(row.pinned),
confidence=float(row.confidence or 0.0),
expires_at=row.expires_at,
hits=int(row.hits or 0),
last_used_at=row.last_used_at,
created_by_user_id=row.created_by_user_id,
created_at=row.created_at,
updated_at=row.updated_at,
deleted_at=row.deleted_at,
)
class MemoryRepository:
"""Org memory persistence. Every method is tenant-scoped or keyed by id."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- reads ---------------------------------------------------------------
async def list_for_org(
self,
organization_id: str,
*,
kind: MemoryKind | None = None,
search: str | None = None,
include_deleted: bool = False,
limit: int = 200,
offset: int = 0,
) -> list[Memory]:
stmt = select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id
)
if not include_deleted:
stmt = stmt.where(OrgMemoryRow.deleted_at.is_(None))
if kind is not None:
stmt = stmt.where(OrgMemoryRow.kind == str(kind))
if search:
# Literal LIKE, not user-written pattern: a memory search must not
# be able to inject SQL. Parameterised by SQLAlchemy either way.
needle = f"%{search.strip()}%"
stmt = stmt.where(
or_(
OrgMemoryRow.content.like(needle),
cast(OrgMemoryRow.key, String).like(needle),
)
)
stmt = stmt.order_by(
OrgMemoryRow.pinned.desc(), OrgMemoryRow.updated_at.desc()
).limit(limit).offset(offset)
rows = (await self.session.execute(stmt)).scalars().all()
return [_to_domain(row) for row in rows]
async def get(self, organization_id: str, memory_id: str) -> Memory | None:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
)
)
return _to_domain(row) if row else None
async def find_duplicate(
self, organization_id: str, *, kind: MemoryKind, key: str | None, digest: str
) -> Memory | None:
"""The entry this write would duplicate: same key, or same content.
Matching on the key first is what turns "our port is 8000" into a
correction of one memory instead of a second, similar memory.
"""
if key:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.key == key,
OrgMemoryRow.deleted_at.is_(None),
)
)
if row is not None:
return _to_domain(row)
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.kind == str(kind),
OrgMemoryRow.content_hash == digest,
OrgMemoryRow.deleted_at.is_(None),
)
)
return _to_domain(row) if row else None
async def count_for_task(self, organization_id: str, evidence_task_id: str) -> int:
return int(
await self.session.scalar(
select(func.count())
.select_from(OrgMemoryRow)
.where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.evidence_task_id == evidence_task_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
or 0
)
async def total_chars(self, organization_id: str) -> int:
"""Characters the org currently spends on live memory."""
rows = (
await self.session.execute(
select(func.length(OrgMemoryRow.content)).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
).scalars()
return int(sum(int(value or 0) for value in rows))
# -- writes --------------------------------------------------------------
async def create(self, memory: Memory) -> Memory:
row = OrgMemoryRow(
id=memory.id,
organization_id=memory.organization_id,
kind=str(memory.kind),
key=memory.key,
content=memory.content,
content_hash=memory.content_hash,
source=str(memory.source),
evidence_task_id=memory.evidence_task_id,
pinned=memory.pinned,
confidence=memory.confidence,
expires_at=memory.expires_at,
hits=memory.hits,
last_used_at=memory.last_used_at,
created_by_user_id=memory.created_by_user_id,
created_at=memory.created_at,
updated_at=memory.updated_at,
deleted_at=memory.deleted_at,
)
self.session.add(row)
await self.session.flush()
return memory
async def save(self, memory: Memory) -> Memory:
"""Persist a mutated in-memory record, hash and timestamp included."""
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == memory.organization_id,
OrgMemoryRow.id == memory.id,
)
)
if row is None: # pragma: no cover - the service always loads first
return await self.create(memory)
row.kind = str(memory.kind)
row.key = memory.key
row.content = memory.content
row.content_hash = memory.content_hash
row.source = str(memory.source)
row.evidence_task_id = memory.evidence_task_id
row.pinned = memory.pinned
row.confidence = memory.confidence
row.expires_at = memory.expires_at
row.hits = memory.hits
row.last_used_at = memory.last_used_at
row.deleted_at = memory.deleted_at
row.updated_at = utcnow()
await self.session.flush()
return memory
async def mark_used(self, ids: list[str]) -> None:
"""Count a ``build_context`` hit. One UPDATE, never one per memory."""
if not ids:
return
await self.session.execute(
update(OrgMemoryRow)
.where(OrgMemoryRow.id.in_(ids))
.values(hits=OrgMemoryRow.hits + 1, last_used_at=utcnow())
)
async def soft_delete(self, organization_id: str, memory_id: str) -> bool:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
if row is None:
return False
row.deleted_at = utcnow()
await self.session.flush()
return True
# -- maintenance ---------------------------------------------------------
async def expire(self, organization_id: str | None = None) -> int:
"""Soft-delete expired lessons. Pinned entries never expire.
Called from ``expire_memories`` — which the plan wants exposed as a
plain function because the worker that owns the schedule is not mine.
"""
stmt = select(OrgMemoryRow).where(
OrgMemoryRow.deleted_at.is_(None),
OrgMemoryRow.pinned.is_(False),
OrgMemoryRow.expires_at.is_not(None),
OrgMemoryRow.expires_at < utcnow(),
)
if organization_id:
stmt = stmt.where(OrgMemoryRow.organization_id == organization_id)
rows = (await self.session.execute(stmt)).scalars().all()
now = utcnow()
for row in rows:
row.deleted_at = now
await self.session.flush()
return len(rows)
async def evictable_lessons(
self, organization_id: str, *, limit: int
) -> list[Memory]:
"""The oldest unpinned lessons — the only entries this table may drop."""
rows = (
await self.session.execute(
select(OrgMemoryRow)
.where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.deleted_at.is_(None),
OrgMemoryRow.pinned.is_(False),
OrgMemoryRow.kind == str(MemoryKind.LESSON),
)
.order_by(OrgMemoryRow.created_at.asc())
.limit(limit)
)
).scalars().all()
return [_to_domain(row) for row in rows]
async def hard_delete(self, organization_id: str, memory_id: str) -> bool:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
__all__ = ["MemoryRepository"]
@@ -0,0 +1,237 @@
"""Repositories for owner-granted models and per-organization policy (Agent 1).
``org_model_grants`` is foundation-owned but has no repository elsewhere; these
live in a file no other workstream edits (the local shim the plan allows).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import new_id, utcnow
from app.infrastructure.db.models import OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
@dataclass(slots=True, kw_only=True)
class OrgGrantRecord:
organization_id: str
ai_model_id: str
daily_credit_cap: float | None = None
is_active: bool = True
created_by_user_id: str | None = None
id: str = ""
@dataclass(slots=True, kw_only=True)
class DefaultGrantRecord:
ai_model_id: str
daily_credit_cap: float | None = None
is_active: bool = True
created_by_user_id: str | None = None
id: str = ""
@dataclass(slots=True, kw_only=True)
class OrgPolicyRecord:
organization_id: str
max_employees: int = 5
monthly_credit_budget: float | None = None
automation_enabled: bool = True
mcp_enabled: bool = True
updated_at: datetime | None = None
class OrgModelGrantRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: OrgModelGrantRow) -> OrgGrantRecord:
return OrgGrantRecord(
id=row.id,
organization_id=row.organization_id,
ai_model_id=row.ai_model_id,
daily_credit_cap=row.daily_credit_cap,
is_active=row.is_active,
created_by_user_id=row.created_by_user_id,
)
async def get(self, organization_id: str, ai_model_id: str) -> OrgGrantRecord | None:
stmt = select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return self._to_record(row) if row else None
async def list_for_org(
self, organization_id: str, *, active_only: bool = False
) -> list[OrgGrantRecord]:
stmt = select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id
)
if active_only:
stmt = stmt.where(OrgModelGrantRow.is_active)
rows = (await self.session.execute(stmt)).scalars().all()
return [self._to_record(r) for r in rows]
async def upsert(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None,
is_active: bool,
created_by_user_id: str | None,
) -> OrgGrantRecord:
row = await self.session.scalar(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
)
if row is None:
row = OrgModelGrantRow(
id=new_id(),
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
is_active=is_active,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
else:
row.daily_credit_cap = daily_credit_cap
row.is_active = is_active
await self.session.flush()
return self._to_record(row)
async def delete(self, organization_id: str, ai_model_id: str) -> bool:
row = await self.session.scalar(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
class DefaultModelGrantRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: DefaultModelGrantRow) -> DefaultGrantRecord:
return DefaultGrantRecord(
id=row.id,
ai_model_id=row.ai_model_id,
daily_credit_cap=row.daily_credit_cap,
is_active=row.is_active,
created_by_user_id=row.created_by_user_id,
)
async def list_all(self) -> list[DefaultGrantRecord]:
rows = (await self.session.execute(select(DefaultModelGrantRow))).scalars().all()
return [self._to_record(r) for r in rows]
async def list_active(self) -> list[DefaultGrantRecord]:
stmt = select(DefaultModelGrantRow).where(DefaultModelGrantRow.is_active)
return [self._to_record(r) for r in (await self.session.execute(stmt)).scalars()]
async def upsert(
self,
*,
ai_model_id: str,
daily_credit_cap: float | None,
is_active: bool,
created_by_user_id: str | None,
) -> DefaultGrantRecord:
row = await self.session.scalar(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == ai_model_id
)
)
if row is None:
row = DefaultModelGrantRow(
id=new_id(),
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
is_active=is_active,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
else:
row.daily_credit_cap = daily_credit_cap
row.is_active = is_active
await self.session.flush()
return self._to_record(row)
async def delete(self, ai_model_id: str) -> bool:
row = await self.session.scalar(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == ai_model_id
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
class OrgPolicyRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: OrgPolicyRow) -> OrgPolicyRecord:
return OrgPolicyRecord(
organization_id=row.organization_id,
max_employees=row.max_employees,
monthly_credit_budget=row.monthly_credit_budget,
automation_enabled=row.automation_enabled,
mcp_enabled=row.mcp_enabled,
updated_at=row.updated_at,
)
async def get(self, organization_id: str) -> OrgPolicyRecord | None:
row = await self.session.get(OrgPolicyRow, organization_id)
return self._to_record(row) if row else None
async def upsert(
self,
*,
organization_id: str,
max_employees: int,
monthly_credit_budget: float | None,
automation_enabled: bool,
mcp_enabled: bool | None = None,
) -> OrgPolicyRecord:
row = await self.session.get(OrgPolicyRow, organization_id)
if row is None:
row = OrgPolicyRow(
organization_id=organization_id,
max_employees=max_employees,
monthly_credit_budget=monthly_credit_budget,
automation_enabled=automation_enabled,
mcp_enabled=True if mcp_enabled is None else mcp_enabled,
)
self.session.add(row)
else:
row.max_employees = max_employees
row.monthly_credit_budget = monthly_credit_budget
row.automation_enabled = automation_enabled
if mcp_enabled is not None:
row.mcp_enabled = mcp_enabled
row.updated_at = utcnow()
await self.session.flush()
return self._to_record(row)