Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions packages/agami-core/src/mcp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
_current_org_ctx,
bootstrap_paths,
record_tool_call,
resolved_org_id,
server_version,
set_injected_executor,
)
Expand Down Expand Up @@ -104,10 +105,18 @@ def _build_auth_provider() -> AuthProvider:


def _build_org_resolver() -> SingleTenantOrgResolver:
"""The OSS default tenancy: single-tenant, one configured org (id from AGAMI_ORG_ID, default
"local"). Multi-tenant is a future change at the *schema* layer (rows key on datasource, not
(org, datasource)) plus an authz check — not a resolver swap, so the seam lives here now."""
org_id = os.environ.get("AGAMI_ORG_ID", "").strip() or "local"
"""The OSS default tenancy: single-tenant, one configured org. The id is the F14 minted uuid
resolved by `tools.resolved_org_id()` (AGAMI_ORG_ID env -> org.yaml -> "local") — the SAME
resolution the deploy stamp uses, so writes and reads agree. Multi-tenant is a future change at
the *schema* layer plus an authz check — not a resolver swap, so the seam lives here now."""
org_id = resolved_org_id()
# Load-bearing for a hand-rolled DB-only deploy: log the resolved id + where it came from, so an
# operator can see "org_id=local (default)" and realize org.yaml/AGAMI_ORG_ID isn't reaching the
# server (see F14's documented residual risk).
source = "env" if os.environ.get("AGAMI_ORG_ID", "").strip() else (
"default" if org_id == "local" else "org.yaml"
)
_log.info("single-tenant org_id=%s (source=%s)", org_id, source)
return SingleTenantOrgResolver(Org(id=org_id))


Expand Down
18 changes: 18 additions & 0 deletions packages/agami-core/src/migrations/core/012_users_org_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Tenant-scope the users table (F14 / ACE-057). The serving + runtime tables already carry `org_id`
-- (added earlier); `users` is the last per-customer table that didn't. Adding it here lets an
-- authorized-user roster ride along when a self-hosted deployment is lifted into hosted as one tenant.
--
-- Plain ADD COLUMN with a constant default is portable across SQLite + Postgres (no table rebuild) —
-- same shape as 007_user_names.sql. It DEFAULTs to the 'local' sentinel so existing rows land on it;
-- the minted uuid isn't known here (a static .sql migration can't generate one — SQLite has no uuid
-- function, and run_migrations applies static SQL only), so model_deploy runs a code backfill right
-- after migrations that moves every 'local' row (here and in the serving/runtime tables) onto the
-- resolved org_id.
--
-- Unlike the serving tables, `org_id` is NOT part of the primary key here: `users.id` (uuid4) is the
-- PK and logins resolve by the global `UNIQUE(username)` — correct for single-tenant (N=1). An indexed
-- non-PK column is the right shape; `username` stays globally unique (a per-org unique is a paid
-- multi-tenant concern, out of scope).

ALTER TABLE users ADD COLUMN org_id TEXT NOT NULL DEFAULT 'local';
CREATE INDEX idx_users_org ON users (org_id);
44 changes: 39 additions & 5 deletions packages/agami-core/src/model_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from __future__ import annotations

import os
import sys
from pathlib import Path

Expand All @@ -20,10 +19,41 @@


def _default_org() -> str:
"""The org to deploy under when the caller names none. A CLI has no request, so this is exactly
what the server's read path falls back to (AGAMI_ORG_ID / 'local') — the two MUST agree, or the
model is written under one org and read under another and the server sees no model."""
return os.environ.get("AGAMI_ORG_ID") or "local"
"""The org to deploy under when the caller names none. A CLI has no request, so it calls the SAME
resolver the server's read path uses (`tools.resolved_org_id`: AGAMI_ORG_ID -> the minted uuid in
org.yaml -> 'local') — the two MUST agree, or the model is written under one org and read under
another and the server sees no model (F14 / ACE-056)."""
from tools import resolved_org_id # lazy: keeps the deploy CLI's import surface small

return resolved_org_id()


# Every tenant-scoped table carrying `org_id` — the serving model (9), the append-only runtime logs (2),
# and the user roster (1, added by 012). The backfill moves legacy 'local' rows across all of them.
_BACKFILL_TABLES = (
"datasource_model", "subject_area", "model_table", "metric", "entity",
"relationship", "prompt_example", "memory", "model_version", # serving
"query_executions", "tool_calls", # runtime (append-only)
"users", # auth roster
)


def _backfill_org_id(store: Store, org_id: str) -> None:
"""Move rows written under the legacy 'local' sentinel onto the resolved minted `org_id`
(F14 / ACE-057). Runs once at boot, right after migrations, so an EXISTING deployment that ran
under 'local' before this feature adopts its minted id instead of orphaning those rows.

Why an UPDATE-move and not a re-seed: `model_store.write_organization`'s redeploy DELETE is scoped
to (org_id, datasource), so re-deploying under a NEW org_id would leave the old 'local' serving
rows behind (doubled); the runtime tables are append-only and can only be corrected by an UPDATE.
Idempotent + safe: a no-op when the target is still 'local' (a pre-F14 / un-minted deployment), and
`WHERE org_id='local'` matches zero rows once moved, so re-runs do nothing. Never touches
`username`, so the users UNIQUE index can't trip."""
if org_id == "local":
return
for tbl in _BACKFILL_TABLES:
store.execute(f"UPDATE {tbl} SET org_id = ? WHERE org_id = 'local'", (org_id,))
store.commit()


def deploy_one(store: Store, datasource: str, profile_dir: Path, org_id: str | None = None) -> None:
Expand Down Expand Up @@ -108,6 +138,10 @@ def main(argv: list[str] | None = None) -> int:
# server (whose lifespan auto-migrates) is up, so the schema may not be applied yet.
# run_migrations is idempotent, so the later lifespan pass is a harmless no-op.
store.run_migrations()
# Move any legacy 'local' rows onto this deployment's minted org_id BEFORE deploying — so the
# redeploy's (org_id, datasource)-scoped overwrite lines up with the just-moved serving rows
# instead of orphaning them. No-op on a fresh or un-minted ('local') deployment.
_backfill_org_id(store, _default_org())
artifacts_dir = agami_paths.artifacts_dir()
if not artifacts_dir.is_dir(): # clean exit, not an uncaught FileNotFoundError from iterdir()
store.close()
Expand Down
24 changes: 24 additions & 0 deletions packages/agami-core/src/semantic_model/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,26 @@ class WriteReport:
files_written: list[str] = field(default_factory=list)


def ensure_org_id(out: Path, existing: Optional[str] = None) -> str:
"""Idempotent, deployment-scoped mint of the org identity (F14 / ACE-056). Returns, in order:
``existing`` (an id the caller already resolved); the org_id already persisted at ``out/org.yaml``
(preserved across re-introspect — the immutability guarantee); an id already minted for a SIBLING
profile in the same artifacts dir (``out.parent``) — so a company with several datasources stays
ONE tenant rather than fragmenting; else a freshly minted ``uuid4().hex``. Pure-local — a uuid4
plus (later) a file write, no network egress; a random uuid4 is globally unique with no
coordinator, the only option under no-egress."""
from uuid import uuid4 # local generation only — no egress (F14 invariant)

from . import loader

return (
existing
or loader.load_org_id(out)
or loader.deployment_org_id(out.parent) # adopt the deployment's id (deployment-scoped)
or uuid4().hex
)


def write_tree(
org: Organization,
out: Path,
Expand All @@ -498,7 +518,11 @@ def write(rel: str, content: str) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")

# F14: the deployment identity is the first key. `ensure_org_id` reads the *previous* org.yaml
# (about to be overwritten) so a re-introspect/curate/snapshot preserves the minted id rather
# than re-minting — the immutability guarantee, centralized on the one write chokepoint.
write("org.yaml", _dump({
"org_id": ensure_org_id(out, org.org_id),
"organization": org.organization,
"version": org.version,
"description": org.description,
Expand Down
25 changes: 25 additions & 0 deletions packages/agami-core/src/semantic_model/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ def cmd_snapshot(args) -> int:
return 0 if h else 1


def cmd_ensure_org_id(args) -> int:
"""Mint + persist the deployment org_id into <root>/org.yaml if absent (F14 / ACE-056), then
print it. Idempotent — an already-minted id is returned unchanged. The introspect/curate paths
mint inline via write_tree; this command is for paths that write a model WITHOUT them — e.g.
agami-connect's sample copy (6A) drops a prebuilt model that carries no id. Deployment-scoped:
adopts a sibling profile's id if one exists, so multi-datasource stays one tenant."""
from pathlib import Path

from . import build
root = Path(args.root)
org_path = root / "org.yaml"
doc = L._read_yaml(org_path) or {}
existing = doc.get("org_id")
oid = build.ensure_org_id(root, existing or None)
if oid != existing:
Comment on lines +99 to +103

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this — the graceful failure you're describing is already in place, so no change here.

_read_yaml does open the file unguarded, so it raises FileNotFoundError. But cli.main() has a purpose-built handler for exactly that case (cli.py:1332-1339):

except FileNotFoundError as e:
    if "org.yaml" in str(e):
        _print_json({"error": "no_model", "detail": str(e)})
        return 3     # "no model here — run agami-connect"

Verified:

$ sm ensure-org-id /tmp/dir-with-no-org-yaml
{ "error": "no_model", "detail": "[Errno 2] No such file or directory: '.../org.yaml'" }
exit code: 3

That's a clear error and a non-zero exit, using the repo's established convention (exit 3 = "no model", distinct from 1 = validation and 2 = usage) shared by every other sm subcommand. Adding a local guard would make ensure-org-id report this condition differently from its siblings, which is the opposite of what we want.

doc["org_id"] = oid
org_path.write_text(build._dump(doc), encoding="utf-8")
print(oid)
return 0


def cmd_context(args) -> int:
org = L.load_organization(args.root)
out = L.get_table_context(
Expand Down Expand Up @@ -1052,6 +1073,10 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("root")
sp.set_defaults(func=cmd_snapshot)

sp = sub.add_parser("ensure-org-id", help="mint + persist the deployment org_id into org.yaml if absent (e.g. after copying a sample model)")
sp.add_argument("root")
sp.set_defaults(func=cmd_ensure_org_id)

sp = sub.add_parser("context", help="assemble get_table_context")
sp.add_argument("root")
sp.add_argument("--area", default=None)
Expand Down
39 changes: 39 additions & 0 deletions packages/agami-core/src/semantic_model/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def load_organization(root: str | Path, *, include_rejected: bool = False) -> Or
subject_areas.append(_load_subject_area(sa_dir, include_rejected=include_rejected))

org = Organization(
org_id=org_doc.get("org_id"), # F14: minted uuid4 (None for pre-F14 files)
organization=org_doc.get("organization", root.name),
version=org_doc.get("version", 1),
description=org_doc.get("description", ""),
Expand All @@ -128,6 +129,42 @@ def load_organization(root: str | Path, *, include_rejected: bool = False) -> Or
return org


def load_org_id(root: str | Path) -> str | None:
"""Return the minted org_id recorded in ``<root>/org.yaml``, or ``None`` if the file is absent
or predates F14 (no ``org_id`` key). This is the serve-time identity read (F14 / ACE-056).

Deliberately read-only and lenient — unlike ``load_organization`` it never raises on a missing
file — so the single-tenant resolver can fall through to its ``"local"`` default. Reads only the
top-level key; it does NOT build the full pydantic model (identity resolution runs per process,
not per query, so it stays cheap)."""
org_path = Path(root) / "org.yaml"
if not org_path.exists():
return None
doc = _read_yaml(org_path) or {}
oid = doc.get("org_id")
return oid or None


def deployment_org_id(artifacts_dir: str | Path) -> str | None:
"""The deployment-wide org identity: the first ``org_id`` found across ``<artifacts_dir>/*/org.yaml``,
or ``None`` if no profile carries one (F14 / ACE-056, the *deployment-scoped* rule).

A deployment is ONE tenant even with several datasource profiles, so the minted id is shared across
every profile's ``org.yaml``. Resolving by scanning the artifacts dir (rather than one 'active'
profile) means the deploy stamp and the serve resolver agree even when ``AGAMI_PROFILE`` is unset and
the real model lives under a named profile (e.g. ``northpeak_salesforce``, not ``default``). Read-only;
never raises. Profiles are expected to agree; the first (sorted) is returned deterministically."""
root = Path(artifacts_dir)
if not root.is_dir():
return None
for prof in sorted(root.iterdir()):
if prof.is_dir():
oid = load_org_id(prof)
if oid:
return oid
return None


def _rejected(obj) -> bool:
return getattr(obj, "review_state", None) == "rejected"

Expand Down Expand Up @@ -603,6 +640,8 @@ def list_prompt_examples(root: str | Path, area: str,

__all__ = [
"load_organization",
"load_org_id",
"deployment_org_id",
"collect_default_filters",
"get_table_index",
"get_table_context",
Expand Down
5 changes: 5 additions & 0 deletions packages/agami-core/src/semantic_model/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,11 @@ def defined_table(self, name: str) -> Optional[Table]:


class Organization(_Base):
# Locally-minted, globally-unique deployment identity (F14 / ACE-056): a uuid4 hex minted ONCE at
# agami-connect and persisted in org.yaml, then immutable. Optional/None so pre-F14 org.yaml files
# (which have no org_id key) still load under the model's `extra="forbid"` policy. Never transmitted;
# it only travels the day the operator exports their own DB — see F14's no-egress invariant.
org_id: Optional[str] = None
organization: str
version: int = 1
description: str = ""
Expand Down
57 changes: 54 additions & 3 deletions packages/agami-core/src/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from __future__ import annotations

import csv
import functools
import io
import json
import os
Expand Down Expand Up @@ -279,17 +280,67 @@ def _model_version(profile: str) -> str | None:
_current_org_ctx: ContextVar[str | None] = ContextVar("agami_current_org_id", default=None)


@functools.lru_cache(maxsize=None)
def resolved_org_id() -> str:
"""The single-tenant deployment org id, resolved once per process (F14 / ACE-056). Precedence:
``AGAMI_ORG_ID`` env override -> the minted uuid found by scanning the artifacts dir
(``loader.deployment_org_id``) -> ``"local"``. The SAME function backs both the deploy-time stamp
(``model_deploy._default_org``) and the serve-time resolver, so a deployment writes and reads its
rows under one identical id.

It scans the artifacts dir rather than reading one 'active' profile deliberately. The id is
DEPLOYMENT-scoped — every profile in the dir carries the same one — so any of them answers the
question, while ``resolve_profile()`` would fall back to the literal ``"default"`` whenever
``AGAMI_PROFILE`` is unset and there is no ``.config`` (exactly a deploy container, whose model
lives under its datasource's name). That would find no ``org.yaml``, silently resolve ``"local"``,
and make the whole feature inert on the deployments it exists for.

Memoized: at most one artifacts-dir scan per process (this sits on the per-request path via
``_current_org_id``). Tests that vary env/profile must call ``.cache_clear()``."""
env = os.environ.get("AGAMI_ORG_ID", "").strip()
if env:
return env
try:
from semantic_model import loader as L # lazy: keeps tools import light + avoids a cycle

# Deployment-scoped: scan the whole artifacts dir (not one 'active' profile) so a deploy with
# AGAMI_PROFILE unset and the model under a named profile still finds the minted id.
oid = L.deployment_org_id(resolve_artifacts_dir())
except Exception:
oid = None # missing/legacy org.yaml or absent model deps -> single-tenant default
return oid or "local"


def _current_org_id() -> str:
"""The org id to scope this process's model cache by: the request's resolved org when the HTTP server
set it (per-request under a multi-tenant resolver), else AGAMI_ORG_ID / 'local' (single-tenant / stdio)."""
return _current_org_ctx.get() or os.environ.get("AGAMI_ORG_ID") or "local"
set it (per-request under a multi-tenant resolver), else the process-wide minted/`local` id."""
return _current_org_ctx.get() or resolved_org_id()


# Public alias: a consumer's tool handler needs to know the org its call resolved to (to scope its own
# store), and the request never reaches the handler — only this contextvar does.
current_org_id = _current_org_id


def _credential_org_id() -> str:
"""The org that selects WAREHOUSE CREDENTIALS — deliberately NOT always the row-scoping org.

`execute_sql` is fail-closed: a NAMED tenant never falls back to the shared, org-less
`DATASOURCE_URL[__<PROFILE>]` vars, so one tenant can't silently borrow another's warehouse. That
rule keys on the single-tenant sentinel `"local"`. F14 makes a single-tenant deployment's org a
minted uuid — it is still ONE deployment using ITS OWN credentials — so the deployment's own id must
keep behaving like the sentinel here, or every existing `DATASOURCE_URL`-based deploy would lose its
credentials the moment an id is minted.

So: the deployment's own resolved id (no `AGAMI_ORG_ID` naming a tenant) maps to `"local"`; anything
else — an explicitly-named `AGAMI_ORG_ID`, or a tenant a multi-tenant resolver picked per request —
is passed through unchanged and stays fail-closed."""
org = _current_org_id()
if not os.environ.get("AGAMI_ORG_ID", "").strip() and org == resolved_org_id():
return "local"
return org


# Per-process semantic-model cache (ACE-045). The long-lived server loads the whole model 2-3x per query
# (_resolve_units + _resolve_receipt) and re-loads it every query; caching serves it warm across queries and
# users. Keyed (org_id, datasource, model_version): org-scoped so a multi-tenant server never serves one org's
Expand Down Expand Up @@ -979,7 +1030,7 @@ def _run_in_process(
cap_token = execute_sql._max_rows_override.set(max_rows)
try:
result = execute_sql.execute_guarded(
sql, profile, area, executor=executor, org_id=_current_org_id()
sql, profile, area, executor=executor, org_id=_credential_org_id()
)
except execute_sql.GuardRefused as refusal:
# A read-only refusal (envelope present) is already caught by tool_execute_sql's upstream
Expand Down
Loading
Loading