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
30 changes: 30 additions & 0 deletions packages/agami-core/src/migrations/core/013_organization.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- The organization record (F15 / ACE-068): the deployment's ONE company-level row, derived from the
-- disk record <artifacts_dir>/organization.yaml (ACE-067). This is the registry `001_serving.sql`
-- admitted it pointed at but never had ("org_id is just the scoping key here; any registry it points
-- at lives outside this table"). It holds the company-wide facts (name, description, fiscal year,
-- display conventions, glossary) that used to be duplicated into every per-datasource datasource_model
-- row and drift.
--
-- Keyed on `org_id` ALONE — deliberately unlike every (org_id, datasource) serving table: there is one
-- company per deployment, many datasources under it. Portable DDL (TEXT only, app-minted key, no JSONB)
-- so the same file runs on SQLite (tests / small self-host) and Postgres (prod), same rules as
-- 001_serving.sql. `doc` carries the OrgRecord's structured tail as JSON (fiscal_year_start_month +
-- display_conventions + glossary); name/description are promoted so a reader can filter/show them
-- without decoding the blob.
--
-- COLUMN SUPERSET (coordination with agami-hosted): the hosted product's tenant registry is ALSO a
-- table named `organization` (org_id, org_name, created_at) and other hosted tables FK-reference it.
-- To let one physical table serve both, this DDL is a superset of hosted's columns: `org_name` (not a
-- bare `name`) so hosted's INSERT finds its column, `created_at` present (hosted-owned; core never
-- writes it), and `doc` carries a DEFAULT so hosted's INSERT — which omits `doc` — still satisfies NOT
-- NULL. Core writes this row with an FK-safe UPSERT (model_store.write_organization_record), never a
-- DELETE, so a redeploy can't violate the hosted FKs. Core migrations apply BEFORE the hosted overlay,
-- so this definition wins and hosted's `CREATE TABLE IF NOT EXISTS organization` becomes a no-op.

CREATE TABLE organization (
org_id TEXT PRIMARY KEY, -- the deployment's org_id (F14); the one table keyed on org alone
org_name TEXT, -- company display name (OrgRecord.name); shared with hosted's column
description TEXT, -- company description
doc TEXT NOT NULL DEFAULT '{}', -- JSON: fiscal_year_start_month + display_conventions + glossary
created_at TEXT -- hosted-owned tenant-creation stamp; core leaves it NULL
);
25 changes: 24 additions & 1 deletion packages/agami-core/src/model_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,36 @@ def deploy_one(store: Store, datasource: str, profile_dir: Path, org_id: str | N
def _deploy_user_memory(store: Store, artifacts_dir: Path, org_id: str | None = None) -> None:
"""USER_MEMORY.md is **cross-datasource** (one row per org, keyed by the global sentinel inside
write_memory) and lives at the artifacts ROOT — not per profile — matching how the server reads it
(`tools._domain_memory` → `artifacts/USER_MEMORY.md`). Written once per run; absent ⇒ nothing to do."""
(`tools._context_sources` → `artifacts/USER_MEMORY.md`). Written once per run; absent ⇒ nothing to do."""
f = artifacts_dir / "USER_MEMORY.md"
if f.exists():
org_id = org_id if org_id is not None else _default_org()
model_store.write_memory(store, "", user=f.read_text(), org_id=org_id) # global user row
store.commit()


def _deploy_org_record(store: Store, artifacts_dir: Path, org_id: str | None = None) -> None:
"""Derive the deployment-level org record (F15 / ACE-067's `<artifacts_dir>/organization.yaml`) into
the one `organization` row — company-wide context shared across every datasource, written ONCE per
run at the artifacts ROOT (like USER_MEMORY.md), never per datasource. Absent record ⇒ nothing to do
(a pre-F15 deployment; composition degrades to per-profile). No tenant-row backfill — F14/ACE-057
already stamped `org_id`; this only upserts the single org row (FK-safe, see write_organization_record)."""
from semantic_model import org_record as OR # lazy: keeps the deploy CLI's import surface small

record = OR.load_org_record(artifacts_dir)
if record is not None:
# Refresh the datasource list from disk so the derived row reflects what's actually deployed.
# Only when a record already exists — an absent record still degrades (no mint at deploy time).
record = OR.refresh_datasources(artifacts_dir) or record
org_id = org_id if org_id is not None else _default_org()
model_store.write_organization_record(store, record, org_id=org_id)
# The company NARRATIVE is prose, not structured — it lives in a company-level `memory` row
# (datasource='' sentinel, like USER_MEMORY.md) so the served two-level context can read it.
narrative = OR.narrative_path(artifacts_dir)
if narrative.exists():
model_store.write_memory(store, "", organization=narrative.read_text(), org_id=org_id)


def deploy_models(store: Store, artifacts_dir: Path, org_id: str | None = None) -> list[str]:
"""Load every datasource model under `artifacts_dir` (a *directory* with an `org.yaml`) into the store.
Returns the datasources loaded. The `local/` dir (gitignored secrets/state) and any non-directory or
Expand Down Expand Up @@ -170,6 +192,7 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
_deploy_user_memory(store, artifacts_dir) # install-global USER_MEMORY.md, once
_deploy_org_record(store, artifacts_dir) # deployment-level company record, once (F15)
except Exception as e: # noqa: BLE001 — any load/write failure is a clean fail-closed exit, not a traceback
print(f"model_deploy: failed: {e}", file=sys.stderr)
return 1
Expand Down
53 changes: 52 additions & 1 deletion packages/agami-core/src/model_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import Any
from uuid import uuid4

from semantic_model.models import Organization
from semantic_model.models import Organization, OrgRecord
from store import Store

# The per-datasource model tables write_organization clears before a re-seed (so a redeploy
Expand Down Expand Up @@ -249,6 +249,57 @@ def write_model_version(
store.commit()


def write_organization_record(store: Store, record: OrgRecord, org_id: str = DEFAULT_ORG) -> None:
"""Derive the deployment's `OrgRecord` (ACE-067) into the `organization` table — the one company-level
row (org_id, name, description, doc), keyed on org alone.

FK-SAFE UPSERT, deliberately NOT the clear-then-insert (`DELETE`+`INSERT`) every other writer here
uses: in the hosted stack `org_membership.org_id` and `license.org_id` hold foreign keys to this row,
so a `DELETE` on redeploy would violate them (and FK enforcement is ON — store.py). `ON CONFLICT DO
UPDATE` rewrites only the content columns and preserves any hosted-owned `org_name`/`created_at`, so
hosted onboarding (which INSERTs the row first) and core deploy (which upserts content) coexist on one
row. Portable across SQLite (>=3.24) and Postgres. Idempotent — a redeploy replaces, never duplicates."""
doc = json.dumps(
{
"fiscal_year_start_month": record.fiscal_year_start_month,
"display_conventions": record.display_conventions.model_dump(mode="json"),
"glossary": record.glossary,
"datasources": record.datasources,
}
)
store.execute(
"INSERT INTO organization (org_id, org_name, description, doc) VALUES (?, ?, ?, ?) "
"ON CONFLICT (org_id) DO UPDATE SET "
"org_name = COALESCE(organization.org_name, excluded.org_name), "
"description = excluded.description, "
"doc = excluded.doc",
(org_id, record.name, record.description, doc),
)
store.commit()


def load_organization_record(store: Store, org_id: str = DEFAULT_ORG) -> OrgRecord | None:
"""Rebuild the `OrgRecord` for `org_id` from the `organization` row, or `None` when no row exists —
the graceful-degradation contract ACE-069's composition relies on. A hosted-only row (tenant onboarded
but no model deployed yet) has `doc='{}'` and rebuilds to a bare record carrying just the name."""
rows = store.query(
"SELECT org_name, description, doc FROM organization WHERE org_id = ?", (org_id,)
)
if not rows:
return None
row = rows[0]
doc = json.loads(row["doc"]) if row["doc"] else {}
return OrgRecord(
org_id=org_id,
name=row["org_name"],
description=row["description"],
fiscal_year_start_month=doc.get("fiscal_year_start_month"),
display_conventions=doc.get("display_conventions") or {},
glossary=doc.get("glossary") or {},
datasources=doc.get("datasources") or [],
)


def newest_model_version(store: Store, datasource: str, org_id: str = DEFAULT_ORG) -> str | None:
"""The newest recorded version for a datasource (what the receipt pins), or None."""
rows = store.query(
Expand Down
51 changes: 31 additions & 20 deletions packages/agami-core/src/semantic_model/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,24 +478,30 @@ 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."""
def ensure_org_id(out: Path, existing: Optional[str] = None, *, dry_run: bool = False) -> str:
"""Idempotent, deployment-scoped resolution of the org identity (F14 / ACE-056; relocated by
F15 / ACE-067). 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);
else the DEPLOYMENT record's id (``organization.yaml`` at ``out.parent``), minting the record on
first use.

F15 relocated the id's home from the profiles up into the one root record, which is now the
deployment-scoped consistency mechanism — so the old "adopt a sibling profile's id" scan is GONE:
a second profile reads the same id straight from the record instead of scanning siblings. Pure-local
(a uuid4 minted on-box, no coordinator — the only option under F14's no-egress invariant). On
``dry_run`` nothing is persisted: a preview reads an existing record or surfaces a throwaway id."""
from uuid import uuid4 # local generation only — no egress (F14 invariant)

from . import loader
from . import loader, org_record

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
)
resolved = existing or loader.load_org_id(out)
if resolved:
return resolved
if dry_run:
# Preview: never mint/persist a record. Use an existing one if present, else a throwaway id.
record = org_record.load_org_record(out.parent)
return record.org_id if record is not None else uuid4().hex
return org_record.ensure_org_record(out.parent).org_id


def write_tree(
Expand All @@ -518,11 +524,13 @@ 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.
# F14/F15: 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 — and otherwise takes it from the deployment record
# (minting the record on first use). Resolved once, honoring dry_run so a preview persists nothing.
org_id = ensure_org_id(out, org.org_id, dry_run=dry_run)
write("org.yaml", _dump({
"org_id": ensure_org_id(out, org.org_id),
"org_id": org_id,
"organization": org.organization,
"version": org.version,
"description": org.description,
Expand Down Expand Up @@ -560,8 +568,11 @@ def write(rel: str, content: str) -> None:
_dump({"examples": examples_by_area[sa.name]}))
if not dry_run:
# Stamp the model_version pin for the freshly-written model (best-effort).
from . import snapshot
from . import org_record, snapshot
snapshot.write_snapshot(out)
# Keep the deployment record's datasource list in sync with what's actually on disk — this
# profile was just written, so rebuild the list from the profile dirs (auto-maintained, no drift).
org_record.refresh_datasources(out.parent)
return rep


Expand Down
57 changes: 45 additions & 12 deletions packages/agami-core/src/semantic_model/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ def cmd_snapshot(args) -> int:


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."""
"""Mint + persist the deployment org_id into <root>/org.yaml if absent (F14 / ACE-056; relocated
by F15 / ACE-067), 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: the id now comes from the root ``organization.yaml`` record (minted on first
use), so every profile in the dir resolves the same one — no sibling scan."""
from pathlib import Path

from . import build
Expand Down Expand Up @@ -143,14 +144,40 @@ def cmd_org_draft(args) -> int:


def cmd_org_context(args) -> int:
# The full domain context for the LLM: the human's ORGANIZATION.md narrative (comments
# stripped) + the model-derived summary (subject areas, conventions, decoded glossary),
# assembled fresh. This is what the query path injects as `## Organization context`.
# The full domain context for the LLM, two-level (F15 / ACE-069): the shared COMPANY block from the
# deployment record (<artifacts_dir>/organization.yaml + the root ORGANIZATION.md), then this
# datasource's source-specific narrative + model-derived summary. With no record it degrades to the
# pre-F15 per-profile assembly (byte-identical). This is what the query path injects as context.
from . import org_draft
org = L.load_organization(args.root, include_rejected=False)
org_md = Path(args.root) / "ORGANIZATION.md"
human = org_md.read_text(encoding="utf-8") if org_md.exists() else ""
sys.stdout.write(org_draft.compose_context(human, org))
from . import org_record as OR
root = Path(args.root)
org = L.load_organization(root, include_rejected=False)
src_md = root / "ORGANIZATION.md"
source_narrative = src_md.read_text(encoding="utf-8") if src_md.exists() else ""
art = root.parent # the artifacts dir holds the deployment-level record + company narrative
record = OR.load_org_record(art)
comp_md = OR.narrative_path(art)
company_narrative = comp_md.read_text(encoding="utf-8") if comp_md.exists() else ""
sys.stdout.write(
org_draft.compose_org_context(
record, [org], company_narrative=company_narrative, source_narratives=[source_narrative]
)
)
return 0


def cmd_set_org(args) -> int:
# Write the human-authored company fields (name / short description) into the deployment-level
# organization record. Onboarding calls this after asking the company question, so the record
# carries more than just its org_id. Only the flags passed are updated; others are left as-is.
from . import org_record as OR

record = OR.set_org_fields(
args.artifacts_dir, name=args.name, description=args.description
)
_print_json(
{"org_id": record.org_id, "name": record.name, "description": record.description}
)
return 0


Expand Down Expand Up @@ -1106,6 +1133,12 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("root")
sp.set_defaults(func=cmd_org_context)

sp = sub.add_parser("set-org", help="write the company name / description into the deployment organization record")
sp.add_argument("artifacts_dir")
sp.add_argument("--name", default=None)
sp.add_argument("--description", default=None)
sp.set_defaults(func=cmd_set_org)

sp = sub.add_parser("examples", help="rank prompt examples for a query")
sp.add_argument("root")
sp.add_argument("--area", required=True)
Expand Down
Loading
Loading