From b883633027239e545c1624313a7f2e0e0283b352 Mon Sep 17 00:00:00 2001 From: vishal-agami Date: Thu, 23 Jul 2026 19:22:58 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20F15=20organization=20context?= =?UTF-8?q?=20=E2=80=94=20shared=20company=20record=20+=20per-datasource?= =?UTF-8?q?=20ontology=20(ACE-067=E2=80=93070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment can connect several databases under ONE company. Today org == profile == datasource are fused 1:1:1, so company-wide facts (org_id, name, description, fiscal year, glossary) get duplicated into every per-profile org.yaml and drift. F15 gives the deployment one shared company record, written once, while each datasource keeps its own vocabulary — so "Account" resolves to a customer in the CRM and a GL account in the ERP, and a federated question carries the same company context once plus both vocabularies. Stays single-tenant (N=1). - ACE-067: OrgRecord/DisplayConventions types; new org_record.py (ensure/load_org_record over /organization.yaml); relocate org_id into the record (mint-once, immutable, idempotent legacy lift) and delete F14's adopt-sibling scan; resolved_org_id reads the record. - ACE-068: 013_organization.sql (the one table keyed on org_id alone); write/load_organization_record wired once-per-run into model_deploy. The write is an FK-SAFE UPSERT (ON CONFLICT DO UPDATE), not the clear-then-insert used elsewhere, and the table is a COLUMN SUPERSET of agami-hosted's tenant `organization` (org_name + created_at present, doc DEFAULT '{}') so one physical table serves both and a redeploy can't violate the hosted org_membership/license foreign keys. - ACE-069: two-level compose_org_context (company block once + per-datasource ontology), wired into both the local CLI (org-context) and the served path (get_datasource_schema); byte-identical graceful degradation when no record exists; content-routing rule documented. - ACE-070: agami-connect onboarding UX — author company context once, attach subsequent databases under the org, and route a different company to a new folder. Scope: agami-core only. Backward-compatible — pre-F15 deployments load and compose unchanged. No data egress (organization.yaml is local file I/O; the privacy gate stays green). 1552 tests pass, including the FK-safety crux (a re-upsert survives an FK-referencing membership row under PRAGMA foreign_keys=ON) and hosted coexistence. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/migrations/core/013_organization.sql | 30 ++++ packages/agami-core/src/model_deploy.py | 20 +++ packages/agami-core/src/model_store.py | 51 +++++- .../agami-core/src/semantic_model/build.py | 46 +++--- packages/agami-core/src/semantic_model/cli.py | 36 ++-- .../agami-core/src/semantic_model/models.py | 46 ++++++ .../src/semantic_model/org_draft.py | 83 +++++++++- .../src/semantic_model/org_record.py | 97 +++++++++++ packages/agami-core/src/tools.py | 71 +++++--- .../shared/organization-context-format.md | 20 +++ plugins/agami/skills/agami-connect/SKILL.md | 31 +++- tests/test_compose_org_context.py | 86 ++++++++++ tests/test_org_record.py | 132 +++++++++++++++ tests/test_org_record_deploy.py | 155 ++++++++++++++++++ tests/test_organization_context_e2e.py | 117 +++++++++++++ tests/test_schema.py | 16 ++ 16 files changed, 979 insertions(+), 58 deletions(-) create mode 100644 packages/agami-core/src/migrations/core/013_organization.sql create mode 100644 packages/agami-core/src/semantic_model/org_record.py create mode 100644 tests/test_compose_org_context.py create mode 100644 tests/test_org_record.py create mode 100644 tests/test_org_record_deploy.py create mode 100644 tests/test_organization_context_e2e.py diff --git a/packages/agami-core/src/migrations/core/013_organization.sql b/packages/agami-core/src/migrations/core/013_organization.sql new file mode 100644 index 00000000..44fe6606 --- /dev/null +++ b/packages/agami-core/src/migrations/core/013_organization.sql @@ -0,0 +1,30 @@ +-- The organization record (F15 / ACE-068): the deployment's ONE company-level row, derived from the +-- disk record /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 +); diff --git a/packages/agami-core/src/model_deploy.py b/packages/agami-core/src/model_deploy.py index b4f52cda..8c060f50 100644 --- a/packages/agami-core/src/model_deploy.py +++ b/packages/agami-core/src/model_deploy.py @@ -111,6 +111,25 @@ def _deploy_user_memory(store: Store, artifacts_dir: Path, org_id: str | None = 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 `/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: + 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 @@ -170,6 +189,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 diff --git a/packages/agami-core/src/model_store.py b/packages/agami-core/src/model_store.py index 789648b8..77899392 100644 --- a/packages/agami-core/src/model_store.py +++ b/packages/agami-core/src/model_store.py @@ -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 @@ -249,6 +249,55 @@ 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, + } + ) + 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 {}, + ) + + 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( diff --git a/packages/agami-core/src/semantic_model/build.py b/packages/agami-core/src/semantic_model/build.py index d7fc1d37..f7d9b847 100644 --- a/packages/agami-core/src/semantic_model/build.py +++ b/packages/agami-core/src/semantic_model/build.py @@ -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( @@ -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, diff --git a/packages/agami-core/src/semantic_model/cli.py b/packages/agami-core/src/semantic_model/cli.py index 2c44cef2..6fa1509e 100644 --- a/packages/agami-core/src/semantic_model/cli.py +++ b/packages/agami-core/src/semantic_model/cli.py @@ -87,11 +87,12 @@ def cmd_snapshot(args) -> int: def cmd_ensure_org_id(args) -> int: - """Mint + persist the deployment org_id into /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 /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 @@ -143,14 +144,25 @@ 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 (/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 diff --git a/packages/agami-core/src/semantic_model/models.py b/packages/agami-core/src/semantic_model/models.py index c4fe88d0..3838e3b6 100644 --- a/packages/agami-core/src/semantic_model/models.py +++ b/packages/agami-core/src/semantic_model/models.py @@ -676,6 +676,50 @@ def storage_connection(self, name: str) -> Optional[StorageConnection]: return None +# --------------------------------------------------------------------------- +# Organization record (F15 / ACE-067) — the deployment-level company record that +# sits ABOVE the per-datasource Organization models. One per artifacts dir. +# --------------------------------------------------------------------------- + + +class DisplayConventions(_Base): + """Company-wide, machine-actionable rendering conventions the SQL/answer path can act on + directly rather than parse out of prose. All optional; `notes` carries free-form conventions + that don't fit a structured slot.""" + + currency: Optional[str] = None + rounding: Optional[int] = None + week_start: Optional[str] = None + notes: list[str] = Field(default_factory=list) + + +class OrgRecord(_Base): + """The deployment-level company record (F15). Written ONCE at ``/organization.yaml`` + and shared across every datasource under the deployment, so company-wide facts (name, description, + fiscal year, display conventions, glossary) live in one place instead of drifting across each + profile's ``org.yaml``. ``org_id`` is F14's deployment identity, relocated here from the per-profile + ``org.yaml`` (minted once, immutable, deployment-scoped — the value is preserved, never re-minted). + Every non-id field is optional so a bare record (id only) validates; company content is authored + later by onboarding/curation.""" + + org_id: str + name: Optional[str] = None + description: Optional[str] = None + fiscal_year_start_month: Optional[int] = None + display_conventions: DisplayConventions = Field(default_factory=DisplayConventions) + # Company-wide glossary: term -> one-line definition. Same shape as Organization.key_terminology + # (the established glossary type), but scoped to the COMPANY rather than a single datasource. + glossary: dict[str, str] = Field(default_factory=dict) + + @field_validator("fiscal_year_start_month") + @classmethod + def _fy_month(cls, v: Optional[int]) -> Optional[int]: + # Same 1..12 bound as Organization, but None is allowed (an unauthored record has no fiscal year). + if v is not None and not 1 <= v <= 12: + raise ValueError("fiscal_year_start_month must be 1..12") + return v + + # Resolve forward references (TrustBlock.migrated_from -> MigratedFrom). TrustBlock.model_rebuild() @@ -708,6 +752,8 @@ def storage_connection(self, name: str) -> Optional[StorageConnection]: "CrossSubjectAreaRelationship", "SubjectArea", "Organization", + "DisplayConventions", + "OrgRecord", # constants "DEEP_TABLE_COLUMN_THRESHOLD", ] diff --git a/packages/agami-core/src/semantic_model/org_draft.py b/packages/agami-core/src/semantic_model/org_draft.py index b4f88fb0..5f7f2472 100644 --- a/packages/agami-core/src/semantic_model/org_draft.py +++ b/packages/agami-core/src/semantic_model/org_draft.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .models import Organization + from .models import Organization, OrgRecord def _plural(n: int, word: str) -> str: @@ -118,6 +118,87 @@ def compose_context(human_md: str, org: "Organization") -> str: return "\n\n".join(parts).strip() +def _company_block(record: "OrgRecord", narrative: str) -> str: + """The shared COMPANY context (F15): name/description + the root ORGANIZATION.md narrative + + company-wide display conventions + the company glossary. Rendered ONCE above every datasource.""" + lines: list[str] = [f"# {record.name or 'Company'} — company context", ""] + if record.description: + lines += [record.description, ""] + human = _strip_comments(narrative) + if human: + lines += [human, ""] + + dc = record.display_conventions + conventions: list[str] = [] + if record.fiscal_year_start_month: + conventions.append(f"Fiscal year starts in month {record.fiscal_year_start_month}.") + if dc.currency: + conventions.append(f"Currency: {dc.currency}.") + if dc.rounding is not None: + conventions.append(f"Rounding: {dc.rounding} decimal places.") + if dc.week_start: + conventions.append(f"Week starts on {dc.week_start}.") + conventions += [n for n in (dc.notes or []) if n] + if conventions: + lines.append("### Company conventions") + lines.append("") + lines += [f"- {c}" for c in conventions] + lines.append("") + + if record.glossary: + lines.append("### Company glossary") + lines.append("") + lines += [f"- **{term}**: {defn}" for term, defn in record.glossary.items()] + lines.append("") + return "\n".join(lines).strip() + + +def _source_block(org: "Organization", narrative: str) -> str: + """One datasource's context: its optional source-specific narrative + the model-derived summary, + under a heading naming the datasource (so a federated answer keeps the vocabularies apart).""" + seg = [f"## {org.organization} — datasource context"] + src = _strip_comments(narrative) + if src: + seg.append(src) + body = derived_context(org) + if body: + seg.append(body) + return "\n\n".join(seg).strip() + + +def compose_org_context( + org_record: "OrgRecord | None", + ontologies: "list[Organization]", + *, + company_narrative: str = "", + source_narratives: "list[str] | None" = None, +) -> str: + """Two-level org context (F15 / ACE-069). Renders the shared COMPANY block ONCE from the ``OrgRecord`` + (name/description + the root ``ORGANIZATION.md`` narrative + display conventions + company glossary), + then each datasource's model-derived ontology under its own heading. Single-source passes one + ontology; a FEDERATED question passes several — the company block still renders exactly once, and each + source keeps its own vocabulary (so "Account" resolves per source). + + ``source_narratives`` (optional, aligned by index to ``ontologies``) carries each datasource's + source-specific ``ORGANIZATION.md`` prose; company-wide prose now lives in ``company_narrative`` (the + root file), not the per-profile ones. + + Graceful degradation: when ``org_record`` is ``None`` this returns exactly today's per-profile + assembly (``compose_context`` of each ontology with its narrative) — byte-identical to pre-F15, so + every deployment without a record keeps working untouched.""" + ontologies = list(ontologies) + narrs = list(source_narratives or []) + narrs += [""] * (len(ontologies) - len(narrs)) # pad so index access is safe + + if org_record is None: + # No record: fall back to the pre-F15 single-level assembly, one block per ontology. + return "\n\n".join(compose_context(narrs[i], org) for i, org in enumerate(ontologies)).strip() + + parts = [_company_block(org_record, company_narrative)] + parts += [_source_block(org, narrs[i]) for i, org in enumerate(ontologies)] + return "\n\n".join(p for p in parts if p).strip() + + def starter_organization_md(org: "Organization") -> str: """A human-narrative STARTER for the skip path — never blank. It seeds `# About this database` with a one-line factual SUMMARY drawn from the model (org + what the subject diff --git a/packages/agami-core/src/semantic_model/org_record.py b/packages/agami-core/src/semantic_model/org_record.py new file mode 100644 index 00000000..61ac7abe --- /dev/null +++ b/packages/agami-core/src/semantic_model/org_record.py @@ -0,0 +1,97 @@ +"""The deployment-level organization record (F15 / ACE-067). + +One ``OrgRecord`` lives at ``/organization.yaml`` — ABOVE the per-profile +``//org.yaml`` models — and holds the company-wide facts (name, description, +fiscal year, display conventions, glossary) that would otherwise be duplicated into every profile's +``org.yaml`` and drift. The company narrative lives beside it at ``/ORGANIZATION.md``. + +This module owns: + + * ``load_org_record(art)`` — read the record (``None`` when absent — the graceful-degradation path + the composition layer, ACE-069, relies on). + * ``ensure_org_record(art)`` — read-or-mint. Relocates F14's ``org_id`` up into the record: the id is + minted ONCE (``uuid4``, immutable, deployment-scoped — F14's rules verbatim) and, for a deployment + that already carried a per-profile id (post-F14), LIFTED up instead of re-minted. + +No network egress — pure local file I/O (stdlib + PyYAML + pydantic). ``tests/test_privacy_no_network.py`` +is a static source scan of this tree; keep the imports egress-free. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import yaml + +from .models import OrgRecord + +# The record and the company narrative both sit at the artifacts-dir ROOT (one deployment = one company), +# NOT under a profile dir — that is the whole point: written once, shared by every datasource. +RECORD_FILENAME = "organization.yaml" +NARRATIVE_FILENAME = "ORGANIZATION.md" + + +def record_path(artifacts_dir: str | Path) -> Path: + return Path(artifacts_dir) / RECORD_FILENAME + + +def narrative_path(artifacts_dir: str | Path) -> Path: + return Path(artifacts_dir) / NARRATIVE_FILENAME + + +def load_org_record(artifacts_dir: str | Path) -> Optional[OrgRecord]: + """Return the ``OrgRecord`` at ``/organization.yaml``, or ``None`` if the deployment + has no record yet. Read-only and lenient (never raises on a missing file) so a pre-F15 deployment + degrades to today's per-profile behaviour rather than erroring.""" + path = record_path(artifacts_dir) + if not path.exists(): + return None + with path.open(encoding="utf-8") as fh: + doc = yaml.safe_load(fh) or {} + return OrgRecord.model_validate(doc) + + +def ensure_org_record(artifacts_dir: str | Path) -> OrgRecord: + """Read the deployment's ``OrgRecord``, or mint a fresh one and persist it. The ``org_id`` mint + chokepoint (relocated here from the per-profile ``org.yaml`` — F14's ``ensure_org_id``): + + 1. an existing ``organization.yaml`` is returned unchanged (mint-once / immutable); + 2. else, if a profile ``org.yaml`` already carries an id (a post-F14 deployment), that id is + LIFTED up into a new record — never re-minted (preserves F14's immutable value); + 3. else a fresh ``uuid4().hex`` is minted into a new record. + + Idempotent: a second call returns the same record (same id). Pure-local — the uuid4 is generated + on-box with no coordinator (the only option under F14's no-egress invariant).""" + existing = load_org_record(artifacts_dir) + if existing is not None: + return existing + + record = OrgRecord(org_id=_lifted_or_minted_org_id(artifacts_dir)) + write_org_record(artifacts_dir, record) + return record + + +def write_org_record(artifacts_dir: str | Path, record: OrgRecord) -> Path: + """Persist ``record`` to ``/organization.yaml`` (creating the dir if needed) and + return the path. Written with the same default permissions as the sibling ``org.yaml`` — the record + holds company context, not secrets, and mode-600 model files are unreadable by the deploy + container user (a known crash-loop), so this deliberately does NOT ``chmod 600``.""" + path = record_path(artifacts_dir) + path.parent.mkdir(parents=True, exist_ok=True) + doc = record.model_dump(mode="json", exclude_none=True) + path.write_text( + yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100), + encoding="utf-8", + ) + return path + + +def _lifted_or_minted_org_id(artifacts_dir: str | Path) -> str: + """The legacy lift: reuse a per-profile ``org_id`` if one exists (post-F14 deployment), else mint. + Kept here (not in the resolver) so the id is written into the record exactly once.""" + from uuid import uuid4 # local generation only — no egress (F14 invariant) + + from . import loader + + return loader.deployment_org_id(artifacts_dir) or uuid4().hex diff --git a/packages/agami-core/src/tools.py b/packages/agami-core/src/tools.py index 5ee504ac..6ad94ccf 100644 --- a/packages/agami-core/src/tools.py +++ b/packages/agami-core/src/tools.py @@ -282,32 +282,34 @@ def _model_version(profile: str) -> str | 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()``.""" + """The single-tenant deployment org id, resolved once per process (F14 / ACE-056; relocated by + F15 / ACE-067). Precedence: ``AGAMI_ORG_ID`` env override -> the id in the deployment-level record + (``organization.yaml``, via ``org_record.load_org_record``) -> the LEGACY per-profile id found by + scanning the artifacts dir (``loader.deployment_org_id`` — a pre-record deployment) -> ``"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. + + F15 relocates the id's home from each profile's ``org.yaml`` up into the one root record, so the + org owns its own identity; the per-profile scan is kept only as the legacy fallback for a deployment + that has a per-profile id but no record yet (the id is lifted into a record on the next onboard). + The artifacts-dir scope (not one 'active' profile) is preserved: a deploy with ``AGAMI_PROFILE`` + unset and the model under a named profile still resolves the deployment id. + + Memoized: at most one resolve 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 + from semantic_model import org_record as OR - # 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()) + art = resolve_artifacts_dir() + record = OR.load_org_record(art) # F15: the record is the home of the id + # Legacy fallback: a pre-record deployment still keeps its id in each profile's org.yaml. + oid = record.org_id if record is not None else L.deployment_org_id(art) except Exception: - oid = None # missing/legacy org.yaml or absent model deps -> single-tenant default + oid = None # missing/legacy record or absent model deps -> single-tenant default return oid or "local" @@ -400,6 +402,29 @@ def _domain_memory(profile: str) -> tuple[str, str | None]: ) +def _company_context(org_id: str) -> "tuple[Any, str]": + """(deployment ``OrgRecord`` | None, company narrative) for the two-level org context (F15 / ACE-069) + — from the DB when AGAMI_DB_URL is set (no file read), else from disk. The company narrative is stored + like USER_MEMORY.md: a company-level ``memory`` row under the empty-datasource sentinel. Both absent ⇒ + ``(None, "")`` so composition degrades to today's per-profile output.""" + from store import Store + + store = Store.from_env() + if store is not None: + from model_store import load_memory, load_organization_record + + try: + record = load_organization_record(store, org_id) + mem = load_memory(store, "", org_id=org_id) # the company narrative rides datasource='' + finally: + store.close() + return record, (mem.get("organization") or "") + from semantic_model import org_record as OR + + art = resolve_artifacts_dir() + return OR.load_org_record(art), (_read_text(OR.narrative_path(art)) or "") + + def _resolve_receipt(profile: str, sql: str) -> dict | None: """The FULL trust receipt (tables / relationships / metrics+review_state / assumptions / warnings) for this query — the SAME assembler the skill uses, so the 'what did this @@ -839,7 +864,13 @@ def tool_get_datasource_schema(args: dict[str, Any]) -> str: from semantic_model import org_draft as _OD org_md_raw, user_md_raw = _domain_memory(profile) - domain_context = _OD.compose_context(org_md_raw, org) + # Two-level (F15 / ACE-069): the shared COMPANY block from the deployment record + this datasource's + # source-specific narrative + derived summary. No record ⇒ compose_org_context degrades to the exact + # pre-F15 single-level output, so a deployment without a record is unaffected. + record, company_md = _company_context(_current_org_id()) + domain_context = _OD.compose_org_context( + record, [org], company_narrative=company_md, source_narratives=[org_md_raw] + ) if domain_context: parts.append(f"\n## Domain context\n{domain_context}") user_mem = _distill_for_llm(user_md_raw) diff --git a/plugins/agami/shared/organization-context-format.md b/plugins/agami/shared/organization-context-format.md index 716205fc..8e3cb789 100644 --- a/plugins/agami/shared/organization-context-format.md +++ b/plugins/agami/shared/organization-context-format.md @@ -2,6 +2,26 @@ Free-form Markdown describing **what this database is about** in the human's own words: the company / product, what the data represents, who the users are. One file per profile at `//ORGANIZATION.md` — context is database-specific. +## Two levels: the company record vs the per-datasource narrative (F15) + +A deployment can connect several databases under **one company**. Company-wide context is written **once** at the deployment level and shared by every datasource; each datasource keeps its **own** vocabulary. Two homes, joined at read time: + +| Level | Where | Holds | Written | +|---|---|---|---| +| **Company** (the deployment) | `/organization.yaml` (the `OrgRecord`) + `/ORGANIZATION.md` (company narrative) | company name/description, `fiscal_year_start_month`, `display_conventions` (currency/rounding/week_start), the company-wide `glossary` — and the company narrative prose | once, at first onboarding; edited via `/agami-model` | +| **Datasource** (each profile) | `//org.yaml` + `/ORGANIZATION.md` | that source's ontology (`key_terminology`, subject areas, …) and a **source-specific** narrative only | per profile | + +`cli org-context` (local) and `get_datasource_schema` (served) both assemble these two levels with `org_draft.compose_org_context(record, [ontology], …)`: the **company block once**, then each datasource's source-specific narrative + derived summary. A federated question spanning several datasources renders the company block **once** and both vocabularies. **With no company record, output degrades to exactly the pre-F15 per-profile assembly** — no error, nothing to migrate. + +### Content-routing rule — where each kind of context goes + +- **Company-wide** (fiscal year, company glossary, a display convention true for the whole company, "who we are" prose) → the **company record** (`organization.yaml` + root `ORGANIZATION.md`). +- **Source-specific** (what THIS database means, a term that resolves differently here) → the **per-profile** files (`/org.yaml` `key_terminology` / `/ORGANIZATION.md`). +- **Per-column** units/encodings → the column's `field_metadata` in the structured model — **never** prose. +- **Personal / stylistic** (how *I* like results displayed) → `USER_MEMORY.md`. + +The rest of this doc describes the per-datasource `ORGANIZATION.md`. + **It holds the human narrative ONLY.** The factual summary — subject areas, conventions, and the decoded domain **glossary** — is NOT stored here. That's derived from the structured model at read time and combined with the narrative when a reader needs the full context. The two homes stay separate so: - a human editing their prose can never accidentally overwrite or delete the auto facts, and diff --git a/plugins/agami/skills/agami-connect/SKILL.md b/plugins/agami/skills/agami-connect/SKILL.md index e4500001..084cbd0e 100644 --- a/plugins/agami/skills/agami-connect/SKILL.md +++ b/plugins/agami/skills/agami-connect/SKILL.md @@ -427,7 +427,7 @@ Introspection can take a while against cloud DBs. Tell the user **before** the f If `//org.yaml` exists and `$ARGUMENTS != reintrospect`: the profile is already onboarded. Offer (AskUserQuestion, no `(Recommended)` — these are equal-weight choices), capped at 4: - **Re-introspect ``** — refresh the structure from the live DB (new/changed tables, columns, FKs) while preserving descriptions, entities, metrics, caveats, and sign-offs (the `reintrospect` path). - **Open model explorer** — browse + curate the existing model and review/sign off the trust layer (`/agami-model`). -- **Onboard another database** — set up a **different** database (a different connection) under a **new** profile, leaving `` untouched. On this choice, **start a fresh onboarding for a new profile**: jump to the profile-naming step (Phase 0a's naming question) → have the user name the new profile (must differ from `` and any existing `[section]` in `/local/credentials`) and pick its DB type → write that profile's `credentials.example` → run the full flow for it. Never reuse or overwrite the current profile's credentials or model. +- **Onboard another database** — set up a **different** database (a different connection), leaving `` untouched. On this choice, **first ask whether it's the same company or a different one** (F15 — see 1.1a), then start a fresh onboarding: jump to the profile-naming step (Phase 0a's naming question) → have the user name the new profile (must differ from `` and any existing `[section]` in `/local/credentials`) and pick its DB type → write that profile's `credentials.example` → run the full flow for it. Never reuse or overwrite the current profile's credentials or model. - **Try the sample database** — explore agami's bundled **`agami-example`** sample (retail + subscriptions, no connection needed), leaving `` untouched. Routes to [Phase 0s](#phase-0s-sample-database-bootstrap-no-connection). This MUST be a real selectable option here (not a prose aside) — a returning user with an onboarded profile has no other visible path to the sample, since plain `/agami-connect` resolves their active profile and lands on this menu. (Cancel isn't a listed option — the modal's Esc / "Other" covers "do nothing"; the four real choices are the actions above. Keep the list at exactly these four.) @@ -436,6 +436,18 @@ If `//org.yaml` exists and `$ARGUMENTS != reintrospect`: The engine **auto-backs-up any legacy (v1) model** (`index.yaml` + per-schema `_schema.yaml`) it finds at the profile root into `.legacy_backup/` before writing — so a first run over an old profile is safe and reversible; surface a one-liner when that happens. +### 1.1a — Same company vs different company (F15 org routing) + +When the user chooses **Onboard another database**, ask (AskUserQuestion) whether the new database belongs to the **same company** or a **different** one. A deployment (one artifacts dir) holds exactly **one** company/organization with many datasources under it; a genuinely different company is a **different folder**. + +- **Same company (the common case)** → the new profile **attaches under the deployment org**: it lands in the **same** ``, shares the same `org_id` (resolved from the root `organization.yaml` — no re-mint, no sibling scan), and **shares the company context** authored earlier. Do **not** re-ask the company narrative (1.4 detects the existing record and skips it). Narrate it: *"This attaches under your **``** organization, sharing the company context you set earlier — its per-database notes stay its own."* +- **Different company** → set up a **new deployment folder**. Pick/confirm a new artifacts dir path, create it, and **repoint** the pointer to it (the same idiom Phase 0a.1 uses for a non-default dir): + ```bash + mkdir -p "" && mkdir -p ~/.config/agami && printf '%s\n' "" > ~/.config/agami/path + grep -qxF 'local/' "/.gitignore" 2>/dev/null || printf 'local/\n' >> "/.gitignore" + ``` + The current org's files are **untouched**; the first onboarding in the new folder mints a **fresh** `org_id` + its own company record (1.4 authors it there). This is the only way to run two companies from one machine — one folder each. + ### 1.2 — Scope: schemas, and the no-catalog case Run `cli areas`/probe is not needed yet — schema discovery happens inside the engine. But **decide scope first**: @@ -466,12 +478,21 @@ Example to say BEFORE introspecting: *"Found 70 tables across 18 schemas. I'll m ### 1.4 — Organization context (MANDATORY — ALWAYS ASK) -This runs on **every** invocation. The user's yes/skip is theirs; the skill never decides for them. "don't ask clarifying questions" does NOT cancel this — it's required state-gathering, not a clarifying question. **Only conditional skip:** `ORGANIZATION.md` exists and has been edited beyond the template. +Two levels (F15): **company-wide** context is written **once** at the deployment root and shared by every datasource; each datasource may add its own **source-specific** note. The skill never decides yes/skip for the user; "don't ask clarifying questions" does NOT cancel this — it's required state-gathering. + +**Branch on whether this deployment already has a company record.** Check for `/organization.yaml` **or** `/ORGANIZATION.md` at the artifacts **root** (not the profile dir): + +**A. First onboard — no company record yet → ask the COMPANY question (once).** +> Want to give me a one-paragraph description of what your **company** is about? It's shared across every database you connect here and improves NL→SQL a lot. Examples: what the company/product is, what "MRR" or "active user" means company-wide. + +`Yes — I'll type it now (Other field)` → write their words to the **root** `/ORGANIZATION.md` under `# About this company` (**company narrative ONLY** — no model facts; those are derived at read time via `cli org-context`). The deployment's `org_id` + a starter `/organization.yaml` record are minted automatically when the model is written (Phase 2 build), so you need only author the prose here. `Skip — I'll auto-fill it later (Recommended)` → leave the root narrative absent (composition degrades cleanly). Rich company-context editing (description, conventions, glossary) lives in `/agami-model` — mention it. + +**B. Subsequent onboard — a company record already exists → do NOT re-ask the company question.** The new profile attaches under the existing org (1.1a); narrate *"Sharing the **``** context you set earlier."* Then optionally ask a **source-specific** note for THIS database only: +> Anything specific to **this** database I should know (what it is, a term that means something different here)? Optional — the company context already applies. -**AskUserQuestion:** -> Want to give me a one-paragraph description of what this database is about? It improves NL→SQL accuracy a lot. Examples: what the company/product is, what "MRR" or "active user" means in your terms. +`Yes` → write to the **per-profile** `//ORGANIZATION.md` under `# About this database` (source-specific narrative only). `Skip` → leave it absent; Phase 2f writes a short per-database starter. -`Yes — I'll type it now (Other field)` → write their words to `//ORGANIZATION.md` under `# About this database`. **Their narrative ONLY** — do NOT append model facts (subject areas, metrics, glossary). Those are *derived from the model at read time* (the query path assembles them via `cli org-context`), so they never get baked into the editable prose file where a human could clobber them. `Skip — I'll auto-fill it from my data (Recommended)` → leave ORGANIZATION.md absent for now; Phase 2f writes a short human-narrative starter. `chmod 600` whatever you write. See [`shared/organization-context-format.md`](../../shared/organization-context-format.md). +`chmod 600` whatever you write. See [`shared/organization-context-format.md`](../../shared/organization-context-format.md) for the content-routing rule (company-wide → root; source-specific → per-profile; per-column units → the structured model; personal → `USER_MEMORY.md`). ### 1.5 — Existing data model / semantic layer (MANDATORY — ALWAYS ASK) diff --git a/tests/test_compose_org_context.py b/tests/test_compose_org_context.py new file mode 100644 index 00000000..2e0587b2 --- /dev/null +++ b/tests/test_compose_org_context.py @@ -0,0 +1,86 @@ +"""F15 / ACE-069: two-level org-context composition (shared company block + per-datasource ontology). + +Pins the headline behaviours: per-source vocabulary isolation (single-source), the company block rendered +EXACTLY ONCE for a federated (multi-source) question, and byte-identical graceful degradation to the +pre-F15 `compose_context` when there is no record (what keeps every old deployment working). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("pydantic") + +PKG_SRC = Path(__file__).resolve().parent.parent / "packages" / "agami-core" / "src" +if str(PKG_SRC) not in sys.path: + sys.path.insert(0, str(PKG_SRC)) + +from semantic_model import org_draft as OD # noqa: E402 +from semantic_model.models import ( # noqa: E402 + DisplayConventions, + Organization, + OrgRecord, + SubjectArea, +) + + +def _org(name: str, area: str, account_means: str) -> Organization: + return Organization( + organization=name, + subject_areas=[SubjectArea(name=area, description=f"{area} area")], + key_terminology={"Account": account_means}, + ) + + +def _record() -> OrgRecord: + return OrgRecord( + org_id="o1", + name="Acme Corp", + description="A demo company.", + fiscal_year_start_month=4, + display_conventions=DisplayConventions(currency="USD", rounding=2, week_start="monday"), + glossary={"ARR": "annual recurring revenue"}, + ) + + +CRM = _org("crm", "Sales", "customer") +ERP = _org("erp", "Ledger", "GL account") + + +def test_single_source_carries_company_block_and_that_sources_vocabulary_only(): + a = OD.compose_org_context(_record(), [CRM]) + b = OD.compose_org_context(_record(), [ERP]) + # both carry the shared company block … + assert "Acme Corp — company context" in a and "Acme Corp — company context" in b + assert "ARR" in a and "ARR" in b # …and the company glossary + # … but each carries ONLY its own source vocabulary (per-source isolation, both directions). + assert "customer" in a and "GL account" not in a + assert "GL account" in b and "customer" not in b + + +def test_federated_renders_company_block_once_and_both_vocabularies(): + fed = OD.compose_org_context(_record(), [CRM, ERP]) + assert fed.count("Acme Corp — company context") == 1 # company context exactly once + assert "crm — datasource context" in fed and "erp — datasource context" in fed + assert "customer" in fed and "GL account" in fed # both vocabularies present + + +def test_no_record_degrades_byte_identically_to_pre_f15_output(): + # The degradation contract: without a record, output equals today's per-profile compose_context. + got = OD.compose_org_context(None, [CRM], source_narratives=["CRM-specific notes."]) + assert got == OD.compose_context("CRM-specific notes.", CRM) + + +def test_company_conventions_and_narrative_render_in_the_company_block(): + got = OD.compose_org_context( + _record(), [CRM], company_narrative="We sell widgets.", source_narratives=["src note"] + ) + assert "We sell widgets." in got # company narrative + assert "Fiscal year starts in month 4." in got # structured conventions + assert "Currency: USD." in got + assert "Rounding: 2 decimal places." in got + assert "Week starts on monday." in got + assert "src note" in got # source-specific narrative still shown, under the datasource heading diff --git a/tests/test_org_record.py b/tests/test_org_record.py new file mode 100644 index 00000000..7cc38f04 --- /dev/null +++ b/tests/test_org_record.py @@ -0,0 +1,132 @@ +"""F15 / ACE-067: the deployment-level organization record + the relocated org_id. + +The record (`/organization.yaml`) is the new home of F14's `org_id`: minted once, +immutable, deployment-scoped — but stored ABOVE the profiles so a multi-datasource company writes its +identity (and, later, its company context) once instead of per profile. These tests pin the mint-once +rule, the "second profile shares the id via the record (no sibling scan)" behaviour, the idempotent +legacy lift of a pre-record id, and lossless round-tripping of the record's structured content. + +No network is touched (uuid4 + file I/O only); `tests/test_privacy_no_network.py` is the separate +static gate proving no egress primitive was introduced. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("pydantic") +pytest.importorskip("yaml") + +import yaml # noqa: E402 + +PKG_SRC = Path(__file__).resolve().parent.parent / "packages" / "agami-core" / "src" +if str(PKG_SRC) not in sys.path: + sys.path.insert(0, str(PKG_SRC)) + +import tools # noqa: E402 +from semantic_model import build, loader # noqa: E402 +from semantic_model import org_record as OR # noqa: E402 +from semantic_model.models import DisplayConventions, Organization, OrgRecord # noqa: E402 + +_HEX = set("0123456789abcdef") + + +def _minimal_org(name: str = "acme") -> Organization: + return Organization(organization=name) + + +def test_ensure_org_record_mints_once_into_organization_yaml(tmp_path): + assert OR.load_org_record(tmp_path) is None # nothing yet + rec = OR.ensure_org_record(tmp_path) + + assert OR.record_path(tmp_path).exists() # persisted at the artifacts-dir ROOT + assert len(rec.org_id) == 32 and set(rec.org_id) <= _HEX # a locally-minted uuid4 hex + # Idempotent: a second call returns the SAME id, never a re-mint. + assert OR.ensure_org_record(tmp_path).org_id == rec.org_id + assert OR.load_org_record(tmp_path).org_id == rec.org_id + + +def test_second_profile_shares_the_record_id_without_a_sibling_scan(tmp_path, monkeypatch): + # A company with several datasources stays ONE tenant — but F15 gets the shared id from the root + # record, not by scanning sibling profiles. Assert the deletion: the sibling-scan resolver is never + # consulted while writing the second profile. + build.write_tree(_minimal_org("acme"), tmp_path / "sales") + deployment_id = OR.load_org_record(tmp_path).org_id + assert loader.load_org_id(tmp_path / "sales") == deployment_id # profile stamp agrees + + calls = {"n": 0} + real = loader.deployment_org_id + + def _counting(art): + calls["n"] += 1 + return real(art) + + monkeypatch.setattr(loader, "deployment_org_id", _counting) + build.write_tree(_minimal_org("acme"), tmp_path / "support") + + assert loader.load_org_id(tmp_path / "support") == deployment_id # same id, from the record + assert calls["n"] == 0 # adopt-sibling is gone: the record answered without a scan + + +def test_legacy_lift_preserves_a_pre_record_org_id_idempotently(tmp_path): + # A post-F14 / pre-F15 deployment keeps its id in a profile's org.yaml and has no record yet. + # ensure_org_record LIFTS that id up (never re-mints), and is idempotent on re-run. + prof = tmp_path / "northpeak_salesforce" + prof.mkdir() + (prof / "org.yaml").write_text( + yaml.safe_dump({"org_id": "legacyid0000", "organization": "acme"}), encoding="utf-8" + ) + assert OR.load_org_record(tmp_path) is None + + rec = OR.ensure_org_record(tmp_path) + assert rec.org_id == "legacyid0000" # lifted, not a fresh uuid4 + assert OR.ensure_org_record(tmp_path).org_id == "legacyid0000" # idempotent + + +def test_resolver_reads_the_record_then_falls_back_to_legacy_then_local(tmp_path, monkeypatch): + monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.delenv("AGAMI_ORG_ID", raising=False) + + # (a) no record, no profile id -> the single-tenant "local" sentinel (degradation, no crash). + tools.resolved_org_id.cache_clear() + assert tools.resolved_org_id() == "local" + + # (b) a legacy per-profile id, still no record -> the legacy scan resolves it. + prof = tmp_path / "old" + prof.mkdir() + (prof / "org.yaml").write_text( + yaml.safe_dump({"org_id": "legacyid0000", "organization": "x"}), encoding="utf-8" + ) + tools.resolved_org_id.cache_clear() + assert tools.resolved_org_id() == "legacyid0000" + + # (c) once a record exists, IT is the source of truth (precedence over the per-profile scan). + rec = OR.ensure_org_record(tmp_path) # lifts legacyid0000 up into the record + tools.resolved_org_id.cache_clear() + assert tools.resolved_org_id() == rec.org_id == "legacyid0000" + + +def test_org_record_roundtrips_structured_content_losslessly(tmp_path): + rec = OrgRecord( + org_id="abc123", + name="Acme", + description="a demo company", + fiscal_year_start_month=4, + display_conventions=DisplayConventions( + currency="USD", rounding=2, week_start="monday", notes=["fiscal weeks"] + ), + glossary={"ARR": "annual recurring revenue"}, + ) + OR.write_org_record(tmp_path, rec) + assert ( + OR.load_org_record(tmp_path) == rec + ) # every structured field survives the YAML round-trip + + +def test_bare_record_is_valid_and_fiscal_year_bounds_enforced(): + assert OrgRecord(org_id="x").fiscal_year_start_month is None # id-only record validates + with pytest.raises(ValueError): + OrgRecord(org_id="x", fiscal_year_start_month=13) # same 1..12 bound as Organization diff --git a/tests/test_org_record_deploy.py b/tests/test_org_record_deploy.py new file mode 100644 index 00000000..b24f85c2 --- /dev/null +++ b/tests/test_org_record_deploy.py @@ -0,0 +1,155 @@ +"""F15 / ACE-068: derive the org record into the Postgres `organization` table. + +The table is the one company-level row (keyed on org_id alone) and is written with an FK-SAFE UPSERT, +not the clear-then-insert the other serving writers use — because in the hosted stack org_membership / +license FK-reference it. These tests pin: the migration + columns; lossless round-trip; upsert-replaces; +the None-degradation contract; the FK-safety crux (a redeploy must NOT delete an FK-referenced row); the +hosted coexistence the whole shared-table decision rests on; and the end-to-end "one org row + two +datasource_model rows on one org_id" shape. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("pydantic") +pytest.importorskip("yaml") + +PKG_SRC = Path(__file__).resolve().parent.parent / "packages" / "agami-core" / "src" +if str(PKG_SRC) not in sys.path: + sys.path.insert(0, str(PKG_SRC)) + +import model_deploy # noqa: E402 +import model_store as MS # noqa: E402 +import tools # noqa: E402 +from semantic_model import org_record as OR # noqa: E402 +from semantic_model.models import DisplayConventions, OrgRecord # noqa: E402 +from store import Store # noqa: E402 + + +def _store() -> Store: + s = Store.connect("sqlite://") + s.run_migrations() + return s + + +def _full_record(org_id: str = "org1") -> OrgRecord: + return OrgRecord( + org_id=org_id, + name="Acme", + description="a demo company", + fiscal_year_start_month=4, + display_conventions=DisplayConventions(currency="USD", rounding=2, week_start="monday"), + glossary={"ARR": "annual recurring revenue"}, + ) + + +def test_write_then_load_round_trips_losslessly(): + s = _store() + rec = _full_record() + MS.write_organization_record(s, rec, org_id="org1") + assert ( + MS.load_organization_record(s, "org1") == rec + ) # incl. display_conventions + glossary in doc + s.close() + + +def test_second_write_replaces_not_duplicates(): + s = _store() + MS.write_organization_record(s, _full_record(), org_id="org1") + changed = OrgRecord(org_id="org1", name="Acme", description="new desc") + MS.write_organization_record(s, changed, org_id="org1") + n = s.query("SELECT COUNT(*) AS c FROM organization WHERE org_id = ?", ("org1",))[0]["c"] + assert n == 1 # a redeploy replaces the row, never appends + assert MS.load_organization_record(s, "org1").description == "new desc" + s.close() + + +def test_load_returns_none_when_absent(): + s = _store() + assert MS.load_organization_record(s, "nope") is None # the ACE-069 degradation contract + s.close() + + +def test_upsert_is_fk_safe_against_a_referencing_membership(): + # THE CRUX. Mirror agami-hosted: a membership row FK-references organization(org_id). A DELETE-based + # write would raise FOREIGN KEY constraint failed on redeploy; the UPSERT must not. + s = _store() + MS.write_organization_record(s, _full_record(), org_id="org1") + s.execute( + "CREATE TABLE org_membership (principal_id TEXT NOT NULL, " + "org_id TEXT NOT NULL REFERENCES organization(org_id), PRIMARY KEY (principal_id, org_id))" + ) + s.execute("INSERT INTO org_membership (principal_id, org_id) VALUES (?, ?)", ("bob", "org1")) + s.commit() + + MS.write_organization_record(s, _full_record(), org_id="org1") # redeploy — must not FK-crash + assert ( + s.query("SELECT COUNT(*) AS c FROM org_membership WHERE org_id = ?", ("org1",))[0]["c"] == 1 + ) + s.close() + + +def test_coexists_with_a_hosted_shape_row(): + # Hosted onboards a tenant first: INSERT (org_id, org_name, created_at) — no `doc` (rides the DEFAULT). + # Then core deploys: the upsert fills content but PRESERVES hosted's org_name + created_at. + s = _store() + s.execute( + "INSERT INTO organization (org_id, org_name, created_at) VALUES (?, ?, ?)", + ("org2", "Hosted Co", "2026-07-23T00:00:00Z"), + ) + s.commit() + MS.write_organization_record( + s, OrgRecord(org_id="org2", name=None, description="core desc"), org_id="org2" + ) + row = s.query( + "SELECT org_name, created_at, description FROM organization WHERE org_id = ?", ("org2",) + )[0] + assert row["org_name"] == "Hosted Co" # preserved (COALESCE keeps the existing non-null) + assert row["created_at"] == "2026-07-23T00:00:00Z" # core never touches it + assert row["description"] == "core desc" # content upserted + s.close() + + +def _write_profile(root: Path, datasource: str) -> None: + d = root / datasource + d.mkdir(parents=True, exist_ok=True) + (d / "org.yaml").write_text( + f"organization: acme\nversion: 1\ndescription: {datasource} model.\n" + "storage_connections:\n - name: warehouse\n storage_type: PostgreSQL\nsubject_areas: []\n" + ) + + +def test_deploy_writes_one_org_row_and_two_datasource_rows_on_one_org_id(tmp_path, monkeypatch): + arts = tmp_path / "artifacts" + arts.mkdir() + _write_profile(arts, "crm") + _write_profile(arts, "erp") + OR.write_org_record(arts, _full_record(org_id="deployorg")) # the root organization.yaml + OR.narrative_path(arts).write_text("# About Acme\nWe sell widgets.\n") # company narrative → memory row + + monkeypatch.setenv("AGAMI_DB_URL", "sqlite://" + str(tmp_path / "m.db")) + monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(arts)) + monkeypatch.setenv("AGAMI_ORG_ID", "deployorg") # pin the resolved org for the assertion + tools.resolved_org_id.cache_clear() + + assert model_deploy.main([]) == 0 + + s = Store.connect("sqlite://" + str(tmp_path / "m.db")) + org_rows = s.query("SELECT org_id FROM organization") + ds_rows = s.query("SELECT org_id, datasource FROM datasource_model ORDER BY datasource") + company_mem = s.query( + "SELECT content FROM memory WHERE org_id = ? AND datasource = '' AND kind = 'organization'", + ("deployorg",), + ) + s.close() + + assert [r["org_id"] for r in org_rows] == ["deployorg"] # exactly ONE org row + assert company_mem and "widgets" in company_mem[0]["content"] # company narrative → company-level row + assert [(r["org_id"], r["datasource"]) for r in ds_rows] == [ + ("deployorg", "crm"), + ("deployorg", "erp"), + ] # two datasource rows, same org_id diff --git a/tests/test_organization_context_e2e.py b/tests/test_organization_context_e2e.py new file mode 100644 index 00000000..cf03e596 --- /dev/null +++ b/tests/test_organization_context_e2e.py @@ -0,0 +1,117 @@ +"""F15 end-to-end: one shared company record across two datasources, each with its OWN vocabulary, +reflected on BOTH surfaces (local `cli org-context` and the served `get_datasource_schema`). + +This is the feature demo as a test: a company connects a CRM and an ERP under one org; "Account" resolves +to a customer in the CRM and a GL account in the ERP; every answer carries the same company context; and a +deployment with no record degrades to the pre-F15 per-profile output. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("pydantic") +pytest.importorskip("yaml") + +import yaml # noqa: E402 + +PKG_SRC = Path(__file__).resolve().parent.parent / "packages" / "agami-core" / "src" +if str(PKG_SRC) not in sys.path: + sys.path.insert(0, str(PKG_SRC)) + +import tools # noqa: E402 +from semantic_model import build # noqa: E402 +from semantic_model import org_record as OR # noqa: E402 +from semantic_model.cli import main as cli_main # noqa: E402 +from semantic_model.models import ( # noqa: E402 + DisplayConventions, + Organization, + OrgRecord, + SubjectArea, +) + + +def _profile(root: Path, name: str, area: str, account_means: str) -> None: + org = Organization( + organization=name, + subject_areas=[SubjectArea(name=area, description=f"{area} area")], + ) + build.write_tree(org, root / name) + # key_terminology is written by the enrichment / set-terminology path, not write_tree — inject it so + # the source-specific vocabulary ("Account" resolves per source) round-trips through load_organization. + org_yaml = root / name / "org.yaml" + doc = yaml.safe_load(org_yaml.read_text()) + doc["key_terminology"] = {"Account": account_means} + org_yaml.write_text(yaml.safe_dump(doc, sort_keys=False)) + + +def _company(root: Path) -> None: + OR.write_org_record( + root, + OrgRecord( + org_id="deployorg", + name="Acme Corp", + description="A demo company.", + fiscal_year_start_month=4, + display_conventions=DisplayConventions(currency="USD"), + glossary={"ARR": "annual recurring revenue"}, + ), + ) + OR.narrative_path(root).write_text("# About Acme\nWe sell widgets across regions.\n") + + +def _org_context(root: Path, profile: str, capsys) -> str: + assert cli_main(["org-context", str(root / profile)]) == 0 + return capsys.readouterr().out + + +def _served_domain_context(profile: str) -> str: + # Disk-backed serve path (no AGAMI_DB_URL): domain-context text trails the JSON head. + out = tools.tool_get_datasource_schema({"datasource": profile}) + head, end = json.JSONDecoder().raw_decode(out) + return out[end:] + + +def test_local_surface_shows_company_block_once_and_this_sources_vocabulary(tmp_path, capsys, monkeypatch): + _profile(tmp_path, "crm", "Sales", "customer") + _profile(tmp_path, "erp", "Ledger", "GL account") + _company(tmp_path) + monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(tmp_path)) + + crm_ctx = _org_context(tmp_path, "crm", capsys) + assert crm_ctx.count("Acme Corp — company context") == 1 # shared company block, once + assert "ARR" in crm_ctx # company glossary + assert "customer" in crm_ctx and "GL account" not in crm_ctx # CRM's own vocabulary only + + erp_ctx = _org_context(tmp_path, "erp", capsys) + assert "GL account" in erp_ctx and "customer" not in erp_ctx # ERP's own vocabulary only + + +def test_served_surface_reflects_the_two_level_composition(tmp_path, monkeypatch): + _profile(tmp_path, "crm", "Sales", "customer") + _company(tmp_path) + monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(tmp_path)) + monkeypatch.delenv("AGAMI_DB_URL", raising=False) + monkeypatch.delenv("AGAMI_ORG_ID", raising=False) + tools.resolved_org_id.cache_clear() + + ctx = _served_domain_context("crm") + assert "Acme Corp — company context" in ctx # the served MCP context carries the company block … + assert "We sell widgets across regions." in ctx # … incl. the company narrative … + assert "customer" in ctx # … and the CRM vocabulary + + +def test_no_record_degrades_to_per_profile_on_both_surfaces(tmp_path, capsys, monkeypatch): + _profile(tmp_path, "crm", "Sales", "customer") + # A record was minted by write_tree; remove it to simulate a pre-F15 deployment. + OR.record_path(tmp_path).unlink() + monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(tmp_path)) + tools.resolved_org_id.cache_clear() + + ctx = _org_context(tmp_path, "crm", capsys) + assert "company context" not in ctx # no company block … + assert "customer" in ctx # … but the per-profile model still composes, no error diff --git a/tests/test_schema.py b/tests/test_schema.py index 824ac866..a05e6a13 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -36,6 +36,22 @@ def test_real_migrations_create_all_tables_on_empty_db(): s.close() +def test_organization_registry_table_exists(): + # F15 / ACE-068: the one company-level row, keyed on org_id ALONE (not (org_id, datasource)). Its + # columns are a superset of agami-hosted's tenant `organization` table so one physical table serves + # both; `doc` carries a DEFAULT so hosted's INSERT (which omits it) still satisfies NOT NULL. + s = Store.connect("sqlite://") + ran = s.run_migrations() + assert "013_organization.sql" in ran + assert "organization" in _tables(s) + cols = {r["name"] for r in s.query("PRAGMA table_info(organization)")} + assert {"org_id", "org_name", "description", "doc", "created_at"} == cols + # keyed on org_id alone + pk = [r["name"] for r in s.query("PRAGMA table_info(organization)") if r["pk"]] + assert pk == ["org_id"] + s.close() + + def test_oauth_tables_exist(): # The OAuth provider owns these: oauth_client (registered clients) + oauth_state (authorization # codes bound to their PKCE challenge + redirect + the authenticated username). From 47a0518698ecac566ea78221dc25ad500893683d Mon Sep 17 00:00:00 2001 From: vishal-agami Date: Fri, 24 Jul 2026 15:40:31 +0530 Subject: [PATCH 2/2] feat(core): populate org record (name/description + datasources list) + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the F15 organization-context work, addressing testing feedback and PR review: - Populate the root record: onboarding now writes the company name + description via a new `sm set-org` CLI command (set_org_fields), and the seeded connect todo list includes a mandatory organization-context step so the company question is actually asked (it was being skipped because it wasn't in the plan). - Auto-maintained `datasources` list on OrgRecord: rebuilt from the on-disk profile dirs on every onboard (build.write_tree) and deploy (model_deploy), carried into the Postgres organization row's doc. Never hand-edited, so it can't drift. - Keep per-profile ORGANIZATION.md as the datasource narrative (restored the per-database question every onboard); the root organization.yaml is purely additive. Review fixes (PR #143): - Served context path opens ONE DB connection instead of two: _domain_memory + _company_context collapsed into a single _context_sources() that reads per-datasource memory, the org record, and the company narrative on one Store (hot tool path). - chmod 644, not 600, on ORGANIZATION.md narrative files — non-secret model files that must stay readable in deploy bundles / for teammates (600 causes the deploy container crash-loop). Secrets (local/, credentials, .config) keep 600. - Fully-rooted //ORGANIZATION.md paths in the shared doc. - Scrubbed remaining internal project references from the plugin skills/shared docs (instructions only). Verified: full gate green (ruff + tests + gitleaks), 99% patch coverage. Second-datasource scenario verified — same org_id, name/description preserved, datasources list grows to include both. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agami-core/src/model_deploy.py | 5 +- packages/agami-core/src/model_store.py | 2 + .../agami-core/src/semantic_model/build.py | 5 +- packages/agami-core/src/semantic_model/cli.py | 21 +++++++ .../agami-core/src/semantic_model/models.py | 3 + .../src/semantic_model/org_record.py | 39 +++++++++++++ packages/agami-core/src/tools.py | 57 ++++++++----------- .../shared/organization-context-format.md | 10 ++-- plugins/agami/skills/agami-connect/SKILL.md | 50 +++++++++------- tests/test_model_store_roundtrip.py | 2 +- tests/test_org_record.py | 47 +++++++++++++++ tests/test_org_record_deploy.py | 3 + 12 files changed, 180 insertions(+), 64 deletions(-) diff --git a/packages/agami-core/src/model_deploy.py b/packages/agami-core/src/model_deploy.py index 8c060f50..92712c7c 100644 --- a/packages/agami-core/src/model_deploy.py +++ b/packages/agami-core/src/model_deploy.py @@ -103,7 +103,7 @@ 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() @@ -121,6 +121,9 @@ def _deploy_org_record(store: Store, artifacts_dir: Path, org_id: str | None = N 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 diff --git a/packages/agami-core/src/model_store.py b/packages/agami-core/src/model_store.py index 77899392..7af40d07 100644 --- a/packages/agami-core/src/model_store.py +++ b/packages/agami-core/src/model_store.py @@ -264,6 +264,7 @@ def write_organization_record(store: Store, record: OrgRecord, org_id: str = DEF "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( @@ -295,6 +296,7 @@ def load_organization_record(store: Store, org_id: str = DEFAULT_ORG) -> OrgReco 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 [], ) diff --git a/packages/agami-core/src/semantic_model/build.py b/packages/agami-core/src/semantic_model/build.py index f7d9b847..173a9edb 100644 --- a/packages/agami-core/src/semantic_model/build.py +++ b/packages/agami-core/src/semantic_model/build.py @@ -568,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 diff --git a/packages/agami-core/src/semantic_model/cli.py b/packages/agami-core/src/semantic_model/cli.py index 6fa1509e..8f54d0d8 100644 --- a/packages/agami-core/src/semantic_model/cli.py +++ b/packages/agami-core/src/semantic_model/cli.py @@ -166,6 +166,21 @@ def cmd_org_context(args) -> int: 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 + + def cmd_examples(args) -> int: examples = L.list_prompt_examples(args.root, args.area) matches = RT.get_prompt_examples(args.query, examples, top_k=args.top_k) @@ -1118,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) diff --git a/packages/agami-core/src/semantic_model/models.py b/packages/agami-core/src/semantic_model/models.py index 3838e3b6..cca23cb2 100644 --- a/packages/agami-core/src/semantic_model/models.py +++ b/packages/agami-core/src/semantic_model/models.py @@ -710,6 +710,9 @@ class OrgRecord(_Base): # Company-wide glossary: term -> one-line definition. Same shape as Organization.key_terminology # (the established glossary type), but scoped to the COMPANY rather than a single datasource. glossary: dict[str, str] = Field(default_factory=dict) + # The datasources (profile names) attached under this org. Auto-maintained: rebuilt from the profile + # directories present on disk on every onboard/deploy, never hand-edited, so it cannot drift. + datasources: list[str] = Field(default_factory=list) @field_validator("fiscal_year_start_month") @classmethod diff --git a/packages/agami-core/src/semantic_model/org_record.py b/packages/agami-core/src/semantic_model/org_record.py index 61ac7abe..922c8463 100644 --- a/packages/agami-core/src/semantic_model/org_record.py +++ b/packages/agami-core/src/semantic_model/org_record.py @@ -87,6 +87,45 @@ def write_org_record(artifacts_dir: str | Path, record: OrgRecord) -> Path: return path +def set_org_fields( + artifacts_dir: str | Path, + *, + name: Optional[str] = None, + description: Optional[str] = None, +) -> OrgRecord: + """Set the human-authored company fields on the record, minting it first if absent. Only the fields + passed (non-``None``) are updated; the rest are left untouched. Persists and returns the record. + This is the write path onboarding uses to populate ``name``/``description`` (the record is otherwise + minted with just an ``org_id``).""" + record = ensure_org_record(artifacts_dir) + changes = {k: v for k, v in {"name": name, "description": description}.items() if v is not None} + if changes: + record = record.model_copy(update=changes) + write_org_record(artifacts_dir, record) + return record + + +def refresh_datasources(artifacts_dir: str | Path) -> Optional[OrgRecord]: + """Rebuild the record's ``datasources`` list from the profile directories actually present on disk + (each immediate subdir holding an ``org.yaml``), so the list is auto-maintained and can never drift. + Returns ``None`` (and writes nothing) when there is neither a record nor any profile yet; otherwise + mints the record if needed, updates the list, persists, and returns it.""" + art = Path(artifacts_dir) + names = ( + sorted(p.name for p in art.iterdir() if p.is_dir() and (p / "org.yaml").exists()) + if art.is_dir() + else [] + ) + existing = load_org_record(artifacts_dir) + if existing is None and not names: + return None + record = existing or ensure_org_record(artifacts_dir) + if record.datasources != names: + record = record.model_copy(update={"datasources": names}) + write_org_record(artifacts_dir, record) + return record + + def _lifted_or_minted_org_id(artifacts_dir: str | Path) -> str: """The legacy lift: reuse a per-profile ``org_id`` if one exists (post-F14 deployment), else mint. Kept here (not in the resolver) so the id is written into the record exactly once.""" diff --git a/packages/agami-core/src/tools.py b/packages/agami-core/src/tools.py index 6ad94ccf..d650f020 100644 --- a/packages/agami-core/src/tools.py +++ b/packages/agami-core/src/tools.py @@ -382,31 +382,13 @@ def get_cached_org(profile: str): return org -def _domain_memory(profile: str) -> tuple[str, str | None]: - """(ORGANIZATION.md text, USER_MEMORY.md text) for the domain-context block — from the DB when - AGAMI_DB_URL is set (no file read at runtime), else from disk.""" - from store import Store - - store = Store.from_env() - if store is not None: - from model_store import load_memory - - try: - mem = load_memory(store, profile, org_id=_current_org_id()) - finally: - store.close() - return mem.get("organization") or "", mem.get("user") - artifacts = resolve_artifacts_dir() - return (_read_text(artifacts / profile / "ORGANIZATION.md") or ""), _read_text( - artifacts / "USER_MEMORY.md" - ) - - -def _company_context(org_id: str) -> "tuple[Any, str]": - """(deployment ``OrgRecord`` | None, company narrative) for the two-level org context (F15 / ACE-069) - — from the DB when AGAMI_DB_URL is set (no file read), else from disk. The company narrative is stored - like USER_MEMORY.md: a company-level ``memory`` row under the empty-datasource sentinel. Both absent ⇒ - ``(None, "")`` so composition degrades to today's per-profile output.""" +def _context_sources(profile: str, org_id: str) -> "tuple[str, str | None, Any, str]": + """Every piece of domain-context text the served schema needs, read in ONE place: the per-datasource + ORGANIZATION.md, USER_MEMORY.md, the deployment ``OrgRecord``, and the company narrative. Under the DB + backend all of it is read on a SINGLE connection — this is a hot tool path, so open ``Store`` once, not + per-source; with no DB configured it falls back to file reads (a DB deploy reads no files at runtime). + Returns ``(org_md, user_md, record | None, company_md)``; missing pieces come back empty/``None`` so the + two-level composition degrades cleanly.""" from store import Store store = Store.from_env() @@ -414,15 +396,22 @@ def _company_context(org_id: str) -> "tuple[Any, str]": from model_store import load_memory, load_organization_record try: - record = load_organization_record(store, org_id) - mem = load_memory(store, "", org_id=org_id) # the company narrative rides datasource='' + mem = load_memory(store, profile, org_id=org_id) # per-datasource ORGANIZATION.md + USER_MEMORY.md + record = load_organization_record(store, org_id) # the deployment company record + company = load_memory(store, "", org_id=org_id) # company narrative rides the datasource='' row finally: store.close() - return record, (mem.get("organization") or "") + return mem.get("organization") or "", mem.get("user"), record, (company.get("organization") or "") + from semantic_model import org_record as OR art = resolve_artifacts_dir() - return OR.load_org_record(art), (_read_text(OR.narrative_path(art)) or "") + return ( + _read_text(art / profile / "ORGANIZATION.md") or "", + _read_text(art / "USER_MEMORY.md"), + OR.load_org_record(art), + _read_text(OR.narrative_path(art)) or "", + ) def _resolve_receipt(profile: str, sql: str) -> dict | None: @@ -863,11 +852,11 @@ def tool_get_datasource_schema(args: dict[str, Any]) -> str: # otherwise — so a DB-only deploy reads no files at runtime. from semantic_model import org_draft as _OD - org_md_raw, user_md_raw = _domain_memory(profile) - # Two-level (F15 / ACE-069): the shared COMPANY block from the deployment record + this datasource's - # source-specific narrative + derived summary. No record ⇒ compose_org_context degrades to the exact - # pre-F15 single-level output, so a deployment without a record is unaffected. - record, company_md = _company_context(_current_org_id()) + # Two-level context: the shared COMPANY block from the deployment record + this datasource's own + # narrative + derived summary. All the text is read on ONE DB connection (see _context_sources). No + # record ⇒ compose_org_context degrades to the single-level output, so a deployment without a record + # is unaffected. + org_md_raw, user_md_raw, record, company_md = _context_sources(profile, _current_org_id()) domain_context = _OD.compose_org_context( record, [org], company_narrative=company_md, source_narratives=[org_md_raw] ) diff --git a/plugins/agami/shared/organization-context-format.md b/plugins/agami/shared/organization-context-format.md index 8e3cb789..55cbed5f 100644 --- a/plugins/agami/shared/organization-context-format.md +++ b/plugins/agami/shared/organization-context-format.md @@ -2,21 +2,21 @@ Free-form Markdown describing **what this database is about** in the human's own words: the company / product, what the data represents, who the users are. One file per profile at `//ORGANIZATION.md` — context is database-specific. -## Two levels: the company record vs the per-datasource narrative (F15) +## Two levels: the company record vs the per-datasource narrative A deployment can connect several databases under **one company**. Company-wide context is written **once** at the deployment level and shared by every datasource; each datasource keeps its **own** vocabulary. Two homes, joined at read time: | Level | Where | Holds | Written | |---|---|---|---| -| **Company** (the deployment) | `/organization.yaml` (the `OrgRecord`) + `/ORGANIZATION.md` (company narrative) | company name/description, `fiscal_year_start_month`, `display_conventions` (currency/rounding/week_start), the company-wide `glossary` — and the company narrative prose | once, at first onboarding; edited via `/agami-model` | -| **Datasource** (each profile) | `//org.yaml` + `/ORGANIZATION.md` | that source's ontology (`key_terminology`, subject areas, …) and a **source-specific** narrative only | per profile | +| **Company** (the deployment) | `/organization.yaml` (the `OrgRecord`) + `/ORGANIZATION.md` (company narrative) | company `name`/`description`, `fiscal_year_start_month`, `display_conventions` (currency/rounding/week_start), the company-wide `glossary`, and an auto-maintained `datasources` list — plus the company narrative prose | `name`/`description` + narrative at first onboarding; `datasources` rebuilt automatically on each onboard/deploy; conventions/glossary edited via `/agami-model` | +| **Datasource** (each profile) | `//org.yaml` + `//ORGANIZATION.md` | that source's ontology (`key_terminology`, subject areas, …) and a **source-specific** narrative only | per profile | -`cli org-context` (local) and `get_datasource_schema` (served) both assemble these two levels with `org_draft.compose_org_context(record, [ontology], …)`: the **company block once**, then each datasource's source-specific narrative + derived summary. A federated question spanning several datasources renders the company block **once** and both vocabularies. **With no company record, output degrades to exactly the pre-F15 per-profile assembly** — no error, nothing to migrate. +`cli org-context` (local) and `get_datasource_schema` (served) both assemble these two levels: the **company block once**, then each datasource's per-database narrative + derived summary. A federated question spanning several datasources renders the company block **once** and both vocabularies. **With no company record, the output is just the per-database assembly** — no error, nothing to migrate. ### Content-routing rule — where each kind of context goes - **Company-wide** (fiscal year, company glossary, a display convention true for the whole company, "who we are" prose) → the **company record** (`organization.yaml` + root `ORGANIZATION.md`). -- **Source-specific** (what THIS database means, a term that resolves differently here) → the **per-profile** files (`/org.yaml` `key_terminology` / `/ORGANIZATION.md`). +- **Source-specific** (what THIS database means, a term that resolves differently here) → the **per-profile** files (`//org.yaml` `key_terminology` / `//ORGANIZATION.md`). - **Per-column** units/encodings → the column's `field_metadata` in the structured model — **never** prose. - **Personal / stylistic** (how *I* like results displayed) → `USER_MEMORY.md`. diff --git a/plugins/agami/skills/agami-connect/SKILL.md b/plugins/agami/skills/agami-connect/SKILL.md index 084cbd0e..0f6873a3 100644 --- a/plugins/agami/skills/agami-connect/SKILL.md +++ b/plugins/agami/skills/agami-connect/SKILL.md @@ -34,14 +34,15 @@ Seed (one task per major phase, in order): ``` 1. Preflight: credentials check + tool detection -2. Discover & prune: list tables + columns, user prunes what they don't need -3. Introspect the KEPT tables → semantic model (engine: grain, FK cardinality) -4. Enrich: descriptions, entities, metrics (LLM, validated into the model) -5. Curate before examples: exclude columns/tables + sign off metrics & entities -6. Generate seed NL→SQL examples (validated against the live DB) -7. Validate every seed example (user reviews via dashboard) -8. Post-introspect trust summary -9. Follow-up suggestions +2. Organization context (MANDATORY): first onboard → company name + description (root record via `sm set-org`); every onboard → this-database narrative +3. Discover & prune: list tables + columns, user prunes what they don't need +4. Introspect the KEPT tables → semantic model (engine: grain, FK cardinality) +5. Enrich: descriptions, entities, metrics (LLM, validated into the model) +6. Curate before examples: exclude columns/tables + sign off metrics & entities +7. Generate seed NL→SQL examples (validated against the live DB) +8. Validate every seed example (user reviews via dashboard) +9. Post-introspect trust summary +10. Follow-up suggestions ``` Use `content` for the imperative form and `activeForm` for the present-continuous form. **Mark each todo `in_progress` when its phase starts and `completed` immediately when it ends.** Exactly one `in_progress` at a time. @@ -427,7 +428,7 @@ Introspection can take a while against cloud DBs. Tell the user **before** the f If `//org.yaml` exists and `$ARGUMENTS != reintrospect`: the profile is already onboarded. Offer (AskUserQuestion, no `(Recommended)` — these are equal-weight choices), capped at 4: - **Re-introspect ``** — refresh the structure from the live DB (new/changed tables, columns, FKs) while preserving descriptions, entities, metrics, caveats, and sign-offs (the `reintrospect` path). - **Open model explorer** — browse + curate the existing model and review/sign off the trust layer (`/agami-model`). -- **Onboard another database** — set up a **different** database (a different connection), leaving `` untouched. On this choice, **first ask whether it's the same company or a different one** (F15 — see 1.1a), then start a fresh onboarding: jump to the profile-naming step (Phase 0a's naming question) → have the user name the new profile (must differ from `` and any existing `[section]` in `/local/credentials`) and pick its DB type → write that profile's `credentials.example` → run the full flow for it. Never reuse or overwrite the current profile's credentials or model. +- **Onboard another database** — set up a **different** database (a different connection), leaving `` untouched. On this choice, **first ask whether it's the same company or a different one** (see 1.1a), then start a fresh onboarding: jump to the profile-naming step (Phase 0a's naming question) → have the user name the new profile (must differ from `` and any existing `[section]` in `/local/credentials`) and pick its DB type → write that profile's `credentials.example` → run the full flow for it. Never reuse or overwrite the current profile's credentials or model. - **Try the sample database** — explore agami's bundled **`agami-example`** sample (retail + subscriptions, no connection needed), leaving `` untouched. Routes to [Phase 0s](#phase-0s-sample-database-bootstrap-no-connection). This MUST be a real selectable option here (not a prose aside) — a returning user with an onboarded profile has no other visible path to the sample, since plain `/agami-connect` resolves their active profile and lands on this menu. (Cancel isn't a listed option — the modal's Esc / "Other" covers "do nothing"; the four real choices are the actions above. Keep the list at exactly these four.) @@ -436,11 +437,11 @@ If `//org.yaml` exists and `$ARGUMENTS != reintrospect`: The engine **auto-backs-up any legacy (v1) model** (`index.yaml` + per-schema `_schema.yaml`) it finds at the profile root into `.legacy_backup/` before writing — so a first run over an old profile is safe and reversible; surface a one-liner when that happens. -### 1.1a — Same company vs different company (F15 org routing) +### 1.1a — Same company vs different company When the user chooses **Onboard another database**, ask (AskUserQuestion) whether the new database belongs to the **same company** or a **different** one. A deployment (one artifacts dir) holds exactly **one** company/organization with many datasources under it; a genuinely different company is a **different folder**. -- **Same company (the common case)** → the new profile **attaches under the deployment org**: it lands in the **same** ``, shares the same `org_id` (resolved from the root `organization.yaml` — no re-mint, no sibling scan), and **shares the company context** authored earlier. Do **not** re-ask the company narrative (1.4 detects the existing record and skips it). Narrate it: *"This attaches under your **``** organization, sharing the company context you set earlier — its per-database notes stay its own."* +- **Same company (the common case)** → the new profile **attaches under the deployment org**: it lands in the **same** ``, shares the same `org_id` from the root `organization.yaml`, and **shares the company context** authored earlier. Do **not** re-ask the company description (1.4 detects the already-authored company context and skips it); the per-database note is still asked. Narrate it: *"This attaches under your **``** organization, sharing the company context you set earlier — its per-database notes stay its own."* - **Different company** → set up a **new deployment folder**. Pick/confirm a new artifacts dir path, create it, and **repoint** the pointer to it (the same idiom Phase 0a.1 uses for a non-default dir): ```bash mkdir -p "" && mkdir -p ~/.config/agami && printf '%s\n' "" > ~/.config/agami/path @@ -478,21 +479,26 @@ Example to say BEFORE introspecting: *"Found 70 tables across 18 schemas. I'll m ### 1.4 — Organization context (MANDATORY — ALWAYS ASK) -Two levels (F15): **company-wide** context is written **once** at the deployment root and shared by every datasource; each datasource may add its own **source-specific** note. The skill never decides yes/skip for the user; "don't ask clarifying questions" does NOT cancel this — it's required state-gathering. +Two levels: a **company** description written **once** at the deployment root and shared by every datasource, and a **per-database** narrative for the datasource being connected now. The skill never decides yes/skip for the user; "don't ask clarifying questions" does NOT cancel this — it's required state-gathering. Ask **A** then **B**. -**Branch on whether this deployment already has a company record.** Check for `/organization.yaml` **or** `/ORGANIZATION.md` at the artifacts **root** (not the profile dir): +**A. Company context — ask ONCE, on the first onboard into this deployment.** Decide "already authored" by content, not by file existence: treat it as done only if the root `/ORGANIZATION.md` exists **or** the root `/organization.yaml` already has a non-empty `name`/`description`. If neither, this is the first onboard → ask: +> What's your **company/organization** called, and a sentence or two on what it does? It's shared across every database you connect here and improves NL→SQL. (e.g. what the company/product is, what "MRR" or "active user" means company-wide.) -**A. First onboard — no company record yet → ask the COMPANY question (once).** -> Want to give me a one-paragraph description of what your **company** is about? It's shared across every database you connect here and improves NL→SQL a lot. Examples: what the company/product is, what "MRR" or "active user" means company-wide. +On an answer, do **BOTH** of these (not one — the record write is what populates `organization.yaml`): +1. **Run this command** to write the company `name` + a short `description` into the record (this is the ONLY thing that fills those fields — a file write alone will not): + `bash "$AGAMI_PLUGIN_ROOT/scripts/sm" set-org "" --name "" --description ""` +2. **Write** the fuller paragraph to the **root** `/ORGANIZATION.md` under `# About this company` (**narrative prose ONLY** — no model facts). -`Yes — I'll type it now (Other field)` → write their words to the **root** `/ORGANIZATION.md` under `# About this company` (**company narrative ONLY** — no model facts; those are derived at read time via `cli org-context`). The deployment's `org_id` + a starter `/organization.yaml` record are minted automatically when the model is written (Phase 2 build), so you need only author the prose here. `Skip — I'll auto-fill it later (Recommended)` → leave the root narrative absent (composition degrades cleanly). Rich company-context editing (description, conventions, glossary) lives in `/agami-model` — mention it. +Then confirm both landed: `/organization.yaml` now shows `name:`/`description:`, and `/ORGANIZATION.md` exists. -**B. Subsequent onboard — a company record already exists → do NOT re-ask the company question.** The new profile attaches under the existing org (1.1a); narrate *"Sharing the **``** context you set earlier."* Then optionally ask a **source-specific** note for THIS database only: -> Anything specific to **this** database I should know (what it is, a term that means something different here)? Optional — the company context already applies. +`Skip` → leave the company context empty (the composition simply omits the company block). Mention that richer company context (conventions, glossary) can be added later in `/agami-model`. On a **subsequent** onboard (company context already authored) **do NOT re-ask** — narrate *"Sharing the **``** context you set earlier."* -`Yes` → write to the **per-profile** `//ORGANIZATION.md` under `# About this database` (source-specific narrative only). `Skip` → leave it absent; Phase 2f writes a short per-database starter. +**B. This-database narrative — ask on EVERY onboard.** For the datasource being connected now: +> Want a one-paragraph description of what **this database** is about — what it holds, and any term that means something specific here? Optional. -`chmod 600` whatever you write. See [`shared/organization-context-format.md`](../../shared/organization-context-format.md) for the content-routing rule (company-wide → root; source-specific → per-profile; per-column units → the structured model; personal → `USER_MEMORY.md`). +`Yes` → write to the **per-profile** `//ORGANIZATION.md` under `# About this database` (this datasource's narrative only). `Skip` → leave it absent; Phase 2f writes a short per-database starter from the enriched model. + +`chmod 644` whatever you write — these are **non-secret model files** and must stay readable (e.g. by the deploy container / a teammate reading a checked-in copy); never `chmod 600` them (that's for `local/` secrets only). See [`shared/organization-context-format.md`](../../shared/organization-context-format.md) for the content-routing rule (company-wide → root; per-database → the profile; per-column units → the structured model; personal → `USER_MEMORY.md`). ### 1.5 — Existing data model / semantic layer (MANDATORY — ALWAYS ASK) @@ -755,12 +761,12 @@ On `reintrospect`, the engine rewrites the structural skeleton. **Preserve hand- ORGANIZATION.md is the human's narrative ONLY — it does **not** hold model facts. The factual summary (subject areas, conventions, the decoded glossary) is **derived from the structured model at read time** (`cli org-context` assembles it for the query path; the explorer shows it as a read-only field). The two homes stay separate by construction. -**Only on the skip path** (ORGANIZATION.md missing/empty, the user gave no narrative), **seed it with a short factual narrative** so `# About this database` reads as something, not a blank. You've just enriched every table — so write a **1–2 sentence description of what this database is about**, synthesised from the table/area descriptions (e.g. *"Tracks an electric-vehicle battery-swap network — stations, vehicles, battery packs, the swap transactions between them, and the alerts they raise."*). Write it under `# About this database` with the editable comment, and `chmod 600`. Keep it factual (don't invent business specifics you can't see); it's a starting **draft** the user edits. +**Only on the skip path** (ORGANIZATION.md missing/empty, the user gave no narrative), **seed it with a short factual narrative** so `# About this database` reads as something, not a blank. You've just enriched every table — so write a **1–2 sentence description of what this database is about**, synthesised from the table/area descriptions (e.g. *"Tracks an electric-vehicle battery-swap network — stations, vehicles, battery packs, the swap transactions between them, and the alerts they raise."*). Write it under `# About this database` with the editable comment, and `chmod 644` (a non-secret model file — keep it readable). Keep it factual (don't invent business specifics you can't see); it's a starting **draft** the user edits. If you can't summarise (no descriptions yet), fall back to the deterministic starter: ```bash bash "$AGAMI_PLUGIN_ROOT/scripts/sm" org-draft "$ROOT" > "$ROOT/ORGANIZATION.md" # one-line factual seed -chmod 600 "$ROOT/ORGANIZATION.md" +chmod 644 "$ROOT/ORGANIZATION.md" # non-secret model file — must stay readable (deploy bundle / teammates) ``` If the user **did** write a narrative in 1.4, leave it untouched. The glossary from `set-terminology` (2b) needs no re-render — it's surfaced automatically by `org-context`. Mention in the Phase 7 summary that the glossary + summary are auto-derived and the narrative is theirs to edit. diff --git a/tests/test_model_store_roundtrip.py b/tests/test_model_store_roundtrip.py index 01bffc63..4165860f 100644 --- a/tests/test_model_store_roundtrip.py +++ b/tests/test_model_store_roundtrip.py @@ -220,7 +220,7 @@ def test_tools_serve_memory_and_version_from_db_no_files(tmp_path, monkeypatch): monkeypatch.setenv("AGAMI_DB_URL", db_url) monkeypatch.setenv("AGAMI_ARTIFACTS_DIR", str(tmp_path / "does-not-exist")) assert tools._model_version("main") == "v-deadbeef" - org_md, user_md = tools._domain_memory("main") + org_md, user_md, _, _ = tools._context_sources("main", tools._current_org_id()) assert "Widgets co." in org_md and user_md == "exclude test users" diff --git a/tests/test_org_record.py b/tests/test_org_record.py index 7cc38f04..c65911b7 100644 --- a/tests/test_org_record.py +++ b/tests/test_org_record.py @@ -130,3 +130,50 @@ def test_bare_record_is_valid_and_fiscal_year_bounds_enforced(): assert OrgRecord(org_id="x").fiscal_year_start_month is None # id-only record validates with pytest.raises(ValueError): OrgRecord(org_id="x", fiscal_year_start_month=13) # same 1..12 bound as Organization + + +def test_set_org_fields_mints_then_updates_only_passed_fields(tmp_path): + rec = OR.set_org_fields(tmp_path, name="Acme", description="Subscription commerce.") + assert rec.name == "Acme" and rec.description == "Subscription commerce." + assert len(rec.org_id) == 32 # minted the record on first write + # A later call updating only `description` leaves `name` (and the id) intact. + rec2 = OR.set_org_fields(tmp_path, description="Updated.") + assert rec2.name == "Acme" and rec2.description == "Updated." + assert rec2.org_id == rec.org_id + assert OR.load_org_record(tmp_path) == rec2 # persisted + + +def test_refresh_datasources_tracks_profiles_on_disk(tmp_path): + OR.set_org_fields(tmp_path, name="Acme") # a record with authored company content + for prof in ("crm", "erp"): + (tmp_path / prof).mkdir() + (tmp_path / prof / "org.yaml").write_text(f"org_id: x\norganization: {prof}\n") + + rec = OR.refresh_datasources(tmp_path) + assert rec.datasources == ["crm", "erp"] # sorted, rebuilt from the profile dirs on disk + assert rec.name == "Acme" # the refresh never disturbs the authored fields + + import shutil + + shutil.rmtree(tmp_path / "erp") # removing a datasource dir drops it on the next refresh + assert OR.refresh_datasources(tmp_path).datasources == ["crm"] # no drift + + +def test_refresh_datasources_noop_when_nothing_exists(tmp_path): + assert OR.refresh_datasources(tmp_path) is None # no record and no profiles -> nothing to do + assert OR.load_org_record(tmp_path) is None # and it wrote nothing (never mints on an empty dir) + + +def test_build_write_tree_auto_maintains_the_datasource_list(tmp_path): + build.write_tree(_minimal_org("sales"), tmp_path / "sales") + build.write_tree(_minimal_org("support"), tmp_path / "support") + rec = OR.load_org_record(tmp_path) + assert rec.datasources == ["sales", "support"] # the build keeps the record's list in sync + + +def test_set_org_cli_writes_name_and_description(tmp_path): + from semantic_model import cli + + assert cli.main(["set-org", str(tmp_path), "--name", "Acme", "--description", "Demo co."]) == 0 + rec = OR.load_org_record(tmp_path) + assert rec.name == "Acme" and rec.description == "Demo co." diff --git a/tests/test_org_record_deploy.py b/tests/test_org_record_deploy.py index b24f85c2..ca56df8a 100644 --- a/tests/test_org_record_deploy.py +++ b/tests/test_org_record_deploy.py @@ -145,6 +145,7 @@ def test_deploy_writes_one_org_row_and_two_datasource_rows_on_one_org_id(tmp_pat "SELECT content FROM memory WHERE org_id = ? AND datasource = '' AND kind = 'organization'", ("deployorg",), ) + deployed_record = MS.load_organization_record(s, "deployorg") s.close() assert [r["org_id"] for r in org_rows] == ["deployorg"] # exactly ONE org row @@ -153,3 +154,5 @@ def test_deploy_writes_one_org_row_and_two_datasource_rows_on_one_org_id(tmp_pat ("deployorg", "crm"), ("deployorg", "erp"), ] # two datasource rows, same org_id + # The deploy refreshed the datasource list from disk and derived it into the org row's doc. + assert deployed_record.datasources == ["crm", "erp"]