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
+70 -28
View File
@@ -1,44 +1,86 @@
"""Wiring for the org-platform workstream (memory, model grants).
Foundation defaults: no memory, and every active model granted to every org (today's
behaviour). The org-platform agent replaces the bodies with the real services; the
function names and return types are FROZEN so other workstreams can call them now.
``build_memory`` is the frozen ``MemoryPort`` builder the engine calls; it now
returns the real ``MemoryService``. ``build_memory_service`` is the same object,
typed for the router (list, teach, pin, confirm, delete). ``build_model_grants``
returns the real ``ModelGrantService``; ``build_model_grant_service`` is its
typed twin for the admin router.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.memory import MemoryPort, NullMemory
from app.application.ports.model_grants import (
AllActiveModelsGrants,
GrantedModel,
ModelGrantPort,
tier_for,
from app.application.ports.memory import MemoryPort
from app.application.ports.model_grants import ModelGrantPort
from app.application.services.memory_service import MemoryService
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.memory import MemoryRepository
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
from app.infrastructure.db.models import AiModelRow
_null_memory = NullMemory()
def build_memory(session: AsyncSession) -> MemoryPort:
return _null_memory
"""The real memory service: recall, teach, and one reflection per task.
``get_model_provider`` is resolved from ``container``'s namespace rather
than from the registry directly: that module is what the composition root
re-exports, so a deployment (or the test suite) that swaps the adapter in
one place swaps it here too.
"""
from app.application.container import get_model_provider
return build_memory_service(session, provider=get_model_provider())
def build_memory_service(
session: AsyncSession,
*,
provider=None,
) -> MemoryService:
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
if provider is None:
from app.application.container import get_model_provider
provider = get_model_provider()
return MemoryService(
session=session,
memories=MemoryRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
provider=provider,
)
def build_model_grants(session: AsyncSession) -> ModelGrantPort:
async def _all_active() -> list[GrantedModel]:
rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all()
return [
GrantedModel(
ai_model_id=row.id,
tier=tier_for(
capabilities=list(row.capabilities or []),
output_price_per_1m=row.output_price_per_1m,
),
capabilities=tuple(row.capabilities or []),
)
for row in rows
]
"""The real grant service: an organization sees only what the owner gave it."""
return build_model_grant_service(session)
return AllActiveModelsGrants(_all_active)
def build_model_grant_service(session: AsyncSession) -> ModelGrantService:
"""The same object, typed for the admin router (grants, defaults, policy)."""
return ModelGrantService(
session=session,
grants=OrgModelGrantRepository(session),
defaults=DefaultModelGrantRepository(session),
policies=OrgPolicyRepository(session),
)
__all__ = [
"build_memory",
"build_memory_service",
"build_model_grant_service",
"build_model_grants",
]
@@ -0,0 +1,337 @@
"""MCP tokens: issue, list, revoke, verify.
Owned by Agent 1. One table, one service, three rules that matter:
1. **A token is shown once.** Only ``sha256(token)`` and a six-character prefix
are stored, so a database leak cannot authenticate anyone; the prefix is for
the audit trail and the settings screen, and is never enough to log in.
2. **Verification is constant-time and total.** ``hmac.compare_digest`` on the
hash, and every failure mode — unknown, revoked, expired — returns the same
``None``. A caller cannot tell "wrong token" from "revoked token", because a
revoked token is not a fact about the token, it is a decision.
3. **Scopes are a floor, not a ceiling.** A token carries the scopes it was
minted with and nothing else; an empty scope list is "no scopes", never "all".
The per-organization switch lives in ``org_policies.mcp_enabled`` (Phase 3).
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from collections import deque
from dataclasses import dataclass, field
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id, utcnow
from app.infrastructure.db.models_org import McpTokenRow
#: Every scope the gateway understands. A scope nobody granted a token is a
#: scope the gateway refuses, whatever the asks say.
MCP_SCOPES: tuple[str, ...] = (
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
)
#: Enough entropy that a token is unguessable, short enough to paste into a
#: client config file.
TOKEN_ENTROPY_BYTES = 24
#: How the raw token looks to a human: the prefix is what the UI shows back.
TOKEN_PREFIX = "smo_"
#: Displayed characters; ``token_hash`` is what authenticates.
PREFIX_DISPLAY_CHARS = 6
@dataclass(slots=True, kw_only=True)
class McpToken:
"""A token as the service hands it back — never the raw secret."""
id: str
organization_id: str
name: str
prefix: str
scopes: list[str]
created_by_user_id: str | None = None
expires_at: object | None = None
last_used_at: object | None = None
revoked_at: object | None = None
created_at: object | None = None
@dataclass(slots=True, kw_only=True)
class IssuedToken:
"""The one and only response that carries the raw token."""
token: str
record: McpToken
@dataclass(slots=True, kw_only=True)
class TokenIdentity:
"""What a verified token proves: which organization, and what it may do."""
organization_id: str
token_id: str
prefix: str
scopes: frozenset[str] = field(default_factory=frozenset)
def allows(self, scope: str) -> bool:
return scope in self.scopes
def as_utc(value):
"""Normalise a timestamp read from the database for comparison.
SQLite (the development and test store) hands back naive datetimes; every
expiry and revocation check compares against ``utcnow()``, so a naive value
is assumed to be UTC rather than raising in the middle of a refusal.
"""
if value is None:
return None
from datetime import UTC
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def hash_token(raw: str) -> str:
"""The only form of a token that is ever stored."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _mint() -> str:
return f"{TOKEN_PREFIX}{secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)}"
def _scopes_of(raw: str) -> list[str]:
"""Parse a CSV scope field, keeping only known scopes and their order."""
seen: list[str] = []
for piece in (raw or "").split(","):
scope = piece.strip()
if scope and scope in MCP_SCOPES and scope not in seen:
seen.append(scope)
return seen
def validate_scopes(scopes: list[str] | None) -> list[str]:
"""Refuse an unknown scope rather than silently dropping it."""
if not scopes:
return []
unknown = [scope for scope in scopes if scope not in MCP_SCOPES]
if unknown:
raise ValidationError(f"محدودهٔ ناشناخته: {', '.join(sorted(unknown))}")
# De-duplicated, order preserved, so the UI's checkbox set round-trips.
return list(dict.fromkeys(scopes))
class TokenRateLimiter:
"""In-memory, per-token call budget for the current minute.
Deliberately process-local: the gateway is one process, and a distributed
limiter would need the Redis the deployment does not have. A restart forgives
a burst — which is the right trade for a spare process only the owner's own
agents reach.
"""
def __init__(self, *, limit_per_minute: int | None = None) -> None:
self.limit = int(limit_per_minute or settings.mcp_rate_limit_per_minute)
self._calls: dict[str, deque[float]] = {}
def allow(self, key: str) -> bool:
import time
now = time.monotonic()
window_start = now - 60.0
bucket = self._calls.setdefault(key, deque())
while bucket and bucket[0] < window_start:
bucket.popleft()
if len(bucket) >= self.limit:
return False
bucket.append(now)
return True
def reset(self) -> None:
self._calls.clear()
class McpService:
"""Token lifecycle and verification for the MCP gateway."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- lifecycle -----------------------------------------------------------
async def issue(
self,
*,
organization_id: str,
name: str,
scopes: list[str],
created_by_user_id: str | None = None,
ttl_days: int | None = None,
) -> IssuedToken:
clean_name = name.strip()
if not clean_name:
raise ValidationError("نام توکن نمی‌تواند خالی باشد.")
wanted = validate_scopes(scopes)
if not wanted:
raise ValidationError("حداقل یک محدودهٔ دسترسی انتخاب کنید.")
days = int(ttl_days or settings.mcp_token_ttl_days)
if days < 1:
raise ValidationError("مدت اعتبار توکن نامعتبر است.")
raw = _mint()
row = McpTokenRow(
id=new_id(),
organization_id=organization_id,
name=clean_name[:120],
token_hash=hash_token(raw),
prefix=raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS],
scopes=",".join(wanted),
created_by_user_id=created_by_user_id,
expires_at=utcnow() + timedelta(days=days),
)
self.session.add(row)
await self.session.flush()
return IssuedToken(token=raw, record=_to_domain(row))
async def list(self, organization_id: str) -> list[McpToken]:
rows = (
(
await self.session.execute(
select(McpTokenRow)
.where(McpTokenRow.organization_id == organization_id)
.order_by(McpTokenRow.created_at.desc())
)
)
.scalars()
.all()
)
return [_to_domain(row) for row in rows]
async def revoke(self, *, organization_id: str, token_id: str) -> None:
"""Revoke a token. Revoking twice is a 404, not a silent success — the
same idempotence rule the grant endpoints follow, so a caller can tell
"already gone" from "never existed" only by looking, not by guessing."""
row = await self._row(organization_id, token_id)
if row is None or row.revoked_at is not None:
raise NotFoundError("توکن یافت نشد.")
row.revoked_at = utcnow()
await self.session.flush()
# -- verification --------------------------------------------------------
async def verify(self, raw_token: str) -> TokenIdentity | None:
"""Resolve a raw token, or ``None`` for every kind of refusal.
The lookup is by hash (the token is never stored), and the comparison is
constant-time so a token that shares a prefix with a real one cannot be
discovered by timing. An expired or revoked token is simply not an
identity any more.
"""
if not raw_token or not raw_token.startswith(TOKEN_PREFIX):
return None
digest = hash_token(raw_token)
rows = (
(
await self.session.execute(
select(McpTokenRow).where(
McpTokenRow.token_hash == digest,
McpTokenRow.revoked_at.is_(None),
)
)
)
.scalars()
.all()
)
if not rows:
return None
row = rows[0]
if not hmac.compare_digest(row.token_hash, digest):
return None
if as_utc(row.expires_at) is not None and as_utc(row.expires_at) <= utcnow():
return None
return TokenIdentity(
organization_id=row.organization_id,
token_id=row.id,
prefix=row.prefix,
scopes=frozenset(_scopes_of(row.scopes)),
)
async def creator_of(self, token_id: str) -> str | None:
"""The user who minted this token — the accountable actor for its writes.
A task created through the gateway is attributed to the administrator who
created the token, not to a ghost user: the token is a delegated key, and
delegation never erases who holds it.
"""
row = await self.session.get(McpTokenRow, token_id)
return row.created_by_user_id if row else None
async def touch(self, token_id: str) -> None:
"""Record last use. Never allowed to fail a call."""
row = await self.session.get(McpTokenRow, token_id)
if row is not None:
row.last_used_at = utcnow()
await self.session.flush()
async def count_per_org(self) -> dict[str, int]:
"""Active tokens per organization, for the owner's /admin/mcp screen."""
rows = (
await self.session.execute(
select(McpTokenRow.organization_id).where(
McpTokenRow.revoked_at.is_(None)
)
)
).scalars()
counts: dict[str, int] = {}
for org_id in rows:
counts[org_id] = counts.get(org_id, 0) + 1
return counts
async def _row(self, organization_id: str, token_id: str) -> McpTokenRow | None:
return await self.session.scalar(
select(McpTokenRow).where(
McpTokenRow.organization_id == organization_id,
McpTokenRow.id == token_id,
)
)
def _to_domain(row: McpTokenRow) -> McpToken:
return McpToken(
id=row.id,
organization_id=row.organization_id,
name=row.name,
prefix=row.prefix,
scopes=_scopes_of(row.scopes),
created_by_user_id=row.created_by_user_id,
expires_at=row.expires_at,
last_used_at=row.last_used_at,
revoked_at=row.revoked_at,
created_at=row.created_at,
)
__all__ = [
"MCP_SCOPES",
"TOKEN_PREFIX",
"IssuedToken",
"McpService",
"McpToken",
"TokenIdentity",
"TokenRateLimiter",
"hash_token",
"validate_scopes",
]
@@ -0,0 +1,535 @@
"""Org memory use cases: what an organization remembers, and how it learns.
This module is the real ``MemoryPort`` implementation (see
``container_org.build_memory``). The engine calls it three ways — build a
context for a task, record something a human taught, and react to a finished
task — and this service owns three invariants around those calls:
1. **Memory is capped.** One organization may spend at most
``MEMORY_ORG_CHAR_CAP`` characters on live memory. When a write would cross
it, the oldest unpinned *lesson* is evicted first — a lesson is disposable by
definition, a rule or a preference is not.
2. **Teaching the same thing twice teaches nothing.** A write whose content (or
whose key) already exists updates that entry instead of adding a duplicate,
which is also what lets "the port is 8001" correct "the port is 8000".
3. **A reflection can never break the engine.** ``on_task_finished`` asks one
cheap model for what the task taught. If the call fails, the error is
recorded and the function returns: the task is finished, and the org simply
learned nothing this time.
Nothing here returns a model name, a provider or an infrastructure detail — the
engine's contract is a block of text.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
from app.application.ports.memory import TaskOutcome
from app.application.ports.model_provider import ModelResponse
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact_text
from app.domain.ai_control.models import RateUnit, RoutingMode
from app.domain.base import utcnow
from app.domain.identity.models import ROLE_RANK, Role
from app.domain.memory.models import (
CONFIRMED_CONFIDENCE,
KIND_LABELS,
MAX_WRITES_PER_TASK,
REFLECTION_CONFIDENCE,
Memory,
MemoryKind,
MemorySource,
coerce_kind,
coerce_source,
content_hash,
lesson_expiry,
normalise,
relevance,
tokenise,
validate_confidence,
validate_content,
validate_key,
visible_kinds,
)
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.memory import MemoryRepository
#: What a reflection is asked to produce. Deliberately narrow: two lessons and a
#: code-map paragraph, in a shape the parser can read without guessing.
REFLECTION_SYSTEM = (
"تو بازبین کارهای یک سازمان هستی. حداکثر دو درس آموختنی و یک پاراگراف نقشهٔ "
"کد بنویس. هر درس یک خط است و فقط یک واقعیت اجرایی را می‌گوید — نه تعریف. "
"اگر درسی نیست، فقط کلمهٔ «هیچ» را بنویس."
)
REFLECTION_TEMPLATE = """کار: {title}
وضعیت: {status}
خلاصه: {summary}
فایل‌های تغییرکرده: {files}
خطاهای دروازه: {failures}
پاسخ را دقیقاً در این قالب بنویس:
درس ۱: ...
درس ۲: ...
نقشهٔ کد: ..."""
#: The block header. Short on purpose: the block is a prompt fragment, and a
#: prompt fragment pays for every character.
CONTEXT_HEADER = "یادداشت‌های سازمان:"
class MemoryService:
"""Recalls, teaches, corrects and prunes one organization's memory."""
def __init__(
self,
*,
session: AsyncSession,
memories: MemoryRepository,
models: AiModelRepository,
credentials: ModelCredentialRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
provider: ModelProviderPort,
) -> None:
self.session = session
self.memories = memories
self.models = models
self.credentials = credentials
self.rate_cards = rate_cards
self.ledger = ledger
self.provider = provider
# ------------------------------------------------------------------ #
# MemoryPort #
# ------------------------------------------------------------------ #
async def record(
self,
*,
organization_id: str,
kind: str,
content: str,
source: str,
evidence_task_id: str | None = None,
key: str | None = None,
created_by_user_id: str | None = None,
) -> str | None:
"""Store — or correct — one memory. Returns its id, or ``None``.
``None`` is an answer, not a failure: the write was refused because the
task had already taught its three memories. The caller (usually the
reflection path) must not treat that as an error.
"""
cleaned = validate_content(content)
# A memory lands in a future prompt. A credential pasted into one would
# be broadcast to every employee from then on, so nothing is stored
# unredacted — not even what "looks safe".
cleaned = redact_text(cleaned)
entry_kind = coerce_kind(kind)
entry_source = coerce_source(source)
entry_key = validate_key(key)
digest = content_hash(cleaned)
if evidence_task_id:
writes = await self.memories.count_for_task(organization_id, evidence_task_id)
if writes >= MAX_WRITES_PER_TASK:
return None
duplicate = await self.memories.find_duplicate(
organization_id, kind=entry_kind, key=entry_key, digest=digest
)
if duplicate is not None:
# Same thought, same organization: correct the entry in place.
# Confidence and pinned state survive, because re-reading a rule is
# not a retraction of it.
duplicate.content = cleaned
duplicate.source = entry_source
duplicate.confidence = _confidence_for(entry_source, duplicate.confidence)
duplicate.expires_at = _expiry_for(entry_kind, duplicate)
if created_by_user_id:
duplicate.created_by_user_id = duplicate.created_by_user_id or created_by_user_id
await self.memories.save(duplicate)
return duplicate.id
memory = Memory(
organization_id=organization_id,
kind=entry_kind,
key=entry_key,
content=cleaned,
source=entry_source,
evidence_task_id=evidence_task_id,
created_by_user_id=created_by_user_id,
confidence=_confidence_for(entry_source, 1.0),
)
memory.expires_at = _expiry_for(entry_kind, memory)
await self.memories.create(memory)
await self._enforce_cap(organization_id)
return memory.id
async def build_context(
self,
*,
organization_id: str,
role: str,
query: str,
budget_chars: int = 2500,
) -> str:
"""The memory block for one task, within ``budget_chars``.
Pinned rules and preferences lead: they are standing decisions, not
search results. Everything else follows by relevance to the query.
Whatever does not fit is dropped — including a pinned entry, because a
prompt over budget is worse than an incomplete one.
"""
kinds = visible_kinds(role)
if budget_chars <= 0 or not kinds:
return ""
memories = await self.memories.list_for_org(organization_id, limit=200)
live = [m for m in memories if m.kind in kinds and m.is_live()]
if not live:
return ""
query_tokens = tokenise(query)
leading = [m for m in live if m.pinned and m.kind in _LEADING_KINDS]
leading_ids = {m.id for m in leading}
rest = [m for m in live if m.id not in leading_ids]
leading.sort(key=lambda m: (_kind_order(m.kind), -(m.updated_at or utcnow()).timestamp()))
rest.sort(
key=lambda m: (
-relevance(m, query_tokens),
-(m.updated_at or utcnow()).timestamp(),
)
)
budget = int(budget_chars)
lines: list[str] = []
used = len(CONTEXT_HEADER) + 1
included: list[Memory] = []
for memory in [*leading, *rest]:
line = _format_entry(memory)
if used + len(line) + 1 > budget:
continue
used += len(line) + 1
lines.append(line)
included.append(memory)
if not lines:
return ""
await self.memories.mark_used([m.id for m in included])
return "\n".join([CONTEXT_HEADER, *lines])
async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None:
"""Let the organization learn from a finished task. Never raises.
One cheap completion, at most ``MAX_WRITES_PER_TASK`` memories, all
written with ``source=reflection`` and half confidence. A failure is
recorded and swallowed: the task is already finished, and the engine must
not have to know whether an LLM happened to answer.
"""
try:
answer = await self._reflect(organization_id=organization_id, task=task)
except Exception as exc: # noqa: BLE001 - reflection must never raise
await record_error(
kind="memory.reflection_failed",
message=str(exc),
exc=exc,
organization_id=organization_id,
metadata={"task_id": task.task_id, "status": task.status},
)
return
if not answer:
return
for kind, content in _parse_reflection(answer)[:MAX_WRITES_PER_TASK]:
await self.record(
organization_id=organization_id,
kind=str(kind),
content=content,
source=str(MemorySource.REFLECTION),
evidence_task_id=task.task_id,
)
# ------------------------------------------------------------------ #
# Writes the UI and the MCP gateway expose #
# ------------------------------------------------------------------ #
async def list(
self,
organization_id: str,
*,
kind: str | None = None,
search: str | None = None,
) -> list[Memory]:
parsed = coerce_kind(kind) if kind else None
return await self.memories.list_for_org(organization_id, kind=parsed, search=search)
async def teach(
self,
*,
organization_id: str,
user_id: str,
role: Role,
kind: str,
content: str,
key: str | None = None,
) -> Memory:
"""A human teaching the organization — a rule or a preference.
The role check lives here and not only in the router: this service is
also reached by the MCP gateway (Phase 5), and "someone with a token"
must hit the same wall as someone with a session.
"""
entry_kind = coerce_kind(kind)
if entry_kind not in _TEACHABLE_KINDS:
raise PermissionDeniedError("فقط «قاعده» یا «ترجیح» قابل آموزش است.")
if ROLE_RANK[role] < ROLE_RANK[Role.MEMBER]:
raise PermissionDeniedError("برای آموزش سازمان باید عضو آن باشید.")
memory_id = await self.record(
organization_id=organization_id,
kind=str(entry_kind),
content=content,
source=str(MemorySource.USER),
key=key,
created_by_user_id=user_id,
)
if memory_id is None: # pragma: no cover - only the task cap returns None
raise ValidationError("یادداشت ذخیره نشد.")
memory = await self.memories.get(organization_id, memory_id)
if memory is None: # pragma: no cover - created one call above
raise NotFoundError("یادداشت یافت نشد.")
return memory
async def get(self, organization_id: str, memory_id: str) -> Memory:
memory = await self.memories.get(organization_id, memory_id)
if memory is None or memory.deleted_at is not None:
raise NotFoundError("یادداشت یافت نشد.")
return memory
async def edit(
self, *, organization_id: str, memory_id: str, content: str, key: str | None = None
) -> Memory:
memory = await self.get(organization_id, memory_id)
memory.content = validate_content(content)
if key is not None:
memory.key = validate_key(key)
memory.confidence = validate_confidence(memory.confidence)
memory.expires_at = _expiry_for(memory.kind, memory)
await self.memories.save(memory)
return memory
async def set_pinned(
self, *, organization_id: str, memory_id: str, pinned: bool
) -> Memory:
memory = await self.get(organization_id, memory_id)
memory.pinned = bool(pinned)
# Pinning is a promise the organization keeps, so a pinned lesson stops
# expiring and becomes part of every context.
memory.expires_at = _expiry_for(memory.kind, memory)
await self.memories.save(memory)
return memory
async def confirm(self, *, organization_id: str, memory_id: str) -> Memory:
"""A reviewed lesson becomes settled knowledge: full confidence, no expiry."""
memory = await self.get(organization_id, memory_id)
if memory.kind is not MemoryKind.LESSON:
raise ValidationError("فقط یک «درس» قابل تأیید است.")
memory.confidence = CONFIRMED_CONFIDENCE
memory.pinned = True
memory.expires_at = None
await self.memories.save(memory)
return memory
async def delete(self, *, organization_id: str, memory_id: str) -> None:
if not await self.memories.soft_delete(organization_id, memory_id):
raise NotFoundError("یادداشت یافت نشد.")
async def expire_memories(self, organization_id: str | None = None) -> int:
"""Soft-delete expired lessons. Exposed for the worker hook (see report)."""
return await self.memories.expire(organization_id)
async def stats(self, organization_id: str) -> dict[str, int]:
memories = await self.memories.list_for_org(organization_id, limit=200)
return {
"total": len(memories),
"pinned": sum(1 for m in memories if m.pinned),
"chars": await self.memories.total_chars(organization_id),
}
# ------------------------------------------------------------------ #
# Internals #
# ------------------------------------------------------------------ #
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
"""One cheap completion about the finished task, priced in the ledger."""
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return ""
pool = await self.credentials.list_active_for_provider(model.provider_id)
response = await self.provider.complete(
model=model,
credential=pool[0] if pool else None,
credentials=pool or None,
messages=[
ChatMessage(
role="user",
content=REFLECTION_TEMPLATE.format(
title=task.title,
status=task.status,
summary=task.summary or "—",
files="، ".join(task.changed_files[:20]) or "—",
failures="، ".join(task.gate_failures) or "—",
),
)
],
system=REFLECTION_SYSTEM,
temperature=0.2,
max_tokens=600,
)
await self._record_usage(organization_id=organization_id, response=response)
return response.content or ""
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
"""Price a reflection exactly like any other model call."""
for unit, quantity in (
(RateUnit.MODEL_INPUT_TOKENS, float(response.input_tokens or 0)),
(RateUnit.MODEL_OUTPUT_TOKENS, float(response.output_tokens or 0)),
):
card = await self.rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else 0.0
internal_cost = card.internal_cost_estimate if card else 0.0
await self.ledger.record(
usage=UsageRecord(
organization_id=organization_id,
unit=unit,
quantity=quantity,
unit_price=unit_price,
internal_cost_estimate=quantity * internal_cost,
total_price=quantity * unit_price,
metadata_json={"memory_reflection": True},
)
)
async def _enforce_cap(self, organization_id: str) -> int:
"""Trim the organization to its character budget. Returns evicted count."""
cap = int(settings.memory_org_char_cap)
evicted = 0
while await self.memories.total_chars(organization_id) > cap:
victims = await self.memories.evictable_lessons(organization_id, limit=1)
if not victims:
# Only rules and preferences are left: the organization chose to
# spend its whole budget on standing decisions, and silently
# deleting one of those is worse than being one character over.
return evicted
await self.memories.soft_delete(organization_id, victims[0].id)
evicted += 1
return evicted
# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
#: Kinds a human may teach directly; everything else is the system's voice.
_TEACHABLE_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
#: A pinned rule outranks a pinned preference: one says what to do, the other
#: says how to like it.
_LEADING_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
_KIND_ORDER: dict[MemoryKind, int] = {
MemoryKind.RULE: 0,
MemoryKind.PREFERENCE: 1,
}
def _kind_order(kind: MemoryKind) -> int:
return _KIND_ORDER.get(kind, 9)
def _confidence_for(source: MemorySource, fallback: float) -> float:
"""Reflections get half confidence; a human's entry keeps its own."""
if source is MemorySource.REFLECTION:
return REFLECTION_CONFIDENCE
return validate_confidence(fallback)
def _expiry_for(kind: MemoryKind, memory: Memory):
"""Lessons expire; a rule, a preference, a fact and the code map do not."""
if kind is not MemoryKind.LESSON:
return None
return lesson_expiry(
pinned=memory.pinned, confirmed=memory.confidence >= CONFIRMED_CONFIDENCE
)
def _format_entry(memory: Memory) -> str:
label = KIND_LABELS.get(str(memory.kind), "یادداشت")
suffix = ""
if memory.kind is MemoryKind.LESSON:
suffix = f" (اعتماد {int(memory.confidence * 100)}٪)"
elif memory.source is MemorySource.REFLECTION:
suffix = " (بازتاب)"
return f"- [{label}]{suffix} {memory.content.strip()}"
def _parse_reflection(text: str) -> list[tuple[MemoryKind, str]]:
"""Read a reflection's answer into ``(kind, content)`` pairs.
Tolerant by design: the model is asked for a rigid template, but a model is
a model. A line that does not start with a known marker is ignored rather
than stored, so a chatty answer cannot smuggle in a third lesson.
"""
entries: list[tuple[MemoryKind, str]] = []
for raw_line in (text or "").splitlines():
line = normalise(raw_line).lstrip("-*• \t").strip()
if not line:
continue
lowered = line.casefold()
for marker, kind in _REFLECTION_MARKERS:
if lowered.startswith(marker):
body = line[len(marker):].strip(" :،-–")
if body:
entries.append((kind, body))
break
return entries
#: Marker → kind. Longest marker first, so «نقشهٔ کد:» cannot be swallowed by a
#: shorter prefix, and both the Persian and the English spelling is accepted.
_REFLECTION_MARKERS: tuple[tuple[str, MemoryKind], ...] = (
("نقشهٔ کد:", MemoryKind.CODE_MAP),
("نقشه کد:", MemoryKind.CODE_MAP),
("code map:", MemoryKind.CODE_MAP),
("درس ۱:", MemoryKind.LESSON),
("درس ۱", MemoryKind.LESSON),
("درس ۲:", MemoryKind.LESSON),
("درس ۲", MemoryKind.LESSON),
("درس:", MemoryKind.LESSON),
("درس", MemoryKind.LESSON),
("lesson 1:", MemoryKind.LESSON),
("lesson 1", MemoryKind.LESSON),
("lesson 2:", MemoryKind.LESSON),
("lesson 2", MemoryKind.LESSON),
("lesson:", MemoryKind.LESSON),
)
__all__ = ["CONTEXT_HEADER", "MemoryService"]
@@ -0,0 +1,456 @@
"""Owner model grants and per-organization policy.
Owned by Agent 1. This is the real ``ModelGrantPort`` implementation (see
``container_org.build_model_grants``): the engine asks "which models may this
organization use?" and gets a *neutral* answer — a tier (``fast`` /
``balanced`` / ``strong`` ≈ «سریع» / «متعادل» / «قوی») and the capability list,
never a model name. Model names stay an owner-only concept, visible in the
admin grant matrix and nowhere else.
Two independent switches have to agree before a model is usable:
* the model itself is active in the catalogue (``ai_models.is_active``), and
* the owner granted it to *this* organization (``org_model_grants.is_active``).
Either one being false means "not granted". That is what makes revoking a model
from one organization a real revocation while other organizations keep working.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.model_grants import GrantedModel, tier_for
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.infrastructure.db.models import AiModelRow
from app.infrastructure.repositories.org_grants import (
DefaultGrantRecord,
DefaultModelGrantRepository,
OrgGrantRecord,
OrgModelGrantRepository,
OrgPolicyRecord,
OrgPolicyRepository,
)
#: The only vocabulary an organization's users ever see for a model.
TIER_LABELS: dict[str, str] = {
"fast": "سریع",
"balanced": "متعادل",
"strong": "قوی",
}
#: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first.
DEFAULT_MAX_EMPLOYEES = 5
@dataclass(slots=True)
class ModelView:
"""One catalogue model as the owner sees it, plus its grant state for an org."""
ai_model_id: str
label: str
provider_id: str
capabilities: list[str] = field(default_factory=list)
tier: str = "balanced"
tier_label: str = "متعادل"
output_price_per_1m: float = 0.0
model_active: bool = True
granted: bool = False
daily_credit_cap: float | None = None
@dataclass(slots=True)
class OrgGrantView:
ai_model_id: str
label: str
tier: str
tier_label: str
capabilities: list[str]
daily_credit_cap: float | None
is_active: bool
@dataclass(slots=True)
class PolicyView:
organization_id: str
max_employees: int
monthly_credit_budget: float | None
automation_enabled: bool
mcp_enabled: bool
max_employees_limit: int
def _tier_of(row: AiModelRow) -> str:
return tier_for(
capabilities=list(row.capabilities or []),
output_price_per_1m=float(row.output_price_per_1m or 0.0),
)
def _tier_label(tier: str) -> str:
return TIER_LABELS.get(tier, TIER_LABELS["balanced"])
class ModelGrantService:
"""Grants, default grant set and org policy — all in one place."""
def __init__(
self,
*,
session: AsyncSession,
grants: OrgModelGrantRepository,
defaults: DefaultModelGrantRepository,
policies: OrgPolicyRepository,
) -> None:
self.session = session
self.grants = grants
self.defaults = defaults
self.policies = policies
# -- catalogue ----------------------------------------------------------
async def _model(self, ai_model_id: str) -> AiModelRow:
"""The catalogue row, or a 404. Never returns an inactive model's row.
Inactive means "the owner retired it": granting it would be meaningless,
so it is reported as missing rather than as a validation error.
"""
row = await self.session.scalar(
select(AiModelRow).where(
AiModelRow.id == ai_model_id, AiModelRow.is_active
)
)
if row is None:
raise NotFoundError("مدل مورد نظر یافت نشد.")
return row
async def list_models(self, *, organization_id: str) -> list[ModelView]:
"""The full grant matrix for one organization (owner view)."""
models = (
await self.session.execute(
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
)
).scalars().all()
active_grants = {
g.ai_model_id: g
for g in await self.grants.list_for_org(organization_id, active_only=True)
}
return [
self._view(row, active_grants.get(row.id)) for row in models
]
def _view(self, row: AiModelRow, grant: OrgGrantRecord | None) -> ModelView:
tier = _tier_of(row)
return ModelView(
ai_model_id=row.id,
label=row.display_name_for_admin or row.internal_name,
provider_id=row.provider_id,
capabilities=list(row.capabilities or []),
tier=tier,
tier_label=_tier_label(tier),
output_price_per_1m=float(row.output_price_per_1m or 0.0),
model_active=bool(row.is_active),
granted=grant is not None,
daily_credit_cap=grant.daily_credit_cap if grant else None,
)
# -- ModelGrantPort -----------------------------------------------------
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
"""Active models with an active grant for this organization."""
grants = await self.grants.list_for_org(organization_id, active_only=True)
if not grants:
return []
by_id = {g.ai_model_id: g for g in grants}
rows = (
await self.session.execute(
select(AiModelRow).where(
AiModelRow.is_active, AiModelRow.id.in_(list(by_id))
)
)
).scalars().all()
granted = [
GrantedModel(
ai_model_id=row.id,
tier=_tier_of(row),
capabilities=tuple(row.capabilities or []),
daily_credit_cap=by_id[row.id].daily_credit_cap,
)
for row in rows
]
# Cheapest first: a caller that picks the head of the list gets the
# economical model, which is the sane default for bulk work.
granted.sort(key=lambda m: m.daily_credit_cap if m.daily_credit_cap is not None else -1)
return granted
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
grant = await self.grants.get(organization_id, ai_model_id)
if grant is None or not grant.is_active:
return False
exists = await self.session.scalar(
select(AiModelRow.id).where(
AiModelRow.id == ai_model_id, AiModelRow.is_active
)
)
return exists is not None
# -- grants -------------------------------------------------------------
async def list_grants(
self, *, organization_id: str, active_only: bool = False
) -> list[OrgGrantView]:
records = await self.grants.list_for_org(organization_id, active_only=active_only)
if not records:
return []
rows = (
await self.session.execute(
select(AiModelRow).where(
AiModelRow.id.in_([r.ai_model_id for r in records])
)
)
).scalars().all()
by_id = {row.id: row for row in rows}
views: list[OrgGrantView] = []
for record in records:
row = by_id.get(record.ai_model_id)
if row is None: # deleted from the catalogue: keep it out of the UI
continue
tier = _tier_of(row)
views.append(
OrgGrantView(
ai_model_id=row.id,
label=row.display_name_for_admin or row.internal_name,
tier=tier,
tier_label=_tier_label(tier),
capabilities=list(row.capabilities or []),
daily_credit_cap=record.daily_credit_cap,
is_active=record.is_active,
)
)
return views
@staticmethod
def _validate_cap(daily_credit_cap: float | None) -> float | None:
if daily_credit_cap is None:
return None
cap = float(daily_credit_cap)
if cap < 0:
raise ValidationError("سقف اعتبار روزانه نمی‌تواند منفی باشد.")
return cap
async def grant_model(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None = None,
actor_user_id: str | None = None,
) -> OrgGrantView:
await self._model(ai_model_id)
cap = self._validate_cap(daily_credit_cap)
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=cap,
is_active=True,
created_by_user_id=actor_user_id,
)
views = await self.list_grants(organization_id=organization_id, active_only=True)
return next(v for v in views if v.ai_model_id == ai_model_id)
async def revoke_model(
self, *, organization_id: str, ai_model_id: str, actor_user_id: str | None = None
) -> None:
"""Revoke by deactivating, never by deleting.
The row is kept so the history ("was granted, then revoked") survives
and re-granting is idempotent instead of racing a unique constraint.
"""
existing = await self.grants.get(organization_id, ai_model_id)
if existing is None or not existing.is_active:
raise NotFoundError("این مدل برای سازمان فعال نیست.")
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=existing.daily_credit_cap,
is_active=False,
created_by_user_id=actor_user_id or existing.created_by_user_id,
)
async def set_grant_cap(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None,
actor_user_id: str | None = None,
) -> OrgGrantView:
existing = await self.grants.get(organization_id, ai_model_id)
if existing is None or not existing.is_active:
raise NotFoundError("این مدل برای سازمان فعال نیست.")
return await self.grant_model(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
actor_user_id=actor_user_id,
)
# -- default grant set --------------------------------------------------
async def list_defaults(self) -> list[ModelView]:
models = (
await self.session.execute(
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
)
).scalars().all()
active = {d.ai_model_id: d for d in await self.defaults.list_active()}
return [self._view(row, _as_grant(active.get(row.id))) for row in models]
async def set_default(
self,
*,
ai_model_id: str,
daily_credit_cap: float | None = None,
is_active: bool = True,
actor_user_id: str | None = None,
) -> None:
await self._model(ai_model_id)
await self.defaults.upsert(
ai_model_id=ai_model_id,
daily_credit_cap=self._validate_cap(daily_credit_cap),
is_active=is_active,
created_by_user_id=actor_user_id,
)
async def remove_default(self, *, ai_model_id: str) -> None:
if not await self.defaults.delete(ai_model_id):
raise NotFoundError("این مدل در مجموعهٔ پیش‌فرض نیست.")
async def copy_defaults_into(
self, *, organization_id: str, actor_user_id: str | None = None
) -> int:
"""Materialise the default set into a new organization. Returns the count.
Only the *active* defaults are copied: a default the owner switched off
must not silently reappear in the next organization created.
"""
copied = 0
for record in await self.defaults.list_active():
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=record.ai_model_id,
daily_credit_cap=record.daily_credit_cap,
is_active=True,
created_by_user_id=actor_user_id,
)
copied += 1
return copied
# -- org policy ---------------------------------------------------------
@staticmethod
def _policy_view(record: OrgPolicyRecord | None, organization_id: str) -> PolicyView:
limit = max(1, int(settings.engine_max_employees))
if record is None:
return PolicyView(
organization_id=organization_id,
max_employees=min(DEFAULT_MAX_EMPLOYEES, limit),
monthly_credit_budget=None,
automation_enabled=True,
mcp_enabled=True,
max_employees_limit=limit,
)
return PolicyView(
organization_id=organization_id,
max_employees=min(int(record.max_employees), limit),
monthly_credit_budget=record.monthly_credit_budget,
automation_enabled=bool(record.automation_enabled),
mcp_enabled=bool(record.mcp_enabled),
max_employees_limit=limit,
)
async def get_org_policy(self, organization_id: str) -> PolicyView:
"""Readable by anyone who may manage the org (Agent 2 reads this later)."""
return self._policy_view(await self.policies.get(organization_id), organization_id)
async def set_org_policy(
self,
*,
organization_id: str,
max_employees: int | None = None,
monthly_credit_budget: float | None = None,
automation_enabled: bool | None = None,
mcp_enabled: bool | None = None,
actor_user_id: str | None = None,
) -> PolicyView:
current = await self.get_org_policy(organization_id)
employees = current.max_employees if max_employees is None else int(max_employees)
if employees < 1:
raise ValidationError("حداقل تعداد پرسنل یک است.")
limit = current.max_employees_limit
if employees > limit:
raise ValidationError(
f"حداکثر تعداد پرسنل نمی‌تواند از سقف سامانه ({limit}) بیشتر باشد."
)
budget = (
current.monthly_credit_budget
if monthly_credit_budget is None
else float(monthly_credit_budget)
)
if budget is not None and budget < 0:
raise ValidationError("بودجهٔ ماهانه نمی‌تواند منفی باشد.")
automation = (
current.automation_enabled
if automation_enabled is None
else bool(automation_enabled)
)
mcp = current.mcp_enabled if mcp_enabled is None else bool(mcp_enabled)
record = await self.policies.upsert(
organization_id=organization_id,
max_employees=employees,
monthly_credit_budget=budget,
automation_enabled=automation,
mcp_enabled=mcp,
)
return self._policy_view(record, organization_id)
async def set_mcp_enabled(
self, *, organization_id: str, enabled: bool, actor_user_id: str | None = None
) -> PolicyView:
"""Owner switch for the MCP gateway (Phase 5)."""
return await self.set_org_policy(
organization_id=organization_id, mcp_enabled=enabled, actor_user_id=actor_user_id
)
async def mcp_enabled(self, organization_id: str) -> bool:
return (await self.get_org_policy(organization_id)).mcp_enabled
def _as_grant(record: DefaultGrantRecord | None) -> OrgGrantRecord | None:
"""Default rows reuse the org-grant view; only the shape matters here."""
if record is None:
return None
return OrgGrantRecord(
id=record.id,
organization_id="",
ai_model_id=record.ai_model_id,
daily_credit_cap=record.daily_credit_cap,
is_active=record.is_active,
created_by_user_id=record.created_by_user_id,
)
__all__ = [
"DEFAULT_MAX_EMPLOYEES",
"ModelGrantService",
"ModelView",
"OrgGrantView",
"PolicyView",
"TIER_LABELS",
]
@@ -243,6 +243,9 @@ class OrgSetupService:
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
# -- default model grants (owner-provided, Phase 3) -------------------
await self._copy_default_grants(org.id, user.id)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
@@ -377,6 +380,30 @@ class OrgSetupService:
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _copy_default_grants(self, organization_id: str, user_id: str) -> int:
"""Copy the platform owner's default grant set into the new organization.
Runs inside the creation transaction on purpose: an organization that
exists without its grants would be an organization whose AI staff cannot
think. An empty default set is legitimate (the owner granted nothing).
"""
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
service = ModelGrantService(
session=self.session,
grants=OrgModelGrantRepository(self.session),
defaults=DefaultModelGrantRepository(self.session),
policies=OrgPolicyRepository(self.session),
)
return await service.copy_defaults_into(
organization_id=organization_id, actor_user_id=user_id
)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
+7
View File
@@ -130,7 +130,14 @@ class Settings(BaseSettings):
# >>> agent-1 (org platform): add settings ONLY between these markers
mcp_gateway_host: str = "127.0.0.1"
mcp_gateway_port: int = 8765
#: The owner's kill switch for the whole gateway (Phase 5). The per-org
#: switch lives in ``org_policies.mcp_enabled``.
mcp_gateway_enabled: bool = True
mcp_token_ttl_days: int = 90
#: Calls one token may make per minute. Enforced in memory, per gateway
#: process — a restart forgives, which is the right trade for a spare
#: gateway process that only the owner's own agents talk to.
mcp_rate_limit_per_minute: int = 60
memory_org_char_cap: int = 60000
platform_invite_ttl_hours: int = 72
# <<< agent-1
@@ -0,0 +1,44 @@
"""Audit action names introduced by the org-platform workstream.
Kept in the identity domain instead of the shared ``app.domain.audit`` module so
the parallel workstreams never edit the same file (``app.domain.audit`` is
foundation-owned). Values are free-form strings — ``AuditAction`` accepts any.
"""
from __future__ import annotations
# --- platform invites (owner creates a new manager account) ----------------
PLATFORM_INVITE_CREATE = "platform_invite.create"
PLATFORM_INVITE_REVOKE = "platform_invite.revoke"
PLATFORM_INVITE_ACCEPT = "platform_invite.accept"
# --- org sharing -----------------------------------------------------------
ORG_TRANSFER_OWNERSHIP = "organization.transfer_ownership"
ORG_LEAVE = "organization.leave"
ORG_INVITE_REVOKE = "member.invite_revoke"
# --- wizard ----------------------------------------------------------------
ORG_CREATED_VIA_WIZARD = "organization.created_via_wizard"
APP_PROFILE_UPDATE = "app_profile.update"
APP_GIT_ROTATE = "app_git.rotate"
APP_DEPLOY_TARGET_SET = "app_deploy_target.set"
# --- owner model grants and org policy -------------------------------------
MODEL_GRANT_SET = "model_grant.set"
MODEL_GRANT_REVOKE = "model_grant.revoke"
DEFAULT_MODEL_GRANT_SET = "default_model_grant.set"
DEFAULT_MODEL_GRANT_REMOVE = "default_model_grant.remove"
ORG_POLICY_UPDATE = "org_policy.update"
# --- org memory ------------------------------------------------------------
MEMORY_CREATE = "memory.create"
MEMORY_UPDATE = "memory.update"
MEMORY_PIN = "memory.pin"
MEMORY_CONFIRM = "memory.confirm"
MEMORY_DELETE = "memory.delete"
# --- MCP gateway -----------------------------------------------------------
MCP_TOKEN_CREATE = "mcp_token.create"
MCP_TOKEN_REVOKE = "mcp_token.revoke"
MCP_GLOBAL_SWITCH = "mcp.global_switch"
MCP_ORG_SWITCH = "mcp.org_switch"
+57
View File
@@ -0,0 +1,57 @@
"""Org memory domain package (owned by Agent 1)."""
from app.domain.memory.models import (
CONFIRMED_CONFIDENCE,
CONTENT_MAX_CHARS,
KEY_MAX_CHARS,
KIND_LABELS,
LESSON_TTL_DAYS,
MAX_WRITES_PER_TASK,
PREFERENCE_ROLES,
REFLECTION_CONFIDENCE,
SOURCE_LABELS,
STRUCTURAL_KINDS,
STRUCTURAL_ROLES,
Memory,
MemoryKind,
MemorySource,
coerce_kind,
coerce_source,
content_hash,
lesson_expiry,
normalise,
relevance,
tokenise,
validate_confidence,
validate_content,
validate_key,
visible_kinds,
)
__all__ = [
"CONFIRMED_CONFIDENCE",
"CONTENT_MAX_CHARS",
"KEY_MAX_CHARS",
"KIND_LABELS",
"LESSON_TTL_DAYS",
"MAX_WRITES_PER_TASK",
"Memory",
"MemoryKind",
"MemorySource",
"PREFERENCE_ROLES",
"REFLECTION_CONFIDENCE",
"SOURCE_LABELS",
"STRUCTURAL_KINDS",
"STRUCTURAL_ROLES",
"coerce_kind",
"coerce_source",
"content_hash",
"lesson_expiry",
"normalise",
"relevance",
"tokenise",
"validate_confidence",
"validate_content",
"validate_key",
"visible_kinds",
]
+315
View File
@@ -0,0 +1,315 @@
"""Org memory domain: the rules of what an organization may remember.
Pure Python — no FastAPI, no SQLAlchemy. Three concerns live here and nowhere
else:
* **The vocabulary.** ``MemoryKind`` and ``MemorySource`` are the only two
taxonomies the platform has. An org's memory is *small* on purpose: a handful
of standing rules, a few preferences, the lessons a finished task leaves
behind, and a code map of the one app the org owns.
* **The limits.** A key is 80 characters, an entry 1,500. Anything longer is a
document, and documents belong in the Knowledge Base (``knowledge.py``), which
is why the memory page shows that second tab rather than re-implementing it.
* **The shape of an entry.** ``normalise`` + ``content_hash`` turn "the same
thought typed twice" into one stored memory, which is what keeps an org's
memory from becoming a duplicate pile.
Nothing here touches the database: the repository (``repositories/memory.py``)
moves rows, and the service (``services/memory_service.py``) decides what may be
written.
"""
from __future__ import annotations
import hashlib
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
class MemoryKind(StrEnum):
"""What a memory *is*, which decides who may read it and how long it lives."""
#: A standing instruction from the org: "always write tests first".
RULE = "rule"
#: A taste the org expressed: "prefer Persian comments".
PREFERENCE = "preference"
#: Something a finished task taught the org. Expires, because a lesson that
#: was true once is not a law.
LESSON = "lesson"
#: A durable fact about the app or the organisation ("the API runs on 8000").
FACT = "fact"
#: A short map of the codebase, rewritten as the code changes.
CODE_MAP = "code_map"
class MemorySource(StrEnum):
"""Who wrote it. ``reflection`` means the org's own self-review did."""
USER = "user"
MANAGER = "manager"
SYSTEM = "system"
REFLECTION = "reflection"
#: Hard limits, enforced here so a router, an MCP tool and the reflection writer
#: all share one definition of "too big".
KEY_MAX_CHARS = 80
CONTENT_MAX_CHARS = 1500
#: How long a lesson stays usable. ``30`` days is long enough to outlive the
#: task that produced it and short enough that stale advice dies quietly.
LESSON_TTL_DAYS = 30
#: One task may teach at most this many memories. Without a ceiling a single
#: runaway reflection could fill the org's whole budget with one task's opinion.
MAX_WRITES_PER_TASK = 3
#: A reflection's own confidence: it observed, it did not verify.
REFLECTION_CONFIDENCE = 0.5
#: A confirmed lesson is treated as settled knowledge and stops expiring.
CONFIRMED_CONFIDENCE = 1.0
#: Kinds that describe the *code*: only the roles that read code get them.
STRUCTURAL_KINDS = frozenset({MemoryKind.CODE_MAP, MemoryKind.FACT})
#: ``programmer`` is the plan's spelling of the engine's ``developer`` role key;
#: both are accepted so a caller cannot silently lose the code map.
STRUCTURAL_ROLES = frozenset(
{"planner", "developer", "programmer", "reviewer", "tester"}
)
#: Preferences are the manager's business — the employee who interprets them.
PREFERENCE_ROLES = frozenset({"manager"})
#: One-line Persian labels for the context block and the UI.
KIND_LABELS: dict[str, str] = {
MemoryKind.RULE: "قاعده",
MemoryKind.PREFERENCE: "ترجیح",
MemoryKind.LESSON: "درس",
MemoryKind.FACT: "واقعیت",
MemoryKind.CODE_MAP: "نقشهٔ کد",
}
SOURCE_LABELS: dict[str, str] = {
MemorySource.USER: "کاربر",
MemorySource.MANAGER: "مدیر",
MemorySource.SYSTEM: "سامانه",
MemorySource.REFLECTION: "بازتاب",
}
_WHITESPACE = re.compile(r"\s+")
#: Words that carry no meaning in either language; matching against them would
#: make every memory "relevant" to every question.
_STOP_WORDS = frozenset(
{
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "with",
"without", "from", "into", "that", "this", "these", "those", "is", "are",
"was", "were", "be", "been", "to", "of", "in", "on", "at", "by", "as",
"it", "its", "do", "does", "did", "not", "no", "so", "we", "you", "use",
"using", "should", "must", "را", "که", "از", "به", "با", "این", "آن",
"و", "در", "برای", "تا", "بر", "شود", "است", "هست", "می",
}
)
@dataclass(slots=True, kw_only=True)
class Memory:
"""One thing the organization knows. ``id`` and timestamps are domain-owned."""
organization_id: str
kind: MemoryKind
content: str
source: MemorySource = MemorySource.USER
id: str = field(default_factory=new_id)
#: Optional stable handle: the same key always addresses the same memory,
#: which is how "our port is 8000" is corrected instead of duplicated.
key: str | None = None
#: The task that produced a lesson, when it came from a reflection.
evidence_task_id: str | None = None
pinned: bool = False
confidence: float = CONFIRMED_CONFIDENCE
expires_at: datetime | None = None
hits: int = 0
last_used_at: datetime | None = None
created_by_user_id: str | None = None
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
deleted_at: datetime | None = None
@property
def label(self) -> str:
return KIND_LABELS.get(str(self.kind), "یادداشت")
@property
def content_hash(self) -> str:
"""Always the digest of the *current* content.
A property, not a column the writer has to remember to refresh: a memory
whose text changed must dedupe as the new text, never as the old one.
"""
return content_hash(self.content)
def is_live(self, *, now: datetime | None = None) -> bool:
"""Not deleted, not expired. Pinned entries never expire."""
if self.deleted_at is not None:
return False
if self.pinned or self.expires_at is None:
return True
return self.expires_at > (now or utcnow())
def is_confirmed(self) -> bool:
return self.confidence >= CONFIRMED_CONFIDENCE
def normalise(content: str) -> str:
"""Canonical form for comparison: NFC, whitespace collapsed, trimmed.
Two spellings of the same sentence («the api runs on 8000» / «The API runs
on 8000») must dedupe, so the comparison is case-folded.
"""
text = unicodedata.normalize("NFC", content or "")
return _WHITESPACE.sub(" ", text).strip()
def content_hash(content: str) -> str:
"""A stable short digest of the normalised content, for dedupe."""
digest = hashlib.sha256(normalise(content).casefold().encode("utf-8"))
return digest.hexdigest()[:32]
def validate_key(key: str | None) -> str | None:
"""``None`` (or empty) means "no key"; otherwise a short trimmed handle."""
if key is None:
return None
trimmed = normalise(key)
if not trimmed:
return None
if len(trimmed) > KEY_MAX_CHARS:
raise ValidationError("کلید یادداشت نمی‌تواند بیش از ۸۰ نویسه باشد.")
return trimmed
def validate_content(content: str) -> str:
cleaned = normalise(content)
if not cleaned:
raise ValidationError("محتوای یادداشت نمی‌تواند خالی باشد.")
if len(cleaned) > CONTENT_MAX_CHARS:
raise ValidationError("محتوای هر یادداشت نمی‌تواند بیش از ۱۵۰۰ نویسه باشد.")
return cleaned
def validate_confidence(value: float) -> float:
try:
confidence = float(value)
except (TypeError, ValueError) as exc: # pragma: no cover - guarded by callers
raise ValidationError("میزان اطمینان نامعتبر است.") from exc
if confidence < 0 or confidence > 1:
raise ValidationError("میزان اطمینان باید بین ۰ و ۱ باشد.")
return round(confidence, 2)
def coerce_kind(kind: str) -> MemoryKind:
try:
return MemoryKind(str(kind).strip().lower())
except ValueError as exc:
raise ValidationError("نوع یادداشت نامعتبر است.") from exc
def coerce_source(source: str) -> MemorySource:
try:
return MemorySource(str(source).strip().lower())
except ValueError as exc:
raise ValidationError("منبع یادداشت نامعتبر است.") from exc
def visible_kinds(role: str) -> frozenset[MemoryKind]:
"""The kinds one employee role may be shown.
Rules and lessons are everyone's business. ``code_map`` and ``fact``
describe the code, so only the roles that read code get them; preferences
belong to the manager alone, because a preference is an instruction to
interpret work, not a fact about it.
"""
role_key = str(role or "").strip().lower()
kinds = {MemoryKind.RULE, MemoryKind.LESSON}
if role_key in STRUCTURAL_ROLES:
kinds.update(STRUCTURAL_KINDS)
if role_key in PREFERENCE_ROLES:
kinds.add(MemoryKind.PREFERENCE)
return frozenset(kinds)
def lesson_expiry(*, pinned: bool, confirmed: bool) -> datetime | None:
"""Lessons expire; a pinned or confirmed lesson is treated as settled."""
if pinned or confirmed:
return None
return utcnow() + timedelta(days=LESSON_TTL_DAYS)
def tokenise(text: str) -> frozenset[str]:
"""Lower-cased words of both scripts, stop words removed."""
words = re.findall(r"[\w\u0600-\u06FF]+", (text or "").casefold())
return frozenset(w for w in words if len(w) > 1 and w not in _STOP_WORDS)
def relevance(memory: Memory, query_tokens: frozenset[str]) -> float:
"""How well a memory answers a query: keyword overlap, then recency.
Deliberately simple and explainable. An embedding search is a Phase-X
upgrade (the Knowledge Base already has a vector port to borrow); until an
org's memory is large enough for that to matter, word overlap is the honest
answer and it cannot surprise a reader.
"""
own = tokenise(memory.content)
overlap = len(query_tokens & own) / len(query_tokens) if query_tokens and own else 0.0
age_days = _age_days(memory.last_used_at or memory.created_at)
recency = 1.0 / (1.0 + age_days / LESSON_TTL_DAYS)
pinned_bonus = 0.1 if memory.pinned else 0.0
return round(overlap * 3.0 + recency + pinned_bonus, 4)
def _age_days(when: datetime | None) -> float:
"""Days since ``when``, treating a naive timestamp as UTC.
SQLite (the development and test store) hands back naive datetimes; a
recency score is not the place to discover that. A missing timestamp means
"very old", not "brand new".
"""
if when is None:
return LESSON_TTL_DAYS * 30
if when.tzinfo is None:
when = when.replace(tzinfo=UTC)
return max(0.0, (utcnow() - when).days)
__all__ = [
"CONFIRMED_CONFIDENCE",
"CONTENT_MAX_CHARS",
"KEY_MAX_CHARS",
"KIND_LABELS",
"LESSON_TTL_DAYS",
"MAX_WRITES_PER_TASK",
"Memory",
"MemoryKind",
"MemorySource",
"PREFERENCE_ROLES",
"REFLECTION_CONFIDENCE",
"SOURCE_LABELS",
"STRUCTURAL_KINDS",
"STRUCTURAL_ROLES",
"coerce_kind",
"coerce_source",
"content_hash",
"lesson_expiry",
"normalise",
"relevance",
"tokenise",
"validate_confidence",
"validate_content",
"validate_key",
"visible_kinds",
]
+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)
@@ -2,10 +2,358 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
The rule this router enforces: **an organization may only use the models its
owner granted it.** The endpoints here are the only way a grant appears, so
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
next call) rather than a change that only takes effect after a cache expires.
Everything is owner-only, guarded at the router level, and every mutation is
audited. The model *names* live here and nowhere else: organization users only
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
"""
from __future__ import annotations
from fastapi import APIRouter
from dataclasses import asdict
router = APIRouter(tags=["admin-grants"])
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.model_grant_service import (
ModelGrantService,
ModelView,
OrgGrantView,
PolicyView,
)
from app.core.database import get_session
from app.core.errors import ValidationError
from app.domain.identity.audit_actions import (
DEFAULT_MODEL_GRANT_REMOVE,
DEFAULT_MODEL_GRANT_SET,
MODEL_GRANT_REVOKE,
MODEL_GRANT_SET,
ORG_POLICY_UPDATE,
)
from app.domain.identity.models import User
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-grants"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
async def get_model_grant_service(
session: AsyncSession = Depends(get_session),
) -> ModelGrantService:
from app.application.container_org import build_model_grant_service
return build_model_grant_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ModelViewOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
provider_id: str
capabilities: list[str]
tier: str
tier_label: str
output_price_per_1m: float
model_active: bool
granted: bool
daily_credit_cap: float | None = None
class OrgGrantOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
tier: str
tier_label: str
capabilities: list[str]
daily_credit_cap: float | None = None
is_active: bool
class OrgGrantsOut(BaseModel):
"""The grant matrix for one organization: every active model + its state."""
organization_id: str
models: list[ModelViewOut]
class SetGrantRequest(BaseModel):
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
daily_credit_cap: float | None = Field(default=None, ge=0)
class SetDefaultGrantRequest(BaseModel):
is_active: bool = True
daily_credit_cap: float | None = Field(default=None, ge=0)
class PolicyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
max_employees: int
monthly_credit_budget: float | None = None
automation_enabled: bool
mcp_enabled: bool
max_employees_limit: int
class UpdatePolicyRequest(BaseModel):
max_employees: int | None = Field(default=None, ge=1)
monthly_credit_budget: float | None = Field(default=None, ge=0)
automation_enabled: bool | None = None
mcp_enabled: bool | None = None
def _model_out(view: ModelView) -> ModelViewOut:
return ModelViewOut(**asdict(view))
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
return OrgGrantOut(**asdict(view))
def _policy_out(view: PolicyView) -> PolicyOut:
return PolicyOut(**asdict(view))
def _require_change(*values: object) -> None:
if all(value is None for value in values):
raise ValidationError("هیچ تغییری ارسال نشده است.")
# ---------------------------------------------------------------------------
# Grants per organization
# ---------------------------------------------------------------------------
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
async def list_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> OrgGrantsOut:
"""Every active catalogue model, flagged with whether this org may use it."""
models = await service.list_models(organization_id=organization_id)
return OrgGrantsOut(
organization_id=organization_id, models=[_model_out(m) for m in models]
)
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
async def set_org_grant(
organization_id: str,
ai_model_id: str,
payload: SetGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
# FastAPI caches ``get_session`` per request, so this is the very session
# the service writes through: the audit row lands in the same transaction.
session: AsyncSession = Depends(get_session),
) -> OrgGrantOut:
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
granted = await service.grant_model(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_SET,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
# The model label is a name, not a secret, but keeping the audit rows
# id-only makes them stable when the owner renames a model.
metadata={"daily_credit_cap": payload.daily_credit_cap},
ip=ip,
user_agent=ua,
)
return _grant_out(granted)
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
async def list_active_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[OrgGrantOut]:
"""Just the active grants — what the engine will actually be allowed to use."""
grants = await service.list_grants(organization_id=organization_id, active_only=True)
return [_grant_out(g) for g in grants]
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
async def revoke_org_grant(
organization_id: str,
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Revoke a model from one organization. Other organizations are unaffected."""
await service.revoke_model(
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_REVOKE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
# ---------------------------------------------------------------------------
# The default set copied into every new organization
# ---------------------------------------------------------------------------
@router.get("/default-model-grants", response_model=list[ModelViewOut])
async def list_default_grants(
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[ModelViewOut]:
return [_model_out(m) for m in await service.list_defaults()]
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
async def set_default_grant(
ai_model_id: str,
payload: SetDefaultGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> ModelViewOut:
"""Add a model to the default set (or switch its default off without losing it)."""
await service.set_default(
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
is_active=payload.is_active,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_SET,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
metadata={
"is_active": payload.is_active,
"daily_credit_cap": payload.daily_credit_cap,
},
ip=ip,
user_agent=ua,
)
view = next(
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
)
assert view is not None
return _model_out(view)
@router.delete("/default-model-grants/{ai_model_id}")
async def remove_default_grant(
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Remove a model from the default set. Already-created orgs keep their grant."""
await service.remove_default(ai_model_id=ai_model_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_REMOVE,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"ai_model_id": ai_model_id, "in_default_set": False}
# ---------------------------------------------------------------------------
# Org policy
# ---------------------------------------------------------------------------
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
async def get_org_policy(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> PolicyOut:
return _policy_out(await service.get_org_policy(organization_id))
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
async def update_org_policy(
organization_id: str,
payload: UpdatePolicyRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> PolicyOut:
"""Set the employee cap, monthly budget and automation switch for one org."""
_require_change(
payload.max_employees,
payload.monthly_credit_budget,
payload.automation_enabled,
payload.mcp_enabled,
)
policy = await service.set_org_policy(
organization_id=organization_id,
max_employees=payload.max_employees,
monthly_credit_budget=payload.monthly_credit_budget,
automation_enabled=payload.automation_enabled,
mcp_enabled=payload.mcp_enabled,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=ORG_POLICY_UPDATE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={
"max_employees": policy.max_employees,
"monthly_credit_budget": policy.monthly_credit_budget,
"automation_enabled": policy.automation_enabled,
"mcp_enabled": policy.mcp_enabled,
},
ip=ip,
user_agent=ua,
)
return _policy_out(policy)
@@ -2,10 +2,341 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Two audiences, one rule each:
* an **organization admin** mints, lists and revokes its own tokens, and picks the
scopes — a token can never be broader than the admin's own intent, and it can
never be issued for another organization;
* the **platform owner** holds the two kill switches: the gateway as a whole, and
one organization's access to it.
The raw token is returned exactly once, by the create call, and is never stored —
only its hash. Everything after that is the prefix.
"""
from __future__ import annotations
from fastapi import APIRouter
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grant_service
from app.application.services.audit_service import record_audit
from app.application.services.mcp_service import (
MCP_SCOPES,
McpService,
as_utc,
validate_scopes,
)
from app.core.config import settings
from app.core.database import get_session
from app.domain.base import utcnow
from app.domain.identity.audit_actions import (
MCP_GLOBAL_SWITCH,
MCP_ORG_SWITCH,
MCP_TOKEN_CREATE,
MCP_TOKEN_REVOKE,
)
from app.domain.identity.models import Role, User
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_platform_owner,
require_role_at_least,
)
router = APIRouter(tags=["mcp-tokens"])
#: Minting a token is admin+ (it grants a third party real access).
_admin_only = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
return McpService(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class McpTokenOut(BaseModel):
id: str
organization_id: str
name: str
prefix: str
scopes: list[str]
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime | None = None
status: str
class McpTokenCreatedOut(McpTokenOut):
"""The only response that ever carries the raw token."""
token: str
class McpTokenIn(BaseModel):
name: str = Field(min_length=2, max_length=120)
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
ttl_days: int | None = Field(default=None, ge=1, le=3650)
class McpSettingsOut(BaseModel):
organization_id: str
mcp_enabled: bool
gateway_enabled: bool
scopes: list[str]
token_count: int
class McpGlobalOut(BaseModel):
gateway_enabled: bool
organizations: list[McpOrgStateOut]
total_tokens: int
class McpOrgStateOut(BaseModel):
organization_id: str
name: str
slug: str
mcp_enabled: bool
token_count: int
class McpGlobalIn(BaseModel):
enabled: bool
class McpOrgSwitchIn(BaseModel):
enabled: bool
def _status(token) -> str:
"""``active`` only while the token is neither revoked nor expired."""
if token.revoked_at is not None:
return "revoked"
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
return "expired"
return "active"
def _out(token) -> McpTokenOut:
return McpTokenOut(
id=token.id,
organization_id=token.organization_id,
name=token.name,
prefix=token.prefix,
scopes=token.scopes,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
created_at=token.created_at,
status=_status(token),
)
# ---------------------------------------------------------------------------
# Organization-scoped tokens (admin+)
# ---------------------------------------------------------------------------
@router.get(
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
)
async def list_tokens(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> list[McpTokenOut]:
"""Every token this organization ever minted, with its live status.
Deliberately includes revoked and expired rows: the history of who could
reach the organization is the part that matters after an incident.
"""
return [_out(token) for token in await service.list(context.org_id)]
@router.post(
"/organizations/{org_id}/mcp-tokens",
response_model=McpTokenCreatedOut,
status_code=status.HTTP_201_CREATED,
)
async def create_token(
payload: McpTokenIn,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> McpTokenCreatedOut:
"""Mint a token. The raw value appears in this response and nowhere else."""
issued = await service.issue(
organization_id=context.org_id,
name=payload.name,
scopes=validate_scopes(payload.scopes),
created_by_user_id=context.user.id,
ttl_days=payload.ttl_days,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=issued.record.id,
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
ip=ip,
user_agent=ua,
)
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
async def revoke_token(
token_id: str,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
await service.revoke(organization_id=context.org_id, token_id=token_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_REVOKE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=token_id,
ip=ip,
user_agent=ua,
)
return {"id": token_id, "revoked": True}
@router.get(
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
)
async def get_mcp_settings(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> McpSettingsOut:
"""The switches this organization's own admins may see and the scope list."""
policy = await build_model_grant_service(service.session).get_org_policy(
context.org_id
)
tokens = await service.list(context.org_id)
return McpSettingsOut(
organization_id=context.org_id,
mcp_enabled=policy.mcp_enabled,
gateway_enabled=settings.mcp_gateway_enabled,
scopes=list(MCP_SCOPES),
token_count=sum(1 for token in tokens if _status(token) == "active"),
)
# ---------------------------------------------------------------------------
# Owner: the global switch and the per-organization switch
# ---------------------------------------------------------------------------
@router.get("/admin/mcp", response_model=McpGlobalOut)
async def get_mcp_state(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> McpGlobalOut:
"""Every organization with its MCP state and active token count."""
from sqlalchemy import select
from app.infrastructure.db.models import OrganizationRow
service = McpService(session)
grants = build_model_grant_service(session)
orgs = (
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
.scalars()
.all()
)
counts = await service.count_per_org()
states = [
McpOrgStateOut(
organization_id=org.id,
name=org.name,
slug=org.slug,
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
token_count=counts.get(org.id, 0),
)
for org in orgs
]
return McpGlobalOut(
gateway_enabled=settings.mcp_gateway_enabled,
organizations=states,
total_tokens=sum(counts.values()),
)
@router.put("/admin/mcp")
async def set_mcp_state(
payload: McpGlobalIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Disable (or re-enable) the gateway platform-wide.
The switch is a setting rather than a table: it must hold even before any
token exists, and a gateway that cannot reach the database should not be the
thing deciding it can serve traffic.
"""
settings.mcp_gateway_enabled = bool(payload.enabled)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_GLOBAL_SWITCH,
actor_user_id=owner.id,
target_type="platform",
target_id="mcp",
metadata={"enabled": bool(payload.enabled)},
ip=ip,
user_agent=ua,
)
return {"gateway_enabled": settings.mcp_gateway_enabled}
@router.put("/admin/mcp/organizations/{organization_id}")
async def set_org_mcp_state(
organization_id: str,
payload: McpOrgSwitchIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""One organization's access to the gateway, independent of its tokens."""
policy = await build_model_grant_service(session).set_mcp_enabled(
organization_id=organization_id,
enabled=bool(payload.enabled),
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_ORG_SWITCH,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={"enabled": policy.mcp_enabled},
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
__all__ = ["router"]
+308 -1
View File
@@ -2,10 +2,317 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
knows is a rule an agent can bypass.
Everything stored here went through ``redact`` on the way in, so no endpoint can
ever return a credential: what you can read is what was safe to write.
"""
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_memory_service
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
from app.application.services.audit_service import record_audit
from app.application.services.memory_service import MemoryService
from app.core.database import get_session
from app.domain.identity.audit_actions import (
MEMORY_CONFIRM,
MEMORY_CREATE,
MEMORY_DELETE,
MEMORY_PIN,
MEMORY_UPDATE,
)
from app.domain.identity.models import Role
from app.domain.memory.models import (
KIND_LABELS,
SOURCE_LABELS,
Memory,
MemoryKind,
)
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_role_at_least,
)
router = APIRouter(tags=["memory"])
#: Anyone with a membership reads; teaching is member+; editing is admin+.
_teacher = require_role_at_least(Role.MEMBER)
_editor = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
return build_memory_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class MemoryOut(BaseModel):
id: str
kind: str
kind_label: str
key: str | None = None
content: str
source: str
source_label: str
evidence_task_id: str | None = None
pinned: bool
confidence: float
expires_at: str | None = None
hits: int
last_used_at: str | None = None
created_by_user_id: str | None = None
created_at: str | None = None
updated_at: str | None = None
class MemoryListOut(BaseModel):
organization_id: str
items: list[MemoryOut]
total: int
pinned: int
chars: int
class MemoryCreateIn(BaseModel):
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryUpdateIn(BaseModel):
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryPinIn(BaseModel):
pinned: bool = True
def _out(memory: Memory) -> MemoryOut:
return MemoryOut(
id=memory.id,
kind=str(memory.kind),
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
key=memory.key,
content=memory.content,
source=str(memory.source),
source_label=SOURCE_LABELS.get(str(memory.source), ""),
evidence_task_id=memory.evidence_task_id,
pinned=memory.pinned,
confidence=memory.confidence,
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
hits=memory.hits,
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
created_by_user_id=memory.created_by_user_id,
created_at=memory.created_at.isoformat() if memory.created_at else None,
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
)
async def _audit(
session: AsyncSession,
request: Request,
*,
action: str,
context: OrgContext,
target_id: str,
metadata: dict | None = None,
) -> None:
ip, ua = client_meta(request)
await record_audit(
session,
action=action,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="memory",
target_id=target_id,
metadata=metadata or {},
ip=ip,
user_agent=ua,
)
async def _announce(context: OrgContext, memory: Memory) -> None:
"""Tell the tenant's open tabs that memory changed.
``memory.changed`` is published on the org channel and nowhere else: a
memory belongs to one organization, and the realtime authorisation for
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
A failed publish is a lost refresh, never a failed write.
"""
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={
"memory_id": memory.id,
"kind": str(memory.kind),
"pinned": memory.pinned,
},
)
)
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
async def list_memory(
kind: str | None = Query(default=None, max_length=16),
q: str | None = Query(default="", max_length=120),
context: OrgContext = Depends(get_org_context),
service: MemoryService = Depends(_service),
) -> MemoryListOut:
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
Filtering by ``kind`` is a convenience, not a security boundary: an
organization's whole memory is visible to any member by design.
"""
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
stats = await service.stats(context.org_id)
return MemoryListOut(
organization_id=context.org_id,
items=[_out(m) for m in items],
total=stats["total"],
pinned=stats["pinned"],
chars=stats["chars"],
)
# ---------------------------------------------------------------------------
# Teach
# ---------------------------------------------------------------------------
@router.post(
"/organizations/{org_id}/memory",
response_model=MemoryOut,
status_code=status.HTTP_201_CREATED,
)
async def teach_memory(
payload: MemoryCreateIn,
request: Request,
context: OrgContext = Depends(_teacher),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
memory = await service.teach(
organization_id=context.org_id,
user_id=context.user.id,
role=context.membership.role,
kind=payload.kind,
content=payload.content,
key=payload.key,
)
await _audit(
session,
request,
action=MEMORY_CREATE,
context=context,
target_id=memory.id,
metadata={"kind": str(memory.kind), "key": memory.key},
)
await _announce(context, memory)
return _out(memory)
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
async def edit_memory(
memory_id: str,
payload: MemoryUpdateIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
memory = await service.edit(
organization_id=context.org_id,
memory_id=memory_id,
content=payload.content,
key=payload.key,
)
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
async def pin_memory(
memory_id: str,
payload: MemoryPinIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
"""Pin → always in context and never evicted; unpin → back to search."""
memory = await service.set_pinned(
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
)
await _audit(
session,
request,
action=MEMORY_PIN,
context=context,
target_id=memory_id,
metadata={"pinned": payload.pinned},
)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
async def confirm_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
)
await _announce(context, memory)
return _out(memory)
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> None:
await service.delete(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
)
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={"memory_id": memory_id, "deleted": True},
)
)
+21 -1
View File
@@ -1 +1,21 @@
"""MCP gateway (owned by Agent 1)."""
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
``gateway`` holds the tools and their rules; ``server`` binds them to the
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
"""
from app.interfaces.mcp.gateway import (
TOOL_SPECS,
McpCallResult,
McpError,
McpGateway,
build_gateway,
)
__all__ = [
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+619
View File
@@ -0,0 +1,619 @@
"""The MCP tools themselves — framework-free, so they can be called directly.
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
holds the actual work and is what the tests exercise. Four rules are enforced
here, once, for every tool:
1. **The organization comes from the token, never from the arguments.** A tool
body receives an authenticated ``TokenIdentity`` and uses its
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
agent cannot ask about a tenant it was not issued for.
2. **A scope is checked before the work starts.** Not after.
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
and whether it succeeded), rate-limited, and its output redacted.
4. **An error is an answer, not a crash**: the agent gets a plain message, and
the exception never reaches the transport.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from app.application.container_org import (
build_memory_service,
build_model_grant_service,
)
from app.application.services.mcp_service import McpService, TokenIdentity
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact
from app.domain.memory.models import MemoryKind
from app.infrastructure.error_log import record_error
@dataclass(frozen=True, slots=True)
class ToolSpec:
"""One tool: its name, the scope it needs, and its JSON schema."""
name: str
scope: str
description: str
parameters: dict[str, Any]
TOOL_SPECS: tuple[ToolSpec, ...] = (
ToolSpec(
name="app_get_profile",
scope="app:read",
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریم‌ورک، "
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_list",
scope="tasks:read",
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_get",
scope="tasks:read",
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
parameters={
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "شناسهٔ کار"},
},
"required": ["task_id"],
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_create",
scope="tasks:write",
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
"و به کارمندان می‌سپارد.",
parameters={
"type": "object",
"properties": {
"title": {"type": "string", "description": "عنوان کوتاه کار"},
"description": {"type": "string", "description": "شرح کامل درخواست"},
},
"required": ["title"],
"additionalProperties": False,
},
),
ToolSpec(
name="memory_search",
scope="memory:read",
description="جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"kind": {
"type": "string",
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
},
"additionalProperties": False,
},
),
ToolSpec(
name="memory_write",
scope="memory:write",
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
"هرگز ذخیره نمی‌شود.",
parameters={
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["fact", "preference"],
"description": "فقط «fact» یا «preference»",
},
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
},
"required": ["kind", "content"],
"additionalProperties": False,
},
),
ToolSpec(
name="knowledge_search",
scope="memory:read",
description="جست‌وجو در منابع دانش سازمان (اسناد بلند).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
},
"required": ["query"],
"additionalProperties": False,
},
),
ToolSpec(
name="pipeline_status",
scope="delivery:read",
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
)
#: The only kinds an external agent may write. A rule is the organization's own
#: to set through the UI, and a lesson is the engine's to learn.
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
class McpError(Exception):
"""A refusal the agent can read. Never carries a secret."""
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
super().__init__(message)
self.code = code
@dataclass(slots=True)
class McpCallResult:
"""What one tool call produced: a redacted payload, or a readable refusal."""
ok: bool
payload: dict[str, Any]
error: str | None = None
code: str = "mcp_error"
def as_dict(self) -> dict[str, Any]:
if self.ok:
return self.payload
return {
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
}
def to_json(self) -> str:
"""The JSON text an agent receives. Always redacted, always short."""
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
class McpGateway:
"""Executes the tools for one authenticated token.
``session_factory`` is injectable so the tests can point the gateway at their
in-memory database; production passes nothing and gets the application's own
factory, exactly like the workers.
"""
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
from app.application.services.mcp_service import TokenRateLimiter
self._session_factory = session_factory
self._limiter = rate_limiter or TokenRateLimiter()
# -- public API ----------------------------------------------------------
async def call(
self,
*,
tool: str,
arguments: dict[str, Any] | None = None,
token: str,
identity: TokenIdentity | None = None,
) -> McpCallResult:
"""Run one tool for one token. Every failure mode is an answer."""
spec = _SPECS_BY_NAME.get(tool)
if spec is None:
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
args = arguments or {}
if not settings.mcp_gateway_enabled:
# The owner's kill switch. Nothing is attributable yet, so this lands
# in the error ledger rather than in one organization's audit trail.
await _note("mcp.gateway_disabled", f"tool={spec.name}")
return self._fail(
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
code="mcp_disabled",
)
factory = self._session_factory or _default_factory()
async with factory() as session:
service = McpService(session)
identity = identity or await service.verify(token)
if identity is None:
# A token that does not exist, was revoked, or expired is one
# answer — and it is unattributable, so it goes to the error
# ledger where a human can see the pattern.
await _note("mcp.invalid_token", f"tool={spec.name}")
return self._fail(
"توکن نامعتبر یا منقضی است.", code="invalid_token"
)
async def refuse(message: str, code: str) -> McpCallResult:
"""Every refusal from here on is audited against this organization.
The audit row is committed inside the refusal: a denial nobody can
see afterwards is not an audit trail, it is a hope.
"""
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code=code)
if not identity.allows(spec.scope):
return await refuse(
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
)
policy = await build_model_grant_service(session).get_org_policy(
identity.organization_id
)
if not policy.mcp_enabled:
return await refuse(
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
)
if not self._limiter.allow(identity.token_id):
return await refuse(
"تعداد درخواست‌ها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
"rate_limited",
)
handler = _HANDLERS[spec.name]
try:
payload = await handler(self, session=session, identity=identity, **args)
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
message = str(getattr(exc, "message", exc))
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
except TypeError:
# An argument shape the tool did not expect: the agent's fault,
# not the platform's.
return await refuse(
"آرگومان‌های ابزار نامعتبر است.", "bad_arguments"
)
except Exception as exc: # noqa: BLE001 - an outage is an answer too
# Anything else is infrastructure, and its message may contain a
# DSN or a heap address. The agent gets a plain sentence; the
# operator gets the real error in the error console.
await record_error(
kind="mcp.tool_crashed",
message=str(exc),
exc=exc,
organization_id=identity.organization_id,
metadata={"tool": spec.name, "prefix": identity.prefix},
)
await self._audit(
session, identity=identity, spec=spec, ok=False, error="internal"
)
await session.commit()
return self._fail(
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
)
await service.touch(identity.token_id)
await self._audit(session, identity=identity, spec=spec, ok=True)
await session.commit()
return McpCallResult(ok=True, payload=payload)
# -- helpers -------------------------------------------------------------
@staticmethod
def _fail(message: str, *, code: str) -> McpCallResult:
"""A refusal is a normal answer: the agent gets a message it can act on."""
return McpCallResult(ok=False, payload={}, error=message, code=code)
async def _audit(
self,
session,
*,
identity: TokenIdentity,
spec: ToolSpec,
ok: bool,
error: str = "",
) -> None:
"""One audit row per call. Never allowed to fail the call itself."""
from app.application.services.audit_service import record_audit
try:
await record_audit(
session,
action="mcp.tool_call",
organization_id=identity.organization_id,
actor_user_id=None,
target_type="mcp_tool",
target_id=spec.name,
metadata={
"prefix": identity.prefix,
"scope": spec.scope,
"ok": ok,
"error": error[:200],
},
ip="mcp",
user_agent=f"mcp/{identity.prefix}",
)
except Exception: # noqa: BLE001 - auditing must not fail a tool call
return
# ---------------------------------------------------------------------------
# Tool bodies
# ---------------------------------------------------------------------------
async def _app_profile(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.projects import ProjectRepository
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
if profile is None:
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
project = await ProjectRepository(session).get(profile.project_id)
return {
"project_name": project.name if project else "",
"project_slug": project.slug if project else "",
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"default_branch": profile.default_branch,
"git_source": profile.git_source,
"repo_url": profile.external_repo_url,
}
async def _tasks_list(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
status: str | None = None,
limit: int = 25,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_workforce_service
from app.domain.workforce.models import TaskStatus
service = build_workforce_service(session)
parsed: TaskStatus | None = None
if status:
try:
parsed = TaskStatus(str(status).strip().lower())
except ValueError as exc:
raise ValidationError("وضعیت کار نامعتبر است.") from exc
tasks = await service.list_tasks(
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
)
return {
"tasks": [
{
"id": task.id,
"title": task.title,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"assigned_employee_id": task.assigned_employee_id,
}
for task in tasks
]
}
async def _tasks_get(
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
) -> dict[str, Any]:
from app.application.container import build_workforce_service
if not task_id:
raise ValidationError("شناسهٔ کار لازم است.")
service = build_workforce_service(session)
task = await service.get_task_or_404(
organization_id=identity.organization_id, task_id=task_id
)
return {
"task": {
"id": task.id,
"title": task.title,
"description": task.description,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"project_id": task.project_id,
}
}
async def _tasks_create(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
title: str,
description: str = "",
**_ignored: Any,
) -> dict[str, Any]:
"""A user order to the manager: the current public signature, untouched."""
from app.application.container import build_workforce_service
if not title or not title.strip():
raise ValidationError("عنوان کار لازم است.")
creator = await McpService(session).creator_of(identity.token_id)
service = build_workforce_service(session)
task = await service.create_task(
organization_id=identity.organization_id,
created_by_user_id=creator or "",
title=title,
description=description,
)
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
async def _memory_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str = "",
kind: str | None = None,
limit: int = 10,
**_ignored: Any,
) -> dict[str, Any]:
service = build_memory_service(session)
items = await service.list(
identity.organization_id, kind=kind, search=query or None
)
return {
"memories": [
{
"id": memory.id,
"kind": str(memory.kind),
"key": memory.key,
"content": memory.content,
"pinned": memory.pinned,
"confidence": memory.confidence,
}
for memory in items[: max(1, min(int(limit), 50))]
]
}
async def _memory_write(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
kind: str,
content: str,
**_ignored: Any,
) -> dict[str, Any]:
from app.domain.memory.models import coerce_kind
entry_kind = coerce_kind(kind)
if entry_kind not in MCP_WRITABLE_KINDS:
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
service = build_memory_service(session)
memory_id = await service.record(
organization_id=identity.organization_id,
kind=str(entry_kind),
content=content,
source="user",
)
if memory_id is None: # pragma: no cover - the task cap cannot apply here
raise McpError("یادداشت ذخیره نشد.")
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
async def _knowledge_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str,
limit: int = 5,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_knowledge_service
if not query:
raise ValidationError("عبارت جست‌وجو لازم است.")
service = build_knowledge_service(session)
hits = await service.search(
organization_id=identity.organization_id,
query=query,
limit=max(1, min(int(limit), 20)),
)
return {
"results": [
{
"content": hit.get("content", ""),
"source_name": hit.get("source_name", ""),
"document_title": hit.get("document_title", ""),
}
for hit in hits
]
}
async def _pipeline_status(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
"""Delivery is another workstream's table; the tool exists and says so."""
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
"app_get_profile": _app_profile,
"tasks_list": _tasks_list,
"tasks_get": _tasks_get,
"tasks_create": _tasks_create,
"memory_search": _memory_search,
"memory_write": _memory_write,
"knowledge_search": _knowledge_search,
"pipeline_status": _pipeline_status,
}
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
def _default_factory():
from app.core.database import SessionFactory
return SessionFactory
async def _note(kind: str, detail: str) -> None:
"""Record an unattributable attempt in the error ledger, best-effort.
A bad token and a disabled gateway happen before the caller is known, so they
belong to nobody's audit trail — but they are exactly the patterns an operator
needs to see, which is what the error console is for.
"""
from app.infrastructure.error_log import record_error
try:
await record_error(kind=kind, message=detail, source="mcp")
except Exception: # noqa: BLE001 - telemetry must never fail a call
return
def build_gateway(*, session_factory=None) -> McpGateway:
"""The gateway the server and the tests both use.
Each gateway owns its own in-memory rate limiter: one process, one budget.
"""
return McpGateway(session_factory=session_factory)
__all__ = [
"MCP_WRITABLE_KINDS",
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+170 -6
View File
@@ -1,14 +1,178 @@
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
"""The MCP gateway server: the official SDK, bound to our tools.
This module is the *adapter*, not the logic. The tools live in
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
happens here is transport only:
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
the argument schema the gateway validates;
* every call resolves the caller's token from the ``Authorization: Bearer``
header — never from the arguments — and hands it to the gateway;
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
tool result: an agent sees "this token does not have that scope" instead of a
stack trace;
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
runs. Putting Caddy in front of it is the owner's job (see
``services/mcp-gateway/README.md``).
"""
from __future__ import annotations
from typing import Any
def main() -> None:
raise SystemExit(
"MCP gateway is not implemented yet "
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import ToolError
from app.core.config import settings
from app.core.redact import redact
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
#: How a client authenticates. One header, one token, no query parameter: a
#: token in a URL ends up in proxy logs and browser history.
BEARER_PREFIX = "bearer "
def _bearer_token(ctx: Any) -> str:
"""The bearer token of this request, or an empty string.
``ctx.headers`` raises when there is no request at all (a stdio transport, or
a direct call in a test), so the read is defensive by design: no request
means no token, which means no tool runs.
"""
try:
headers = getattr(ctx, "headers", None) if ctx is not None else None
except ValueError: # pragma: no cover - "Context not available"
return ""
if not headers:
return ""
raw = headers.get("authorization") or headers.get("Authorization") or ""
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
raw = raw.decode("utf-8", "ignore")
if raw.lower().startswith(BEARER_PREFIX):
return raw[len(BEARER_PREFIX) :].strip()
return raw.strip()
def _build_server(gateway: McpGateway) -> Any:
"""Build the SDK server with our eight tools registered."""
from mcp.server.mcpserver import MCPServer
server: Any = MCPServer(
name="smart-org-gateway",
version="1.0.0",
instructions=(
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
"می‌شود، نه از آرگومان‌ها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
"می‌شود."
),
)
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
result: McpCallResult = await gateway.call(
tool=tool, arguments=arguments, token=_bearer_token(ctx)
)
if not result.ok:
raise ToolError(result.error or "عملیات ناموفق بود.")
# Redacted on the way out as well as on the way in: a tool may return a
# stored value that predates a redaction rule, and the agent is outside
# the platform's trust boundary.
payload: dict[str, Any] = redact(result.payload)
return payload
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریم‌ورک، دستورها، پورت."""
return await _dispatch("app_get_profile", ctx)
async def tasks_list(
status: str | None = None, limit: int = 25, ctx: Context | None = None
) -> dict[str, Any]:
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
"""یک کار بر اساس شناسهٔ آن."""
return await _dispatch("tasks_get", ctx, task_id=task_id)
async def tasks_create(
title: str, description: str = "", ctx: Context | None = None
) -> dict[str, Any]:
"""ثبت یک کار تازه برای مدیر سازمان."""
return await _dispatch("tasks_create", ctx, title=title, description=description)
async def memory_search(
query: str = "", kind: str | None = None, limit: int = 10,
ctx: Context | None = None,
) -> dict[str, Any]:
"""جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد."""
return await _dispatch(
"memory_search", ctx, query=query, kind=kind, limit=limit
)
async def memory_write(
kind: str, content: str, ctx: Context | None = None
) -> dict[str, Any]:
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
return await _dispatch("memory_write", ctx, kind=kind, content=content)
async def knowledge_search(
query: str, limit: int = 5, ctx: Context | None = None
) -> dict[str, Any]:
"""جست‌وجو در منابع دانش سازمان."""
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
"""وضعیت خط تحویل (استقرار و دمو)."""
return await _dispatch("pipeline_status", ctx)
for tool in (
app_get_profile,
tasks_list,
tasks_get,
tasks_create,
memory_search,
memory_write,
knowledge_search,
pipeline_status,
):
server.add_tool(
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
)
return server
def build_app(gateway: McpGateway | None = None) -> Any:
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
server = _build_server(gateway or build_gateway())
return server.streamable_http_app(
streamable_http_path="/mcp", host=settings.mcp_gateway_host
)
if __name__ == "__main__":
def main(argv: list[str] | None = None) -> None:
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
import sys
import uvicorn
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
if not settings.mcp_gateway_enabled:
# Starting anyway is right: the switch is the owner's, and the process
# answering "disabled" is more diagnosable than a missing port.
print(
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
file=sys.stderr,
flush=True,
)
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
uvicorn.run(
_build_server(build_gateway()).streamable_http_app(
streamable_http_path="/mcp", host=host
),
host=host,
port=port,
log_level="info",
)
if __name__ == "__main__": # pragma: no cover - the entry point
main()