diff --git a/build.py b/build.py index 35c81ee..b7886fe 100644 --- a/build.py +++ b/build.py @@ -194,43 +194,61 @@ def build_bundle(folder: Path, dry_run: bool = False) -> None: # ── Template bundle ────────────────────────────────────────────────────────── -# Component kinds shipped in the vendored template. ``placeholder`` names the -# seed token an agent renames: "resource" -> item/Item, "family" -> example/ -# Example; "" means ship as-is. +# Component kinds shipped in the vendored template. The template is the vet +# sample: a concrete veterinary-clinic domain (parents, pets, treatments, +# vets, appointments + a jobs queue for the worker). Agents copy slices and +# adapt them to their own domain — no seed-token renaming. ``placeholder`` is +# always "" and kept only for manifest-shape compatibility with the client / +# drone / release bundles. _TEMPLATE_COMPONENTS: list[dict[str, str]] = [ - # Per-resource slice (seed: item/Item). - {"kind": "model", "path": "domain/item/models.py", "placeholder": "resource", - "description": "Business Pydantic model for a resource."}, - {"kind": "ports-db", "path": "domain/item/ports/db.py", "placeholder": "resource", - "description": "Document model + repository factory for a resource."}, - {"kind": "ports-service", "path": "domain/item/ports/service.py", "placeholder": "resource", - "description": "CRUD service wired through the DI library."}, - {"kind": "converters", "path": "domain/item/ports/converters.py", "placeholder": "resource", - "description": "Optional pure converters between domain types."}, - {"kind": "routes", "path": "interfaces/item/routes.py", "placeholder": "resource", - "description": "Thin HTTP handlers bound to operationIds."}, + # Per-resource slices — copy one as the pattern for a new aggregate. + # parents: simplest slice (no foreign keys). + {"kind": "parent-model", "path": "domain/parents/models.py", "placeholder": "", + "description": "Example domain model (parents — the simplest slice)."}, + {"kind": "parent-ports-db", "path": "domain/parents/ports/db.py", "placeholder": "", + "description": "Example document model + repository factory."}, + {"kind": "parent-ports-service", "path": "domain/parents/ports/service.py", "placeholder": "", + "description": "Example CRUD service wired through the DI library."}, + {"kind": "parent-routes", "path": "interfaces/parents/routes.py", "placeholder": "", + "description": "Example thin HTTP handlers bound to operationIds."}, + # pets: embedded list-of-refs + file upload. + {"kind": "pet-model", "path": "domain/pets/models.py", "placeholder": "", + "description": "Domain model with an embedded parents list."}, + {"kind": "pet-ports-db", "path": "domain/pets/ports/db.py", "placeholder": "", + "description": "Populating document + repository factory (FK wiring)."}, + {"kind": "pet-routes", "path": "interfaces/pets/routes.py", "placeholder": "", + "description": "Routes including the file-upload endpoint."}, + # appointments: single FKs expanded on read, filtered list. + {"kind": "appointment-model", "path": "domain/appointments/models.py", "placeholder": "", + "description": "Domain model with single-id FKs + nested value objects."}, + {"kind": "appointment-routes", "path": "interfaces/appointments/routes.py", "placeholder": "", + "description": "Routes with expand_ref FK handling and list filters."}, + # jobs: the worker's queue (second resource shipped end to end). + {"kind": "job-model", "path": "domain/jobs/models.py", "placeholder": "", + "description": "Job queue model (the worker's unit of work)."}, + {"kind": "job-ports-db", "path": "domain/jobs/ports/db.py", "placeholder": "", + "description": "Job repository factory."}, + {"kind": "job-ports-service", "path": "domain/jobs/ports/service.py", "placeholder": "", + "description": "Job CRUD service."}, + {"kind": "job-routes", "path": "interfaces/jobs/routes.py", "placeholder": "", + "description": "Job routes (enqueue/inspect via the API)."}, + # Interface-layer shared files. + {"kind": "interface-common", "path": "interfaces/_common.py", "placeholder": "", + "description": "Shared helpers: svc resolution, dump, pagination, expand_ref."}, {"kind": "openapi-routes", "path": "interfaces/openapi.py", "placeholder": "", "description": "Route registry: flat re-export of every handler namespace."}, {"kind": "auth", "path": "interfaces/auth/security.yaml", "placeholder": "", "description": "Mergeable bearerAuth security scheme + global security for openapi.yaml."}, - {"kind": "test-model", "path": "tests/unit/test_item_models.py", "placeholder": "resource", - "description": "Unit test for the resource model."}, - {"kind": "test-routes", "path": "tests/integration/test_item_routes.py", "placeholder": "resource", - "description": "Integration test at the HTTP edge."}, - {"kind": "test-openapi-build", "path": "tests/integration/test_openapi_app_builds.py", "placeholder": "", - "description": "Generic test: every spec operationId resolves and the app builds."}, - {"kind": "test-pipeline", "path": "tests/unit/test_item_sync.py", "placeholder": "", - "description": "Unit test for the smallest pipeline form."}, - # Per-family pipeline scaffold (seed: example/Example). - {"kind": "pipeline-abc", "path": "pipelines/example/abc.py", "placeholder": "family", + # Worked pipeline family (appointment_reminder) — the copy-me pattern. + {"kind": "pipeline-abc", "path": "pipelines/appointment_reminder/abc.py", "placeholder": "", "description": "Port contracts for a pipeline family."}, - {"kind": "pipeline-impl", "path": "pipelines/example/impl.py", "placeholder": "family", + {"kind": "pipeline-impl", "path": "pipelines/appointment_reminder/impl.py", "placeholder": "", "description": "Concrete stages, wired root, and family registration."}, - {"kind": "pipeline-yaml", "path": "pipelines/example/pipeline.yaml", "placeholder": "family", + {"kind": "pipeline-yaml", "path": "pipelines/appointment_reminder/pipeline.yaml", "placeholder": "", "description": "Graph spec (nodes, edges, roots, schema)."}, - {"kind": "pipeline-resources", "path": "pipelines/example/resources/pipeline.yaml", "placeholder": "family", + {"kind": "pipeline-resources", "path": "pipelines/appointment_reminder/resources/pipeline.yaml", "placeholder": "", "description": "Runtime spec consumed by the shared loader."}, - {"kind": "pipeline-pattern", "path": "pipelines/example/resources/patterns/example.yaml", "placeholder": "family", + {"kind": "pipeline-pattern", "path": "pipelines/appointment_reminder/resources/patterns/reminder.yaml", "placeholder": "", "description": "Pattern/rule fragment merged by the loader."}, # Shared pipeline scaffolding (ship as-is). {"kind": "pipeline-ports", "path": "pipelines/shared/ports.py", "placeholder": "", @@ -239,32 +257,23 @@ _TEMPLATE_COMPONENTS: list[dict[str, str]] = [ "description": "Shared loader: DI wiring + pattern merge."}, {"kind": "pipeline-registry", "path": "pipelines/shared/registry.py", "placeholder": "", "description": "Shared registry: (kind, variant, mode) -> root class."}, - {"kind": "pipeline-simple", "path": "pipelines/item_sync.py", "placeholder": "", - "description": "Smallest pipeline form (plain async steps)."}, - # Second resource slice (seed: job). - {"kind": "job-model", "path": "domain/job/models.py", "placeholder": "", - "description": "Seeded second resource model (job)."}, - {"kind": "job-ports-db", "path": "domain/job/ports/db.py", "placeholder": "", - "description": "Seeded second resource repository (job)."}, - {"kind": "job-ports-service", "path": "domain/job/ports/service.py", "placeholder": "", - "description": "Seeded second resource service (job)."}, - {"kind": "job-routes", "path": "interfaces/job/routes.py", "placeholder": "", - "description": "Seeded second resource routes (job)."}, # Root / shared files (ship as-is). {"kind": "config", "path": "config.py", "placeholder": "", - "description": "Settings dataclass + singleton."}, + "description": "Settings dataclass + singleton (incl. worker knobs)."}, {"kind": "init", "path": "init.py", "placeholder": "", - "description": "Composition root (init/shutdown, get_svc)."}, + "description": "Composition root (init/shutdown, resources dict)."}, {"kind": "main", "path": "main.py", "placeholder": "", "description": "Spec-first app bootstrap + lifespan."}, {"kind": "worker", "path": "worker.py", "placeholder": "", - "description": "Poll loop + job status machine."}, + "description": "Poll loop + job dispatch by kind + status machine."}, {"kind": "openapi", "path": "openapi.yaml", "placeholder": "", - "description": "OpenAPI contract skeleton."}, + "description": "OpenAPI contract (x- extensions drive the admin client)."}, {"kind": "pyproject", "path": "pyproject.toml", "placeholder": "", "description": "Packaging + dependency pins."}, {"kind": "changelog", "path": "CHANGELOG.md", "placeholder": "", "description": "Keep a Changelog file with an [Unreleased] section (versioning standard)."}, + {"kind": "version-file", "path": "VERSION", "placeholder": "", + "description": "Single-line version file (service-docker CI checks tag == VERSION)."}, {"kind": "dockerfile", "path": "Dockerfile", "placeholder": "", "description": "API + worker image with private-index wiring."}, {"kind": "dockerignore", "path": ".dockerignore", "placeholder": "", @@ -279,12 +288,18 @@ _TEMPLATE_COMPONENTS: list[dict[str, str]] = [ "description": "Mongo connection lifecycle."}, {"kind": "repo", "path": "infra/db/repos/mongo.py", "placeholder": "", "description": "Repository factory (base/populating/cached)."}, + {"kind": "repo-int", "path": "infra/db/repos/mongo_int.py", "placeholder": "", + "description": "Integer-id repository variants (create_repo factory)."}, + {"kind": "repo-memory", "path": "infra/db/repos/memory.py", "placeholder": "", + "description": "In-memory repository flavour (zero-dependency demo store)."}, {"kind": "service-adapter", "path": "infra/adapters/service.py", "placeholder": "", "description": "Generic CRUD service adaptor."}, {"kind": "http-cache", "path": "infra/http_cache.py", "placeholder": "", "description": "Response cache decorator + Redis store (generation invalidation)."}, {"kind": "progress-bus", "path": "infra/adapters/sse.py", "placeholder": "", "description": "In-memory SSE progress bus (keyed by an event attribute)."}, + {"kind": "actions-stream", "path": "infra/adapters/actions_stream.py", "placeholder": "", + "description": "Per-resource action stream (SSE) adaptor."}, {"kind": "kv-store", "path": "infra/adapters/kv.py", "placeholder": "", "description": "KV port + in-memory fallback + optional Redis adaptor."}, {"kind": "package-init", "path": "domain/__init__.py", "placeholder": "", @@ -293,6 +308,15 @@ _TEMPLATE_COMPONENTS: list[dict[str, str]] = [ "description": "Operator seed script."}, {"kind": "script-drop", "path": "scripts/drop.py", "placeholder": "", "description": "Operator drop script."}, + # Tests shipped with the sample. + {"kind": "test-openapi-build", "path": "tests/integration/test_openapi_app_builds.py", "placeholder": "", + "description": "Generic test: every spec operationId resolves and the app builds."}, + {"kind": "test-jobs-routes", "path": "tests/unit/test_jobs_routes.py", "placeholder": "", + "description": "Unit test pattern for a resource's routes with service fakes."}, + {"kind": "test-pipeline", "path": "tests/unit/test_appointment_reminder_pipeline.py", "placeholder": "", + "description": "Unit test pattern for a pipeline family."}, + {"kind": "test-worker", "path": "tests/unit/test_worker.py", "placeholder": "", + "description": "Unit test pattern for worker dispatch and status transitions."}, ] @@ -303,7 +327,7 @@ def build_components_manifest(template_root: Path) -> dict[str, object]: template_root (Path): The vendored ``templates/app`` directory. Returns: - dict[str, object]: Manifest with the seed tokens and component entries. + dict[str, object]: Manifest with the component entries. """ components: list[dict[str, str]] = [] for entry in _TEMPLATE_COMPONENTS: @@ -319,7 +343,7 @@ def build_components_manifest(template_root: Path) -> dict[str, object]: return { "project": "standards", "template": "templates/app", - "seed": {"resource": "item", "family": "example"}, + "sample": "vet", "components": components, } diff --git a/config.yml b/config.yml index 4237e1b..4850717 100644 --- a/config.yml +++ b/config.yml @@ -151,15 +151,6 @@ repos: lib: site api: site - - name: vet - source: ../react/vet - title: Vet - description: Full-stack reference application — OpenAPI-first FastAPI server paired with the spec-driven React admin client (@aetoskia/react-openapi + @aetoskia/react-auth). - section: apps - docs: - lib: site - mcp: { bundle: docs/mcp, server: vet, port: 8013 } - static: - kind: wiki repo: standards diff --git a/registry.py b/registry.py index f7179e0..0b74e28 100644 --- a/registry.py +++ b/registry.py @@ -19,7 +19,7 @@ Tools: ping (from the base server) find_library (capability -> matching libraries + MCP endpoints) list_app_components (the template component kinds) - get_app_component (kind -> exact template file, seed tokens renamed) + get_app_component (kind -> exact template file) get_app_template (the whole app skeleton as a path -> content map) list_client_components (the client template component kinds) get_client_component (kind -> exact client template file) @@ -41,7 +41,6 @@ Prompts: from __future__ import annotations import json -import re from pathlib import Path from typing import Any @@ -49,11 +48,6 @@ from config import MCPBundle from mcp_server import MCPServer -def _pascal(name: str) -> str: - """Convert a snake/kebab name to PascalCase.""" - return "".join(part.capitalize() for part in re.split(r"[_\-\s]+", name) if part) - - _INTAKE_TEXT = ( "# Start a new app — intake\n\n" "Gather the following, then call `build` (or `start_app`) with the values:\n\n" @@ -93,8 +87,9 @@ def _build_text(app_name: str, resources: str, pipelines: str, auth: str) -> str "3. Call `find_library(capability)` for db, di, routing, ingestion, " "and pipeline; never reimplement a Tier A library.\n" "4. Scaffold with `get_app_template()` (whole tree) or pull single " - "files with `list_app_components()` and " - "`get_app_component(kind, resource=..., family=...)`.\n" + "files with `list_app_components()` and `get_app_component(kind)` — " + "the template is the vet sample; copy a slice and adapt it to your " + "domain.\n" "5. If a UI is needed, scaffold the client with `get_client_template()` " "(or pull files via `list_client_components()` / " "`get_client_component(kind)`) — the admin renders from your spec's x- " @@ -279,28 +274,17 @@ class StandardsMCPServer(MCPServer): files[rel] = path.read_text(encoding="utf-8") return files - def _read_component( - self, - entry: dict[str, Any], - resource: str | None, - family: str | None, - ) -> dict[str, Any]: - """Read a vendored template file, renaming seed tokens when asked.""" + def _read_component(self, entry: dict[str, Any]) -> dict[str, Any]: + """Read a vendored template file as-is (no token rewriting).""" rel = entry["path"] path = Path(self.mcp_root) / "templates" / "app" / rel if not path.exists(): return {"error": "not_found", "path": str(path)} - text = path.read_text(encoding="utf-8") - placeholder = entry.get("placeholder") - if resource and placeholder == "resource": - text = text.replace("Item", _pascal(resource)).replace("item", resource) - if family and placeholder == "family": - text = text.replace("Example", _pascal(family)).replace("example", family) return { "kind": entry["kind"], "path": rel, - "placeholder": placeholder, - "content": text, + "placeholder": entry.get("placeholder"), + "content": path.read_text(encoding="utf-8"), } def _client(self) -> dict[str, Any]: @@ -454,22 +438,17 @@ class StandardsMCPServer(MCPServer): return self._components().get("components", []) @self.app.tool() - def get_app_component( - kind: str, - resource: str | None = None, - family: str | None = None, - ) -> dict[str, Any]: + def get_app_component(kind: str) -> dict[str, Any]: """Return the exact template file for a component kind. - Use this to copy current, app-neutral skeleton code instead of - inventing it. See :func:`list_app_components` for valid kinds. + Use this to copy current sample code instead of inventing it. The + template is the vet sample (parents/pets/treatments/vets/ + appointments + jobs + the appointment_reminder pipeline family); + copy a slice and adapt it to your domain. See + :func:`list_app_components` for valid kinds. Args: - kind (str): Component kind, e.g. "routes", "pipeline-impl". - resource (str | None): Rename the resource seed ("item"/"Item") - to this name (snake_case), for per-resource kinds. - family (str | None): Rename the family seed ("example"/"Example") - to this name (snake_case), for per-family kinds. + kind (str): Component kind, e.g. "parent-routes", "pipeline-impl". Returns: dict[str, Any]: ``{kind, path, placeholder, content}`` or an @@ -482,7 +461,7 @@ class StandardsMCPServer(MCPServer): "kind": kind, "available": [c["kind"] for c in self._components().get("components", [])], } - return self._read_component(entry, resource, family) + return self._read_component(entry) @self.app.tool() def get_app_template(prefix: str = "") -> dict[str, Any]: @@ -490,8 +469,8 @@ class StandardsMCPServer(MCPServer): Use this to scaffold in one shot instead of pulling components one by one; combine with :func:`list_app_components` for the kind and - placeholder metadata. Seed tokens (``item`` / ``example``) are left - as-is here — fetch a renamed file with :func:`get_app_component`. + description metadata. The tree is the vet sample — replace the + domain with your own, keeping the layer structure. Args: prefix (str): Optional path prefix filter with POSIX @@ -655,14 +634,13 @@ class StandardsMCPServer(MCPServer): def list_repos(type: str | None = None) -> list[dict[str, Any]]: """List the repositories covered by the CI and versioning standards. - Each entry has: ``name``, ``type`` ("package" | "service" | "app"), + Each entry has: ``name``, ``type`` ("package" | "service"), ``path`` (relative to the aetos/ workspace root), ``version_source`` ("pyproject.toml" | "package.json" | "VERSION"), ``drone`` (the template file), ``trigger`` ("tag"), ``docs`` kinds, and ``mcp``. Args: - type (str | None): Optional filter, e.g. "package", "service" - or "app". + type (str | None): Optional filter, e.g. "package" or "service". Returns: list[dict[str, Any]]: Matching repository entries. diff --git a/standards/lib/01_build_recipe/index.html b/standards/lib/01_build_recipe/index.html index 6967ed2..8694fa8 100644 --- a/standards/lib/01_build_recipe/index.html +++ b/standards/lib/01_build_recipe/index.html @@ -927,58 +927,58 @@ environment-only; never commit them.

- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs` and note the Tier A libraries that already exist — never reimplement one; - call `list_app_components()` and pull each file you need with - `get_app_component(kind, resource=..., family=...)` instead of writing it from - scratch. - -## Steps - -1. **Scaffold** — copy `standards/src/templates/app/` into `apps/<your-app>/` - (or pull the same tree via `get_app_template`). Keep the tree: `domain/`, - `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus - `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`, - `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`. - -2. **Author the spec (app-specific)** — write `openapi.yaml`: resources, - schemas, `operationId`s, security, and extension metadata. This is the - contract and drives all routing. Every operation MUST have an `operationId`. - -3. **Generate the scaffold (codegen)** — from the spec, generate the wire - models and the exact handler signatures: - ```bash - openapi-first models openapi.yaml -o _gen/models.py - openapi-first routes openapi.yaml -o _gen/routes \ - --use-models --models-module _gen.models --inject-request --package --template-style - ``` - `_gen/models.py` is the **wire contract** (Pydantic, one class per - `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every - `operationId` with its exact parameter signature. `--template-style` makes - those stubs `async def` with a `body` parameter, matching the handlers you - keep in `interfaces/` (without it they are sync `def` stubs taking - `payload`; both bind the same way). Treat `_gen/` as generated - (never hand-edit it) — the canonical handlers live in `interfaces/`. - -4. **Write each CRUD slice (app-specific)** — per resource create the four-file - slice: `models.py` (domain model, **no `id`**), `ports/db.py` - (`class XDocument(BaseDocument, X)` — id comes from `BaseDocument`), - `ports/service.py`, and optional `converters.py`. Follow the CRUD template. - -5. **Wire the composition root** — in `init.py`, build leaf repositories, then - dependent repositories, then services; add infra adapters (db, cache, stores, - progress bus, fetchers). Expose `get_svc` overloads. - -6. **Add routes** — create `interfaces/<resource>/routes.py` thin handlers whose - names match the `operationId`s (use the signatures from step 3 as your - checklist) and re-export them in `interfaces/openapi.py`. Add the cache - decorator to read endpoints. - -7. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app - with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll - the job collection, run the pipeline, and publish progress. - -8. **Add pipeline families (only if data-driven)** — copy `pipelines/example/` - to `pipelines/<family>/` with `abc.py`, `impl.py`, `pipeline.yaml`, - `resources/`, and reuse `pipelines/shared/` (loader + registry). Register the - root. Add fixtures and a parity test. + `get_app_component(kind)` instead of writing it from scratch. + +## Steps + +1. **Scaffold** — copy `standards/src/templates/app/` into `apps/<your-app>/` + (or pull the same tree via `get_app_template`). Keep the tree: `domain/`, + `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus + `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`, + `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`. + +2. **Author the spec (app-specific)** — write `openapi.yaml`: resources, + schemas, `operationId`s, security, and extension metadata. This is the + contract and drives all routing. Every operation MUST have an `operationId`. + +3. **Generate the scaffold (codegen)** — from the spec, generate the wire + models and the exact handler signatures: + ```bash + openapi-first models openapi.yaml -o _gen/models.py + openapi-first routes openapi.yaml -o _gen/routes \ + --use-models --models-module _gen.models --inject-request --package --template-style + ``` + `_gen/models.py` is the **wire contract** (Pydantic, one class per + `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every + `operationId` with its exact parameter signature. `--template-style` makes + those stubs `async def` with a `body` parameter, matching the handlers you + keep in `interfaces/` (without it they are sync `def` stubs taking + `payload`; both bind the same way). Treat `_gen/` as generated + (never hand-edit it) — the canonical handlers live in `interfaces/`. + +4. **Write each CRUD slice (app-specific)** — per resource create the four-file + slice: `models.py` (domain model, **no `id`**), `ports/db.py` + (`class XDocument(BaseDocument, X)` — id comes from `BaseDocument`), + `ports/service.py`, and optional `converters.py`. Follow the CRUD template. + +5. **Wire the composition root** — in `init.py`, build leaf repositories, then + dependent repositories, then services; add infra adapters (db, cache, stores, + progress bus, fetchers). Expose `get_svc` overloads. + +6. **Add routes** — create `interfaces/<resource>/routes.py` thin handlers whose + names match the `operationId`s (use the signatures from step 3 as your + checklist) and re-export them in `interfaces/openapi.py`. Add the cache + decorator to read endpoints. + +7. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app + with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll + the job collection, run the pipeline, and publish progress. + +8. **Add pipeline families (only if data-driven)** — copy the worked family + `pipelines/appointment_reminder/` to `pipelines/<family>/` with `abc.py`, + `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/` + (loader + registry). Register the root, add the `job.kind` handler in + `worker.py`. Add fixtures and a parity test. 9. **Add infrastructure only where needed** — Tier A capability: use the library. Tier B capability: copy the template. Never hand-write a Tier A diff --git a/standards/lib/40_ui_admin/index.html b/standards/lib/40_ui_admin/index.html index 49d766d..d4eb219 100644 --- a/standards/lib/40_ui_admin/index.html +++ b/standards/lib/40_ui_admin/index.html @@ -713,7 +713,7 @@ Read it from the MCP bundle:

The app's own spec carries the per-resource values; the catalog explains what each field does. The template's openapi.yaml shows the convention on the -example Item and Job schemas.

+example Job schema.


Golden rules

    @@ -735,8 +735,10 @@ example Item and Job schemas.

  1. The server template ships authless by default (see 22 – Auth Integration); the client template pairs with it and degrades to "no login" until a security scheme is declared.
  2. -
  3. react/vet is the full-stack reference implementation: spec with x- - extensions, server, and the consuming client in one tree.
  4. +
  5. The standards app + client templates are the full-stack reference: the + server spec carries the x- extensions, and the client renders the admin from + it. The wiki's Reference App walkthrough (06_reference_app) documents + the pairing end to end.
  6. diff --git a/standards/lib/90_drone_pipelines/index.html b/standards/lib/90_drone_pipelines/index.html index 7ee4d21..51048f9 100644 --- a/standards/lib/90_drone_pipelines/index.html +++ b/standards/lib/90_drone_pipelines/index.html @@ -693,7 +693,7 @@ chosen by what the repository produces:

    C — Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container -auth-server, vet +auth-server diff --git a/standards/lib/94_repository_inventory/index.html b/standards/lib/94_repository_inventory/index.html index a90acfd..374770f 100644 --- a/standards/lib/94_repository_inventory/index.html +++ b/standards/lib/94_repository_inventory/index.html @@ -620,9 +620,9 @@
  7. - + - Services & Apps (Type C) + Services (Type C) @@ -780,7 +780,7 @@ source is package.json; Drone file is npm-lib.yaml (ta
    -

    Services & Apps (Type C)

    +

    Services (Type C)

    A container image pushed to the registry and run on the Docker host. Version source is the VERSION file; Drone file is service-docker.yaml.

    @@ -788,7 +788,6 @@ source is the VERSION file; Drone file is service-docker.yaml - @@ -797,17 +796,9 @@ source is the VERSION file; Drone file is service-docker.yaml - - - - - - - -
    Repo PathType Trigger Docs
    auth-server auth-serverservice tag api, lib, wiki
    vetreact/vetapptaglib, wiki

    Tag-triggered and enforces tag == VERSION.

    @@ -836,7 +827,7 @@ source is the VERSION file; Drone file is service-docker.yaml npm-lib.yaml -service / app +service VERSION CHANGELOG.md service-docker.yaml diff --git a/standards/lib/search/search_index.json b/standards/lib/search/search_index.json index 70f8289..d05c6ac 100644 --- a/standards/lib/search/search_index.json +++ b/standards/lib/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Standards Reference","text":"

    The reference half of the Standards description: the enforceable rules, patterns, and the internal library catalog that agents consult while coding.

    The reference is organized as an anatomy \u2014 one entry checklist and four pillars describing what an application is made of, plus delivery/meta pages and an optional pipelines layer.

    "},{"location":"#anatomy","title":"Anatomy","text":"
    • Build Recipe \u2014 the ordered, agentic checklist for a new app; the entry point that walks the whole anatomy.
    "},{"location":"#pillar-1-domains","title":"Pillar 1 \u2014 Domains","text":"
    • Layering Rules \u2014 the strict request \u2192 domain \u2192 repository layering and the rules that enforce it.
    "},{"location":"#pillar-2-openapi-spec-the-contract","title":"Pillar 2 \u2014 OpenAPI Spec (the contract)","text":"
    • Composition Root \u2014 a single composition point wiring the whole app.
    • Spec-First Routing \u2014 the OpenAPI contract as the routing source of truth.
    • CRUD Template \u2014 the standardized generic resource endpoint scaffold.
    • Auth Integration \u2014 consuming the shared auth-server service (spec-driven, no auth code).
    "},{"location":"#pillar-3-interfaces-infra","title":"Pillar 3 \u2014 Interfaces & Infra","text":"
    • Infra Adapters \u2014 how infrastructure connects without leaking into domain.
    "},{"location":"#pillar-4-ui","title":"Pillar 4 \u2014 UI","text":"
    • UI: Auto-Rendered Admin & Custom Pages \u2014 the @aetoskia/react-openapi admin rendered from the spec's x- extensions, and @aetoskia/react-auth for auth pages; the client template scaffold.
    "},{"location":"#pipelines-optional-layer","title":"Pipelines (optional layer)","text":"
    • Pipeline Scaffold \u2014 the deterministic pipeline skeleton for multi-stage jobs.
    • Worker & Progress \u2014 queued work, progress reporting, and visibility.
    • Human-in-the-Loop \u2014 guardrails and approvals inside automations.
    "},{"location":"#delivery-meta","title":"Delivery & Meta","text":"
    • Drone Pipelines \u2014 the three CI shapes: a PyPI package, an npm package, or a Docker deployment.
    • Versioning & Changelog \u2014 SemVer, the tag\u2194version contract, and the release changelog.
    • Repository Inventory \u2014 every covered repo, its type, version source, and Drone shape.
    • Genericity Matrix \u2014 what is library-backed, template-provided, or authored.
    • Library Registry \u2014 the catalog of internal libraries behind all of it.

    Read the Wiki for the philosophy and rationale; these pages are the reference that mirrors the standards MCP bundle.

    "},{"location":"01_build_recipe/","title":"Build Recipe","text":""},{"location":"01_build_recipe/#summary","title":"Summary","text":"

    The ordered, deterministic checklist for generating a new application of the standard shape. Every step is fixed; only the resource model (step 2), the codegen pass (step 3), and the pipeline family (step 8) need app-specific work.

    The reference pages behind this recipe are organized as an anatomy: the domains layer, the OpenAPI contract that drives routing and the admin UI, the interfaces/infra seam, the auto-rendered UI client, and an optional pipelines layer \u2014 see the Standards Reference index.

    This page is served by the standards MCP as docs://modules/build_recipe. The start_app prompt on that server collects the two axes and hands off here; pull exact files with get_app_component.

    "},{"location":"01_build_recipe/#bootstrap","title":"\ud83d\ude80 Bootstrap","text":"

    From the standards docs checkout:

    cp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n

    Pair it with the React client template when a UI is needed (auto-rendered admin from the spec \u2014 see 40 \u2013 UI):

    cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client && npm install\n

    Install the internal libraries \u2014 editable local checkouts (preferred in development) or the private index (CI / fresh machines). Credentials are environment-only; never commit them.

    # local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n            -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n  \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n
    "},{"location":"01_build_recipe/#agent-prompt-copyable","title":"\ud83d\udccb Agent prompt (copyable)","text":"
    # Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2\u20134 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n  and note the Tier A libraries that already exist \u2014 never reimplement one;\n- call `list_app_components()` and pull each file you need with\n  `get_app_component(kind, resource=..., family=...)` instead of writing it from\n  scratch.\n\n## Steps\n\n1. **Scaffold** \u2014 copy `standards/src/templates/app/` into `apps/<your-app>/`\n   (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n   `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n   `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n   `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** \u2014 write `openapi.yaml`: resources,\n   schemas, `operationId`s, security, and extension metadata. This is the\n   contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** \u2014 from the spec, generate the wire\n   models and the exact handler signatures:\n   ```bash\n   openapi-first models openapi.yaml -o _gen/models.py\n   openapi-first routes openapi.yaml -o _gen/routes \\\n     --use-models --models-module _gen.models --inject-request --package --template-style\n   ```\n   `_gen/models.py` is the **wire contract** (Pydantic, one class per\n   `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every\n   `operationId` with its exact parameter signature. `--template-style` makes\n   those stubs `async def` with a `body` parameter, matching the handlers you\n   keep in `interfaces/` (without it they are sync `def` stubs taking\n   `payload`; both bind the same way). Treat `_gen/` as generated\n   (never hand-edit it) \u2014 the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** \u2014 per resource create the four-file\n   slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n   (`class XDocument(BaseDocument, X)` \u2014 id comes from `BaseDocument`),\n   `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n   dependent repositories, then services; add infra adapters (db, cache, stores,\n   progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers whose\n   names match the `operationId`s (use the signatures from step 3 as your\n   checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n   decorator to read endpoints.\n\n7. **Bootstrap app and worker** \u2014 confirm `main.py` runs the OpenAPI-first app\n   with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n   the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** \u2014 copy `pipelines/example/`\n   to `pipelines/<family>/` with `abc.py`, `impl.py`, `pipeline.yaml`,\n   `resources/`, and reuse `pipelines/shared/` (loader + registry). Register the\n   root. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** \u2014 Tier A capability: use the\n   library. Tier B capability: copy the template. Never hand-write a Tier A\n   library.\n\n10. **Tests** \u2014 integration tests with fakes at the HTTP edge; unit tests for\n    domain and pipeline logic; a config-consistency test for rule-driven\n    pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n    the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n    `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.<name>`.\n\n12. **Docs** \u2014 author GSDFC docstrings, generate `.pyi` stubs, mark the package\n    `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n
    "},{"location":"01_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"

    The steps map onto the layers as follows:

    Step Package / file Depends on 1 \u00b7 Scaffold whole templates/app/ \u2014 2 \u00b7 Spec openapi.yaml \u2014 3 \u00b7 Codegen _gen/ (generated) spec, openapi-first 4 \u00b7 CRUD slices domain/<resource>/ spec, CRUD template 5 \u00b7 Wiring init.py every slice 6 \u00b7 Routes interfaces/<resource>/ services, openapi route registry 7 \u00b7 Bootstrap main.py, worker.py composition root 8 \u00b7 Pipelines pipelines/<family>/, shared/ library pipeline engine 9 \u00b7 Infra infra/ domain ports 10 \u00b7 Tests tests/ app + fixtures 11 \u00b7 Scripts scripts/ composition root 12 \u00b7 Docs docstrings + docs build code, Doc Forge"},{"location":"01_build_recipe/#the-codegen-fast-path-in-detail","title":"\ud83d\udd27 The codegen fast path, in detail","text":"

    openapi-first is spec \u2192 code only (it never generates a spec). Use it to erase the two mechanical parts of the build:

    Generated artifact Replaces Notes _gen/models.py hand-writing Pydantic wire models one class per components.schemas entry _gen/routes/<resource>/routes.py hand-typing handler signatures names == operationIds; request: Request injected

    Rules of the road:

    • Wire models are not domain models. _gen/models.py is the HTTP contract; domain/<resource>/models.py is your business shape. If the spec's schema has a required id: str, keep it off the domain model and let the document get its id from BaseDocument.
    • Use --template-style. Stubs are then async def ...(request, body: ...) matching interfaces/; without it they are sync def ...(payload: ...). Either binds (the router introspects the signature), but --template-style keeps the scaffold consistent with the code you keep.
    • Resource names are sanitized. /fetch-requests \u2192 fetch_requests.py.
    • Multipart bodies expand to UploadFile = File(...) / Form(...) params.
    • _gen/ is disposable. Regenerate on every spec change; git diff shows exactly what moved.
    "},{"location":"01_build_recipe/#definition-of-done","title":"\u26a0\ufe0f Definition of done","text":"
    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No Tier A library was reimplemented.
    "},{"location":"01_build_recipe/#related","title":"Related","text":"
    • 10 \u2013 Layering Rules \u00b7 21 \u2013 CRUD Template \u00b7 22 \u2013 Auth Integration \u00b7 40 \u2013 UI \u00b7 50 \u2013 Pipeline Scaffold \u00b7 92 \u2013 Genericity Matrix
    "},{"location":"02_composition_root/","title":"Composition Root","text":""},{"location":"02_composition_root/#summary","title":"Summary","text":"

    All wiring happens in exactly one module. It builds a flat resource registry and exposes it to the HTTP layer and to scripts.

    "},{"location":"02_composition_root/#the-resource-registry","title":"The resource registry","text":"

    init.py returns a plain dict[str, Any]. Keys follow two conventions:

    • <resource>_repo \u2014 a repository adapter for a resource.
    • <resource>_svc \u2014 a service for a resource.

    Plus named infrastructure adaptors:

    • http_cache_store \u2014 response cache backend.
    • progress_bus \u2014 progress/event bus.
    • <vendor>_<kind> \u2014 fetchers, clients, stores.

    The API stores the dict on app.state.db_resources during lifespan startup. Handlers read from it; nothing imports concrete adapters.

    "},{"location":"02_composition_root/#shape","title":"Shape","text":"
    _resources: dict = {}\n_initialized = False\n\nasync def init(settings=None) -> dict:\n    global _initialized\n    if _initialized:\n        return _resources\n\n    await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)\n\n    # 1. leaf repositories\n    user_repo = create_user_repo()\n\n    # 2. dependent repositories (dependencies passed by keyword)\n    wallet_repo = create_wallet_repo(user_repo=user_repo)\n\n    # 3. services (build via the DI library, injecting the repo)\n    _resources[\"user_repo\"]   = user_repo\n    _resources[\"wallet_repo\"] = wallet_repo\n    _resources[\"user_svc\"]    = get_user_service(user_repo)\n    _resources[\"wallet_svc\"]  = get_wallet_service(wallet_repo)\n\n    # 4. infrastructure adaptors (optional)\n    _resources[\"http_cache_store\"] = HttpCacheStore(...)\n    _resources[\"progress_bus\"]     = ProgressEventBus()\n\n    _initialized = True\n    return _resources\n\nasync def shutdown() -> None:\n    global _initialized\n    await close_stores(_resources)\n    await shutdown_database()\n    _resources.clear()\n    _initialized = False\n
    "},{"location":"02_composition_root/#typed-accessors","title":"Typed accessors","text":"

    Expose a get_svc(name) helper with one @overload per key so scripts get typing without importing concrete classes:

    @overload\ndef get_svc(name: Literal[\"user_svc\"]) -> UserService: ...\n@overload\ndef get_svc(name: Literal[\"wallet_svc\"]) -> WalletService: ...\n\ndef get_svc(name: str):\n    return _resources[name]\n

    Also expose get_db() (raw database handle) and pure helpers such as compute_fingerprint(payload) (canonical hash used for deduplication).

    "},{"location":"02_composition_root/#rules","title":"Rules","text":"
    • init() is idempotent.
    • The composition root is the only place that imports concrete adapters.
    • Handlers never call init(); they read request.app.state.db_resources.
    • Scripts call init() / shutdown() around their work.
    "},{"location":"10_layering_rules/","title":"Layering Rules","text":""},{"location":"10_layering_rules/#summary","title":"Summary","text":"

    The dependency direction is fixed. Violating it is a design defect.

    "},{"location":"10_layering_rules/#allowed-imports","title":"Allowed imports","text":"
    interfaces/*      ->  domain/*            (services, ports, models)\ninterfaces/*      ->  infra/adapters/*    (only read-only value objects)\ndomain/*          ->  domain/*            (same layer)\ndomain/*          ->  pipelines/*         (call a pipeline via a port)\ndomain/*/ports/*  ->  infra/*             (bind adapters + libraries at the seam)\ninfra/adapters/*  ->  domain/ports/*      (implement the interface)\nmain.py/init.py   ->  everything          (composition root only)\n
    "},{"location":"10_layering_rules/#forbidden-imports","title":"Forbidden imports","text":"
    domain/*          ->  interfaces/*        (never)\ndomain/*          ->  fastapi/starlette   (never)\ninfra/adapters/*  ->  interfaces/*        (never)\ninterfaces/*      ->  infra/db/*          (state lives in resources, not imports)\n
    "},{"location":"10_layering_rules/#why","title":"Why","text":"
    • The business logic is testable without a server or an event loop.
    • Adapters can be swapped (in-memory fake, real database) without touching business logic.
    • The HTTP edge stays thin and replaceable.
    "},{"location":"10_layering_rules/#the-portadapter-contract","title":"The port/adapter contract","text":"

    The ports/ package is the seam where the domain meets infrastructure. Each domain/<resource>/ports/ module declares the resource's models/document shape and exposes factory functions that bind a concrete adapter \u2014 so a port module is allowed to import from infra/ and from the persistence libraries. Everything outside ports/ in domain/ stays framework- and driver-free.

    domain/<resource>/ports/db.py       -> document model + create_<resource>_repo()\ndomain/<resource>/ports/service.py  -> service + get_<resource>_service()\ninfra/db/repos/<vendor>.py          -> repository adapter factory\ninfra/adapters/service.py           -> generic CRUD service base\n

    The composition root calls the port factories; nothing else constructs an adapter.

    "},{"location":"10_layering_rules/#enforcement","title":"Enforcement","text":"
    • domain/ must never import the web framework (fastapi / starlette) or interfaces/. Only the ports/ seam, infra/, and the composition root touch drivers.
    • Reviewers (and CI) reject import fastapi inside domain/ or infra/.
    "},{"location":"20_spec_first_routing/","title":"Spec-First Routing","text":""},{"location":"20_spec_first_routing/#summary","title":"Summary","text":"

    The OpenAPI document is the single source of truth for the HTTP contract. Handlers are bound to it by name; there is no decorator router.

    "},{"location":"20_spec_first_routing/#the-contract","title":"The contract","text":"
    • openapi.yaml (or .json) declares paths, schemas, parameters, responses, and security.
    • A custom operationId names the handler function.
    • The framework (openapi_first) validates requests/responses against the spec and dispatches to the handler whose name equals operationId.
    "},{"location":"20_spec_first_routing/#app-bootstrap","title":"App bootstrap","text":"
    # main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n    openapi_path=\"openapi.yaml\",\n    routes_module=interfaces.openapi,   # module whose namespace holds handlers\n    title=\"<App> API\",\n    version=\"0.1.0\",\n    lifespan=lifespan,\n)\n

    lifespan calls the composition root:

    @asynccontextmanager\nasync def lifespan(app):\n    app.state.db_resources = await init()\n    yield\n    await shutdown()\n
    "},{"location":"20_spec_first_routing/#handler-registry","title":"Handler registry","text":"

    interfaces/openapi.py is a flat re-export of every handler, so the framework can resolve operationId across the whole app:

    from interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n
    "},{"location":"20_spec_first_routing/#handler-shape","title":"Handler shape","text":"

    Handlers are thin and synchronous with respect to business logic:

    async def create_item(request: Request, body: dict):\n    svc = request.app.state.db_resources[\"item_svc\"]\n    return await svc.create(Item.model_validate(body))\n

    Generated stubs. openapi-first routes emits this exact shape (async def handlers taking body) when given --template-style; without that flag the stub is def create_item(payload: ItemInput) \u2014 sync, with a payload parameter. Both shapes bind identically: the router resolves handlers by operationId and introspects the FastAPI signature, and it never inspects the request body. Pass --template-style so the generated signatures match the handlers you keep in interfaces/.

    "},{"location":"20_spec_first_routing/#conventions","title":"Conventions","text":"
    • operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.
    • Read-only endpoints may use a cache decorator keyed on path + query + user.
    • Validation errors -> 400; not found -> 404; delete -> 204.
    • Security is declared once in the spec and enforced by the framework; handlers contain no auth code.
    "},{"location":"20_spec_first_routing/#authless-apps","title":"Authless apps","text":"

    Auth is deferred in the standard: the same spec-first app runs with or without it, because security lives entirely in the spec, never in handlers. For an app that needs no auth yet, do not invent per-handler auth checks \u2014 just omit the security keys:

    • Remove the top-level security: block.
    • Remove components.securitySchemes.
    • Do not add security: [] to operations. An explicit empty array is a per-operation override that means \"public\", and it only matters when a global security block exists. Leaving every operation without a security key is the cleanest authless spec.

    Profile endpoints (/users/me) and profile headers (x-profile) still exist in an authless app. Define a stand-in identity in the composition root \u2014 for example get_current_user returns or lazily creates a fixed \"Demo User\" \u2014 so handlers stay auth-agnostic and only the wiring changes when real auth arrives.

    The handlers, services, and composition root are unchanged. When auth is needed later, add the global security block and an introspecting dependency; only handlers that must opt out get security: [].

    "},{"location":"20_spec_first_routing/#codegen","title":"Codegen","text":"

    openapi_first scaffolds models and route stubs from the spec (see the Build Recipe, step 3):

    openapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n  --use-models --models-module _gen.models --inject-request --package --template-style\n

    Prefer codegen over hand-writing boilerplate. The result is the wire contract and a set of operationId-named signatures \u2014 not the canonical handlers.

    "},{"location":"21_crud_template/","title":"CRUD Template","text":""},{"location":"21_crud_template/#summary","title":"Summary","text":"

    Every resource follows the same four-file slice. Repetition is intentional: it makes the codebase predictable and generatable.

    "},{"location":"21_crud_template/#the-slice","title":"The slice","text":"
    domain/<resource>/\n    models.py               # pure Pydantic model (no DB fields)\n    ports/\n        db.py               # document model + repository factory\n        service.py          # service class + factory\n        converters.py       # (optional) transforms\n
    "},{"location":"21_crud_template/#the-id-rule-model-vs-document","title":"The id rule (model vs. document)","text":"

    Two different objects, two different jobs:

    Object Lives in Has id? Purpose Domain model domain/<resource>/models.py No business shape, framework-free Document domain/<resource>/ports/db.py Yes (from BaseDocument) Mongo persistence shape

    BaseDocument (mongo-ops) supplies id: Optional[PyObjectId] (aliased to _id) plus created_at / updated_at. The domain model must not declare id; Mongo owns it.

    Trap \u2014 generated wire models. openapi-first models produces the HTTP contract (see the Build Recipe, step 3). Response schemas frequently declare a required id: str, so a generated class is not a domain model. If you write class ItemDocument(Item, BaseDocument) and Item re-exports a required id: str, that field shadows BaseDocument.id and no Mongo _id can ever be set. Fix it one of three ways:

    1. Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required id only on the wire model.
    2. Order the bases so the document wins: class ItemDocument(BaseDocument, Item).
    3. Keep a separate document type and translate with ports/converters.py.
    "},{"location":"21_crud_template/#modelspy","title":"models.py","text":"

    A framework-free Pydantic model describing the business shape (no id).

    class Item(BaseModel):\n    name: str\n    quantity: int = 0\n
    "},{"location":"21_crud_template/#portsdbpy","title":"ports/db.py","text":"

    The persistence model extends the business model and adds database concerns. The repository factory selects the right repository flavour.

    # Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n    pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n    return repo_factory(\n        collection=\"items\",\n        document=ItemDocument,\n        populate_rules=[PopulateRule(field_name=\"owner\", collection_name=\"users\")],\n        cache=None,               # or a CachedBaseRepository config\n    )\n
    "},{"location":"21_crud_template/#nested-references","title":"Nested references","text":"

    The wire contract often declares a reference as an expanded object (expense.entity: Entity), while the domain model stays id-free. Pick one convention and apply it consistently:

    • Embed (default for small, stable refs) \u2014 store the object as a dict / list[dict] on the document; the API returns it verbatim. No populate wiring.
    • Populate \u2014 store an id and declare a PopulateRule; mongo-ops expands it on read. The referenced repo must be registered with the PopulationEngine.
    • Id only \u2014 store the id and let the client resolve it.

    For populate-backed repositories change a reference with update, not patch: patch rejects changes to populated FK fields by design.

    "},{"location":"21_crud_template/#portsservicepy","title":"ports/service.py","text":"

    The service is built through the DI library, injecting the repository. The generic CRUD base provides create/read/update/patch/delete/search/....

    class ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n    model = Item\n    schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n    return build(ItemService, instances={\"repo\": repo})\n
    "},{"location":"21_crud_template/#naming-conventions","title":"Naming conventions","text":"Concept Convention collection plural snake case (items) document <Resource>Document repository factory create_<resource>_repo service <Resource>Service service factory get_<resource>_service resource key <resource>_svc operationId list_items, create_item, ..."},{"location":"21_crud_template/#optional-files","title":"Optional files","text":"
    • converters.py \u2014 pure functions mapping between domain types.
    • ports/enrichment.py \u2014 adapts a pipeline into an async callable the service can invoke (used when a resource is populated by a pipeline).
    "},{"location":"22_auth_integration/","title":"Auth Integration","text":""},{"location":"22_auth_integration/#summary","title":"Summary","text":"

    Authentication is a service, not a library. Every Aetos application shares a single identity service \u2014 the auth-server \u2014 and consumes it through its HTTP contract. An app never implements registration, login, password hashing, or token issuance itself.

    The agent does not need an MCP bundle for the auth-server. It reads the pinned OpenAPI contract (below) and declares a bearer security scheme in its own spec; openapi_first then generates the verifying dependency. No auth code is written.

    "},{"location":"22_auth_integration/#read-the-contract","title":"Read the contract","text":"

    The auth-server OpenAPI document is pinned in the standards bundle and served as an MCP resource:

    docs://spec\n

    It is the authoritative client contract (OpenAPI 3.1). Read it to learn the endpoints and the request/response schemas:

    Endpoint Purpose POST /register Create a user (201, returns PublicUser) POST /login Authenticate and issue a JWT (LoginResponse) GET /me Current user's PublicUser (bearer required) POST /logout Stateless logout (LogoutResponse) POST /introspect Internal token introspection (active, user)

    The pinned snapshot is versioned (currently 0.0.5). If the caller has a live auth-server and needs the current contract, they can pass the full endpoint ({AUTH_SERVER}/openapi.json) and read it directly instead of the pin.

    "},{"location":"22_auth_integration/#wire-it-up-declare-dont-code","title":"Wire it up: declare, don't code","text":"

    Authentication is added by editing the app's own openapi.yaml \u2014 nothing else. Declare a global bearer requirement and point the scheme at the auth server (values come from the environment):

    security:\n  - bearerAuth: []\npaths:\n  # ...\ncomponents:\n  securitySchemes:\n    bearerAuth:\n      type: http\n      scheme: bearer\n      bearerFormat: JWT\n      x-server-url: \"{AUTH_SERVER}\"\n      x-introspect-path: \"/introspect\"\n      x-login-path: \"/login\"\n      x-register-path: \"/register\"\n      x-logout-path: \"/logout\"\n      x-me-path: \"/me\"\n

    openapi_first reads securitySchemes + security and generates a FastAPI dependency that extracts the bearer token, POSTs {\"token\": <jwt>} to {AUTH_SERVER}/introspect, and returns the user when active == true. It raises 401 on an invalid or inactive token and 503 when the auth server is unreachable. The generated handlers receive the authenticated user \u2014 you author no authentication code.

    "},{"location":"22_auth_integration/#public-operations","title":"Public operations","text":"

    A route that must stay open opts out by overriding security with an empty list:

    paths:\n  /health:\n    get:\n      operationId: health\n      security: []        # overrides the global requirement\n      responses:\n        \"200\":\n          description: OK\n
    "},{"location":"22_auth_integration/#environment","title":"Environment","text":"
    AUTH_SERVER=http://localhost:8000     # auth-server base URL\nJWT_SERVER=http://localhost:8000      # jwtlib client base URL (seeding/CLI)\n
    "},{"location":"22_auth_integration/#path-1-jwtlib-client-no-generator","title":"Path 1: jwtlib client (no generator)","text":"

    Apps that do not use a generated dependency verify requests with the py-jwt client:

    from jwtlib.introspection import authenticate_request\n\nauthorized = await authenticate_request(\n    should_skip_authentication,  # allow public paths\n    method,\n    path,\n    authorization_token,         # \"Bearer <jwt>\" or None\n)\n

    authenticate_request POSTs to {JWT_SERVER}/introspect (3 second timeout) and treats {\"active\": true, \"user\": {...}} as valid. Use this for seeding scripts and tests (jwtlib.register_user, jwtlib.login_user).

    "},{"location":"22_auth_integration/#golden-rules","title":"Golden rules","text":"
    1. Never trust an unverified token. Decode through the generated dependency or jwtlib before handling the request.
    2. Treat active: false as unauthenticated, even if the JWT decodes.
    3. Do not implement your own JWT parsing. Revocation and issuer changes must stay centralized in the auth-server.
    4. Do not add auth until the app's outcome is validated. Ship the app authless first; layer the security scheme once the core behaviour works.
    "},{"location":"22_auth_integration/#notes","title":"Notes","text":"
    • The default template is authless on purpose. Add the securitySchemes block above only when auth is required. Pull the shared slice with get_app_component(\"auth\").
    • The auth-server is deployed as a service (no importable package); the client surface is the spec, plus jwtlib for the low-level path.
    "},{"location":"30_infra_adaptors/","title":"Infra Adapters","text":""},{"location":"30_infra_adaptors/#summary","title":"Summary","text":"

    All external systems are reached through adapters in infra/. Prefer an existing internal library over writing a new adapter; where no library exists, copy the Tier B scaffold from templates/app. See the Genericity Matrix for the Tier A / B / C split.

    "},{"location":"30_infra_adaptors/#database-mongodb","title":"Database (MongoDB)","text":"
    • A single connection manager owns the client lifecycle. setup_database(uri, db_name) / shutdown_database().
    • A repository factory selects the repository flavour:
    repo_factory(collection, document, engine=None,\n             populate_rules=None, cache=None)\n
    • base repository \u2014 plain CRUD;
    • populating repository \u2014 resolves referenced documents (foreign keys);
    • cached repository \u2014 an LRU/TTL cache in front of reads.

    • Referenced documents are described declaratively:

    PopulateRule(field_name=\"owner\", collection_name=\"users\",\n             nested_rules=[], max_depth=2, projection=None)\n
    • Transactions use a session context manager provided by the library.

    Tier A \u2014 Library (Mongo Ops). Do not write raw Motor calls in the domain; consult the registry and the library's MCP before touching the driver.

    "},{"location":"30_infra_adaptors/#http-response-cache","title":"HTTP response cache","text":"

    Tier B \u2014 template. Copy from templates/app; not a standalone library.

    A generic GET cache sits behind a decorator.

    @cache_response(ttl=60)\nasync def list_items(request): ...\n
    • Keyed on path + query + user.
    • No-op when no store is wired.
    • invalidate(*resources) bumps a generation counter for write-triggered invalidation.
    "},{"location":"30_infra_adaptors/#event-progress-bus","title":"Event / progress bus","text":"

    Tier B \u2014 template. Copy from templates/app.

    A port with an in-memory default adapter (publish, get_events). Swap for a durable broker in production (see Worker & Progress).

    "},{"location":"30_infra_adaptors/#external-clients-ingestion","title":"External clients (ingestion)","text":"

    External fetchers (files, mail, HTTP) are adapters that wrap ingestion libraries. Construct them in the composition root and inject into orchestrators.

    Tier A \u2014 Libraries: Omniread (formats) and Mail Intake (email).

    "},{"location":"30_infra_adaptors/#llm-clients","title":"LLM clients","text":"

    Tier B \u2014 template. Copy the pool scaffold from templates/app.

    When a pipeline calls a model provider, route through a pool adaptor that handles provider selection, retries, and client lifecycle:

    pool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n

    Providers are selected by LLM_PROVIDER and configured by environment.

    "},{"location":"30_infra_adaptors/#key-value-store","title":"Key-value store","text":"

    Tier B \u2014 template. Copy from templates/app.

    For report/snapshot storage, provide a pluggable store port with a Redis adapter and an in-memory fallback.

    "},{"location":"30_infra_adaptors/#rule","title":"Rule","text":"
    • Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines) \u2014 use the library; writing a private copy is a defect. Query find_library first.
    • Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker, tests) \u2014 these are template code, not libraries: copy them from templates/app rather than searching the registry.
    • Tier C (models, business logic, pipeline families, rules) \u2014 author them yourself.
    "},{"location":"40_ui_admin/","title":"UI: Auto-Rendered Admin & Custom Pages","text":""},{"location":"40_ui_admin/#summary","title":"Summary","text":"

    The UI axis of an Aetos app is a published React client, not code written per application. Two npm packages carry the whole surface:

    Package Role @aetoskia/react-openapi Renders a complete admin panel (CRUD tables, forms, filters, sorting, pagination, uploads) from the app's openapi.yaml \u2014 zero UI code. @aetoskia/react-auth Auth contexts and pages (login/register/me) wired to the auth-server, plus the axios client with token handling.

    Both publish to the private registry https://npm.aetoskia.com. The server spec is the single source of truth: the same openapi.yaml that drives openapi-first routing drives the admin UI, through x- extension fields (see below).

    "},{"location":"40_ui_admin/#scaffold","title":"Scaffold","text":"
    cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client\nnpm install          # deps come from npm.aetoskia.com via .npmrc\nnpm run dev\n

    The client template is a Vite + MUI app with:

    • src/openapi-config.ts \u2014 points the renderer at the app's spec and the auth-server's spec;
    • .env.example \u2014 VITE_API_BASE_URL (app API) and VITE_AUTH_BASE_URL (auth-server);
    • a Home page that auto-lists every resource the spec declares, and a Header/Footer/theme scaffold.

    Custom pages are ordinary React routes layered on top; the auto-rendered admin is available per resource at #/resource/<name>.

    "},{"location":"40_ui_admin/#x-extensions-how-the-spec-steers-the-ui","title":"x- extensions: how the spec steers the UI","text":"

    The admin renderer reads a catalog of OpenAPI extension fields (x-resource, x-list-columns, x-display-format, x-label, x-order, x-hidden, x-sortable, x-filterable, x-autocomplete, x-fk, x-ui-type, x-upload, x-sse, x-profile, and the auth wiring x-server-url / x-login-path / x-me-path / \u2026) declared on schemas and properties.

    The catalog is canonical in the react-openapi source, not in this doc. Read it from the MCP bundle:

    docs://modules/react-openapi        # module: specExtensions \u2014 the full catalog\n

    The app's own spec carries the per-resource values; the catalog explains what each field does. The template's openapi.yaml shows the convention on the example Item and Job schemas.

    "},{"location":"40_ui_admin/#golden-rules","title":"Golden rules","text":"
    1. The spec owns the UI. Add a resource or rename a column in openapi.yaml; the admin follows. Never fork the renderer per app.
    2. Custom pages for workflows, auto-render for CRUD. Anything that is \"list / create / edit / delete an entity\" is a spec concern; anything that is a bespoke workflow is a React page.
    3. Auth is consumed, never re-implemented. @aetoskia/react-auth holds tokens and calls the auth-server endpoints declared by the x- wiring; pages read the context.
    4. Publish, don't vendor. UI shared across apps belongs in @aetoskia/react-openapi or @aetoskia/react-auth (SemVer, tag-triggered npm release), not copy-pasted into app trees.
    "},{"location":"40_ui_admin/#notes","title":"Notes","text":"
    • The server template ships authless by default (see 22 \u2013 Auth Integration); the client template pairs with it and degrades to \"no login\" until a security scheme is declared.
    • react/vet is the full-stack reference implementation: spec with x- extensions, server, and the consuming client in one tree.
    "},{"location":"50_pipeline_scaffold/","title":"Pipeline Scaffold","text":""},{"location":"50_pipeline_scaffold/#summary","title":"Summary","text":"

    Data-driven processing is expressed as a composition graph. The graph is built by a DI library; execution is driven by a DAG engine. Stages are pure and individually testable.

    "},{"location":"50_pipeline_scaffold/#when-to-use-a-pipeline","title":"When to use a pipeline","text":"

    Use a pipeline when a resource is produced by a multi-step transformation of an input (a document, a file, a message) rather than by a simple CRUD write.

    "},{"location":"50_pipeline_scaffold/#structure-of-a-family","title":"Structure of a family","text":"

    A family is one input shape (e.g. a file format or a message kind). Each family has the same skeleton:

    pipelines/<family>/\n    abc.py            # port contracts (stage interfaces + root interface)\n    impl.py           # concrete stages + root implementation\n    pipeline.yaml     # graph spec: nodes, edges, roots, schema\n    resources/\n        pipeline.yaml # runtime spec consumed by the loader\n        patterns/*.yaml  # rule fragments (pattern registry)\n    banks/<variant>.py   # optional variants that re-pin a few slots\n
    "},{"location":"50_pipeline_scaffold/#shared-scaffolding","title":"Shared scaffolding","text":"
    pipelines/shared/\n    ports.py     # RegistryPort, FinalizePort, etc.\n    loader.py    # build(root_cls) -> wires the graph + engine\n    registry.py  # resolve_root(...) -> root class for a (kind, variant)\n
    "},{"location":"50_pipeline_scaffold/#the-loader","title":"The loader","text":"
    def build(root_cls, *, instances=None, config=None):\n    # merge resources/patterns/*.yaml into a pattern registry\n    # bind registry/config into each graph node by id\n    # build the schema (state) class and the engine\n    root = root_cls()\n    root._engine = Engine(graph, state_cls, payload_cls)\n    return root\n
    "},{"location":"50_pipeline_scaffold/#the-registry","title":"The registry","text":"

    A single lookup replaces hand-rolled if/else selection:

    _FAMILIES = {\n    \"csv\":  {\"acme\": {\"heuristic\": AcmeCsvPipeline}},\n    \"json\": {\"globex\": {\"heuristic\": GlobexJsonPipeline}},\n}\n\ndef resolve_root(kind, variant, mode=\"heuristic\"):\n    return _FAMILIES[kind][variant][mode]\n

    Adding a new variant means adding one class and one registry entry.

    "},{"location":"50_pipeline_scaffold/#driving-a-pipeline","title":"Driving a pipeline","text":"

    Stages are driven explicitly so intermediate state can be persisted:

    pipeline = build(resolve_root(kind, variant))\ncontent = pipeline.ingest.materialize(raw_bytes)\nrecords = pipeline.reader.read(content)\nitems   = pipeline.normalize.build(records)\n
    "},{"location":"50_pipeline_scaffold/#testing","title":"Testing","text":"

    Because stages are pure, test each stage in isolation and test the assembled root against fixture inputs whose expected outputs are stored as golden files. A parity test replays fixtures headlessly.

    "},{"location":"51_worker_and_progress/","title":"Worker & Progress","text":""},{"location":"51_worker_and_progress/#summary","title":"Summary","text":"

    Long-running work runs in a background worker. Jobs are durable documents that move through an explicit status machine. Progress is streamed to clients.

    "},{"location":"51_worker_and_progress/#the-status-machine","title":"The status machine","text":"
    pending -> processing -> completed\n                     \\-> failed    (retried up to N times)\n                     \\-> paused    (waiting for human input)\n
    • pending \u2014 queued, eligible for pickup.
    • processing \u2014 claimed by a worker.
    • paused \u2014 blocked on a human-in-the-loop question.
    • failed \u2014 error; retry_count increments until a max, then skipped.
    • completed \u2014 terminal success.
    "},{"location":"51_worker_and_progress/#the-poll-loop","title":"The poll loop","text":"
    async def poll(resources):\n    backoff = MIN_POLL\n    while True:\n        jobs = await job_svc.search(filter={\"status\": \"pending\"}, limit=100)\n        backoff = MIN_POLL if jobs else min(backoff * 2, MAX_POLL)\n        for job in jobs:\n            if (job.retry_count or 0) >= MAX_RETRIES:\n                continue\n            await job_svc.update_status(job.id, \"processing\")\n            try:\n                await process(job, resources)\n            except PauseForResolution:\n                await job_svc.update_status(job.id, \"paused\")\n            except Exception as e:\n                await job_svc.update_status(\n                    job.id, \"failed\", error=str(e),\n                    retry_count=(job.retry_count or 0) + 1,\n                )\n        await asyncio.sleep(backoff)\n

    Tunables live in environment variables: WORKER_POLL_MIN, WORKER_POLL_MAX, WORKER_MAX_RETRIES.

    "},{"location":"51_worker_and_progress/#progress-events","title":"Progress events","text":"

    A worker publishes structured progress events to a bus (a port). The HTTP edge exposes a streaming endpoint that replays events for a given job id.

    class ProgressEvent(BaseModel):\n    job_id: str\n    step: str\n    status: str            # started | progress | completed | skipped | paused\n    message: dict | None = None\n

    The bus is an in-memory adapter by default; because it is a port, it can be swapped for a durable broker. Emit from the orchestrator at each stage:

    await self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n                                     status=\"completed\", message={\"count\": n}))\n
    "},{"location":"51_worker_and_progress/#streaming-endpoint","title":"Streaming endpoint","text":"
    async def stream_job_events(request, id):\n    bus = request.app.state.db_resources.get(\"progress_bus\")\n    if bus is None:\n        return {\"events\": []}\n\n    async def gen():\n        sent = 0\n        while True:\n            events = bus.get_events(id)\n            for e in events[sent:]:\n                yield f\"data: {e.model_dump_json(exclude_none=True)}\\n\\n\"\n                sent += 1\n            await asyncio.sleep(0.5)\n\n    return StreamingResponse(gen(), media_type=\"text/event-stream\")\n

    Known limitation. The default in-memory bus is per-process. If the worker and the API run as separate processes, events published by the worker are not visible to the API. For production, back the bus with a shared broker. The port boundary makes this a one-adapter change.

    "},{"location":"52_human_in_loop/","title":"Human-in-the-Loop","text":""},{"location":"52_human_in_loop/#summary","title":"Summary","text":"

    Some inputs are ambiguous and must be resolved by a human before processing can continue. The blueprint models this as a durable pause: an exception is raised, a question is recorded, the job pauses, and later resumes.

    "},{"location":"52_human_in_loop/#the-mechanism","title":"The mechanism","text":"
    1. During processing, a stage raises a domain exception carrying the context needed to ask the question:
    raise PauseForResolution(\n    job_id=jid,\n    reference=\"<stable key for this decision>\",\n    options=[...],\n    context={...},\n)\n
    1. The orchestrator catches it, records a question document, and re-raises so the worker marks the job paused.
    2. A client lists pending questions and answers one:
    GET  /jobs/{id}/questions            -> list pending questions\nPOST /questions/{qid}/resolve  {choice}  -> record the choice\n
    1. When the last pending question is resolved, the resolve endpoint flips the parent job back to pending.
    2. The worker picks it up again, rebuilds a resolution map from previously answered questions, and seeds it into the pipeline so the same decision is not asked twice.
    "},{"location":"52_human_in_loop/#the-question-document","title":"The question document","text":"
    class Question(BaseDocument):\n    job_id: str\n    reference: str            # stable key: stage|field|value\n    status: str               # pending | resolved\n    options: list[dict]\n    context: dict\n    chosen: dict | None = None\n    resolved_at: str | None = None\n
    "},{"location":"52_human_in_loop/#resume-map","title":"Resume map","text":"

    The worker turns answered questions into a lookup keyed by reference, then injects it into the pipeline so that stage can skip re-raising:

    resolved = {q.reference: q.chosen for q in await svc.search(\n    filter={\"job_id\": jid, \"status\": \"resolved\"})}\n\npipeline = build(root, instances={\"pause\": handler}, config={\"resolved\": resolved})\n
    "},{"location":"52_human_in_loop/#resolve-endpoint","title":"Resolve endpoint","text":"
    async def resolve_question(request, id, body):\n    svc = request.app.state.db_resources[\"question_svc\"]\n    q = await svc.read(id)\n    await svc.update(id, {\"chosen\": body[\"chosen\"], \"status\": \"resolved\",\n                          \"resolved_at\": now()})\n\n    pending = await svc.search(filter={\"job_id\": q.job_id, \"status\": \"pending\"},\n                               limit=1)\n    if not pending:\n        jobs = request.app.state.db_resources[\"job_svc\"]\n        job = await jobs.read(q.job_id)\n        if job and job.status == \"paused\":\n            await jobs.update_status(q.job_id, \"pending\")\n    return q\n
    "},{"location":"52_human_in_loop/#cli-parity","title":"CLI parity","text":"

    The same flow is available through scripts/CLI: list, get, create, questions, resolve, retry.

    "},{"location":"90_drone_pipelines/","title":"Drone Pipelines","text":""},{"location":"90_drone_pipelines/#summary","title":"Summary","text":"

    Every Aetos repository ships a Drone CI definition at .drone.yml. There is exactly one standard per repository, and there are three types of file, chosen by what the repository produces:

    Type File Produces Repos A \u2014 PyPI package templates/drone/python-lib.yaml A wheel + sdist on the private index dagpipe, doc-forge, hexa, mail-intake, mongo-ops, omniread, openapi-first, py-jwt B \u2014 npm package templates/drone/npm-lib.yaml A package published to the private npm registry doc-weaver, react-auth, react-openapi C \u2014 Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container auth-server, vet

    All templates run on platform: linux / arm64 with the workspace at /drone/src. Copy the matching template to <repo>/.drone.yml; the Type A and Type B files need no edits, the Type C file needs its placeholders filled in (below).

    Conventions: .drone.yml (not .yaml), one file per repo, secrets injected with from_secret \u2014 never commit credentials.

    "},{"location":"90_drone_pipelines/#type-a-pypi-package-python-libyaml","title":"Type A \u2014 PyPI package (python-lib.yaml)","text":"

    For internal Python libraries. The file contains two pipelines:

    1. build-and-publish-pypi \u2014 triggered on tag:
    2. check-version \u2014 guard: fail unless $DRONE_TAG equals the version in pyproject.toml; then read name / version and query $PIP_REPO_URL/simple/<package>/; if that version already exists, exit 78 and skip the whole build (see 91 \u2013 Versioning & Changelog).
    3. quality-gate \u2014 install the package with its [dev] extras from the private index, then black --check, ruff check, mypy, pydoclint, pytest.
    4. build-package \u2014 python -m build into dist/.
    5. upload-to-private-pypi \u2014 twine upload to $PIP_REPO_URL.
    6. backfill-pypi-from-tags \u2014 triggered on custom:
    7. fetch-tags \u2014 git fetch --tags --force.
    8. build-and-upload-missing \u2014 walk every tag in version order, skip the ones already on the index, git checkout the rest, verify tag == pyproject version at that commit, build, and upload. This is the disaster-recovery / replay path.

    Secrets: PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD.

    • Ship as-is. Package name and version come from pyproject.toml, so the same file is reused verbatim across every library.
    "},{"location":"90_drone_pipelines/#type-b-npm-package-npm-libyaml","title":"Type B \u2014 npm package (npm-lib.yaml)","text":"

    For internal npm libraries (the React packages). One pipeline, build-and-publish-npm, triggered on tag:

    • check-version \u2014 guard: fail unless $DRONE_TAG equals the version in package.json; query $NPM_REPO_URL for name@version; if it already exists, exit 78 and skip the whole build.
    • quality-gate \u2014 write .npmrc from the secrets, npm install, then npm run quality-gate.
    • build-package \u2014 npm install && npm run build into dist/.
    • publish-to-private-npm \u2014 npm publish --access restricted to $NPM_REPO_URL.

    Secrets: NPM_REPO_URL, NPM_USERNAME, NPM_PASSWORD. Auth is a generated .npmrc (registry= + _auth= base64 of user:pass) written from secrets at runtime \u2014 the committed .npmrc in a repo only points at the registry URL.

    • Ship as-is. Name and version come from package.json, so the same file is reused verbatim across every npm package.
    "},{"location":"90_drone_pipelines/#type-c-docker-deployment-service-dockeryaml","title":"Type C \u2014 Docker deployment (service-docker.yaml)","text":"

    For a service that ships a container image \u2014 a long-running API/worker, e.g. auth-server. The file contains one pipeline, build-and-deploy-service, triggered on tag:

    • fetch-tags \u2014 resolve the latest tag into LATEST_TAG.txt; fail if there are no tags.
    • check-version \u2014 guard: fail unless $DRONE_TAG equals the repo-root VERSION file (see 91 \u2013 Versioning & Changelog).
    • check-remote-image \u2014 if aetos/<app>:<tag> already exists locally, exit 78 and skip the build.
    • quality-gate \u2014 install from requirements.txt and run black / ruff / mypy / pytest.
    • build-image \u2014 docker build with the private-index build args, tagged aetos/<app>:<tag> and :latest.
    • push-image \u2014 login, re-tag with $REGISTRY_HOST, push both tags.
    • stop-old \u2014 docker rm -f <app>.
    • run-container \u2014 docker run -d --name <app> -p <host>:<container> --restart always with the service's -e environment.

    Secrets: PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD (service build args), REGISTRY_HOST, REGISTRY_USER, REGISTRY_PASS, plus the service runtime env (for example MONGO_*, JWT_SECRET).

    Placeholders to fill in per repo:

    Placeholder Meaning Example <app> service image / container name auth-server aetos/<app> image repository on the registry aetos/auth-server <host-port> / <container-port> published : in-image port 9003:8000 <ENV_VAR> runtime environment variable MONGO_HOST"},{"location":"90_drone_pipelines/#what-not-to-do","title":"What not to do","text":"
    • Do not hand-roll a fourth shape. A repo is exactly one of Type A, B, or C, never combinations.
    • Do not inline secrets; always use from_secret.
    • Do not drop the check-version / check-remote-image skip \u2014 it guards the index and the running container against duplicate publishes.
    • Do not drop the tag\u2194version guard. A Python package needs its pyproject.toml version to equal the tag; an npm package needs its package.json version; a service needs a root VERSION file. See 91 \u2013 Versioning & Changelog.
    • Do not rename the pipelines; tooling and runbooks key off build-and-publish-pypi, build-and-publish-npm, and build-and-deploy-service.
    "},{"location":"90_drone_pipelines/#related","title":"Related","text":"
    • 01 \u2013 Build Recipe \u00b7 30 \u2013 Infra Adapters \u00b7 91 \u2013 Versioning & Changelog \u00b7 93 \u2013 Library Registry
    "},{"location":"91_versioning_and_changelog/","title":"Versioning & Changelog","text":""},{"location":"91_versioning_and_changelog/#summary","title":"Summary","text":"

    Every Aetos artifact \u2014 library or service \u2014 is versioned with Semantic Versioning and documents its releases in a Keep a Changelog file. The version file is the source of truth, the git tag must equal it, and a Drone guard enforces that equality before anything is published. Releases are cut manually; a tag is what triggers the pipeline.

    This page pairs with 90 \u2013 Drone Pipelines: the version and tag contract is exactly what those three CI shapes consume.

    "},{"location":"91_versioning_and_changelog/#the-version-contract","title":"The version contract","text":"Artifact Source of truth Notes Library (PyPI package) version in pyproject.toml check-version also reads it to skip duplicates Library (npm package) version in package.json check-version also queries the registry to skip duplicates Service (Docker) VERSION file at the repo root one line, x.y.z; the service reads it (build-arg) \u2014 never hardcode the version

    Rules:

    1. One version per repository. No per-module versions.
    2. Tag = version. The git tag is bare SemVer (0.0.6), no v prefix, and it MUST match the version file byte-for-byte. The Drone guard fails the build otherwise \u2014 this is what stops a tag from silently publishing nothing.
    3. A release is a tag, created after the change is merged. The version bump and the changelog promotion travel inside the merge request (MR); the tag is added on the merged commit and pushed. See Tagging strategy.
    4. Never reuse a tag. If a release is wrong, cut a new patch version.
    "},{"location":"91_versioning_and_changelog/#pre-10-0yz","title":"Pre-1.0 (0.y.z)","text":"

    While a package is 0.y.z, 0.y is treated as the major line:

    • breaking change \u2192 0.(y+1).0
    • new feature \u2192 0.y.(z+1)
    • bug fix \u2192 0.y.(z+1)

    Once a package reaches 1.0.0, normal SemVer applies. Do not ship 1.0.0 until the public API is considered stable.

    "},{"location":"91_versioning_and_changelog/#the-changelog","title":"The changelog","text":"

    CHANGELOG.md at the repo root, in Keep a Changelog form. It always has exactly one open [Unreleased] section with the change categories that apply:

    ## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n

    On release you promote it \u2014 never delete history:

    ## [0.0.6] - 2026-03-07\n\n### Added\n- ...\n

    and add a link reference at the bottom:

    [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/0.0.6...HEAD\n[0.0.6]: https://git.aetoskia.com/aetos/<repo>/compare/0.0.5...0.0.6\n

    Conventions:

    • Write entries for humans: one line each, imperative, referencing the user impact, not the commit hash.
    • Categories: Added, Changed, Deprecated, Removed, Fixed, Security. Omit empty categories.
    • Security fixes get their own entry even if the change is otherwise a bug fix.
    • Keep [Unreleased] current: add an entry in the same change that introduces the behaviour, not later.
    • The [Unreleased] heading is never renamed; copy its content into the new version heading and clear it.
    "},{"location":"91_versioning_and_changelog/#reconcile-before-releasing","title":"Reconcile before releasing","text":"

    [Unreleased] is a draft, not the source of truth. It is only trustworthy when it was maintained alongside each change; a long-running branch or a change that skipped the changelog leaves it stale, so it can silently under-report a release. Verify it against the change before you promote:

    1. List what actually changed since the last release: git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD.
    2. Compare that against [Unreleased]; add an entry for every user-facing change that is missing.
    3. Always show the proposed additions to the user and wait for approval before promoting. If an entry is ambiguous, ask rather than guess.

    Only then promote. Never cut a release with a known gap between the diff and the changelog.

    "},{"location":"91_versioning_and_changelog/#tagging-strategy-mr-based","title":"Tagging strategy (MR-based)","text":"

    The release version is known while the change is still a merge request, so the bump travels with the change and only the tag is added afterwards:

    1. In the MR \u2014 reconcile [Unreleased] against the change (see Reconcile before releasing) and get the user's approval for any additions. Then set the intended release version: version in pyproject.toml or package.json (or the VERSION file) to x.y.z, and promote the changelog (below). Changelog entries and link references name the version/tag, never a commit id.
    2. Merge or squash the MR into main.
    3. Create the tag x.y.z on the resulting commit (the merge/squash commit).
    4. Push the tag (git push origin x.y.z) \u2014 Drone builds the release.

    Because the version bump is inside the tagged commit, the guard's tag == version check can only fail if the wrong commit was tagged.

    "},{"location":"91_versioning_and_changelog/#agent-assisted-tagging","title":"Agent-assisted tagging","text":"

    The agent neither authors nor merges the MR, and never tags before the merge. After the user confirms the MR is merged/squashed and says to proceed:

    1. Wait for that explicit go-ahead.
    2. Find the commit that introduced the version bump:
    3. Python packages: git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml
    4. npm packages: git log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.json
    5. services: git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION
    6. Confirm that commit's version file equals x.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>.
    7. Do not push. Ask the user to push the tag: git push origin x.y.z.
    "},{"location":"91_versioning_and_changelog/#release-checklist","title":"Release checklist","text":"
    1. Confirm the quality gate is green and main is up to date.
    2. In the MR: reconcile [Unreleased] against the change being released \u2014 git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD \u2014 add any missing user-facing entries, and get the user's approval for the additions. Then move every [Unreleased] entry under the new version heading with the target date (## [x.y.z] - YYYY-MM-DD) and restore an empty [Unreleased] on top.
    3. In the MR: add the two compare-link references at the bottom of the changelog, naming the version/tag (never a commit id).
    4. In the MR: set the version file \u2014 pyproject.toml (version = \"x.y.z\"), package.json (\"version\": \"x.y.z\"), or VERSION \u2014 to the intended release version.
    5. Merge or squash the MR into main.
    6. Create the tag on the resulting commit: lightweight git tag x.y.z.
    7. Push the tag (git push origin x.y.z). Watch Drone: Type A publishes the wheel/sdist, Type B publishes the npm package, Type C builds and deploys the image; all three first assert tag == version.
    8. Do not reuse the tag if the run fails \u2014 fix and cut x.y.(z+1).
    "},{"location":"91_versioning_and_changelog/#why-the-guard-matters","title":"Why the guard matters","text":"

    The version file and the tag are two facts that must agree; nothing in git enforces that by itself. A mismatch is not a theoretical risk \u2014 a package can be tagged 0.0.2 while its pyproject.toml still says 0.0.1, and the publish step (which compares the file against the index) then skips the build entirely, so 0.0.2 never reaches the index while looking like a release. The Drone guard turns that silent no-op into a hard failure.

    "},{"location":"91_versioning_and_changelog/#related","title":"Related","text":"
    • 90 \u2013 Drone Pipelines \u00b7 94 \u2013 Repository Inventory \u00b7 01 \u2013 Build Recipe \u00b7 93 \u2013 Library Registry
    "},{"location":"92_genericity_matrix/","title":"Genericity Matrix","text":""},{"location":"92_genericity_matrix/#summary","title":"Summary","text":"

    This page states precisely what is library-backed, what is template-provided, and what is app-specific and must be authored. Use it to scope a new build and to know, for any given capability, whether to call a library, copy template code, or write it yourself.

    "},{"location":"92_genericity_matrix/#tier-a-library-backed-use-never-reimplement","title":"Tier A \u2014 Library-backed (use; never reimplement)","text":"

    These capabilities already exist as internal libraries. Reach them through the library registry MCP (find_library(capability)) and read the library's own MCP for its API. Writing a private copy of any of these is a defect.

    Capability Library MCP Persistence: repositories, cache, populate, transactions Mongo Ops (mongo_ops) :8007 Dependency injection / composition Hexa (hexa) :8008 Deterministic multi-step transformation engine DAG Pipe (dagpipe) :8006 Spec-first HTTP edge, client, codegen OpenAPI First (openapi_first) :8004 Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) Omniread (omniread) :8003 Read-only email ingestion Mail Intake (mail_intake) :8002 JWT auth primitives (register, login, introspect) Py JWT (jwtlib) :8005 Documentation build (MkDocs + MCP) Doc Forge (docforge) :8001"},{"location":"92_genericity_matrix/#tier-b-template-provided-copy-from-templatesapp-then-adapt","title":"Tier B \u2014 Template-provided (copy from templates/app, then adapt)","text":"

    These are reusable internal components shipped inside the template \u2014 they are not independent libraries. Copy them as-is, then rename/populate only what is app-specific. They give every app the same wiring shape as the reference app. Fetch them by kind with get_app_component, or the whole tree with get_app_template.

    Component Template location Action Shipped Layering & dependency rule the package layout + import rules keep \u2705 Composition root shape init.py (get_svc/get_db, idempotency) keep \u2705 Spec-first web edge interfaces/openapi.py, thin handlers bound to operationId keep \u2705 CRUD slice pattern domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py} rename + fill \u2705 Generic CRUD service infra/adapters/service.py (CrudsServiceAdaptor) keep \u2705 Repository factory infra/db/repos/mongo.py (_repo_factory) keep \u2705 Worker poll + status loop worker.py keep \u2705 Progress bus infra/adapters/sse.py (keyed in-memory bus) + an app SSE endpoint keep \u2705 HTTP response cache infra/http_cache.py (cache_response + generation invalidation) keep \u2705 Key-value store infra/adapters/kv.py (port + in-memory + Redis adaptor) keep \u2705 Pipeline scaffold pipelines/shared/{ports,loader,registry}.py + pipelines/<family>/ rename + fill \u2705 Operator scripts scripts/ (init() / get_svc() / shutdown()) rename \u2705 Testing harness integration fakes + fixture-parity pattern keep \u2705 Project scaffolding pyproject.toml, Dockerfile, docker-compose.yaml, .env.example, openapi.yaml rename + fill \u2705"},{"location":"92_genericity_matrix/#tier-b-patterns-port-from-the-reference-app","title":"Tier B (patterns) \u2014 port from the reference app","text":"

    A few Tier B capabilities are real and reusable, but are shaped by the app or the provider, so the template does not ship them verbatim \u2014 copying them blindly would carry app-specific names. Treat them as patterns: lift the shape from the reference app and rename per app.

    Component Reference location Action Shipped Human-in-the-loop durable pause + question document + resolve endpoint + resume map port + reshape \u2717 LLM client pool provider-select + retry/rotation pool scaffold port + reshape \u2717"},{"location":"92_genericity_matrix/#tier-c-app-specific-must-be-authored","title":"Tier C \u2014 App-specific (must be authored)","text":"Artifact Why Resource models the business shape Repository populate/cache rules the relationships Service business logic domain behaviour Pipeline families the transformation graph Pattern/rule resources data-driven policy Fixtures & golden outputs test data"},{"location":"92_genericity_matrix/#the-two-axes","title":"The two axes","text":"

    Everything app-specific reduces to two axes:

    1. Resource model \u2014 the set of resources, their fields, relationships, and the OpenAPI contract.
    2. Pipeline family \u2014 only if inputs require multi-step transformation.

    If you can specify those two axes, the rest of the app follows mechanically: Tier A is imported, Tier B is copied, and Tier C is the only thing you author.

    "},{"location":"93_library_registry/","title":"Library Registry","text":""},{"location":"93_library_registry/#summary","title":"Summary","text":"

    The Aetos ecosystem provides internal libraries for the shared concerns of every application. Use them. Writing a private copy of any capability below is a defect.

    Each library also exposes its own documentation as an MCP server. An AI agent can read a library's full API by connecting to its MCP and reading docs://index, docs://nav, and docs://modules/{module}.

    "},{"location":"93_library_registry/#catalog","title":"Catalog","text":"Library Import Purpose Use instead of MCP DAG Pipe dagpipe Deterministic state-transformation DAG engine hand-rolled orchestration/topological ordering :8006 Hexa hexa Declarative DI / composition-root tooling manual wiring, hand-written factories :8008 Mongo Ops mongo_ops Async MongoDB repositories, cache, populate, transactions raw Motor CRUD and connection lifecycle :8007 Omniread omniread Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) custom scrapers/parsers :8003 OpenAPI First openapi_first Spec-first FastAPI bootstrap, client, codegen decorator routers and hand-written clients :8004 Mail Intake mail_intake Provider-agnostic read-only email ingestion Gmail OAuth + MIME parsing :8002 Py JWT jwtlib JWT auth primitives (register/login/introspect) password hashing + token code :8005 Doc Forge docforge Docstring compiler \u2192 MkDocs + MCP docs custom doc generators :8001 Doc Weaver doc-weaver JSDoc compiler \u2192 TypeDoc + MCP docs (React/TS) custom TypeScript doc generators :8010 React Auth react-auth JWT-backed React auth UI: provider, login page, profile screens, bearer axios client hand-rolled auth contexts and token handling in React apps :8011 React OpenAPI react-openapi Spec-driven React admin UI (CRUD, tables, forms, SSE) derived from an OpenAPI document per-resource hand-written admin screens :8012 Auth Server (service) Central identity service (register/login/me/logout/introspect) building auth endpoints yourself spec: docs://spec"},{"location":"93_library_registry/#how-to-choose","title":"How to choose","text":"You need to\u2026 Use persist and query documents Mongo Ops compose dependencies Hexa run a multi-step transformation DAG Pipe read/parse an arbitrary input format Omniread expose a contract-first HTTP API OpenAPI First ingest email Mail Intake authenticate users / verify tokens Py JWT (+ Auth Server) generate documentation Doc Forge generate React/TypeScript documentation Doc Weaver authenticate a React front-end React Auth (+ Auth Server) build a React admin UI from an OpenAPI spec React OpenAPI"},{"location":"93_library_registry/#discovery","title":"Discovery","text":"

    A single standards MCP exposes find_library(capability), which returns the matching library, its import name, its \"use instead of\" guidance, and its MCP endpoint (or, for the auth-server service, a spec pointer). It also serves the pinned auth-server OpenAPI contract at docs://spec. Query it before writing any infrastructure.

    "},{"location":"93_library_registry/#notes","title":"Notes","text":"
    • Hexa is a full library with its own MCP server on :8008: read docs://index, docs://nav, and docs://modules/{module} (e.g. docs://modules/hexa.container) to discover the DI surface \u2014 build(cls, *, instances, config) and the PortNode model.
    • Auth Server is a service, not an importable library: it has no MCP bundle by design. Read its client contract from the pinned OpenAPI document via docs://spec and wire it by declaring a bearerAuth security scheme in the app's own spec (see Auth Integration); py-jwt covers the low-level token path.
    • Doc Weaver is the TypeScript counterpart to Doc Forge: it reads JSDoc comments and emits a TypeDoc site plus the same doc-forge-shaped MCP bundle for React packages. Its convention is RDFC (the React Doc-forge Convention), the JSDoc parallel to GSDFC.
    • React Auth and React OpenAPI are front-end libraries (TypeScript, React 18 + MUI + React Router peers), not Python services \u2014 they consume the Auth Server and an OpenAPI document rather than serving one. Each ships a wiki, a TypeDoc site and an MCP bundle like every other library above.
    • Library MCP servers expose documentation resources (not executable tools): they tell an agent the available API surface so it does not invent code.
    "},{"location":"94_repository_inventory/","title":"Repository Inventory","text":""},{"location":"94_repository_inventory/#summary","title":"Summary","text":"

    The canonical list of Aetos repositories covered by the CI and versioning standards, with the artifact each one produces, its version source, and its Drone shape. Every entry maps to the contract in 91 \u2013 Versioning & Changelog and the three CI files in 90 \u2013 Drone Pipelines.

    The same data is published machine-readably as the registry://repos MCP resource (and the list_repos tool), sourced from standards/src/repos.json. Paths are relative to the aetos/ workspace root.

    "},{"location":"94_repository_inventory/#packages-type-a-pypi","title":"Packages (Type A \u2014 PyPI)","text":"

    A wheel + sdist published to the private index. Version source is pyproject.toml; Drone file is python-lib.yaml (tag-triggered).

    Repo Path Docs MCP server dagpipe dagpipe lib, wiki dagpipe:8006 doc-forge doc-forge lib, wiki docforge:8001 hexa hexa lib, wiki hexa:8008 mail-intake mail-intake lib, wiki mail_intake:8002 mongo-ops mongo-ops lib, wiki mongo_ops:8007 omniread omniread lib, wiki omniread:8003 openapi-first openapi-first lib, wiki openapi_first:8004 py-jwt py-jwt lib, wiki jwtlib:8005"},{"location":"94_repository_inventory/#packages-type-b-npm","title":"Packages (Type B \u2014 npm)","text":"

    A package published to the private npm registry (npm.aetoskia.com). Version source is package.json; Drone file is npm-lib.yaml (tag-triggered).

    Repo Path Docs MCP server doc-weaver doc-weaver lib, wiki doc-weaver:8010 react-auth react/react-auth lib, wiki react-auth:8011 react-openapi react/react-openapi lib, wiki react-openapi:8012"},{"location":"94_repository_inventory/#services-apps-type-c","title":"Services & Apps (Type C)","text":"

    A container image pushed to the registry and run on the Docker host. Version source is the VERSION file; Drone file is service-docker.yaml.

    Repo Path Type Trigger Docs auth-server auth-server service tag api, lib, wiki vet react/vet app tag lib, wiki

    Tag-triggered and enforces tag == VERSION.

    "},{"location":"94_repository_inventory/#required-files-per-repo","title":"Required files per repo","text":"Type Version source Changelog Drone package (PyPI) pyproject.toml CHANGELOG.md python-lib.yaml package (npm) package.json CHANGELOG.md npm-lib.yaml service / app VERSION CHANGELOG.md service-docker.yaml

    Every covered repository must be able to satisfy tag == version: the version file (never a commit id) is bumped in the release MR, the MR is merged, and the tag is created on the merged commit.

    "},{"location":"94_repository_inventory/#reading-this-at-runtime","title":"Reading this at runtime","text":"
    • Resource: registry://repos
    • Tool: list_repos()
    • Humans: this page (mirror of standards/src/repos.json).
    "},{"location":"94_repository_inventory/#related","title":"Related","text":"
    • 90 \u2013 Drone Pipelines \u00b7 91 \u2013 Versioning & Changelog \u00b7 93 \u2013 Library Registry
    "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Standards Reference","text":"

    The reference half of the Standards description: the enforceable rules, patterns, and the internal library catalog that agents consult while coding.

    The reference is organized as an anatomy \u2014 one entry checklist and four pillars describing what an application is made of, plus delivery/meta pages and an optional pipelines layer.

    "},{"location":"#anatomy","title":"Anatomy","text":"
    • Build Recipe \u2014 the ordered, agentic checklist for a new app; the entry point that walks the whole anatomy.
    "},{"location":"#pillar-1-domains","title":"Pillar 1 \u2014 Domains","text":"
    • Layering Rules \u2014 the strict request \u2192 domain \u2192 repository layering and the rules that enforce it.
    "},{"location":"#pillar-2-openapi-spec-the-contract","title":"Pillar 2 \u2014 OpenAPI Spec (the contract)","text":"
    • Composition Root \u2014 a single composition point wiring the whole app.
    • Spec-First Routing \u2014 the OpenAPI contract as the routing source of truth.
    • CRUD Template \u2014 the standardized generic resource endpoint scaffold.
    • Auth Integration \u2014 consuming the shared auth-server service (spec-driven, no auth code).
    "},{"location":"#pillar-3-interfaces-infra","title":"Pillar 3 \u2014 Interfaces & Infra","text":"
    • Infra Adapters \u2014 how infrastructure connects without leaking into domain.
    "},{"location":"#pillar-4-ui","title":"Pillar 4 \u2014 UI","text":"
    • UI: Auto-Rendered Admin & Custom Pages \u2014 the @aetoskia/react-openapi admin rendered from the spec's x- extensions, and @aetoskia/react-auth for auth pages; the client template scaffold.
    "},{"location":"#pipelines-optional-layer","title":"Pipelines (optional layer)","text":"
    • Pipeline Scaffold \u2014 the deterministic pipeline skeleton for multi-stage jobs.
    • Worker & Progress \u2014 queued work, progress reporting, and visibility.
    • Human-in-the-Loop \u2014 guardrails and approvals inside automations.
    "},{"location":"#delivery-meta","title":"Delivery & Meta","text":"
    • Drone Pipelines \u2014 the three CI shapes: a PyPI package, an npm package, or a Docker deployment.
    • Versioning & Changelog \u2014 SemVer, the tag\u2194version contract, and the release changelog.
    • Repository Inventory \u2014 every covered repo, its type, version source, and Drone shape.
    • Genericity Matrix \u2014 what is library-backed, template-provided, or authored.
    • Library Registry \u2014 the catalog of internal libraries behind all of it.

    Read the Wiki for the philosophy and rationale; these pages are the reference that mirrors the standards MCP bundle.

    "},{"location":"01_build_recipe/","title":"Build Recipe","text":""},{"location":"01_build_recipe/#summary","title":"Summary","text":"

    The ordered, deterministic checklist for generating a new application of the standard shape. Every step is fixed; only the resource model (step 2), the codegen pass (step 3), and the pipeline family (step 8) need app-specific work.

    The reference pages behind this recipe are organized as an anatomy: the domains layer, the OpenAPI contract that drives routing and the admin UI, the interfaces/infra seam, the auto-rendered UI client, and an optional pipelines layer \u2014 see the Standards Reference index.

    This page is served by the standards MCP as docs://modules/build_recipe. The start_app prompt on that server collects the two axes and hands off here; pull exact files with get_app_component.

    "},{"location":"01_build_recipe/#bootstrap","title":"\ud83d\ude80 Bootstrap","text":"

    From the standards docs checkout:

    cp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n

    Pair it with the React client template when a UI is needed (auto-rendered admin from the spec \u2014 see 40 \u2013 UI):

    cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client && npm install\n

    Install the internal libraries \u2014 editable local checkouts (preferred in development) or the private index (CI / fresh machines). Credentials are environment-only; never commit them.

    # local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n            -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n  \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n
    "},{"location":"01_build_recipe/#agent-prompt-copyable","title":"\ud83d\udccb Agent prompt (copyable)","text":"
    # Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2\u20134 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n  and note the Tier A libraries that already exist \u2014 never reimplement one;\n- call `list_app_components()` and pull each file you need with\n  `get_app_component(kind)` instead of writing it from scratch.\n\n## Steps\n\n1. **Scaffold** \u2014 copy `standards/src/templates/app/` into `apps/<your-app>/`\n   (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n   `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n   `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n   `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** \u2014 write `openapi.yaml`: resources,\n   schemas, `operationId`s, security, and extension metadata. This is the\n   contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** \u2014 from the spec, generate the wire\n   models and the exact handler signatures:\n   ```bash\n   openapi-first models openapi.yaml -o _gen/models.py\n   openapi-first routes openapi.yaml -o _gen/routes \\\n     --use-models --models-module _gen.models --inject-request --package --template-style\n   ```\n   `_gen/models.py` is the **wire contract** (Pydantic, one class per\n   `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every\n   `operationId` with its exact parameter signature. `--template-style` makes\n   those stubs `async def` with a `body` parameter, matching the handlers you\n   keep in `interfaces/` (without it they are sync `def` stubs taking\n   `payload`; both bind the same way). Treat `_gen/` as generated\n   (never hand-edit it) \u2014 the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** \u2014 per resource create the four-file\n   slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n   (`class XDocument(BaseDocument, X)` \u2014 id comes from `BaseDocument`),\n   `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n   dependent repositories, then services; add infra adapters (db, cache, stores,\n   progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers whose\n   names match the `operationId`s (use the signatures from step 3 as your\n   checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n   decorator to read endpoints.\n\n7. **Bootstrap app and worker** \u2014 confirm `main.py` runs the OpenAPI-first app\n   with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n   the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** \u2014 copy the worked family\n   `pipelines/appointment_reminder/` to `pipelines/<family>/` with `abc.py`,\n   `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/`\n   (loader + registry). Register the root, add the `job.kind` handler in\n   `worker.py`. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** \u2014 Tier A capability: use the\n   library. Tier B capability: copy the template. Never hand-write a Tier A\n   library.\n\n10. **Tests** \u2014 integration tests with fakes at the HTTP edge; unit tests for\n    domain and pipeline logic; a config-consistency test for rule-driven\n    pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n    the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n    `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.<name>`.\n\n12. **Docs** \u2014 author GSDFC docstrings, generate `.pyi` stubs, mark the package\n    `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n
    "},{"location":"01_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"

    The steps map onto the layers as follows:

    Step Package / file Depends on 1 \u00b7 Scaffold whole templates/app/ \u2014 2 \u00b7 Spec openapi.yaml \u2014 3 \u00b7 Codegen _gen/ (generated) spec, openapi-first 4 \u00b7 CRUD slices domain/<resource>/ spec, CRUD template 5 \u00b7 Wiring init.py every slice 6 \u00b7 Routes interfaces/<resource>/ services, openapi route registry 7 \u00b7 Bootstrap main.py, worker.py composition root 8 \u00b7 Pipelines pipelines/<family>/, shared/ library pipeline engine 9 \u00b7 Infra infra/ domain ports 10 \u00b7 Tests tests/ app + fixtures 11 \u00b7 Scripts scripts/ composition root 12 \u00b7 Docs docstrings + docs build code, Doc Forge"},{"location":"01_build_recipe/#the-codegen-fast-path-in-detail","title":"\ud83d\udd27 The codegen fast path, in detail","text":"

    openapi-first is spec \u2192 code only (it never generates a spec). Use it to erase the two mechanical parts of the build:

    Generated artifact Replaces Notes _gen/models.py hand-writing Pydantic wire models one class per components.schemas entry _gen/routes/<resource>/routes.py hand-typing handler signatures names == operationIds; request: Request injected

    Rules of the road:

    • Wire models are not domain models. _gen/models.py is the HTTP contract; domain/<resource>/models.py is your business shape. If the spec's schema has a required id: str, keep it off the domain model and let the document get its id from BaseDocument.
    • Use --template-style. Stubs are then async def ...(request, body: ...) matching interfaces/; without it they are sync def ...(payload: ...). Either binds (the router introspects the signature), but --template-style keeps the scaffold consistent with the code you keep.
    • Resource names are sanitized. /fetch-requests \u2192 fetch_requests.py.
    • Multipart bodies expand to UploadFile = File(...) / Form(...) params.
    • _gen/ is disposable. Regenerate on every spec change; git diff shows exactly what moved.
    "},{"location":"01_build_recipe/#definition-of-done","title":"\u26a0\ufe0f Definition of done","text":"
    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No Tier A library was reimplemented.
    "},{"location":"01_build_recipe/#related","title":"Related","text":"
    • 10 \u2013 Layering Rules \u00b7 21 \u2013 CRUD Template \u00b7 22 \u2013 Auth Integration \u00b7 40 \u2013 UI \u00b7 50 \u2013 Pipeline Scaffold \u00b7 92 \u2013 Genericity Matrix
    "},{"location":"02_composition_root/","title":"Composition Root","text":""},{"location":"02_composition_root/#summary","title":"Summary","text":"

    All wiring happens in exactly one module. It builds a flat resource registry and exposes it to the HTTP layer and to scripts.

    "},{"location":"02_composition_root/#the-resource-registry","title":"The resource registry","text":"

    init.py returns a plain dict[str, Any]. Keys follow two conventions:

    • <resource>_repo \u2014 a repository adapter for a resource.
    • <resource>_svc \u2014 a service for a resource.

    Plus named infrastructure adaptors:

    • http_cache_store \u2014 response cache backend.
    • progress_bus \u2014 progress/event bus.
    • <vendor>_<kind> \u2014 fetchers, clients, stores.

    The API stores the dict on app.state.db_resources during lifespan startup. Handlers read from it; nothing imports concrete adapters.

    "},{"location":"02_composition_root/#shape","title":"Shape","text":"
    _resources: dict = {}\n_initialized = False\n\nasync def init(settings=None) -> dict:\n    global _initialized\n    if _initialized:\n        return _resources\n\n    await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)\n\n    # 1. leaf repositories\n    user_repo = create_user_repo()\n\n    # 2. dependent repositories (dependencies passed by keyword)\n    wallet_repo = create_wallet_repo(user_repo=user_repo)\n\n    # 3. services (build via the DI library, injecting the repo)\n    _resources[\"user_repo\"]   = user_repo\n    _resources[\"wallet_repo\"] = wallet_repo\n    _resources[\"user_svc\"]    = get_user_service(user_repo)\n    _resources[\"wallet_svc\"]  = get_wallet_service(wallet_repo)\n\n    # 4. infrastructure adaptors (optional)\n    _resources[\"http_cache_store\"] = HttpCacheStore(...)\n    _resources[\"progress_bus\"]     = ProgressEventBus()\n\n    _initialized = True\n    return _resources\n\nasync def shutdown() -> None:\n    global _initialized\n    await close_stores(_resources)\n    await shutdown_database()\n    _resources.clear()\n    _initialized = False\n
    "},{"location":"02_composition_root/#typed-accessors","title":"Typed accessors","text":"

    Expose a get_svc(name) helper with one @overload per key so scripts get typing without importing concrete classes:

    @overload\ndef get_svc(name: Literal[\"user_svc\"]) -> UserService: ...\n@overload\ndef get_svc(name: Literal[\"wallet_svc\"]) -> WalletService: ...\n\ndef get_svc(name: str):\n    return _resources[name]\n

    Also expose get_db() (raw database handle) and pure helpers such as compute_fingerprint(payload) (canonical hash used for deduplication).

    "},{"location":"02_composition_root/#rules","title":"Rules","text":"
    • init() is idempotent.
    • The composition root is the only place that imports concrete adapters.
    • Handlers never call init(); they read request.app.state.db_resources.
    • Scripts call init() / shutdown() around their work.
    "},{"location":"10_layering_rules/","title":"Layering Rules","text":""},{"location":"10_layering_rules/#summary","title":"Summary","text":"

    The dependency direction is fixed. Violating it is a design defect.

    "},{"location":"10_layering_rules/#allowed-imports","title":"Allowed imports","text":"
    interfaces/*      ->  domain/*            (services, ports, models)\ninterfaces/*      ->  infra/adapters/*    (only read-only value objects)\ndomain/*          ->  domain/*            (same layer)\ndomain/*          ->  pipelines/*         (call a pipeline via a port)\ndomain/*/ports/*  ->  infra/*             (bind adapters + libraries at the seam)\ninfra/adapters/*  ->  domain/ports/*      (implement the interface)\nmain.py/init.py   ->  everything          (composition root only)\n
    "},{"location":"10_layering_rules/#forbidden-imports","title":"Forbidden imports","text":"
    domain/*          ->  interfaces/*        (never)\ndomain/*          ->  fastapi/starlette   (never)\ninfra/adapters/*  ->  interfaces/*        (never)\ninterfaces/*      ->  infra/db/*          (state lives in resources, not imports)\n
    "},{"location":"10_layering_rules/#why","title":"Why","text":"
    • The business logic is testable without a server or an event loop.
    • Adapters can be swapped (in-memory fake, real database) without touching business logic.
    • The HTTP edge stays thin and replaceable.
    "},{"location":"10_layering_rules/#the-portadapter-contract","title":"The port/adapter contract","text":"

    The ports/ package is the seam where the domain meets infrastructure. Each domain/<resource>/ports/ module declares the resource's models/document shape and exposes factory functions that bind a concrete adapter \u2014 so a port module is allowed to import from infra/ and from the persistence libraries. Everything outside ports/ in domain/ stays framework- and driver-free.

    domain/<resource>/ports/db.py       -> document model + create_<resource>_repo()\ndomain/<resource>/ports/service.py  -> service + get_<resource>_service()\ninfra/db/repos/<vendor>.py          -> repository adapter factory\ninfra/adapters/service.py           -> generic CRUD service base\n

    The composition root calls the port factories; nothing else constructs an adapter.

    "},{"location":"10_layering_rules/#enforcement","title":"Enforcement","text":"
    • domain/ must never import the web framework (fastapi / starlette) or interfaces/. Only the ports/ seam, infra/, and the composition root touch drivers.
    • Reviewers (and CI) reject import fastapi inside domain/ or infra/.
    "},{"location":"20_spec_first_routing/","title":"Spec-First Routing","text":""},{"location":"20_spec_first_routing/#summary","title":"Summary","text":"

    The OpenAPI document is the single source of truth for the HTTP contract. Handlers are bound to it by name; there is no decorator router.

    "},{"location":"20_spec_first_routing/#the-contract","title":"The contract","text":"
    • openapi.yaml (or .json) declares paths, schemas, parameters, responses, and security.
    • A custom operationId names the handler function.
    • The framework (openapi_first) validates requests/responses against the spec and dispatches to the handler whose name equals operationId.
    "},{"location":"20_spec_first_routing/#app-bootstrap","title":"App bootstrap","text":"
    # main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n    openapi_path=\"openapi.yaml\",\n    routes_module=interfaces.openapi,   # module whose namespace holds handlers\n    title=\"<App> API\",\n    version=\"0.1.0\",\n    lifespan=lifespan,\n)\n

    lifespan calls the composition root:

    @asynccontextmanager\nasync def lifespan(app):\n    app.state.db_resources = await init()\n    yield\n    await shutdown()\n
    "},{"location":"20_spec_first_routing/#handler-registry","title":"Handler registry","text":"

    interfaces/openapi.py is a flat re-export of every handler, so the framework can resolve operationId across the whole app:

    from interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n
    "},{"location":"20_spec_first_routing/#handler-shape","title":"Handler shape","text":"

    Handlers are thin and synchronous with respect to business logic:

    async def create_item(request: Request, body: dict):\n    svc = request.app.state.db_resources[\"item_svc\"]\n    return await svc.create(Item.model_validate(body))\n

    Generated stubs. openapi-first routes emits this exact shape (async def handlers taking body) when given --template-style; without that flag the stub is def create_item(payload: ItemInput) \u2014 sync, with a payload parameter. Both shapes bind identically: the router resolves handlers by operationId and introspects the FastAPI signature, and it never inspects the request body. Pass --template-style so the generated signatures match the handlers you keep in interfaces/.

    "},{"location":"20_spec_first_routing/#conventions","title":"Conventions","text":"
    • operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.
    • Read-only endpoints may use a cache decorator keyed on path + query + user.
    • Validation errors -> 400; not found -> 404; delete -> 204.
    • Security is declared once in the spec and enforced by the framework; handlers contain no auth code.
    "},{"location":"20_spec_first_routing/#authless-apps","title":"Authless apps","text":"

    Auth is deferred in the standard: the same spec-first app runs with or without it, because security lives entirely in the spec, never in handlers. For an app that needs no auth yet, do not invent per-handler auth checks \u2014 just omit the security keys:

    • Remove the top-level security: block.
    • Remove components.securitySchemes.
    • Do not add security: [] to operations. An explicit empty array is a per-operation override that means \"public\", and it only matters when a global security block exists. Leaving every operation without a security key is the cleanest authless spec.

    Profile endpoints (/users/me) and profile headers (x-profile) still exist in an authless app. Define a stand-in identity in the composition root \u2014 for example get_current_user returns or lazily creates a fixed \"Demo User\" \u2014 so handlers stay auth-agnostic and only the wiring changes when real auth arrives.

    The handlers, services, and composition root are unchanged. When auth is needed later, add the global security block and an introspecting dependency; only handlers that must opt out get security: [].

    "},{"location":"20_spec_first_routing/#codegen","title":"Codegen","text":"

    openapi_first scaffolds models and route stubs from the spec (see the Build Recipe, step 3):

    openapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n  --use-models --models-module _gen.models --inject-request --package --template-style\n

    Prefer codegen over hand-writing boilerplate. The result is the wire contract and a set of operationId-named signatures \u2014 not the canonical handlers.

    "},{"location":"21_crud_template/","title":"CRUD Template","text":""},{"location":"21_crud_template/#summary","title":"Summary","text":"

    Every resource follows the same four-file slice. Repetition is intentional: it makes the codebase predictable and generatable.

    "},{"location":"21_crud_template/#the-slice","title":"The slice","text":"
    domain/<resource>/\n    models.py               # pure Pydantic model (no DB fields)\n    ports/\n        db.py               # document model + repository factory\n        service.py          # service class + factory\n        converters.py       # (optional) transforms\n
    "},{"location":"21_crud_template/#the-id-rule-model-vs-document","title":"The id rule (model vs. document)","text":"

    Two different objects, two different jobs:

    Object Lives in Has id? Purpose Domain model domain/<resource>/models.py No business shape, framework-free Document domain/<resource>/ports/db.py Yes (from BaseDocument) Mongo persistence shape

    BaseDocument (mongo-ops) supplies id: Optional[PyObjectId] (aliased to _id) plus created_at / updated_at. The domain model must not declare id; Mongo owns it.

    Trap \u2014 generated wire models. openapi-first models produces the HTTP contract (see the Build Recipe, step 3). Response schemas frequently declare a required id: str, so a generated class is not a domain model. If you write class ItemDocument(Item, BaseDocument) and Item re-exports a required id: str, that field shadows BaseDocument.id and no Mongo _id can ever be set. Fix it one of three ways:

    1. Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required id only on the wire model.
    2. Order the bases so the document wins: class ItemDocument(BaseDocument, Item).
    3. Keep a separate document type and translate with ports/converters.py.
    "},{"location":"21_crud_template/#modelspy","title":"models.py","text":"

    A framework-free Pydantic model describing the business shape (no id).

    class Item(BaseModel):\n    name: str\n    quantity: int = 0\n
    "},{"location":"21_crud_template/#portsdbpy","title":"ports/db.py","text":"

    The persistence model extends the business model and adds database concerns. The repository factory selects the right repository flavour.

    # Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n    pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n    return repo_factory(\n        collection=\"items\",\n        document=ItemDocument,\n        populate_rules=[PopulateRule(field_name=\"owner\", collection_name=\"users\")],\n        cache=None,               # or a CachedBaseRepository config\n    )\n
    "},{"location":"21_crud_template/#nested-references","title":"Nested references","text":"

    The wire contract often declares a reference as an expanded object (expense.entity: Entity), while the domain model stays id-free. Pick one convention and apply it consistently:

    • Embed (default for small, stable refs) \u2014 store the object as a dict / list[dict] on the document; the API returns it verbatim. No populate wiring.
    • Populate \u2014 store an id and declare a PopulateRule; mongo-ops expands it on read. The referenced repo must be registered with the PopulationEngine.
    • Id only \u2014 store the id and let the client resolve it.

    For populate-backed repositories change a reference with update, not patch: patch rejects changes to populated FK fields by design.

    "},{"location":"21_crud_template/#portsservicepy","title":"ports/service.py","text":"

    The service is built through the DI library, injecting the repository. The generic CRUD base provides create/read/update/patch/delete/search/....

    class ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n    model = Item\n    schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n    return build(ItemService, instances={\"repo\": repo})\n
    "},{"location":"21_crud_template/#naming-conventions","title":"Naming conventions","text":"Concept Convention collection plural snake case (items) document <Resource>Document repository factory create_<resource>_repo service <Resource>Service service factory get_<resource>_service resource key <resource>_svc operationId list_items, create_item, ..."},{"location":"21_crud_template/#optional-files","title":"Optional files","text":"
    • converters.py \u2014 pure functions mapping between domain types.
    • ports/enrichment.py \u2014 adapts a pipeline into an async callable the service can invoke (used when a resource is populated by a pipeline).
    "},{"location":"22_auth_integration/","title":"Auth Integration","text":""},{"location":"22_auth_integration/#summary","title":"Summary","text":"

    Authentication is a service, not a library. Every Aetos application shares a single identity service \u2014 the auth-server \u2014 and consumes it through its HTTP contract. An app never implements registration, login, password hashing, or token issuance itself.

    The agent does not need an MCP bundle for the auth-server. It reads the pinned OpenAPI contract (below) and declares a bearer security scheme in its own spec; openapi_first then generates the verifying dependency. No auth code is written.

    "},{"location":"22_auth_integration/#read-the-contract","title":"Read the contract","text":"

    The auth-server OpenAPI document is pinned in the standards bundle and served as an MCP resource:

    docs://spec\n

    It is the authoritative client contract (OpenAPI 3.1). Read it to learn the endpoints and the request/response schemas:

    Endpoint Purpose POST /register Create a user (201, returns PublicUser) POST /login Authenticate and issue a JWT (LoginResponse) GET /me Current user's PublicUser (bearer required) POST /logout Stateless logout (LogoutResponse) POST /introspect Internal token introspection (active, user)

    The pinned snapshot is versioned (currently 0.0.5). If the caller has a live auth-server and needs the current contract, they can pass the full endpoint ({AUTH_SERVER}/openapi.json) and read it directly instead of the pin.

    "},{"location":"22_auth_integration/#wire-it-up-declare-dont-code","title":"Wire it up: declare, don't code","text":"

    Authentication is added by editing the app's own openapi.yaml \u2014 nothing else. Declare a global bearer requirement and point the scheme at the auth server (values come from the environment):

    security:\n  - bearerAuth: []\npaths:\n  # ...\ncomponents:\n  securitySchemes:\n    bearerAuth:\n      type: http\n      scheme: bearer\n      bearerFormat: JWT\n      x-server-url: \"{AUTH_SERVER}\"\n      x-introspect-path: \"/introspect\"\n      x-login-path: \"/login\"\n      x-register-path: \"/register\"\n      x-logout-path: \"/logout\"\n      x-me-path: \"/me\"\n

    openapi_first reads securitySchemes + security and generates a FastAPI dependency that extracts the bearer token, POSTs {\"token\": <jwt>} to {AUTH_SERVER}/introspect, and returns the user when active == true. It raises 401 on an invalid or inactive token and 503 when the auth server is unreachable. The generated handlers receive the authenticated user \u2014 you author no authentication code.

    "},{"location":"22_auth_integration/#public-operations","title":"Public operations","text":"

    A route that must stay open opts out by overriding security with an empty list:

    paths:\n  /health:\n    get:\n      operationId: health\n      security: []        # overrides the global requirement\n      responses:\n        \"200\":\n          description: OK\n
    "},{"location":"22_auth_integration/#environment","title":"Environment","text":"
    AUTH_SERVER=http://localhost:8000     # auth-server base URL\nJWT_SERVER=http://localhost:8000      # jwtlib client base URL (seeding/CLI)\n
    "},{"location":"22_auth_integration/#path-1-jwtlib-client-no-generator","title":"Path 1: jwtlib client (no generator)","text":"

    Apps that do not use a generated dependency verify requests with the py-jwt client:

    from jwtlib.introspection import authenticate_request\n\nauthorized = await authenticate_request(\n    should_skip_authentication,  # allow public paths\n    method,\n    path,\n    authorization_token,         # \"Bearer <jwt>\" or None\n)\n

    authenticate_request POSTs to {JWT_SERVER}/introspect (3 second timeout) and treats {\"active\": true, \"user\": {...}} as valid. Use this for seeding scripts and tests (jwtlib.register_user, jwtlib.login_user).

    "},{"location":"22_auth_integration/#golden-rules","title":"Golden rules","text":"
    1. Never trust an unverified token. Decode through the generated dependency or jwtlib before handling the request.
    2. Treat active: false as unauthenticated, even if the JWT decodes.
    3. Do not implement your own JWT parsing. Revocation and issuer changes must stay centralized in the auth-server.
    4. Do not add auth until the app's outcome is validated. Ship the app authless first; layer the security scheme once the core behaviour works.
    "},{"location":"22_auth_integration/#notes","title":"Notes","text":"
    • The default template is authless on purpose. Add the securitySchemes block above only when auth is required. Pull the shared slice with get_app_component(\"auth\").
    • The auth-server is deployed as a service (no importable package); the client surface is the spec, plus jwtlib for the low-level path.
    "},{"location":"30_infra_adaptors/","title":"Infra Adapters","text":""},{"location":"30_infra_adaptors/#summary","title":"Summary","text":"

    All external systems are reached through adapters in infra/. Prefer an existing internal library over writing a new adapter; where no library exists, copy the Tier B scaffold from templates/app. See the Genericity Matrix for the Tier A / B / C split.

    "},{"location":"30_infra_adaptors/#database-mongodb","title":"Database (MongoDB)","text":"
    • A single connection manager owns the client lifecycle. setup_database(uri, db_name) / shutdown_database().
    • A repository factory selects the repository flavour:
    repo_factory(collection, document, engine=None,\n             populate_rules=None, cache=None)\n
    • base repository \u2014 plain CRUD;
    • populating repository \u2014 resolves referenced documents (foreign keys);
    • cached repository \u2014 an LRU/TTL cache in front of reads.

    • Referenced documents are described declaratively:

    PopulateRule(field_name=\"owner\", collection_name=\"users\",\n             nested_rules=[], max_depth=2, projection=None)\n
    • Transactions use a session context manager provided by the library.

    Tier A \u2014 Library (Mongo Ops). Do not write raw Motor calls in the domain; consult the registry and the library's MCP before touching the driver.

    "},{"location":"30_infra_adaptors/#http-response-cache","title":"HTTP response cache","text":"

    Tier B \u2014 template. Copy from templates/app; not a standalone library.

    A generic GET cache sits behind a decorator.

    @cache_response(ttl=60)\nasync def list_items(request): ...\n
    • Keyed on path + query + user.
    • No-op when no store is wired.
    • invalidate(*resources) bumps a generation counter for write-triggered invalidation.
    "},{"location":"30_infra_adaptors/#event-progress-bus","title":"Event / progress bus","text":"

    Tier B \u2014 template. Copy from templates/app.

    A port with an in-memory default adapter (publish, get_events). Swap for a durable broker in production (see Worker & Progress).

    "},{"location":"30_infra_adaptors/#external-clients-ingestion","title":"External clients (ingestion)","text":"

    External fetchers (files, mail, HTTP) are adapters that wrap ingestion libraries. Construct them in the composition root and inject into orchestrators.

    Tier A \u2014 Libraries: Omniread (formats) and Mail Intake (email).

    "},{"location":"30_infra_adaptors/#llm-clients","title":"LLM clients","text":"

    Tier B \u2014 template. Copy the pool scaffold from templates/app.

    When a pipeline calls a model provider, route through a pool adaptor that handles provider selection, retries, and client lifecycle:

    pool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n

    Providers are selected by LLM_PROVIDER and configured by environment.

    "},{"location":"30_infra_adaptors/#key-value-store","title":"Key-value store","text":"

    Tier B \u2014 template. Copy from templates/app.

    For report/snapshot storage, provide a pluggable store port with a Redis adapter and an in-memory fallback.

    "},{"location":"30_infra_adaptors/#rule","title":"Rule","text":"
    • Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines) \u2014 use the library; writing a private copy is a defect. Query find_library first.
    • Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker, tests) \u2014 these are template code, not libraries: copy them from templates/app rather than searching the registry.
    • Tier C (models, business logic, pipeline families, rules) \u2014 author them yourself.
    "},{"location":"40_ui_admin/","title":"UI: Auto-Rendered Admin & Custom Pages","text":""},{"location":"40_ui_admin/#summary","title":"Summary","text":"

    The UI axis of an Aetos app is a published React client, not code written per application. Two npm packages carry the whole surface:

    Package Role @aetoskia/react-openapi Renders a complete admin panel (CRUD tables, forms, filters, sorting, pagination, uploads) from the app's openapi.yaml \u2014 zero UI code. @aetoskia/react-auth Auth contexts and pages (login/register/me) wired to the auth-server, plus the axios client with token handling.

    Both publish to the private registry https://npm.aetoskia.com. The server spec is the single source of truth: the same openapi.yaml that drives openapi-first routing drives the admin UI, through x- extension fields (see below).

    "},{"location":"40_ui_admin/#scaffold","title":"Scaffold","text":"
    cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client\nnpm install          # deps come from npm.aetoskia.com via .npmrc\nnpm run dev\n

    The client template is a Vite + MUI app with:

    • src/openapi-config.ts \u2014 points the renderer at the app's spec and the auth-server's spec;
    • .env.example \u2014 VITE_API_BASE_URL (app API) and VITE_AUTH_BASE_URL (auth-server);
    • a Home page that auto-lists every resource the spec declares, and a Header/Footer/theme scaffold.

    Custom pages are ordinary React routes layered on top; the auto-rendered admin is available per resource at #/resource/<name>.

    "},{"location":"40_ui_admin/#x-extensions-how-the-spec-steers-the-ui","title":"x- extensions: how the spec steers the UI","text":"

    The admin renderer reads a catalog of OpenAPI extension fields (x-resource, x-list-columns, x-display-format, x-label, x-order, x-hidden, x-sortable, x-filterable, x-autocomplete, x-fk, x-ui-type, x-upload, x-sse, x-profile, and the auth wiring x-server-url / x-login-path / x-me-path / \u2026) declared on schemas and properties.

    The catalog is canonical in the react-openapi source, not in this doc. Read it from the MCP bundle:

    docs://modules/react-openapi        # module: specExtensions \u2014 the full catalog\n

    The app's own spec carries the per-resource values; the catalog explains what each field does. The template's openapi.yaml shows the convention on the example Job schema.

    "},{"location":"40_ui_admin/#golden-rules","title":"Golden rules","text":"
    1. The spec owns the UI. Add a resource or rename a column in openapi.yaml; the admin follows. Never fork the renderer per app.
    2. Custom pages for workflows, auto-render for CRUD. Anything that is \"list / create / edit / delete an entity\" is a spec concern; anything that is a bespoke workflow is a React page.
    3. Auth is consumed, never re-implemented. @aetoskia/react-auth holds tokens and calls the auth-server endpoints declared by the x- wiring; pages read the context.
    4. Publish, don't vendor. UI shared across apps belongs in @aetoskia/react-openapi or @aetoskia/react-auth (SemVer, tag-triggered npm release), not copy-pasted into app trees.
    "},{"location":"40_ui_admin/#notes","title":"Notes","text":"
    • The server template ships authless by default (see 22 \u2013 Auth Integration); the client template pairs with it and degrades to \"no login\" until a security scheme is declared.
    • The standards app + client templates are the full-stack reference: the server spec carries the x- extensions, and the client renders the admin from it. The wiki's Reference App walkthrough (06_reference_app) documents the pairing end to end.
    "},{"location":"50_pipeline_scaffold/","title":"Pipeline Scaffold","text":""},{"location":"50_pipeline_scaffold/#summary","title":"Summary","text":"

    Data-driven processing is expressed as a composition graph. The graph is built by a DI library; execution is driven by a DAG engine. Stages are pure and individually testable.

    "},{"location":"50_pipeline_scaffold/#when-to-use-a-pipeline","title":"When to use a pipeline","text":"

    Use a pipeline when a resource is produced by a multi-step transformation of an input (a document, a file, a message) rather than by a simple CRUD write.

    "},{"location":"50_pipeline_scaffold/#structure-of-a-family","title":"Structure of a family","text":"

    A family is one input shape (e.g. a file format or a message kind). Each family has the same skeleton:

    pipelines/<family>/\n    abc.py            # port contracts (stage interfaces + root interface)\n    impl.py           # concrete stages + root implementation\n    pipeline.yaml     # graph spec: nodes, edges, roots, schema\n    resources/\n        pipeline.yaml # runtime spec consumed by the loader\n        patterns/*.yaml  # rule fragments (pattern registry)\n    banks/<variant>.py   # optional variants that re-pin a few slots\n
    "},{"location":"50_pipeline_scaffold/#shared-scaffolding","title":"Shared scaffolding","text":"
    pipelines/shared/\n    ports.py     # RegistryPort, FinalizePort, etc.\n    loader.py    # build(root_cls) -> wires the graph + engine\n    registry.py  # resolve_root(...) -> root class for a (kind, variant)\n
    "},{"location":"50_pipeline_scaffold/#the-loader","title":"The loader","text":"
    def build(root_cls, *, instances=None, config=None):\n    # merge resources/patterns/*.yaml into a pattern registry\n    # bind registry/config into each graph node by id\n    # build the schema (state) class and the engine\n    root = root_cls()\n    root._engine = Engine(graph, state_cls, payload_cls)\n    return root\n
    "},{"location":"50_pipeline_scaffold/#the-registry","title":"The registry","text":"

    A single lookup replaces hand-rolled if/else selection:

    _FAMILIES = {\n    \"csv\":  {\"acme\": {\"heuristic\": AcmeCsvPipeline}},\n    \"json\": {\"globex\": {\"heuristic\": GlobexJsonPipeline}},\n}\n\ndef resolve_root(kind, variant, mode=\"heuristic\"):\n    return _FAMILIES[kind][variant][mode]\n

    Adding a new variant means adding one class and one registry entry.

    "},{"location":"50_pipeline_scaffold/#driving-a-pipeline","title":"Driving a pipeline","text":"

    Stages are driven explicitly so intermediate state can be persisted:

    pipeline = build(resolve_root(kind, variant))\ncontent = pipeline.ingest.materialize(raw_bytes)\nrecords = pipeline.reader.read(content)\nitems   = pipeline.normalize.build(records)\n
    "},{"location":"50_pipeline_scaffold/#testing","title":"Testing","text":"

    Because stages are pure, test each stage in isolation and test the assembled root against fixture inputs whose expected outputs are stored as golden files. A parity test replays fixtures headlessly.

    "},{"location":"51_worker_and_progress/","title":"Worker & Progress","text":""},{"location":"51_worker_and_progress/#summary","title":"Summary","text":"

    Long-running work runs in a background worker. Jobs are durable documents that move through an explicit status machine. Progress is streamed to clients.

    "},{"location":"51_worker_and_progress/#the-status-machine","title":"The status machine","text":"
    pending -> processing -> completed\n                     \\-> failed    (retried up to N times)\n                     \\-> paused    (waiting for human input)\n
    • pending \u2014 queued, eligible for pickup.
    • processing \u2014 claimed by a worker.
    • paused \u2014 blocked on a human-in-the-loop question.
    • failed \u2014 error; retry_count increments until a max, then skipped.
    • completed \u2014 terminal success.
    "},{"location":"51_worker_and_progress/#the-poll-loop","title":"The poll loop","text":"
    async def poll(resources):\n    backoff = MIN_POLL\n    while True:\n        jobs = await job_svc.search(filter={\"status\": \"pending\"}, limit=100)\n        backoff = MIN_POLL if jobs else min(backoff * 2, MAX_POLL)\n        for job in jobs:\n            if (job.retry_count or 0) >= MAX_RETRIES:\n                continue\n            await job_svc.update_status(job.id, \"processing\")\n            try:\n                await process(job, resources)\n            except PauseForResolution:\n                await job_svc.update_status(job.id, \"paused\")\n            except Exception as e:\n                await job_svc.update_status(\n                    job.id, \"failed\", error=str(e),\n                    retry_count=(job.retry_count or 0) + 1,\n                )\n        await asyncio.sleep(backoff)\n

    Tunables live in environment variables: WORKER_POLL_MIN, WORKER_POLL_MAX, WORKER_MAX_RETRIES.

    "},{"location":"51_worker_and_progress/#progress-events","title":"Progress events","text":"

    A worker publishes structured progress events to a bus (a port). The HTTP edge exposes a streaming endpoint that replays events for a given job id.

    class ProgressEvent(BaseModel):\n    job_id: str\n    step: str\n    status: str            # started | progress | completed | skipped | paused\n    message: dict | None = None\n

    The bus is an in-memory adapter by default; because it is a port, it can be swapped for a durable broker. Emit from the orchestrator at each stage:

    await self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n                                     status=\"completed\", message={\"count\": n}))\n
    "},{"location":"51_worker_and_progress/#streaming-endpoint","title":"Streaming endpoint","text":"
    async def stream_job_events(request, id):\n    bus = request.app.state.db_resources.get(\"progress_bus\")\n    if bus is None:\n        return {\"events\": []}\n\n    async def gen():\n        sent = 0\n        while True:\n            events = bus.get_events(id)\n            for e in events[sent:]:\n                yield f\"data: {e.model_dump_json(exclude_none=True)}\\n\\n\"\n                sent += 1\n            await asyncio.sleep(0.5)\n\n    return StreamingResponse(gen(), media_type=\"text/event-stream\")\n

    Known limitation. The default in-memory bus is per-process. If the worker and the API run as separate processes, events published by the worker are not visible to the API. For production, back the bus with a shared broker. The port boundary makes this a one-adapter change.

    "},{"location":"52_human_in_loop/","title":"Human-in-the-Loop","text":""},{"location":"52_human_in_loop/#summary","title":"Summary","text":"

    Some inputs are ambiguous and must be resolved by a human before processing can continue. The blueprint models this as a durable pause: an exception is raised, a question is recorded, the job pauses, and later resumes.

    "},{"location":"52_human_in_loop/#the-mechanism","title":"The mechanism","text":"
    1. During processing, a stage raises a domain exception carrying the context needed to ask the question:
    raise PauseForResolution(\n    job_id=jid,\n    reference=\"<stable key for this decision>\",\n    options=[...],\n    context={...},\n)\n
    1. The orchestrator catches it, records a question document, and re-raises so the worker marks the job paused.
    2. A client lists pending questions and answers one:
    GET  /jobs/{id}/questions            -> list pending questions\nPOST /questions/{qid}/resolve  {choice}  -> record the choice\n
    1. When the last pending question is resolved, the resolve endpoint flips the parent job back to pending.
    2. The worker picks it up again, rebuilds a resolution map from previously answered questions, and seeds it into the pipeline so the same decision is not asked twice.
    "},{"location":"52_human_in_loop/#the-question-document","title":"The question document","text":"
    class Question(BaseDocument):\n    job_id: str\n    reference: str            # stable key: stage|field|value\n    status: str               # pending | resolved\n    options: list[dict]\n    context: dict\n    chosen: dict | None = None\n    resolved_at: str | None = None\n
    "},{"location":"52_human_in_loop/#resume-map","title":"Resume map","text":"

    The worker turns answered questions into a lookup keyed by reference, then injects it into the pipeline so that stage can skip re-raising:

    resolved = {q.reference: q.chosen for q in await svc.search(\n    filter={\"job_id\": jid, \"status\": \"resolved\"})}\n\npipeline = build(root, instances={\"pause\": handler}, config={\"resolved\": resolved})\n
    "},{"location":"52_human_in_loop/#resolve-endpoint","title":"Resolve endpoint","text":"
    async def resolve_question(request, id, body):\n    svc = request.app.state.db_resources[\"question_svc\"]\n    q = await svc.read(id)\n    await svc.update(id, {\"chosen\": body[\"chosen\"], \"status\": \"resolved\",\n                          \"resolved_at\": now()})\n\n    pending = await svc.search(filter={\"job_id\": q.job_id, \"status\": \"pending\"},\n                               limit=1)\n    if not pending:\n        jobs = request.app.state.db_resources[\"job_svc\"]\n        job = await jobs.read(q.job_id)\n        if job and job.status == \"paused\":\n            await jobs.update_status(q.job_id, \"pending\")\n    return q\n
    "},{"location":"52_human_in_loop/#cli-parity","title":"CLI parity","text":"

    The same flow is available through scripts/CLI: list, get, create, questions, resolve, retry.

    "},{"location":"90_drone_pipelines/","title":"Drone Pipelines","text":""},{"location":"90_drone_pipelines/#summary","title":"Summary","text":"

    Every Aetos repository ships a Drone CI definition at .drone.yml. There is exactly one standard per repository, and there are three types of file, chosen by what the repository produces:

    Type File Produces Repos A \u2014 PyPI package templates/drone/python-lib.yaml A wheel + sdist on the private index dagpipe, doc-forge, hexa, mail-intake, mongo-ops, omniread, openapi-first, py-jwt B \u2014 npm package templates/drone/npm-lib.yaml A package published to the private npm registry doc-weaver, react-auth, react-openapi C \u2014 Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container auth-server

    All templates run on platform: linux / arm64 with the workspace at /drone/src. Copy the matching template to <repo>/.drone.yml; the Type A and Type B files need no edits, the Type C file needs its placeholders filled in (below).

    Conventions: .drone.yml (not .yaml), one file per repo, secrets injected with from_secret \u2014 never commit credentials.

    "},{"location":"90_drone_pipelines/#type-a-pypi-package-python-libyaml","title":"Type A \u2014 PyPI package (python-lib.yaml)","text":"

    For internal Python libraries. The file contains two pipelines:

    1. build-and-publish-pypi \u2014 triggered on tag:
    2. check-version \u2014 guard: fail unless $DRONE_TAG equals the version in pyproject.toml; then read name / version and query $PIP_REPO_URL/simple/<package>/; if that version already exists, exit 78 and skip the whole build (see 91 \u2013 Versioning & Changelog).
    3. quality-gate \u2014 install the package with its [dev] extras from the private index, then black --check, ruff check, mypy, pydoclint, pytest.
    4. build-package \u2014 python -m build into dist/.
    5. upload-to-private-pypi \u2014 twine upload to $PIP_REPO_URL.
    6. backfill-pypi-from-tags \u2014 triggered on custom:
    7. fetch-tags \u2014 git fetch --tags --force.
    8. build-and-upload-missing \u2014 walk every tag in version order, skip the ones already on the index, git checkout the rest, verify tag == pyproject version at that commit, build, and upload. This is the disaster-recovery / replay path.

    Secrets: PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD.

    • Ship as-is. Package name and version come from pyproject.toml, so the same file is reused verbatim across every library.
    "},{"location":"90_drone_pipelines/#type-b-npm-package-npm-libyaml","title":"Type B \u2014 npm package (npm-lib.yaml)","text":"

    For internal npm libraries (the React packages). One pipeline, build-and-publish-npm, triggered on tag:

    • check-version \u2014 guard: fail unless $DRONE_TAG equals the version in package.json; query $NPM_REPO_URL for name@version; if it already exists, exit 78 and skip the whole build.
    • quality-gate \u2014 write .npmrc from the secrets, npm install, then npm run quality-gate.
    • build-package \u2014 npm install && npm run build into dist/.
    • publish-to-private-npm \u2014 npm publish --access restricted to $NPM_REPO_URL.

    Secrets: NPM_REPO_URL, NPM_USERNAME, NPM_PASSWORD. Auth is a generated .npmrc (registry= + _auth= base64 of user:pass) written from secrets at runtime \u2014 the committed .npmrc in a repo only points at the registry URL.

    • Ship as-is. Name and version come from package.json, so the same file is reused verbatim across every npm package.
    "},{"location":"90_drone_pipelines/#type-c-docker-deployment-service-dockeryaml","title":"Type C \u2014 Docker deployment (service-docker.yaml)","text":"

    For a service that ships a container image \u2014 a long-running API/worker, e.g. auth-server. The file contains one pipeline, build-and-deploy-service, triggered on tag:

    • fetch-tags \u2014 resolve the latest tag into LATEST_TAG.txt; fail if there are no tags.
    • check-version \u2014 guard: fail unless $DRONE_TAG equals the repo-root VERSION file (see 91 \u2013 Versioning & Changelog).
    • check-remote-image \u2014 if aetos/<app>:<tag> already exists locally, exit 78 and skip the build.
    • quality-gate \u2014 install from requirements.txt and run black / ruff / mypy / pytest.
    • build-image \u2014 docker build with the private-index build args, tagged aetos/<app>:<tag> and :latest.
    • push-image \u2014 login, re-tag with $REGISTRY_HOST, push both tags.
    • stop-old \u2014 docker rm -f <app>.
    • run-container \u2014 docker run -d --name <app> -p <host>:<container> --restart always with the service's -e environment.

    Secrets: PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD (service build args), REGISTRY_HOST, REGISTRY_USER, REGISTRY_PASS, plus the service runtime env (for example MONGO_*, JWT_SECRET).

    Placeholders to fill in per repo:

    Placeholder Meaning Example <app> service image / container name auth-server aetos/<app> image repository on the registry aetos/auth-server <host-port> / <container-port> published : in-image port 9003:8000 <ENV_VAR> runtime environment variable MONGO_HOST"},{"location":"90_drone_pipelines/#what-not-to-do","title":"What not to do","text":"
    • Do not hand-roll a fourth shape. A repo is exactly one of Type A, B, or C, never combinations.
    • Do not inline secrets; always use from_secret.
    • Do not drop the check-version / check-remote-image skip \u2014 it guards the index and the running container against duplicate publishes.
    • Do not drop the tag\u2194version guard. A Python package needs its pyproject.toml version to equal the tag; an npm package needs its package.json version; a service needs a root VERSION file. See 91 \u2013 Versioning & Changelog.
    • Do not rename the pipelines; tooling and runbooks key off build-and-publish-pypi, build-and-publish-npm, and build-and-deploy-service.
    "},{"location":"90_drone_pipelines/#related","title":"Related","text":"
    • 01 \u2013 Build Recipe \u00b7 30 \u2013 Infra Adapters \u00b7 91 \u2013 Versioning & Changelog \u00b7 93 \u2013 Library Registry
    "},{"location":"91_versioning_and_changelog/","title":"Versioning & Changelog","text":""},{"location":"91_versioning_and_changelog/#summary","title":"Summary","text":"

    Every Aetos artifact \u2014 library or service \u2014 is versioned with Semantic Versioning and documents its releases in a Keep a Changelog file. The version file is the source of truth, the git tag must equal it, and a Drone guard enforces that equality before anything is published. Releases are cut manually; a tag is what triggers the pipeline.

    This page pairs with 90 \u2013 Drone Pipelines: the version and tag contract is exactly what those three CI shapes consume.

    "},{"location":"91_versioning_and_changelog/#the-version-contract","title":"The version contract","text":"Artifact Source of truth Notes Library (PyPI package) version in pyproject.toml check-version also reads it to skip duplicates Library (npm package) version in package.json check-version also queries the registry to skip duplicates Service (Docker) VERSION file at the repo root one line, x.y.z; the service reads it (build-arg) \u2014 never hardcode the version

    Rules:

    1. One version per repository. No per-module versions.
    2. Tag = version. The git tag is bare SemVer (0.0.6), no v prefix, and it MUST match the version file byte-for-byte. The Drone guard fails the build otherwise \u2014 this is what stops a tag from silently publishing nothing.
    3. A release is a tag, created after the change is merged. The version bump and the changelog promotion travel inside the merge request (MR); the tag is added on the merged commit and pushed. See Tagging strategy.
    4. Never reuse a tag. If a release is wrong, cut a new patch version.
    "},{"location":"91_versioning_and_changelog/#pre-10-0yz","title":"Pre-1.0 (0.y.z)","text":"

    While a package is 0.y.z, 0.y is treated as the major line:

    • breaking change \u2192 0.(y+1).0
    • new feature \u2192 0.y.(z+1)
    • bug fix \u2192 0.y.(z+1)

    Once a package reaches 1.0.0, normal SemVer applies. Do not ship 1.0.0 until the public API is considered stable.

    "},{"location":"91_versioning_and_changelog/#the-changelog","title":"The changelog","text":"

    CHANGELOG.md at the repo root, in Keep a Changelog form. It always has exactly one open [Unreleased] section with the change categories that apply:

    ## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n

    On release you promote it \u2014 never delete history:

    ## [0.0.6] - 2026-03-07\n\n### Added\n- ...\n

    and add a link reference at the bottom:

    [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/0.0.6...HEAD\n[0.0.6]: https://git.aetoskia.com/aetos/<repo>/compare/0.0.5...0.0.6\n

    Conventions:

    • Write entries for humans: one line each, imperative, referencing the user impact, not the commit hash.
    • Categories: Added, Changed, Deprecated, Removed, Fixed, Security. Omit empty categories.
    • Security fixes get their own entry even if the change is otherwise a bug fix.
    • Keep [Unreleased] current: add an entry in the same change that introduces the behaviour, not later.
    • The [Unreleased] heading is never renamed; copy its content into the new version heading and clear it.
    "},{"location":"91_versioning_and_changelog/#reconcile-before-releasing","title":"Reconcile before releasing","text":"

    [Unreleased] is a draft, not the source of truth. It is only trustworthy when it was maintained alongside each change; a long-running branch or a change that skipped the changelog leaves it stale, so it can silently under-report a release. Verify it against the change before you promote:

    1. List what actually changed since the last release: git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD.
    2. Compare that against [Unreleased]; add an entry for every user-facing change that is missing.
    3. Always show the proposed additions to the user and wait for approval before promoting. If an entry is ambiguous, ask rather than guess.

    Only then promote. Never cut a release with a known gap between the diff and the changelog.

    "},{"location":"91_versioning_and_changelog/#tagging-strategy-mr-based","title":"Tagging strategy (MR-based)","text":"

    The release version is known while the change is still a merge request, so the bump travels with the change and only the tag is added afterwards:

    1. In the MR \u2014 reconcile [Unreleased] against the change (see Reconcile before releasing) and get the user's approval for any additions. Then set the intended release version: version in pyproject.toml or package.json (or the VERSION file) to x.y.z, and promote the changelog (below). Changelog entries and link references name the version/tag, never a commit id.
    2. Merge or squash the MR into main.
    3. Create the tag x.y.z on the resulting commit (the merge/squash commit).
    4. Push the tag (git push origin x.y.z) \u2014 Drone builds the release.

    Because the version bump is inside the tagged commit, the guard's tag == version check can only fail if the wrong commit was tagged.

    "},{"location":"91_versioning_and_changelog/#agent-assisted-tagging","title":"Agent-assisted tagging","text":"

    The agent neither authors nor merges the MR, and never tags before the merge. After the user confirms the MR is merged/squashed and says to proceed:

    1. Wait for that explicit go-ahead.
    2. Find the commit that introduced the version bump:
    3. Python packages: git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml
    4. npm packages: git log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.json
    5. services: git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION
    6. Confirm that commit's version file equals x.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>.
    7. Do not push. Ask the user to push the tag: git push origin x.y.z.
    "},{"location":"91_versioning_and_changelog/#release-checklist","title":"Release checklist","text":"
    1. Confirm the quality gate is green and main is up to date.
    2. In the MR: reconcile [Unreleased] against the change being released \u2014 git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD \u2014 add any missing user-facing entries, and get the user's approval for the additions. Then move every [Unreleased] entry under the new version heading with the target date (## [x.y.z] - YYYY-MM-DD) and restore an empty [Unreleased] on top.
    3. In the MR: add the two compare-link references at the bottom of the changelog, naming the version/tag (never a commit id).
    4. In the MR: set the version file \u2014 pyproject.toml (version = \"x.y.z\"), package.json (\"version\": \"x.y.z\"), or VERSION \u2014 to the intended release version.
    5. Merge or squash the MR into main.
    6. Create the tag on the resulting commit: lightweight git tag x.y.z.
    7. Push the tag (git push origin x.y.z). Watch Drone: Type A publishes the wheel/sdist, Type B publishes the npm package, Type C builds and deploys the image; all three first assert tag == version.
    8. Do not reuse the tag if the run fails \u2014 fix and cut x.y.(z+1).
    "},{"location":"91_versioning_and_changelog/#why-the-guard-matters","title":"Why the guard matters","text":"

    The version file and the tag are two facts that must agree; nothing in git enforces that by itself. A mismatch is not a theoretical risk \u2014 a package can be tagged 0.0.2 while its pyproject.toml still says 0.0.1, and the publish step (which compares the file against the index) then skips the build entirely, so 0.0.2 never reaches the index while looking like a release. The Drone guard turns that silent no-op into a hard failure.

    "},{"location":"91_versioning_and_changelog/#related","title":"Related","text":"
    • 90 \u2013 Drone Pipelines \u00b7 94 \u2013 Repository Inventory \u00b7 01 \u2013 Build Recipe \u00b7 93 \u2013 Library Registry
    "},{"location":"92_genericity_matrix/","title":"Genericity Matrix","text":""},{"location":"92_genericity_matrix/#summary","title":"Summary","text":"

    This page states precisely what is library-backed, what is template-provided, and what is app-specific and must be authored. Use it to scope a new build and to know, for any given capability, whether to call a library, copy template code, or write it yourself.

    "},{"location":"92_genericity_matrix/#tier-a-library-backed-use-never-reimplement","title":"Tier A \u2014 Library-backed (use; never reimplement)","text":"

    These capabilities already exist as internal libraries. Reach them through the library registry MCP (find_library(capability)) and read the library's own MCP for its API. Writing a private copy of any of these is a defect.

    Capability Library MCP Persistence: repositories, cache, populate, transactions Mongo Ops (mongo_ops) :8007 Dependency injection / composition Hexa (hexa) :8008 Deterministic multi-step transformation engine DAG Pipe (dagpipe) :8006 Spec-first HTTP edge, client, codegen OpenAPI First (openapi_first) :8004 Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) Omniread (omniread) :8003 Read-only email ingestion Mail Intake (mail_intake) :8002 JWT auth primitives (register, login, introspect) Py JWT (jwtlib) :8005 Documentation build (MkDocs + MCP) Doc Forge (docforge) :8001"},{"location":"92_genericity_matrix/#tier-b-template-provided-copy-from-templatesapp-then-adapt","title":"Tier B \u2014 Template-provided (copy from templates/app, then adapt)","text":"

    These are reusable internal components shipped inside the template \u2014 they are not independent libraries. Copy them as-is, then rename/populate only what is app-specific. They give every app the same wiring shape as the reference app. Fetch them by kind with get_app_component, or the whole tree with get_app_template.

    Component Template location Action Shipped Layering & dependency rule the package layout + import rules keep \u2705 Composition root shape init.py (get_svc/get_db, idempotency) keep \u2705 Spec-first web edge interfaces/openapi.py, thin handlers bound to operationId keep \u2705 CRUD slice pattern domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py} rename + fill \u2705 Generic CRUD service infra/adapters/service.py (CrudsServiceAdaptor) keep \u2705 Repository factory infra/db/repos/mongo.py (_repo_factory) keep \u2705 Worker poll + status loop worker.py keep \u2705 Progress bus infra/adapters/sse.py (keyed in-memory bus) + an app SSE endpoint keep \u2705 HTTP response cache infra/http_cache.py (cache_response + generation invalidation) keep \u2705 Key-value store infra/adapters/kv.py (port + in-memory + Redis adaptor) keep \u2705 Pipeline scaffold pipelines/shared/{ports,loader,registry}.py + pipelines/<family>/ rename + fill \u2705 Operator scripts scripts/ (init() / get_svc() / shutdown()) rename \u2705 Testing harness integration fakes + fixture-parity pattern keep \u2705 Project scaffolding pyproject.toml, Dockerfile, docker-compose.yaml, .env.example, openapi.yaml rename + fill \u2705"},{"location":"92_genericity_matrix/#tier-b-patterns-port-from-the-reference-app","title":"Tier B (patterns) \u2014 port from the reference app","text":"

    A few Tier B capabilities are real and reusable, but are shaped by the app or the provider, so the template does not ship them verbatim \u2014 copying them blindly would carry app-specific names. Treat them as patterns: lift the shape from the reference app and rename per app.

    Component Reference location Action Shipped Human-in-the-loop durable pause + question document + resolve endpoint + resume map port + reshape \u2717 LLM client pool provider-select + retry/rotation pool scaffold port + reshape \u2717"},{"location":"92_genericity_matrix/#tier-c-app-specific-must-be-authored","title":"Tier C \u2014 App-specific (must be authored)","text":"Artifact Why Resource models the business shape Repository populate/cache rules the relationships Service business logic domain behaviour Pipeline families the transformation graph Pattern/rule resources data-driven policy Fixtures & golden outputs test data"},{"location":"92_genericity_matrix/#the-two-axes","title":"The two axes","text":"

    Everything app-specific reduces to two axes:

    1. Resource model \u2014 the set of resources, their fields, relationships, and the OpenAPI contract.
    2. Pipeline family \u2014 only if inputs require multi-step transformation.

    If you can specify those two axes, the rest of the app follows mechanically: Tier A is imported, Tier B is copied, and Tier C is the only thing you author.

    "},{"location":"93_library_registry/","title":"Library Registry","text":""},{"location":"93_library_registry/#summary","title":"Summary","text":"

    The Aetos ecosystem provides internal libraries for the shared concerns of every application. Use them. Writing a private copy of any capability below is a defect.

    Each library also exposes its own documentation as an MCP server. An AI agent can read a library's full API by connecting to its MCP and reading docs://index, docs://nav, and docs://modules/{module}.

    "},{"location":"93_library_registry/#catalog","title":"Catalog","text":"Library Import Purpose Use instead of MCP DAG Pipe dagpipe Deterministic state-transformation DAG engine hand-rolled orchestration/topological ordering :8006 Hexa hexa Declarative DI / composition-root tooling manual wiring, hand-written factories :8008 Mongo Ops mongo_ops Async MongoDB repositories, cache, populate, transactions raw Motor CRUD and connection lifecycle :8007 Omniread omniread Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) custom scrapers/parsers :8003 OpenAPI First openapi_first Spec-first FastAPI bootstrap, client, codegen decorator routers and hand-written clients :8004 Mail Intake mail_intake Provider-agnostic read-only email ingestion Gmail OAuth + MIME parsing :8002 Py JWT jwtlib JWT auth primitives (register/login/introspect) password hashing + token code :8005 Doc Forge docforge Docstring compiler \u2192 MkDocs + MCP docs custom doc generators :8001 Doc Weaver doc-weaver JSDoc compiler \u2192 TypeDoc + MCP docs (React/TS) custom TypeScript doc generators :8010 React Auth react-auth JWT-backed React auth UI: provider, login page, profile screens, bearer axios client hand-rolled auth contexts and token handling in React apps :8011 React OpenAPI react-openapi Spec-driven React admin UI (CRUD, tables, forms, SSE) derived from an OpenAPI document per-resource hand-written admin screens :8012 Auth Server (service) Central identity service (register/login/me/logout/introspect) building auth endpoints yourself spec: docs://spec"},{"location":"93_library_registry/#how-to-choose","title":"How to choose","text":"You need to\u2026 Use persist and query documents Mongo Ops compose dependencies Hexa run a multi-step transformation DAG Pipe read/parse an arbitrary input format Omniread expose a contract-first HTTP API OpenAPI First ingest email Mail Intake authenticate users / verify tokens Py JWT (+ Auth Server) generate documentation Doc Forge generate React/TypeScript documentation Doc Weaver authenticate a React front-end React Auth (+ Auth Server) build a React admin UI from an OpenAPI spec React OpenAPI"},{"location":"93_library_registry/#discovery","title":"Discovery","text":"

    A single standards MCP exposes find_library(capability), which returns the matching library, its import name, its \"use instead of\" guidance, and its MCP endpoint (or, for the auth-server service, a spec pointer). It also serves the pinned auth-server OpenAPI contract at docs://spec. Query it before writing any infrastructure.

    "},{"location":"93_library_registry/#notes","title":"Notes","text":"
    • Hexa is a full library with its own MCP server on :8008: read docs://index, docs://nav, and docs://modules/{module} (e.g. docs://modules/hexa.container) to discover the DI surface \u2014 build(cls, *, instances, config) and the PortNode model.
    • Auth Server is a service, not an importable library: it has no MCP bundle by design. Read its client contract from the pinned OpenAPI document via docs://spec and wire it by declaring a bearerAuth security scheme in the app's own spec (see Auth Integration); py-jwt covers the low-level token path.
    • Doc Weaver is the TypeScript counterpart to Doc Forge: it reads JSDoc comments and emits a TypeDoc site plus the same doc-forge-shaped MCP bundle for React packages. Its convention is RDFC (the React Doc-forge Convention), the JSDoc parallel to GSDFC.
    • React Auth and React OpenAPI are front-end libraries (TypeScript, React 18 + MUI + React Router peers), not Python services \u2014 they consume the Auth Server and an OpenAPI document rather than serving one. Each ships a wiki, a TypeDoc site and an MCP bundle like every other library above.
    • Library MCP servers expose documentation resources (not executable tools): they tell an agent the available API surface so it does not invent code.
    "},{"location":"94_repository_inventory/","title":"Repository Inventory","text":""},{"location":"94_repository_inventory/#summary","title":"Summary","text":"

    The canonical list of Aetos repositories covered by the CI and versioning standards, with the artifact each one produces, its version source, and its Drone shape. Every entry maps to the contract in 91 \u2013 Versioning & Changelog and the three CI files in 90 \u2013 Drone Pipelines.

    The same data is published machine-readably as the registry://repos MCP resource (and the list_repos tool), sourced from standards/src/repos.json. Paths are relative to the aetos/ workspace root.

    "},{"location":"94_repository_inventory/#packages-type-a-pypi","title":"Packages (Type A \u2014 PyPI)","text":"

    A wheel + sdist published to the private index. Version source is pyproject.toml; Drone file is python-lib.yaml (tag-triggered).

    Repo Path Docs MCP server dagpipe dagpipe lib, wiki dagpipe:8006 doc-forge doc-forge lib, wiki docforge:8001 hexa hexa lib, wiki hexa:8008 mail-intake mail-intake lib, wiki mail_intake:8002 mongo-ops mongo-ops lib, wiki mongo_ops:8007 omniread omniread lib, wiki omniread:8003 openapi-first openapi-first lib, wiki openapi_first:8004 py-jwt py-jwt lib, wiki jwtlib:8005"},{"location":"94_repository_inventory/#packages-type-b-npm","title":"Packages (Type B \u2014 npm)","text":"

    A package published to the private npm registry (npm.aetoskia.com). Version source is package.json; Drone file is npm-lib.yaml (tag-triggered).

    Repo Path Docs MCP server doc-weaver doc-weaver lib, wiki doc-weaver:8010 react-auth react/react-auth lib, wiki react-auth:8011 react-openapi react/react-openapi lib, wiki react-openapi:8012"},{"location":"94_repository_inventory/#services-type-c","title":"Services (Type C)","text":"

    A container image pushed to the registry and run on the Docker host. Version source is the VERSION file; Drone file is service-docker.yaml.

    Repo Path Trigger Docs auth-server auth-server tag api, lib, wiki

    Tag-triggered and enforces tag == VERSION.

    "},{"location":"94_repository_inventory/#required-files-per-repo","title":"Required files per repo","text":"Type Version source Changelog Drone package (PyPI) pyproject.toml CHANGELOG.md python-lib.yaml package (npm) package.json CHANGELOG.md npm-lib.yaml service VERSION CHANGELOG.md service-docker.yaml

    Every covered repository must be able to satisfy tag == version: the version file (never a commit id) is bumped in the release MR, the MR is merged, and the tag is created on the merged commit.

    "},{"location":"94_repository_inventory/#reading-this-at-runtime","title":"Reading this at runtime","text":"
    • Resource: registry://repos
    • Tool: list_repos()
    • Humans: this page (mirror of standards/src/repos.json).
    "},{"location":"94_repository_inventory/#related","title":"Related","text":"
    • 90 \u2013 Drone Pipelines \u00b7 91 \u2013 Versioning & Changelog \u00b7 93 \u2013 Library Registry
    "}]} \ No newline at end of file diff --git a/standards/mcp/components.json b/standards/mcp/components.json index f272c5c..9386f95 100644 --- a/standards/mcp/components.json +++ b/standards/mcp/components.json @@ -1,40 +1,91 @@ { "project": "standards", "template": "templates/app", - "seed": { - "resource": "item", - "family": "example" - }, + "sample": "vet", "components": [ { - "kind": "model", - "path": "domain/item/models.py", - "placeholder": "resource", - "description": "Business Pydantic model for a resource." + "kind": "parent-model", + "path": "domain/parents/models.py", + "placeholder": "", + "description": "Example domain model (parents — the simplest slice)." }, { - "kind": "ports-db", - "path": "domain/item/ports/db.py", - "placeholder": "resource", - "description": "Document model + repository factory for a resource." + "kind": "parent-ports-db", + "path": "domain/parents/ports/db.py", + "placeholder": "", + "description": "Example document model + repository factory." }, { - "kind": "ports-service", - "path": "domain/item/ports/service.py", - "placeholder": "resource", - "description": "CRUD service wired through the DI library." + "kind": "parent-ports-service", + "path": "domain/parents/ports/service.py", + "placeholder": "", + "description": "Example CRUD service wired through the DI library." }, { - "kind": "converters", - "path": "domain/item/ports/converters.py", - "placeholder": "resource", - "description": "Optional pure converters between domain types." + "kind": "parent-routes", + "path": "interfaces/parents/routes.py", + "placeholder": "", + "description": "Example thin HTTP handlers bound to operationIds." }, { - "kind": "routes", - "path": "interfaces/item/routes.py", - "placeholder": "resource", - "description": "Thin HTTP handlers bound to operationIds." + "kind": "pet-model", + "path": "domain/pets/models.py", + "placeholder": "", + "description": "Domain model with an embedded parents list." + }, + { + "kind": "pet-ports-db", + "path": "domain/pets/ports/db.py", + "placeholder": "", + "description": "Populating document + repository factory (FK wiring)." + }, + { + "kind": "pet-routes", + "path": "interfaces/pets/routes.py", + "placeholder": "", + "description": "Routes including the file-upload endpoint." + }, + { + "kind": "appointment-model", + "path": "domain/appointments/models.py", + "placeholder": "", + "description": "Domain model with single-id FKs + nested value objects." + }, + { + "kind": "appointment-routes", + "path": "interfaces/appointments/routes.py", + "placeholder": "", + "description": "Routes with expand_ref FK handling and list filters." + }, + { + "kind": "job-model", + "path": "domain/jobs/models.py", + "placeholder": "", + "description": "Job queue model (the worker's unit of work)." + }, + { + "kind": "job-ports-db", + "path": "domain/jobs/ports/db.py", + "placeholder": "", + "description": "Job repository factory." + }, + { + "kind": "job-ports-service", + "path": "domain/jobs/ports/service.py", + "placeholder": "", + "description": "Job CRUD service." + }, + { + "kind": "job-routes", + "path": "interfaces/jobs/routes.py", + "placeholder": "", + "description": "Job routes (enqueue/inspect via the API)." + }, + { + "kind": "interface-common", + "path": "interfaces/_common.py", + "placeholder": "", + "description": "Shared helpers: svc resolution, dump, pagination, expand_ref." }, { "kind": "openapi-routes", @@ -48,58 +99,34 @@ "placeholder": "", "description": "Mergeable bearerAuth security scheme + global security for openapi.yaml." }, - { - "kind": "test-model", - "path": "tests/unit/test_item_models.py", - "placeholder": "resource", - "description": "Unit test for the resource model." - }, - { - "kind": "test-routes", - "path": "tests/integration/test_item_routes.py", - "placeholder": "resource", - "description": "Integration test at the HTTP edge." - }, - { - "kind": "test-openapi-build", - "path": "tests/integration/test_openapi_app_builds.py", - "placeholder": "", - "description": "Generic test: every spec operationId resolves and the app builds." - }, - { - "kind": "test-pipeline", - "path": "tests/unit/test_item_sync.py", - "placeholder": "", - "description": "Unit test for the smallest pipeline form." - }, { "kind": "pipeline-abc", - "path": "pipelines/example/abc.py", - "placeholder": "family", + "path": "pipelines/appointment_reminder/abc.py", + "placeholder": "", "description": "Port contracts for a pipeline family." }, { "kind": "pipeline-impl", - "path": "pipelines/example/impl.py", - "placeholder": "family", + "path": "pipelines/appointment_reminder/impl.py", + "placeholder": "", "description": "Concrete stages, wired root, and family registration." }, { "kind": "pipeline-yaml", - "path": "pipelines/example/pipeline.yaml", - "placeholder": "family", + "path": "pipelines/appointment_reminder/pipeline.yaml", + "placeholder": "", "description": "Graph spec (nodes, edges, roots, schema)." }, { "kind": "pipeline-resources", - "path": "pipelines/example/resources/pipeline.yaml", - "placeholder": "family", + "path": "pipelines/appointment_reminder/resources/pipeline.yaml", + "placeholder": "", "description": "Runtime spec consumed by the shared loader." }, { "kind": "pipeline-pattern", - "path": "pipelines/example/resources/patterns/example.yaml", - "placeholder": "family", + "path": "pipelines/appointment_reminder/resources/patterns/reminder.yaml", + "placeholder": "", "description": "Pattern/rule fragment merged by the loader." }, { @@ -120,47 +147,17 @@ "placeholder": "", "description": "Shared registry: (kind, variant, mode) -> root class." }, - { - "kind": "pipeline-simple", - "path": "pipelines/item_sync.py", - "placeholder": "", - "description": "Smallest pipeline form (plain async steps)." - }, - { - "kind": "job-model", - "path": "domain/job/models.py", - "placeholder": "", - "description": "Seeded second resource model (job)." - }, - { - "kind": "job-ports-db", - "path": "domain/job/ports/db.py", - "placeholder": "", - "description": "Seeded second resource repository (job)." - }, - { - "kind": "job-ports-service", - "path": "domain/job/ports/service.py", - "placeholder": "", - "description": "Seeded second resource service (job)." - }, - { - "kind": "job-routes", - "path": "interfaces/job/routes.py", - "placeholder": "", - "description": "Seeded second resource routes (job)." - }, { "kind": "config", "path": "config.py", "placeholder": "", - "description": "Settings dataclass + singleton." + "description": "Settings dataclass + singleton (incl. worker knobs)." }, { "kind": "init", "path": "init.py", "placeholder": "", - "description": "Composition root (init/shutdown, get_svc)." + "description": "Composition root (init/shutdown, resources dict)." }, { "kind": "main", @@ -172,13 +169,13 @@ "kind": "worker", "path": "worker.py", "placeholder": "", - "description": "Poll loop + job status machine." + "description": "Poll loop + job dispatch by kind + status machine." }, { "kind": "openapi", "path": "openapi.yaml", "placeholder": "", - "description": "OpenAPI contract skeleton." + "description": "OpenAPI contract (x- extensions drive the admin client)." }, { "kind": "pyproject", @@ -192,6 +189,12 @@ "placeholder": "", "description": "Keep a Changelog file with an [Unreleased] section (versioning standard)." }, + { + "kind": "version-file", + "path": "VERSION", + "placeholder": "", + "description": "Single-line version file (service-docker CI checks tag == VERSION)." + }, { "kind": "dockerfile", "path": "Dockerfile", @@ -234,6 +237,18 @@ "placeholder": "", "description": "Repository factory (base/populating/cached)." }, + { + "kind": "repo-int", + "path": "infra/db/repos/mongo_int.py", + "placeholder": "", + "description": "Integer-id repository variants (create_repo factory)." + }, + { + "kind": "repo-memory", + "path": "infra/db/repos/memory.py", + "placeholder": "", + "description": "In-memory repository flavour (zero-dependency demo store)." + }, { "kind": "service-adapter", "path": "infra/adapters/service.py", @@ -252,6 +267,12 @@ "placeholder": "", "description": "In-memory SSE progress bus (keyed by an event attribute)." }, + { + "kind": "actions-stream", + "path": "infra/adapters/actions_stream.py", + "placeholder": "", + "description": "Per-resource action stream (SSE) adaptor." + }, { "kind": "kv-store", "path": "infra/adapters/kv.py", @@ -275,6 +296,30 @@ "path": "scripts/drop.py", "placeholder": "", "description": "Operator drop script." + }, + { + "kind": "test-openapi-build", + "path": "tests/integration/test_openapi_app_builds.py", + "placeholder": "", + "description": "Generic test: every spec operationId resolves and the app builds." + }, + { + "kind": "test-jobs-routes", + "path": "tests/unit/test_jobs_routes.py", + "placeholder": "", + "description": "Unit test pattern for a resource's routes with service fakes." + }, + { + "kind": "test-pipeline", + "path": "tests/unit/test_appointment_reminder_pipeline.py", + "placeholder": "", + "description": "Unit test pattern for a pipeline family." + }, + { + "kind": "test-worker", + "path": "tests/unit/test_worker.py", + "placeholder": "", + "description": "Unit test pattern for worker dispatch and status transitions." } ] } \ No newline at end of file diff --git a/standards/mcp/modules/build_recipe.json b/standards/mcp/modules/build_recipe.json index 073a8e1..a9ed209 100644 --- a/standards/mcp/modules/build_recipe.json +++ b/standards/mcp/modules/build_recipe.json @@ -2,7 +2,7 @@ "module": "build_recipe", "content": { "path": "build_recipe", - "docstring": "# Build Recipe\n\n## Summary\n\nThe ordered, deterministic checklist for generating a new application of the\nstandard shape. Every step is fixed; only the resource model (step 2), the\ncodegen pass (step 3), and the pipeline family (step 8) need app-specific work.\n\nThe reference pages behind this recipe are organized as an anatomy: the\ndomains layer, the OpenAPI contract that drives routing *and* the admin UI,\nthe interfaces/infra seam, the auto-rendered UI client, and an optional\npipelines layer — see the [Standards Reference](index.md) index.\n\nThis page is served by the `standards` MCP as `docs://modules/build_recipe`. The\n`start_app` prompt on that server collects the two axes and hands off here; pull\nexact files with `get_app_component`.\n\n---\n\n## 🚀 Bootstrap\n\nFrom the standards docs checkout:\n\n```bash\ncp -r /standards/src/templates/app apps/\ncd apps/\n```\n\nPair it with the React client template when a UI is needed (auto-rendered\nadmin from the spec — see [40 – UI](40_ui_admin.md)):\n\n```bash\ncp -r /standards/src/templates/client apps/-client\ncd apps/-client && npm install\n```\n\nInstall the internal libraries — editable local checkouts (preferred in\ndevelopment) or the private index (CI / fresh machines). Credentials are\nenvironment-only; never commit them.\n\n```bash\n# local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n```\n\n---\n\n## 📋 Agent prompt (copyable)\n\n````markdown\n# Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2–4 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n and note the Tier A libraries that already exist — never reimplement one;\n- call `list_app_components()` and pull each file you need with\n `get_app_component(kind, resource=..., family=...)` instead of writing it from\n scratch.\n\n## Steps\n\n1. **Scaffold** — copy `standards/src/templates/app/` into `apps//`\n (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** — write `openapi.yaml`: resources,\n schemas, `operationId`s, security, and extension metadata. This is the\n contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** — from the spec, generate the wire\n models and the exact handler signatures:\n ```bash\n openapi-first models openapi.yaml -o _gen/models.py\n openapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n ```\n `_gen/models.py` is the **wire contract** (Pydantic, one class per\n `components.schemas` entry). `_gen/routes//routes.py` lists every\n `operationId` with its exact parameter signature. `--template-style` makes\n those stubs `async def` with a `body` parameter, matching the handlers you\n keep in `interfaces/` (without it they are sync `def` stubs taking\n `payload`; both bind the same way). Treat `_gen/` as generated\n (never hand-edit it) — the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** — per resource create the four-file\n slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n (`class XDocument(BaseDocument, X)` — id comes from `BaseDocument`),\n `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** — in `init.py`, build leaf repositories, then\n dependent repositories, then services; add infra adapters (db, cache, stores,\n progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** — create `interfaces//routes.py` thin handlers whose\n names match the `operationId`s (use the signatures from step 3 as your\n checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n decorator to read endpoints.\n\n7. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app\n with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** — copy `pipelines/example/`\n to `pipelines//` with `abc.py`, `impl.py`, `pipeline.yaml`,\n `resources/`, and reuse `pipelines/shared/` (loader + registry). Register the\n root. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** — Tier A capability: use the\n library. Tier B capability: copy the template. Never hand-write a Tier A\n library.\n\n10. **Tests** — integration tests with fakes at the HTTP edge; unit tests for\n domain and pipeline logic; a config-consistency test for rule-driven\n pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** — operator scripts under `scripts/` using\n `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.`.\n\n12. **Docs** — author GSDFC docstrings, generate `.pyi` stubs, mark the package\n `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n````\n\n---\n\n## 🧭 Where the steps land\n\nThe steps map onto the layers as follows:\n\n| Step | Package / file | Depends on |\n|---------------------|-----------------------------------|---------------------------------------------|\n| 1 · Scaffold | whole `templates/app/` | — |\n| 2 · Spec | `openapi.yaml` | — |\n| 3 · Codegen | `_gen/` (generated) | spec, openapi-first |\n| 4 · CRUD slices | `domain//` | spec, CRUD template |\n| 5 · Wiring | `init.py` | every slice |\n| 6 · Routes | `interfaces//` | services, openapi route registry |\n| 7 · Bootstrap | `main.py`, `worker.py` | composition root |\n| 8 · Pipelines | `pipelines//`, `shared/` | library pipeline engine |\n| 9 · Infra | `infra/` | domain ports |\n| 10 · Tests | `tests/` | app + fixtures |\n| 11 · Scripts | `scripts/` | composition root |\n| 12 · Docs | docstrings + docs build | code, Doc Forge |\n\n---\n\n## 🔧 The codegen fast path, in detail\n\n`openapi-first` is spec → code only (it never generates a spec). Use it to erase\nthe two mechanical parts of the build:\n\n| Generated artifact | Replaces | Notes |\n|--------------------|----------|-------|\n| `_gen/models.py` | hand-writing Pydantic wire models | one class per `components.schemas` entry |\n| `_gen/routes//routes.py` | hand-typing handler signatures | names == `operationId`s; `request: Request` injected |\n\nRules of the road:\n\n- **Wire models are not domain models.** `_gen/models.py` is the HTTP contract;\n `domain//models.py` is your business shape. If the spec's schema has\n a required `id: str`, keep it off the domain model and let the document get its\n id from `BaseDocument`.\n- **Use `--template-style`.** Stubs are then `async def ...(request, body: ...)`\n matching `interfaces/`; without it they are sync `def ...(payload: ...)`.\n Either binds (the router introspects the signature), but `--template-style`\n keeps the scaffold consistent with the code you keep.\n- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.\n- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.\n- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows\n exactly what moved.\n\n---\n\n## ⚠️ Definition of done\n\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves the spec's operations; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n\n---\n\n## Related\n\n- [10 – Layering Rules](10_layering_rules.md) · [21 – CRUD Template](21_crud_template.md) · [22 – Auth Integration](22_auth_integration.md) · [40 – UI](40_ui_admin.md) · [50 – Pipeline Scaffold](50_pipeline_scaffold.md) · [92 – Genericity Matrix](92_genericity_matrix.md)\n", + "docstring": "# Build Recipe\n\n## Summary\n\nThe ordered, deterministic checklist for generating a new application of the\nstandard shape. Every step is fixed; only the resource model (step 2), the\ncodegen pass (step 3), and the pipeline family (step 8) need app-specific work.\n\nThe reference pages behind this recipe are organized as an anatomy: the\ndomains layer, the OpenAPI contract that drives routing *and* the admin UI,\nthe interfaces/infra seam, the auto-rendered UI client, and an optional\npipelines layer — see the [Standards Reference](index.md) index.\n\nThis page is served by the `standards` MCP as `docs://modules/build_recipe`. The\n`start_app` prompt on that server collects the two axes and hands off here; pull\nexact files with `get_app_component`.\n\n---\n\n## 🚀 Bootstrap\n\nFrom the standards docs checkout:\n\n```bash\ncp -r /standards/src/templates/app apps/\ncd apps/\n```\n\nPair it with the React client template when a UI is needed (auto-rendered\nadmin from the spec — see [40 – UI](40_ui_admin.md)):\n\n```bash\ncp -r /standards/src/templates/client apps/-client\ncd apps/-client && npm install\n```\n\nInstall the internal libraries — editable local checkouts (preferred in\ndevelopment) or the private index (CI / fresh machines). Credentials are\nenvironment-only; never commit them.\n\n```bash\n# local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n```\n\n---\n\n## 📋 Agent prompt (copyable)\n\n````markdown\n# Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2–4 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n and note the Tier A libraries that already exist — never reimplement one;\n- call `list_app_components()` and pull each file you need with\n `get_app_component(kind)` instead of writing it from scratch.\n\n## Steps\n\n1. **Scaffold** — copy `standards/src/templates/app/` into `apps//`\n (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** — write `openapi.yaml`: resources,\n schemas, `operationId`s, security, and extension metadata. This is the\n contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** — from the spec, generate the wire\n models and the exact handler signatures:\n ```bash\n openapi-first models openapi.yaml -o _gen/models.py\n openapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n ```\n `_gen/models.py` is the **wire contract** (Pydantic, one class per\n `components.schemas` entry). `_gen/routes//routes.py` lists every\n `operationId` with its exact parameter signature. `--template-style` makes\n those stubs `async def` with a `body` parameter, matching the handlers you\n keep in `interfaces/` (without it they are sync `def` stubs taking\n `payload`; both bind the same way). Treat `_gen/` as generated\n (never hand-edit it) — the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** — per resource create the four-file\n slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n (`class XDocument(BaseDocument, X)` — id comes from `BaseDocument`),\n `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** — in `init.py`, build leaf repositories, then\n dependent repositories, then services; add infra adapters (db, cache, stores,\n progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** — create `interfaces//routes.py` thin handlers whose\n names match the `operationId`s (use the signatures from step 3 as your\n checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n decorator to read endpoints.\n\n7. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app\n with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** — copy the worked family\n `pipelines/appointment_reminder/` to `pipelines//` with `abc.py`,\n `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/`\n (loader + registry). Register the root, add the `job.kind` handler in\n `worker.py`. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** — Tier A capability: use the\n library. Tier B capability: copy the template. Never hand-write a Tier A\n library.\n\n10. **Tests** — integration tests with fakes at the HTTP edge; unit tests for\n domain and pipeline logic; a config-consistency test for rule-driven\n pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** — operator scripts under `scripts/` using\n `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.`.\n\n12. **Docs** — author GSDFC docstrings, generate `.pyi` stubs, mark the package\n `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n````\n\n---\n\n## 🧭 Where the steps land\n\nThe steps map onto the layers as follows:\n\n| Step | Package / file | Depends on |\n|---------------------|-----------------------------------|---------------------------------------------|\n| 1 · Scaffold | whole `templates/app/` | — |\n| 2 · Spec | `openapi.yaml` | — |\n| 3 · Codegen | `_gen/` (generated) | spec, openapi-first |\n| 4 · CRUD slices | `domain//` | spec, CRUD template |\n| 5 · Wiring | `init.py` | every slice |\n| 6 · Routes | `interfaces//` | services, openapi route registry |\n| 7 · Bootstrap | `main.py`, `worker.py` | composition root |\n| 8 · Pipelines | `pipelines//`, `shared/` | library pipeline engine |\n| 9 · Infra | `infra/` | domain ports |\n| 10 · Tests | `tests/` | app + fixtures |\n| 11 · Scripts | `scripts/` | composition root |\n| 12 · Docs | docstrings + docs build | code, Doc Forge |\n\n---\n\n## 🔧 The codegen fast path, in detail\n\n`openapi-first` is spec → code only (it never generates a spec). Use it to erase\nthe two mechanical parts of the build:\n\n| Generated artifact | Replaces | Notes |\n|--------------------|----------|-------|\n| `_gen/models.py` | hand-writing Pydantic wire models | one class per `components.schemas` entry |\n| `_gen/routes//routes.py` | hand-typing handler signatures | names == `operationId`s; `request: Request` injected |\n\nRules of the road:\n\n- **Wire models are not domain models.** `_gen/models.py` is the HTTP contract;\n `domain//models.py` is your business shape. If the spec's schema has\n a required `id: str`, keep it off the domain model and let the document get its\n id from `BaseDocument`.\n- **Use `--template-style`.** Stubs are then `async def ...(request, body: ...)`\n matching `interfaces/`; without it they are sync `def ...(payload: ...)`.\n Either binds (the router introspects the signature), but `--template-style`\n keeps the scaffold consistent with the code you keep.\n- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.\n- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.\n- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows\n exactly what moved.\n\n---\n\n## ⚠️ Definition of done\n\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves the spec's operations; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n\n---\n\n## Related\n\n- [10 – Layering Rules](10_layering_rules.md) · [21 – CRUD Template](21_crud_template.md) · [22 – Auth Integration](22_auth_integration.md) · [40 – UI](40_ui_admin.md) · [50 – Pipeline Scaffold](50_pipeline_scaffold.md) · [92 – Genericity Matrix](92_genericity_matrix.md)\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/drone_pipelines.json b/standards/mcp/modules/drone_pipelines.json index 0e5bf50..63067f6 100644 --- a/standards/mcp/modules/drone_pipelines.json +++ b/standards/mcp/modules/drone_pipelines.json @@ -2,7 +2,7 @@ "module": "drone_pipelines", "content": { "path": "drone_pipelines", - "docstring": "# Drone Pipelines\n\n## Summary\n\nEvery Aetos repository ships a Drone CI definition at `.drone.yml`. There is\nexactly **one standard per repository**, and there are **three types of file**,\nchosen by what the repository produces:\n\n| Type | File | Produces | Repos |\n|------|------|----------|-------|\n| **A — PyPI package** | `templates/drone/python-lib.yaml` | A wheel + sdist on the private index | `dagpipe`, `doc-forge`, `hexa`, `mail-intake`, `mongo-ops`, `omniread`, `openapi-first`, `py-jwt` |\n| **B — npm package** | `templates/drone/npm-lib.yaml` | A package published to the private npm registry | `doc-weaver`, `react-auth`, `react-openapi` |\n| **C — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server`, `vet` |\n\nAll templates run on `platform: linux / arm64` with the workspace at\n`/drone/src`. Copy the matching template to `/.drone.yml`; the Type A and\nType B files need no edits, the Type C file needs its placeholders filled in\n(below).\n\n> Conventions: `.drone.yml` (not `.yaml`), one file per repo, secrets injected\n> with `from_secret` — never commit credentials.\n\n---\n\n## Type A — PyPI package (`python-lib.yaml`)\n\nFor internal Python libraries. The file contains **two pipelines**:\n\n1. **`build-and-publish-pypi`** — triggered on `tag`:\n - `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in\n `pyproject.toml`; then read `name` / `version` and query\n `$PIP_REPO_URL/simple//`; if that version already exists, exit\n `78` and skip the whole build (see [91 – Versioning & Changelog](91_versioning_and_changelog.md)).\n - `quality-gate` — install the package with its `[dev]` extras from the\n private index, then `black --check`, `ruff check`, `mypy`, `pydoclint`,\n `pytest`.\n - `build-package` — `python -m build` into `dist/`.\n - `upload-to-private-pypi` — `twine upload` to `$PIP_REPO_URL`.\n2. **`backfill-pypi-from-tags`** — triggered on `custom`:\n - `fetch-tags` — `git fetch --tags --force`.\n - `build-and-upload-missing` — walk every tag in version order, skip the ones\n already on the index, `git checkout` the rest, verify `tag == pyproject\n version` at that commit, build, and upload. This is the disaster-recovery /\n replay path.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.\n\n- **Ship as-is.** Package name and version come from `pyproject.toml`, so the\n same file is reused verbatim across every library.\n\n---\n\n## Type B — npm package (`npm-lib.yaml`)\n\nFor internal npm libraries (the React packages). One pipeline,\n**`build-and-publish-npm`**, triggered on `tag`:\n\n- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in\n `package.json`; query `$NPM_REPO_URL` for `name@version`; if it already\n exists, exit `78` and skip the whole build.\n- `quality-gate` — write `.npmrc` from the secrets, `npm install`, then\n `npm run quality-gate`.\n- `build-package` — `npm install && npm run build` into `dist/`.\n- `publish-to-private-npm` — `npm publish --access restricted` to\n `$NPM_REPO_URL`.\n\nSecrets: `NPM_REPO_URL`, `NPM_USERNAME`, `NPM_PASSWORD`. Auth is a generated\n`.npmrc` (`registry=` + `_auth=` base64 of `user:pass`) written from secrets at\nruntime — the committed `.npmrc` in a repo only points at the registry URL.\n\n- **Ship as-is.** Name and version come from `package.json`, so the same file\n is reused verbatim across every npm package.\n\n---\n\n## Type C — Docker deployment (`service-docker.yaml`)\n\nFor a service that ships a container image — a long-running API/worker, e.g.\n`auth-server`. The file contains **one pipeline**, `build-and-deploy-service`,\ntriggered on `tag`:\n\n- `fetch-tags` — resolve the latest tag into `LATEST_TAG.txt`; fail if there\n are no tags.\n- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the repo-root\n `VERSION` file (see [91 – Versioning & Changelog](91_versioning_and_changelog.md)).\n- `check-remote-image` — if `aetos/:` already exists locally, exit\n `78` and skip the build.\n- `quality-gate` — install from `requirements.txt` and run\n `black` / `ruff` / `mypy` / `pytest`.\n- `build-image` — `docker build` with the private-index build args, tagged\n `aetos/:` and `:latest`.\n- `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.\n- `stop-old` — `docker rm -f `.\n- `run-container` — `docker run -d --name -p :\n --restart always` with the service's `-e` environment.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD` (service build args),\n`REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASS`, plus the service runtime env\n(for example `MONGO_*`, `JWT_SECRET`).\n\nPlaceholders to fill in per repo:\n\n| Placeholder | Meaning | Example |\n|-------------|---------|---------|\n| `` | service image / container name | `auth-server` |\n| `aetos/` | image repository on the registry | `aetos/auth-server` |\n| `` / `` | published : in-image port | `9003:8000` |\n| `` | runtime environment variable | `MONGO_HOST` |\n\n---\n\n## What not to do\n\n- Do not hand-roll a fourth shape. A repo is exactly one of Type A, B, or C,\n never combinations.\n- Do not inline secrets; always use `from_secret`.\n- Do not drop the `check-version` / `check-remote-image` skip — it guards the\n index and the running container against duplicate publishes.\n- Do not drop the tag↔version guard. A Python package needs its\n `pyproject.toml` version to equal the tag; an npm package needs its\n `package.json` version; a service needs a root `VERSION` file. See\n [91 – Versioning & Changelog](91_versioning_and_changelog.md).\n- Do not rename the pipelines; tooling and runbooks key off\n `build-and-publish-pypi`, `build-and-publish-npm`, and\n `build-and-deploy-service`.\n\n---\n\n## Related\n\n- [01 – Build Recipe](01_build_recipe.md) · [30 – Infra Adapters](30_infra_adaptors.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n", + "docstring": "# Drone Pipelines\n\n## Summary\n\nEvery Aetos repository ships a Drone CI definition at `.drone.yml`. There is\nexactly **one standard per repository**, and there are **three types of file**,\nchosen by what the repository produces:\n\n| Type | File | Produces | Repos |\n|------|------|----------|-------|\n| **A — PyPI package** | `templates/drone/python-lib.yaml` | A wheel + sdist on the private index | `dagpipe`, `doc-forge`, `hexa`, `mail-intake`, `mongo-ops`, `omniread`, `openapi-first`, `py-jwt` |\n| **B — npm package** | `templates/drone/npm-lib.yaml` | A package published to the private npm registry | `doc-weaver`, `react-auth`, `react-openapi` |\n| **C — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server` |\n\nAll templates run on `platform: linux / arm64` with the workspace at\n`/drone/src`. Copy the matching template to `/.drone.yml`; the Type A and\nType B files need no edits, the Type C file needs its placeholders filled in\n(below).\n\n> Conventions: `.drone.yml` (not `.yaml`), one file per repo, secrets injected\n> with `from_secret` — never commit credentials.\n\n---\n\n## Type A — PyPI package (`python-lib.yaml`)\n\nFor internal Python libraries. The file contains **two pipelines**:\n\n1. **`build-and-publish-pypi`** — triggered on `tag`:\n - `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in\n `pyproject.toml`; then read `name` / `version` and query\n `$PIP_REPO_URL/simple//`; if that version already exists, exit\n `78` and skip the whole build (see [91 – Versioning & Changelog](91_versioning_and_changelog.md)).\n - `quality-gate` — install the package with its `[dev]` extras from the\n private index, then `black --check`, `ruff check`, `mypy`, `pydoclint`,\n `pytest`.\n - `build-package` — `python -m build` into `dist/`.\n - `upload-to-private-pypi` — `twine upload` to `$PIP_REPO_URL`.\n2. **`backfill-pypi-from-tags`** — triggered on `custom`:\n - `fetch-tags` — `git fetch --tags --force`.\n - `build-and-upload-missing` — walk every tag in version order, skip the ones\n already on the index, `git checkout` the rest, verify `tag == pyproject\n version` at that commit, build, and upload. This is the disaster-recovery /\n replay path.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.\n\n- **Ship as-is.** Package name and version come from `pyproject.toml`, so the\n same file is reused verbatim across every library.\n\n---\n\n## Type B — npm package (`npm-lib.yaml`)\n\nFor internal npm libraries (the React packages). One pipeline,\n**`build-and-publish-npm`**, triggered on `tag`:\n\n- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in\n `package.json`; query `$NPM_REPO_URL` for `name@version`; if it already\n exists, exit `78` and skip the whole build.\n- `quality-gate` — write `.npmrc` from the secrets, `npm install`, then\n `npm run quality-gate`.\n- `build-package` — `npm install && npm run build` into `dist/`.\n- `publish-to-private-npm` — `npm publish --access restricted` to\n `$NPM_REPO_URL`.\n\nSecrets: `NPM_REPO_URL`, `NPM_USERNAME`, `NPM_PASSWORD`. Auth is a generated\n`.npmrc` (`registry=` + `_auth=` base64 of `user:pass`) written from secrets at\nruntime — the committed `.npmrc` in a repo only points at the registry URL.\n\n- **Ship as-is.** Name and version come from `package.json`, so the same file\n is reused verbatim across every npm package.\n\n---\n\n## Type C — Docker deployment (`service-docker.yaml`)\n\nFor a service that ships a container image — a long-running API/worker, e.g.\n`auth-server`. The file contains **one pipeline**, `build-and-deploy-service`,\ntriggered on `tag`:\n\n- `fetch-tags` — resolve the latest tag into `LATEST_TAG.txt`; fail if there\n are no tags.\n- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the repo-root\n `VERSION` file (see [91 – Versioning & Changelog](91_versioning_and_changelog.md)).\n- `check-remote-image` — if `aetos/:` already exists locally, exit\n `78` and skip the build.\n- `quality-gate` — install from `requirements.txt` and run\n `black` / `ruff` / `mypy` / `pytest`.\n- `build-image` — `docker build` with the private-index build args, tagged\n `aetos/:` and `:latest`.\n- `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.\n- `stop-old` — `docker rm -f `.\n- `run-container` — `docker run -d --name -p :\n --restart always` with the service's `-e` environment.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD` (service build args),\n`REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASS`, plus the service runtime env\n(for example `MONGO_*`, `JWT_SECRET`).\n\nPlaceholders to fill in per repo:\n\n| Placeholder | Meaning | Example |\n|-------------|---------|---------|\n| `` | service image / container name | `auth-server` |\n| `aetos/` | image repository on the registry | `aetos/auth-server` |\n| `` / `` | published : in-image port | `9003:8000` |\n| `` | runtime environment variable | `MONGO_HOST` |\n\n---\n\n## What not to do\n\n- Do not hand-roll a fourth shape. A repo is exactly one of Type A, B, or C,\n never combinations.\n- Do not inline secrets; always use `from_secret`.\n- Do not drop the `check-version` / `check-remote-image` skip — it guards the\n index and the running container against duplicate publishes.\n- Do not drop the tag↔version guard. A Python package needs its\n `pyproject.toml` version to equal the tag; an npm package needs its\n `package.json` version; a service needs a root `VERSION` file. See\n [91 – Versioning & Changelog](91_versioning_and_changelog.md).\n- Do not rename the pipelines; tooling and runbooks key off\n `build-and-publish-pypi`, `build-and-publish-npm`, and\n `build-and-deploy-service`.\n\n---\n\n## Related\n\n- [01 – Build Recipe](01_build_recipe.md) · [30 – Infra Adapters](30_infra_adaptors.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/repository_inventory.json b/standards/mcp/modules/repository_inventory.json index 2131e3b..b9bfe1b 100644 --- a/standards/mcp/modules/repository_inventory.json +++ b/standards/mcp/modules/repository_inventory.json @@ -2,7 +2,7 @@ "module": "repository_inventory", "content": { "path": "repository_inventory", - "docstring": "# Repository Inventory\n\n## Summary\n\nThe canonical list of Aetos repositories covered by the CI and versioning\nstandards, with the artifact each one produces, its version source, and its\nDrone shape. Every entry maps to the contract in\n[91 – Versioning & Changelog](91_versioning_and_changelog.md) and the three CI\nfiles in [90 – Drone Pipelines](90_drone_pipelines.md).\n\nThe same data is published machine-readably as the `registry://repos` MCP\nresource (and the `list_repos` tool), sourced from `standards/src/repos.json`.\nPaths are relative to the `aetos/` workspace root.\n\n---\n\n## Packages (Type A — PyPI)\n\nA wheel + sdist published to the private index. Version source is\n`pyproject.toml`; Drone file is `python-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `dagpipe` | `dagpipe` | lib, wiki | `dagpipe:8006` |\n| `doc-forge` | `doc-forge` | lib, wiki | `docforge:8001` |\n| `hexa` | `hexa` | lib, wiki | `hexa:8008` |\n| `mail-intake` | `mail-intake` | lib, wiki | `mail_intake:8002` |\n| `mongo-ops` | `mongo-ops` | lib, wiki | `mongo_ops:8007` |\n| `omniread` | `omniread` | lib, wiki | `omniread:8003` |\n| `openapi-first` | `openapi-first` | lib, wiki | `openapi_first:8004` |\n| `py-jwt` | `py-jwt` | lib, wiki | `jwtlib:8005` |\n\n---\n\n## Packages (Type B — npm)\n\nA package published to the private npm registry (`npm.aetoskia.com`). Version\nsource is `package.json`; Drone file is `npm-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `doc-weaver` | `doc-weaver` | lib, wiki | `doc-weaver:8010` |\n| `react-auth` | `react/react-auth` | lib, wiki | `react-auth:8011` |\n| `react-openapi` | `react/react-openapi` | lib, wiki | `react-openapi:8012` |\n\n---\n\n## Services & Apps (Type C)\n\nA container image pushed to the registry and run on the Docker host. Version\nsource is the `VERSION` file; Drone file is `service-docker.yaml`.\n\n| Repo | Path | Type | Trigger | Docs |\n|------|------|------|---------|------|\n| `auth-server` | `auth-server` | service | tag | api, lib, wiki |\n| `vet` | `react/vet` | app | tag | lib, wiki |\n\nTag-triggered and enforces `tag == VERSION`.\n\n---\n\n## Required files per repo\n\n| Type | Version source | Changelog | Drone |\n|------|----------------|-----------|-------|\n| package (PyPI) | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` |\n| package (npm) | `package.json` | `CHANGELOG.md` | `npm-lib.yaml` |\n| service / app | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` |\n\nEvery covered repository must be able to satisfy `tag == version`:\nthe version file (never a commit id) is bumped in the release MR, the MR is\nmerged, and the tag is created on the merged commit.\n\n---\n\n## Reading this at runtime\n\n- Resource: `registry://repos`\n- Tool: `list_repos()`\n- Humans: this page (mirror of `standards/src/repos.json`).\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n", + "docstring": "# Repository Inventory\n\n## Summary\n\nThe canonical list of Aetos repositories covered by the CI and versioning\nstandards, with the artifact each one produces, its version source, and its\nDrone shape. Every entry maps to the contract in\n[91 – Versioning & Changelog](91_versioning_and_changelog.md) and the three CI\nfiles in [90 – Drone Pipelines](90_drone_pipelines.md).\n\nThe same data is published machine-readably as the `registry://repos` MCP\nresource (and the `list_repos` tool), sourced from `standards/src/repos.json`.\nPaths are relative to the `aetos/` workspace root.\n\n---\n\n## Packages (Type A — PyPI)\n\nA wheel + sdist published to the private index. Version source is\n`pyproject.toml`; Drone file is `python-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `dagpipe` | `dagpipe` | lib, wiki | `dagpipe:8006` |\n| `doc-forge` | `doc-forge` | lib, wiki | `docforge:8001` |\n| `hexa` | `hexa` | lib, wiki | `hexa:8008` |\n| `mail-intake` | `mail-intake` | lib, wiki | `mail_intake:8002` |\n| `mongo-ops` | `mongo-ops` | lib, wiki | `mongo_ops:8007` |\n| `omniread` | `omniread` | lib, wiki | `omniread:8003` |\n| `openapi-first` | `openapi-first` | lib, wiki | `openapi_first:8004` |\n| `py-jwt` | `py-jwt` | lib, wiki | `jwtlib:8005` |\n\n---\n\n## Packages (Type B — npm)\n\nA package published to the private npm registry (`npm.aetoskia.com`). Version\nsource is `package.json`; Drone file is `npm-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `doc-weaver` | `doc-weaver` | lib, wiki | `doc-weaver:8010` |\n| `react-auth` | `react/react-auth` | lib, wiki | `react-auth:8011` |\n| `react-openapi` | `react/react-openapi` | lib, wiki | `react-openapi:8012` |\n\n---\n\n## Services (Type C)\n\nA container image pushed to the registry and run on the Docker host. Version\nsource is the `VERSION` file; Drone file is `service-docker.yaml`.\n\n| Repo | Path | Trigger | Docs |\n|------|------|---------|------|\n| `auth-server` | `auth-server` | tag | api, lib, wiki |\n\nTag-triggered and enforces `tag == VERSION`.\n\n---\n\n## Required files per repo\n\n| Type | Version source | Changelog | Drone |\n|------|----------------|-----------|-------|\n| package (PyPI) | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` |\n| package (npm) | `package.json` | `CHANGELOG.md` | `npm-lib.yaml` |\n| service | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` |\n\nEvery covered repository must be able to satisfy `tag == version`:\nthe version file (never a commit id) is bumped in the release MR, the MR is\nmerged, and the tag is created on the merged commit.\n\n---\n\n## Reading this at runtime\n\n- Resource: `registry://repos`\n- Tool: `list_repos()`\n- Humans: this page (mirror of `standards/src/repos.json`).\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/ui_admin.json b/standards/mcp/modules/ui_admin.json index bc4704f..1c09b18 100644 --- a/standards/mcp/modules/ui_admin.json +++ b/standards/mcp/modules/ui_admin.json @@ -2,7 +2,7 @@ "module": "ui_admin", "content": { "path": "ui_admin", - "docstring": "# UI: Auto-Rendered Admin & Custom Pages\n\n## Summary\n\nThe UI axis of an Aetos app is a **published React client**, not code written\nper application. Two npm packages carry the whole surface:\n\n| Package | Role |\n|---|---|\n| `@aetoskia/react-openapi` | Renders a complete admin panel (CRUD tables, forms, filters, sorting, pagination, uploads) **from the app's `openapi.yaml`** — zero UI code. |\n| `@aetoskia/react-auth` | Auth contexts and pages (login/register/me) wired to the auth-server, plus the axios client with token handling. |\n\nBoth publish to the private registry `https://npm.aetoskia.com`. The server\nspec is the single source of truth: the same `openapi.yaml` that drives\n`openapi-first` routing drives the admin UI, through **x- extension fields**\n(see below).\n\n---\n\n## Scaffold\n\n```bash\ncp -r /standards/src/templates/client apps/-client\ncd apps/-client\nnpm install # deps come from npm.aetoskia.com via .npmrc\nnpm run dev\n```\n\nThe client template is a Vite + MUI app with:\n\n- `src/openapi-config.ts` — points the renderer at the app's spec and the\n auth-server's spec;\n- `.env.example` — `VITE_API_BASE_URL` (app API) and `VITE_AUTH_BASE_URL`\n (auth-server);\n- a `Home` page that auto-lists every resource the spec declares, and a\n `Header`/`Footer`/theme scaffold.\n\nCustom pages are ordinary React routes layered on top; the auto-rendered admin\nis available per resource at `#/resource/`.\n\n---\n\n## x- extensions: how the spec steers the UI\n\nThe admin renderer reads a catalog of OpenAPI extension fields (`x-resource`,\n`x-list-columns`, `x-display-format`, `x-label`, `x-order`, `x-hidden`,\n`x-sortable`, `x-filterable`, `x-autocomplete`, `x-fk`, `x-ui-type`,\n`x-upload`, `x-sse`, `x-profile`, and the auth wiring `x-server-url` /\n`x-login-path` / `x-me-path` / …) declared on schemas and properties.\n\n**The catalog is canonical in the react-openapi source, not in this doc.**\nRead it from the MCP bundle:\n\n```\ndocs://modules/react-openapi # module: specExtensions — the full catalog\n```\n\nThe app's own spec carries the per-resource values; the catalog explains what\neach field does. The template's `openapi.yaml` shows the convention on the\nexample `Item` and `Job` schemas.\n\n---\n\n## Golden rules\n\n1. **The spec owns the UI.** Add a resource or rename a column in\n `openapi.yaml`; the admin follows. Never fork the renderer per app.\n2. **Custom pages for workflows, auto-render for CRUD.** Anything that is\n \"list / create / edit / delete an entity\" is a spec concern; anything that\n is a bespoke workflow is a React page.\n3. **Auth is consumed, never re-implemented.** `@aetoskia/react-auth` holds\n tokens and calls the auth-server endpoints declared by the x- wiring; pages\n read the context.\n4. **Publish, don't vendor.** UI shared across apps belongs in\n `@aetoskia/react-openapi` or `@aetoskia/react-auth` (SemVer, tag-triggered\n npm release), not copy-pasted into app trees.\n\n---\n\n## Notes\n\n- The server template ships authless by default (see\n [22 – Auth Integration](22_auth_integration.md)); the client template pairs\n with it and degrades to \"no login\" until a security scheme is declared.\n- `react/vet` is the full-stack reference implementation: spec with x-\n extensions, server, and the consuming client in one tree.\n", + "docstring": "# UI: Auto-Rendered Admin & Custom Pages\n\n## Summary\n\nThe UI axis of an Aetos app is a **published React client**, not code written\nper application. Two npm packages carry the whole surface:\n\n| Package | Role |\n|---|---|\n| `@aetoskia/react-openapi` | Renders a complete admin panel (CRUD tables, forms, filters, sorting, pagination, uploads) **from the app's `openapi.yaml`** — zero UI code. |\n| `@aetoskia/react-auth` | Auth contexts and pages (login/register/me) wired to the auth-server, plus the axios client with token handling. |\n\nBoth publish to the private registry `https://npm.aetoskia.com`. The server\nspec is the single source of truth: the same `openapi.yaml` that drives\n`openapi-first` routing drives the admin UI, through **x- extension fields**\n(see below).\n\n---\n\n## Scaffold\n\n```bash\ncp -r /standards/src/templates/client apps/-client\ncd apps/-client\nnpm install # deps come from npm.aetoskia.com via .npmrc\nnpm run dev\n```\n\nThe client template is a Vite + MUI app with:\n\n- `src/openapi-config.ts` — points the renderer at the app's spec and the\n auth-server's spec;\n- `.env.example` — `VITE_API_BASE_URL` (app API) and `VITE_AUTH_BASE_URL`\n (auth-server);\n- a `Home` page that auto-lists every resource the spec declares, and a\n `Header`/`Footer`/theme scaffold.\n\nCustom pages are ordinary React routes layered on top; the auto-rendered admin\nis available per resource at `#/resource/`.\n\n---\n\n## x- extensions: how the spec steers the UI\n\nThe admin renderer reads a catalog of OpenAPI extension fields (`x-resource`,\n`x-list-columns`, `x-display-format`, `x-label`, `x-order`, `x-hidden`,\n`x-sortable`, `x-filterable`, `x-autocomplete`, `x-fk`, `x-ui-type`,\n`x-upload`, `x-sse`, `x-profile`, and the auth wiring `x-server-url` /\n`x-login-path` / `x-me-path` / …) declared on schemas and properties.\n\n**The catalog is canonical in the react-openapi source, not in this doc.**\nRead it from the MCP bundle:\n\n```\ndocs://modules/react-openapi # module: specExtensions — the full catalog\n```\n\nThe app's own spec carries the per-resource values; the catalog explains what\neach field does. The template's `openapi.yaml` shows the convention on the\nexample `Job` schema.\n\n---\n\n## Golden rules\n\n1. **The spec owns the UI.** Add a resource or rename a column in\n `openapi.yaml`; the admin follows. Never fork the renderer per app.\n2. **Custom pages for workflows, auto-render for CRUD.** Anything that is\n \"list / create / edit / delete an entity\" is a spec concern; anything that\n is a bespoke workflow is a React page.\n3. **Auth is consumed, never re-implemented.** `@aetoskia/react-auth` holds\n tokens and calls the auth-server endpoints declared by the x- wiring; pages\n read the context.\n4. **Publish, don't vendor.** UI shared across apps belongs in\n `@aetoskia/react-openapi` or `@aetoskia/react-auth` (SemVer, tag-triggered\n npm release), not copy-pasted into app trees.\n\n---\n\n## Notes\n\n- The server template ships authless by default (see\n [22 – Auth Integration](22_auth_integration.md)); the client template pairs\n with it and degrades to \"no login\" until a security scheme is declared.\n- The standards **app + client templates** are the full-stack reference: the\n server spec carries the x- extensions, and the client renders the admin from\n it. The wiki's **Reference App** walkthrough (`06_reference_app`) documents\n the pairing end to end.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/wiki_start_an_app.json b/standards/mcp/modules/wiki_start_an_app.json index 9cd3151..96acb9b 100644 --- a/standards/mcp/modules/wiki_start_an_app.json +++ b/standards/mcp/modules/wiki_start_an_app.json @@ -2,7 +2,7 @@ "module": "wiki_start_an_app", "content": { "path": "wiki_start_an_app", - "docstring": "# 🚀 Start an App\n\nThis is the entry point for building a new application from the blueprint. The\ngoal is one deterministic pass: you supply only the **two axes**, and the agent\ndoes the rest by reading the `standards` MCP, importing Tier A libraries, and\ncopying Tier B template components.\n\n> **Doc model:** this page is the human entry point. The deterministic,\n> machine-facing checklist lives in the reference page\n> [Build Recipe](../lib/01_build_recipe/), served as `docs://modules/build_recipe`.\n\n---\n\n## 🧾 What the agent needs from you\n\nGather these before starting — the `start_app` MCP prompt asks for them:\n\n1. **App name + one-line purpose.**\n2. **Axis 1 — resources.** Each resource, its fields, and relationships. Paste\n `openapi.yaml` if you already have it.\n3. **Axis 2 — pipelines.** Only if an input needs a multi-step transformation:\n the families and their input formats (or \"none\").\n4. **Auth needed now?** Default **no**. Auth is deferred (`py-jwt` primitives\n only), and an authless app is just a spec with no `security` keys — see\n [Spec-First Routing → Authless apps](../lib/20_spec_first_routing/#authless-apps).\n5. **Integrations.** Mongo, ingestion formats, email, LLM provider.\n\nIf you can state axes 1 and 2, the rest is mechanical.\n\n---\n\n## ▶️ How to start\n\nFrom an MCP client connected to the `standards` server:\n\n1. Invoke the **`intake`** prompt (or `start_app` with no arguments) to get the\n intake questions.\n2. Answer them, then invoke **`build`**\n (`build(app_name=..., resources=..., pipelines=..., auth=...)`), or use\n **`start_app`** to intake-and-build in one call.\n3. The prompt hands off to `docs://modules/build_recipe`; the agent scaffolds\n with `get_app_template()` and pulls exact files with\n `get_app_component(kind, resource=..., family=...)`, authoring only the\n app-specific parts.\n4. To validate the standard without touching a real app, invoke **`trial`**\n (`trial(app_name=..., build_path=...)`) to build into a scratch directory,\n prove it builds, and report findings.\n\nManual alternative — copy the skeleton and follow the reference recipe:\n\n```bash\ncp -r /standards/src/templates/app apps/\n```\n\n---\n\n## 📚 Reference\n\n- [Build Recipe](../lib/01_build_recipe/) — the deterministic checklist\n- [Genericity Matrix](../lib/92_genericity_matrix/) — Tier A / B / C\n- [Library Registry](../lib/93_library_registry/) — the Tier A catalog\n- [App Components](02_components.md) — the component model\n\n---\n\n## Related\n\n- [01 – Overview](01_overview.md) · [02 – App Components](02_components.md) · [04 – Testing & Quality](04_development/01_testing.md)\n", + "docstring": "# 🚀 Start an App\n\nThis is the entry point for building a new application from the blueprint. The\ngoal is one deterministic pass: you supply only the **two axes**, and the agent\ndoes the rest by reading the `standards` MCP, importing Tier A libraries, and\ncopying Tier B template components.\n\n> **Doc model:** this page is the human entry point. The deterministic,\n> machine-facing checklist lives in the reference page\n> [Build Recipe](../lib/01_build_recipe/), served as `docs://modules/build_recipe`.\n\n---\n\n## 🧾 What the agent needs from you\n\nGather these before starting — the `start_app` MCP prompt asks for them:\n\n1. **App name + one-line purpose.**\n2. **Axis 1 — resources.** Each resource, its fields, and relationships. Paste\n `openapi.yaml` if you already have it.\n3. **Axis 2 — pipelines.** Only if an input needs a multi-step transformation:\n the families and their input formats (or \"none\").\n4. **Auth needed now?** Default **no**. Auth is deferred (`py-jwt` primitives\n only), and an authless app is just a spec with no `security` keys — see\n [Spec-First Routing → Authless apps](../lib/20_spec_first_routing/#authless-apps).\n5. **Integrations.** Mongo, ingestion formats, email, LLM provider.\n\nIf you can state axes 1 and 2, the rest is mechanical.\n\n---\n\n## ▶️ How to start\n\nFrom an MCP client connected to the `standards` server:\n\n1. Invoke the **`intake`** prompt (or `start_app` with no arguments) to get the\n intake questions.\n2. Answer them, then invoke **`build`**\n (`build(app_name=..., resources=..., pipelines=..., auth=...)`), or use\n **`start_app`** to intake-and-build in one call.\n3. The prompt hands off to `docs://modules/build_recipe`; the agent scaffolds\n with `get_app_template()` and pulls exact files with\n `get_app_component(kind)`, authoring only the app-specific parts.\n4. To validate the standard without touching a real app, invoke **`trial`**\n (`trial(app_name=..., build_path=...)`) to build into a scratch directory,\n prove it builds, and report findings.\n\nManual alternative — copy the skeleton and follow the reference recipe:\n\n```bash\ncp -r /standards/src/templates/app apps/\n```\n\n---\n\n## 📚 Reference\n\n- [Build Recipe](../lib/01_build_recipe/) — the deterministic checklist\n- [Genericity Matrix](../lib/92_genericity_matrix/) — Tier A / B / C\n- [Library Registry](../lib/93_library_registry/) — the Tier A catalog\n- [App Components](02_components.md) — the component model\n\n---\n\n## Related\n\n- [01 – Overview](01_overview.md) · [02 – App Components](02_components.md) · [04 – Testing & Quality](04_development/01_testing.md)\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/repos.json b/standards/mcp/repos.json index 8f0361d..71198ad 100644 --- a/standards/mcp/repos.json +++ b/standards/mcp/repos.json @@ -192,19 +192,6 @@ "wiki" ], "mcp": null - }, - { - "name": "vet", - "type": "app", - "path": "react/vet", - "version_source": "VERSION", - "drone": "service-docker.yaml", - "trigger": "tag", - "docs": [ - "lib", - "wiki" - ], - "mcp": null } ] } \ No newline at end of file diff --git a/standards/mcp/templates/app/.dockerignore b/standards/mcp/templates/app/.dockerignore index 7143c6c..0bfca08 100644 --- a/standards/mcp/templates/app/.dockerignore +++ b/standards/mcp/templates/app/.dockerignore @@ -5,4 +5,8 @@ __pycache__/ .git/ docs/ dist/ -build/ \ No newline at end of file +build/ +uploads/ +_gen/ +.pytest_cache/ +.ruff_cache/ diff --git a/standards/mcp/templates/app/.env.example b/standards/mcp/templates/app/.env.example index 4f8cccb..eb1d3c9 100644 --- a/standards/mcp/templates/app/.env.example +++ b/standards/mcp/templates/app/.env.example @@ -1,9 +1,9 @@ DEBUG=false REPO_BACKEND=memory MONGO_URI=mongodb://localhost:27017 -MONGO_DB=app_template -PORT=8000 -WORKER_POLL_SECONDS=30 -WORKER_MAX_RETRIES=3 -AUTH_SERVER=http://localhost:8000 -JWT_SERVER=http://localhost:8000 +MONGO_DB=vet +UPLOADS_DIR=uploads +PORT=8020 +AUTH_SERVER=https://auth.aetoskia.com +WORKER_POLL_SECONDS=5 +WORKER_MAX_RETRIES=3 \ No newline at end of file diff --git a/standards/mcp/templates/app/.gitignore b/standards/mcp/templates/app/.gitignore index cebb4e9..6007e69 100644 --- a/standards/mcp/templates/app/.gitignore +++ b/standards/mcp/templates/app/.gitignore @@ -15,6 +15,9 @@ venv/ # Generated wire contract (regenerate from openapi.yaml) _gen/ +# Uploads +uploads/ + # Packaging / coverage dist/ build/ diff --git a/standards/mcp/templates/app/Dockerfile b/standards/mcp/templates/app/Dockerfile index b39d077..ed6746c 100644 --- a/standards/mcp/templates/app/Dockerfile +++ b/standards/mcp/templates/app/Dockerfile @@ -12,9 +12,7 @@ COPY . . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir --extra-index-url "${PIP_EXTRA_INDEX_URL}" . -EXPOSE 8000 +EXPOSE 8020 -# Two processes, per the two axes: -# - main.py : the spec-first FastAPI application -# - worker.py : the pipeline consumer polling pending jobs -CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port 8000 & exec python worker.py"] \ No newline at end of file +# Spec-first FastAPI application (no worker by design for this service) +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8020"] \ No newline at end of file diff --git a/standards/mcp/templates/app/VERSION b/standards/mcp/templates/app/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/standards/mcp/templates/app/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/standards/mcp/templates/app/config.py b/standards/mcp/templates/app/config.py index 9cab0b2..38ecfbe 100644 --- a/standards/mcp/templates/app/config.py +++ b/standards/mcp/templates/app/config.py @@ -6,15 +6,17 @@ import time. --- -The template reads these knobs: +The server reads six knobs: -- ``repo_backend`` — ``memory`` (default, zero-dependency process-local store) - or ``mongo`` (persistent, via the shared mongo-ops connection manager). +- ``repo_backend`` — ``memory`` (default, zero-dependency demo store) or + ``mongo`` (persistent, via the shared mongo-ops connection manager). - ``mongo_uri`` / ``mongo_db`` — connection targets when the Mongo backend is selected. -- ``port`` — the port uvicorn binds (keep in sync with the OpenAPI - ``servers:`` entry and the client's ``VITE_API_BASE_URL``). -- ``worker_poll_seconds`` / ``worker_max_retries`` — background worker cadence. +- ``uploads_dir`` — directory that receives uploaded pet photos. +- ``port`` — the port uvicorn binds (kept in sync with the OpenAPI + ``servers:`` entry and the frontend's ``VITE_API_BASE_URL``). +- ``worker_poll_seconds`` — background worker poll cadence. +- ``worker_max_retries`` — failed-attempt budget before a job is skipped. ``AUTH_SERVER`` is intentionally *not* re-exported here: ``openapi_first`` resolves ``{AUTH_SERVER}`` straight from the process environment when the @@ -34,18 +36,24 @@ class Settings: repo_backend (str): Persistence flavour, ``memory`` or ``mongo``. Defaults to ``memory``. mongo_uri (str): MongoDB connection URI. Defaults to mongodb://localhost:27017. - mongo_db (str): MongoDB database name. Defaults to app_template. - port (int): Local HTTP port. Defaults to 8000. - worker_poll_seconds (int): Worker poll interval in seconds. Defaults to 30. - worker_max_retries (int): Maximum job retries before skipping. Defaults to 3. + mongo_db (str): MongoDB database name. Defaults to vet. + auth_server (str): Auth-server base URL surfaced to operators and + scripts. Defaults to https://auth.aetoskia.com. + port (int): Local HTTP port. Defaults to 8020. + uploads_dir (str): Directory for uploaded pet photos. Defaults to uploads. + worker_poll_seconds (float): Worker poll cadence in seconds. Defaults to 5. + worker_max_retries (int): Failed attempts before a job is skipped. + Defaults to 3. """ debug: bool = False repo_backend: str = "memory" mongo_uri: str = "mongodb://localhost:27017" - mongo_db: str = "app_template" - port: int = 8000 - worker_poll_seconds: int = 30 + mongo_db: str = "vet" + auth_server: str = "https://auth.aetoskia.com" + port: int = 8020 + uploads_dir: str = "uploads" + worker_poll_seconds: float = 5.0 worker_max_retries: int = 3 @@ -53,8 +61,10 @@ settings = Settings( debug=os.environ.get("DEBUG", "").lower() in ("1", "true", "yes"), repo_backend=os.environ.get("REPO_BACKEND", "memory").lower(), mongo_uri=os.environ.get("MONGO_URI", "mongodb://localhost:27017"), - mongo_db=os.environ.get("MONGO_DB", "app_template"), - port=int(os.environ.get("PORT", "8000")), - worker_poll_seconds=int(os.environ.get("WORKER_POLL_SECONDS", "30")), + mongo_db=os.environ.get("MONGO_DB", "vet"), + auth_server=os.environ.get("AUTH_SERVER", "https://auth.aetoskia.com"), + port=int(os.environ.get("PORT", "8020")), + uploads_dir=os.environ.get("UPLOADS_DIR", "uploads"), + worker_poll_seconds=float(os.environ.get("WORKER_POLL_SECONDS", "5")), worker_max_retries=int(os.environ.get("WORKER_MAX_RETRIES", "3")), ) diff --git a/standards/mcp/templates/app/docker-compose.yaml b/standards/mcp/templates/app/docker-compose.yaml index d011d83..8e758ac 100644 --- a/standards/mcp/templates/app/docker-compose.yaml +++ b/standards/mcp/templates/app/docker-compose.yaml @@ -1,10 +1,10 @@ services: api: build: . - command: uvicorn main:app --host 0.0.0.0 --port 8000 + command: uvicorn main:app --host 0.0.0.0 --port 8020 env_file: .env ports: - - "8000:8000" + - "8020:8020" depends_on: - mongo diff --git a/standards/mcp/templates/app/domain/appointments/__init__.py b/standards/mcp/templates/app/domain/appointments/__init__.py new file mode 100644 index 0000000..15b9efd --- /dev/null +++ b/standards/mcp/templates/app/domain/appointments/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Appointments domain slice.""" diff --git a/standards/mcp/templates/app/domain/appointments/models.py b/standards/mcp/templates/app/domain/appointments/models.py new file mode 100644 index 0000000..61fdd5b --- /dev/null +++ b/standards/mcp/templates/app/domain/appointments/models.py @@ -0,0 +1,68 @@ +"""# Summary + +Appointments domain — scheduled visits linking a pet, a vet and a treatment. +""" +from pydantic import BaseModel + +from domain.parents.models import Metadata + + +class ProcedureNote(BaseModel): + """A single procedure note; ``noteType`` selects the shape. + + Attributes: + noteType (str): Discriminator (basic, heart_rate, dental, vaccine, + preop, surgery). + summary (str | None): Short summary. Defaults to None. + details (str | None): Longer details. Defaults to None. + """ + + noteType: str + summary: str | None = None + details: str | None = None + + model_config = {"extra": "ignore"} + + +class Procedure(BaseModel): + """A procedure performed during an appointment. + + Attributes: + name (str): Procedure name. + description (str | None): Procedure description. Defaults to None. + cost (float | None): Cost of the procedure. Defaults to None. + notes (ProcedureNote | None): Typed procedure note. Defaults to None. + """ + + name: str + description: str | None = None + cost: float | None = None + notes: ProcedureNote | None = None + + model_config = {"extra": "ignore"} + + +class Appointment(BaseModel): + """A scheduled appointment. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + date (str): ISO date-time of the appointment. + notes (str | None): Additional notes. Defaults to None. + procedures (list[Procedure]): Procedures performed. Defaults to empty. + pet (dict | int | None): Embedded pet object or pet id. + vet (dict | int | None): Embedded vet object or vet id. + treatment (dict | int | None): Embedded treatment or treatment id. + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + date: str + notes: str | None = None + procedures: list[Procedure] = [] + pet: dict | int | None = None + vet: dict | int | None = None + treatment: dict | int | None = None + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/appointments/ports/__init__.py b/standards/mcp/templates/app/domain/appointments/ports/__init__.py new file mode 100644 index 0000000..7f19d84 --- /dev/null +++ b/standards/mcp/templates/app/domain/appointments/ports/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Appointments domain ports.""" diff --git a/standards/mcp/templates/app/domain/appointments/ports/db.py b/standards/mcp/templates/app/domain/appointments/ports/db.py new file mode 100644 index 0000000..9cc59b2 --- /dev/null +++ b/standards/mcp/templates/app/domain/appointments/ports/db.py @@ -0,0 +1,25 @@ +"""# Summary + +Appointments persistence ports — integer-id document and repository factory. +""" +from mongo_ops import BaseDocument +from pydantic import Field + +from domain.appointments.models import Appointment +from infra.db.repos import create_repo + + +class AppointmentDocument(Appointment, BaseDocument): + """Persisted appointment document. The integer ``id`` shadows the ObjectId.""" + + id: int | None = Field(default=None, validation_alias="_id") + + +def create_appointment_repo(): + """Create the appointment repository selected by ``settings.repo_backend``. + + Returns: + IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the + ``appointments`` collection/store. + """ + return create_repo("appointments", AppointmentDocument) diff --git a/standards/mcp/templates/app/domain/appointments/ports/service.py b/standards/mcp/templates/app/domain/appointments/ports/service.py new file mode 100644 index 0000000..992f883 --- /dev/null +++ b/standards/mcp/templates/app/domain/appointments/ports/service.py @@ -0,0 +1,34 @@ +"""# Summary + +CRUD service wiring for the appointment aggregate via the hexa container. +""" +from hexa import build + +from domain.appointments.models import Appointment +from domain.appointments.ports.db import AppointmentDocument +from infra.adapters.service import CrudsServiceAdaptor + + +class AppointmentCrudsService(CrudsServiceAdaptor[Appointment, AppointmentDocument]): + """Appointment CRUD service resolving :class:`AppointmentDocument` from a repo. + + Notes: + Instances are created by :func:`get_appointment_service`. The ``model`` + and ``schema`` class attributes bind the domain model and its document. + """ + + model = Appointment + schema = AppointmentDocument + + +def get_appointment_service(repo) -> AppointmentCrudsService: + """Build an appointment CRUD service wired to ``repo``. + + Args: + repo (Any): Integer-id appointment repository from + :func:`create_appointment_repo`. + + Returns: + AppointmentCrudsService: Ready-to-use appointment CRUD service. + """ + return build(AppointmentCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/domain/item/models.py b/standards/mcp/templates/app/domain/item/models.py deleted file mode 100644 index 0a8cc54..0000000 --- a/standards/mcp/templates/app/domain/item/models.py +++ /dev/null @@ -1,21 +0,0 @@ -"""# Summary - -Item domain model — the neutral resource used throughout the template. -""" -from pydantic import BaseModel, Field - - -class Item(BaseModel): - """A single countable resource. - - Attributes: - id (int | None): Server-assigned identifier. Defaults to None. - name (str): Item name. - quantity (int): Item quantity. Defaults to 1. - """ - - id: int | None = None - name: str - quantity: int = Field(default=1) - - model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/item/ports/converters.py b/standards/mcp/templates/app/domain/item/ports/converters.py deleted file mode 100644 index f386df4..0000000 --- a/standards/mcp/templates/app/domain/item/ports/converters.py +++ /dev/null @@ -1,18 +0,0 @@ -"""# Summary - -Optional converters for the item slice — pure functions mapping between domain -types. Add a converter here when a service must reshape data; keep it pure. -""" -from domain.item.models import Item - - -def to_item(data: dict) -> Item: - """Build an :class:`Item` from a raw mapping. - - Args: - data (dict): Field mapping matching the item model. - - Returns: - Item: The constructed item model. - """ - return Item(**data) diff --git a/standards/mcp/templates/app/domain/item/ports/db.py b/standards/mcp/templates/app/domain/item/ports/db.py deleted file mode 100644 index ce08f65..0000000 --- a/standards/mcp/templates/app/domain/item/ports/db.py +++ /dev/null @@ -1,25 +0,0 @@ -"""# Summary - -Items persistence ports — integer-id document and repository factory. -""" -from mongo_ops import BaseDocument -from pydantic import Field - -from domain.item.models import Item -from infra.db.repos import create_repo - - -class ItemDocument(Item, BaseDocument): - """Persisted item document. The integer ``id`` shadows the ObjectId.""" - - id: int | None = Field(default=None, validation_alias="_id") - - -def create_item_repo(): - """Create the item repository selected by ``settings.repo_backend``. - - Returns: - IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the - ``items`` collection/store. - """ - return create_repo("items", ItemDocument) diff --git a/standards/mcp/templates/app/domain/item/ports/service.py b/standards/mcp/templates/app/domain/item/ports/service.py deleted file mode 100644 index 59daba7..0000000 --- a/standards/mcp/templates/app/domain/item/ports/service.py +++ /dev/null @@ -1,33 +0,0 @@ -"""# Summary - -CRUD service wiring for the item aggregate via the hexa container. -""" -from hexa import build - -from domain.item.models import Item -from domain.item.ports.db import ItemDocument -from infra.adapters.service import CrudsServiceAdaptor - - -class ItemCrudsService(CrudsServiceAdaptor[Item, ItemDocument]): - """Item CRUD service resolving :class:`ItemDocument` from a repo. - - Notes: - Instances are created by :func:`get_item_service`. The ``model`` and - ``schema`` class attributes bind the domain model and its document type. - """ - - model = Item - schema = ItemDocument - - -def get_item_service(repo) -> ItemCrudsService: - """Build an item CRUD service wired to ``repo``. - - Args: - repo (Any): Mongo-backed item repository from :func:`create_item_repo`. - - Returns: - ItemCrudsService: Ready-to-use item CRUD service. - """ - return build(ItemCrudsService, instances={"repo": repo}) \ No newline at end of file diff --git a/standards/mcp/templates/app/domain/job/models.py b/standards/mcp/templates/app/domain/job/models.py deleted file mode 100644 index f90881b..0000000 --- a/standards/mcp/templates/app/domain/job/models.py +++ /dev/null @@ -1,29 +0,0 @@ -"""# Summary - -Job domain model — a pipeline work item consumed by the background worker. -""" -from typing import Literal - -from pydantic import BaseModel, Field - - -class Job(BaseModel): - """A unit of background work. - - Attributes: - id (int | None): Server-assigned identifier. Defaults to None. - name (str): Job name. - status (str): Lifecycle status. Defaults to "pending". - retry_count (int): Times the job failed. Defaults to 0. - error_message (str | None): Last failure message. Defaults to None. - payload (dict | None): Arbitrary job payload. Defaults to None. - """ - - id: int | None = None - name: str - status: Literal["pending", "processing", "done", "failed"] = "pending" - retry_count: int = Field(default=0) - error_message: str | None = None - payload: dict | None = None - - model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/item/__init__.py b/standards/mcp/templates/app/domain/jobs/__init__.py similarity index 100% rename from standards/mcp/templates/app/domain/item/__init__.py rename to standards/mcp/templates/app/domain/jobs/__init__.py diff --git a/standards/mcp/templates/app/domain/jobs/models.py b/standards/mcp/templates/app/domain/jobs/models.py new file mode 100644 index 0000000..645d7c0 --- /dev/null +++ b/standards/mcp/templates/app/domain/jobs/models.py @@ -0,0 +1,37 @@ +"""# Summary + +Jobs domain — the worker's queue. Each job is a unit of pipeline work the +background worker claims and executes. +""" +from typing import Literal + +from pydantic import BaseModel + +from domain.parents.models import Metadata + + +class Job(BaseModel): + """A unit of background work. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + name (str): Human label for the job. + kind (str): Dispatch key — selects the pipeline (e.g. + ``appointment_reminder``). + status (str): Lifecycle status. Defaults to ``pending``. + payload (dict | None): Job-specific input. Defaults to None. + retry_count (int): Failed attempts so far. Defaults to 0. + error_message (str | None): Last failure detail. Defaults to None. + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + name: str + kind: str + status: Literal["pending", "processing", "done", "failed"] = "pending" + payload: dict | None = None + retry_count: int = 0 + error_message: str | None = None + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/item/ports/__init__.py b/standards/mcp/templates/app/domain/jobs/ports/__init__.py similarity index 100% rename from standards/mcp/templates/app/domain/item/ports/__init__.py rename to standards/mcp/templates/app/domain/jobs/ports/__init__.py diff --git a/standards/mcp/templates/app/domain/job/ports/db.py b/standards/mcp/templates/app/domain/jobs/ports/db.py similarity index 84% rename from standards/mcp/templates/app/domain/job/ports/db.py rename to standards/mcp/templates/app/domain/jobs/ports/db.py index f437b3c..6238c2c 100644 --- a/standards/mcp/templates/app/domain/job/ports/db.py +++ b/standards/mcp/templates/app/domain/jobs/ports/db.py @@ -5,7 +5,8 @@ Jobs persistence ports — integer-id document and repository factory. from mongo_ops import BaseDocument from pydantic import Field -from domain.job.models import Job +from config import settings +from domain.jobs.models import Job from infra.db.repos import create_repo @@ -22,4 +23,5 @@ def create_job_repo(): IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the ``jobs`` collection/store. """ + _ = settings # backend chosen centrally by create_repo return create_repo("jobs", JobDocument) diff --git a/standards/mcp/templates/app/domain/job/ports/service.py b/standards/mcp/templates/app/domain/jobs/ports/service.py similarity index 76% rename from standards/mcp/templates/app/domain/job/ports/service.py rename to standards/mcp/templates/app/domain/jobs/ports/service.py index 9f6b714..f8c2567 100644 --- a/standards/mcp/templates/app/domain/job/ports/service.py +++ b/standards/mcp/templates/app/domain/jobs/ports/service.py @@ -4,8 +4,8 @@ CRUD service wiring for the job aggregate via the hexa container. """ from hexa import build -from domain.job.models import Job -from domain.job.ports.db import JobDocument +from domain.jobs.models import Job +from domain.jobs.ports.db import JobDocument from infra.adapters.service import CrudsServiceAdaptor @@ -25,9 +25,9 @@ def get_job_service(repo) -> JobCrudsService: """Build a job CRUD service wired to ``repo``. Args: - repo (Any): Mongo-backed job repository from :func:`create_job_repo`. + repo (Any): Integer-id job repository from :func:`create_job_repo`. Returns: JobCrudsService: Ready-to-use job CRUD service. """ - return build(JobCrudsService, instances={"repo": repo}) \ No newline at end of file + return build(JobCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/domain/parents/__init__.py b/standards/mcp/templates/app/domain/parents/__init__.py new file mode 100644 index 0000000..e64ea5c --- /dev/null +++ b/standards/mcp/templates/app/domain/parents/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Parents domain slice.""" diff --git a/standards/mcp/templates/app/domain/parents/models.py b/standards/mcp/templates/app/domain/parents/models.py new file mode 100644 index 0000000..ddefa27 --- /dev/null +++ b/standards/mcp/templates/app/domain/parents/models.py @@ -0,0 +1,39 @@ +"""# Summary + +Parents domain — pet owners referenced by :class:`domain.pets.models.Pet`. +""" +from pydantic import BaseModel + + +class Metadata(BaseModel): + """Optional presentation metadata carried alongside a resource. + + Attributes: + createdOn (str | None): ISO creation timestamp. Defaults to None. + updatedOn (str | None): ISO update timestamp. Defaults to None. + """ + + createdOn: str | None = None + updatedOn: str | None = None + + model_config = {"extra": "ignore"} + + +class Parent(BaseModel): + """A pet owner. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + name (str): Parent's full name. + email (str): Email address. + phone (str | None): Contact phone number. Defaults to None. + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + name: str + email: str + phone: str | None = None + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/parents/ports/__init__.py b/standards/mcp/templates/app/domain/parents/ports/__init__.py new file mode 100644 index 0000000..bc14698 --- /dev/null +++ b/standards/mcp/templates/app/domain/parents/ports/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Parents domain ports.""" diff --git a/standards/mcp/templates/app/domain/parents/ports/db.py b/standards/mcp/templates/app/domain/parents/ports/db.py new file mode 100644 index 0000000..af4f783 --- /dev/null +++ b/standards/mcp/templates/app/domain/parents/ports/db.py @@ -0,0 +1,27 @@ +"""# Summary + +Parents persistence ports — integer-id document and repository factory. +""" +from mongo_ops import BaseDocument +from pydantic import Field + +from config import settings +from domain.parents.models import Parent +from infra.db.repos import create_repo + + +class ParentDocument(Parent, BaseDocument): + """Persisted parent document. The integer ``id`` shadows the ObjectId.""" + + id: int | None = Field(default=None, validation_alias="_id") + + +def create_parent_repo(): + """Create the parent repository selected by ``settings.repo_backend``. + + Returns: + IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the + ``parents`` collection/store. + """ + _ = settings # backend chosen centrally by create_repo + return create_repo("parents", ParentDocument) diff --git a/standards/mcp/templates/app/domain/parents/ports/service.py b/standards/mcp/templates/app/domain/parents/ports/service.py new file mode 100644 index 0000000..00e033d --- /dev/null +++ b/standards/mcp/templates/app/domain/parents/ports/service.py @@ -0,0 +1,33 @@ +"""# Summary + +CRUD service wiring for the parent aggregate via the hexa container. +""" +from hexa import build + +from domain.parents.models import Parent +from domain.parents.ports.db import ParentDocument +from infra.adapters.service import CrudsServiceAdaptor + + +class ParentCrudsService(CrudsServiceAdaptor[Parent, ParentDocument]): + """Parent CRUD service resolving :class:`ParentDocument` from a repo. + + Notes: + Instances are created by :func:`get_parent_service`. The ``model`` and + ``schema`` class attributes bind the domain model and its document type. + """ + + model = Parent + schema = ParentDocument + + +def get_parent_service(repo) -> ParentCrudsService: + """Build a parent CRUD service wired to ``repo``. + + Args: + repo (Any): Integer-id parent repository from :func:`create_parent_repo`. + + Returns: + ParentCrudsService: Ready-to-use parent CRUD service. + """ + return build(ParentCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/domain/pets/__init__.py b/standards/mcp/templates/app/domain/pets/__init__.py new file mode 100644 index 0000000..1fedcef --- /dev/null +++ b/standards/mcp/templates/app/domain/pets/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Pets domain slice.""" diff --git a/standards/mcp/templates/app/domain/pets/models.py b/standards/mcp/templates/app/domain/pets/models.py new file mode 100644 index 0000000..e28d4e5 --- /dev/null +++ b/standards/mcp/templates/app/domain/pets/models.py @@ -0,0 +1,37 @@ +"""# Summary + +Pets domain — animals owned by parents and treated in appointments. +""" +from typing import Literal + +from pydantic import BaseModel + +from domain.parents.models import Metadata + + +class Pet(BaseModel): + """A pet patient. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + name (str): Name of the pet. + species (str): Type of animal (dog, cat, bird). + age (int | None): Age in years. Defaults to None. + weight (float | None): Weight in kilograms. Defaults to None. + birthDate (str | None): ISO date of birth. Defaults to None. + photo (str | None): Stored photo path after upload. Defaults to None. + parents (list[dict]): Embedded parent objects (or bare ids on input). + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + name: str + species: Literal["dog", "cat", "bird"] + age: int | None = None + weight: float | None = None + birthDate: str | None = None + photo: str | None = None + parents: list[dict] = [] + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/pets/ports/__init__.py b/standards/mcp/templates/app/domain/pets/ports/__init__.py new file mode 100644 index 0000000..5468179 --- /dev/null +++ b/standards/mcp/templates/app/domain/pets/ports/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Pets domain ports.""" diff --git a/standards/mcp/templates/app/domain/pets/ports/db.py b/standards/mcp/templates/app/domain/pets/ports/db.py new file mode 100644 index 0000000..8c92300 --- /dev/null +++ b/standards/mcp/templates/app/domain/pets/ports/db.py @@ -0,0 +1,25 @@ +"""# Summary + +Pets persistence ports — integer-id document and repository factory. +""" +from mongo_ops import BaseDocument +from pydantic import Field + +from domain.pets.models import Pet +from infra.db.repos import create_repo + + +class PetDocument(Pet, BaseDocument): + """Persisted pet document. The integer ``id`` shadows the ObjectId.""" + + id: int | None = Field(default=None, validation_alias="_id") + + +def create_pet_repo(): + """Create the pet repository selected by ``settings.repo_backend``. + + Returns: + IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the + ``pets`` collection/store. + """ + return create_repo("pets", PetDocument) diff --git a/standards/mcp/templates/app/domain/pets/ports/service.py b/standards/mcp/templates/app/domain/pets/ports/service.py new file mode 100644 index 0000000..8ecbb4d --- /dev/null +++ b/standards/mcp/templates/app/domain/pets/ports/service.py @@ -0,0 +1,33 @@ +"""# Summary + +CRUD service wiring for the pet aggregate via the hexa container. +""" +from hexa import build + +from domain.pets.models import Pet +from domain.pets.ports.db import PetDocument +from infra.adapters.service import CrudsServiceAdaptor + + +class PetCrudsService(CrudsServiceAdaptor[Pet, PetDocument]): + """Pet CRUD service resolving :class:`PetDocument` from a repo. + + Notes: + Instances are created by :func:`get_pet_service`. The ``model`` and + ``schema`` class attributes bind the domain model and its document type. + """ + + model = Pet + schema = PetDocument + + +def get_pet_service(repo) -> PetCrudsService: + """Build a pet CRUD service wired to ``repo``. + + Args: + repo (Any): Integer-id pet repository from :func:`create_pet_repo`. + + Returns: + PetCrudsService: Ready-to-use pet CRUD service. + """ + return build(PetCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/domain/treatments/__init__.py b/standards/mcp/templates/app/domain/treatments/__init__.py new file mode 100644 index 0000000..2f7c07a --- /dev/null +++ b/standards/mcp/templates/app/domain/treatments/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Treatments domain slice.""" diff --git a/standards/mcp/templates/app/domain/treatments/models.py b/standards/mcp/templates/app/domain/treatments/models.py new file mode 100644 index 0000000..bdfdc9a --- /dev/null +++ b/standards/mcp/templates/app/domain/treatments/models.py @@ -0,0 +1,25 @@ +"""# Summary + +Treatments domain — the catalogue of treatments offered by the clinic. +""" +from pydantic import BaseModel + +from domain.parents.models import Metadata + + +class Treatment(BaseModel): + """A catalogue treatment. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + label (str): Name of the treatment. + description (str | None): Detailed description. Defaults to None. + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + label: str + description: str | None = None + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/treatments/ports/__init__.py b/standards/mcp/templates/app/domain/treatments/ports/__init__.py new file mode 100644 index 0000000..780df3e --- /dev/null +++ b/standards/mcp/templates/app/domain/treatments/ports/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Treatments domain ports.""" diff --git a/standards/mcp/templates/app/domain/treatments/ports/db.py b/standards/mcp/templates/app/domain/treatments/ports/db.py new file mode 100644 index 0000000..908061c --- /dev/null +++ b/standards/mcp/templates/app/domain/treatments/ports/db.py @@ -0,0 +1,25 @@ +"""# Summary + +Treatments persistence ports — integer-id document and repository factory. +""" +from mongo_ops import BaseDocument +from pydantic import Field + +from domain.treatments.models import Treatment +from infra.db.repos import create_repo + + +class TreatmentDocument(Treatment, BaseDocument): + """Persisted treatment document. The integer ``id`` shadows the ObjectId.""" + + id: int | None = Field(default=None, validation_alias="_id") + + +def create_treatment_repo(): + """Create the treatment repository selected by ``settings.repo_backend``. + + Returns: + IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the + ``treatments`` collection/store. + """ + return create_repo("treatments", TreatmentDocument) diff --git a/standards/mcp/templates/app/domain/treatments/ports/service.py b/standards/mcp/templates/app/domain/treatments/ports/service.py new file mode 100644 index 0000000..aceb34d --- /dev/null +++ b/standards/mcp/templates/app/domain/treatments/ports/service.py @@ -0,0 +1,34 @@ +"""# Summary + +CRUD service wiring for the treatment aggregate via the hexa container. +""" +from hexa import build + +from domain.treatments.models import Treatment +from domain.treatments.ports.db import TreatmentDocument +from infra.adapters.service import CrudsServiceAdaptor + + +class TreatmentCrudsService(CrudsServiceAdaptor[Treatment, TreatmentDocument]): + """Treatment CRUD service resolving :class:`TreatmentDocument` from a repo. + + Notes: + Instances are created by :func:`get_treatment_service`. The ``model`` + and ``schema`` class attributes bind the domain model and its document. + """ + + model = Treatment + schema = TreatmentDocument + + +def get_treatment_service(repo) -> TreatmentCrudsService: + """Build a treatment CRUD service wired to ``repo``. + + Args: + repo (Any): Integer-id treatment repository from + :func:`create_treatment_repo`. + + Returns: + TreatmentCrudsService: Ready-to-use treatment CRUD service. + """ + return build(TreatmentCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/domain/vets/__init__.py b/standards/mcp/templates/app/domain/vets/__init__.py new file mode 100644 index 0000000..378d6ec --- /dev/null +++ b/standards/mcp/templates/app/domain/vets/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Vets domain slice.""" diff --git a/standards/mcp/templates/app/domain/vets/models.py b/standards/mcp/templates/app/domain/vets/models.py new file mode 100644 index 0000000..a2b0847 --- /dev/null +++ b/standards/mcp/templates/app/domain/vets/models.py @@ -0,0 +1,29 @@ +"""# Summary + +Vets domain — veterinarians referenced by appointments. +""" +from pydantic import BaseModel + +from domain.parents.models import Metadata + + +class Vet(BaseModel): + """A veterinarian. + + Attributes: + id (int | None): Server-assigned identifier. Defaults to None. + name (str): Veterinarian's full name. + specialty (str | None): Area of specialization. Defaults to None. + email (str | None): Email address. Defaults to None. + phone (str | None): Contact phone number. Defaults to None. + metadata (Metadata | None): Presentation metadata. Defaults to None. + """ + + id: int | None = None + name: str + specialty: str | None = None + email: str | None = None + phone: str | None = None + metadata: Metadata | None = None + + model_config = {"extra": "ignore"} diff --git a/standards/mcp/templates/app/domain/vets/ports/__init__.py b/standards/mcp/templates/app/domain/vets/ports/__init__.py new file mode 100644 index 0000000..45ee029 --- /dev/null +++ b/standards/mcp/templates/app/domain/vets/ports/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Vets domain ports.""" diff --git a/standards/mcp/templates/app/domain/vets/ports/db.py b/standards/mcp/templates/app/domain/vets/ports/db.py new file mode 100644 index 0000000..006b13f --- /dev/null +++ b/standards/mcp/templates/app/domain/vets/ports/db.py @@ -0,0 +1,25 @@ +"""# Summary + +Vets persistence ports — integer-id document and repository factory. +""" +from mongo_ops import BaseDocument +from pydantic import Field + +from domain.vets.models import Vet +from infra.db.repos import create_repo + + +class VetDocument(Vet, BaseDocument): + """Persisted vet document. The integer ``id`` shadows the ObjectId.""" + + id: int | None = Field(default=None, validation_alias="_id") + + +def create_vet_repo(): + """Create the vet repository selected by ``settings.repo_backend``. + + Returns: + IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the + ``vets`` collection/store. + """ + return create_repo("vets", VetDocument) diff --git a/standards/mcp/templates/app/domain/vets/ports/service.py b/standards/mcp/templates/app/domain/vets/ports/service.py new file mode 100644 index 0000000..16762b9 --- /dev/null +++ b/standards/mcp/templates/app/domain/vets/ports/service.py @@ -0,0 +1,33 @@ +"""# Summary + +CRUD service wiring for the vet aggregate via the hexa container. +""" +from hexa import build + +from domain.vets.models import Vet +from domain.vets.ports.db import VetDocument +from infra.adapters.service import CrudsServiceAdaptor + + +class VetCrudsService(CrudsServiceAdaptor[Vet, VetDocument]): + """Vet CRUD service resolving :class:`VetDocument` from a repo. + + Notes: + Instances are created by :func:`get_vet_service`. The ``model`` and + ``schema`` class attributes bind the domain model and its document type. + """ + + model = Vet + schema = VetDocument + + +def get_vet_service(repo) -> VetCrudsService: + """Build a vet CRUD service wired to ``repo``. + + Args: + repo (Any): Integer-id vet repository from :func:`create_vet_repo`. + + Returns: + VetCrudsService: Ready-to-use vet CRUD service. + """ + return build(VetCrudsService, instances={"repo": repo}) diff --git a/standards/mcp/templates/app/infra/adapters/actions_stream.py b/standards/mcp/templates/app/infra/adapters/actions_stream.py new file mode 100644 index 0000000..49e2485 --- /dev/null +++ b/standards/mcp/templates/app/infra/adapters/actions_stream.py @@ -0,0 +1,88 @@ +"""# Summary + +Species-scoped action stream — the SSE event source behind ``stream_actions``. + +--- + +Every pet gets a background asyncio task that emits random, species-appropriate +behaviors (a wagging dog, a kneading cat, a chirping bird) onto per-subscriber +queues. The HTTP handler subscribes one queue per connected client and +unsubscribes when the response closes. + +Notes: + Process-local and best-effort — the stream is generated, not persisted, + and is intentionally independent of the persistence backend. +""" +import asyncio +import json +import random + +_BEHAVIORS_BY_SPECIES: dict[str, list[str]] = { + "dog": ["wagging_tail", "stretching", "showing_belly", "barking", "panting"], + "cat": ["blep", "stretching", "showing_belly", "purring", "kneading"], + "bird": ["head_tilt", "stretching", "chirping", "wing_flap"], +} + +_subscribers: dict[int, list[asyncio.Queue]] = {} +_worker_tasks: dict[int, asyncio.Task] = {} + + +async def _behavior_worker(pet_id: int, species: str) -> None: + """Emit random behaviors for one pet until cancelled. + + Args: + pet_id (int): Pet whose subscribers receive the events. + species (str): Animal species selecting the behavior pool. + """ + behaviors = _BEHAVIORS_BY_SPECIES.get(species, ["wagging_tail"]) + while True: + action = random.choice(behaviors) + data = json.dumps({"action": action}) + for q in _subscribers.get(pet_id, []): + await q.put(data) + await asyncio.sleep(random.uniform(1, 5)) + + +def _ensure_worker(pet_id: int, species: str) -> None: + """Start the behavior worker for ``pet_id`` if it is not running. + + Args: + pet_id (int): Pet identifier. + species (str): Animal species selecting the behavior pool. + """ + if pet_id not in _worker_tasks or _worker_tasks[pet_id].done(): + _subscribers.setdefault(pet_id, []) + _worker_tasks[pet_id] = asyncio.create_task(_behavior_worker(pet_id, species)) + + +async def subscribe(pet_id: int, species: str) -> asyncio.Queue: + """Subscribe a new listener queue for a pet's action stream. + + Args: + pet_id (int): Pet identifier. + species (str): Animal species selecting the behavior pool. + + Returns: + asyncio.Queue: Queue that receives JSON-encoded action payloads. + """ + q: asyncio.Queue = asyncio.Queue() + _subscribers.setdefault(pet_id, []).append(q) + _ensure_worker(pet_id, species) + return q + + +def unsubscribe(pet_id: int, q: asyncio.Queue) -> None: + """Remove a listener queue and stop the worker when the last one leaves. + + Args: + pet_id (int): Pet identifier. + q (asyncio.Queue): Queue returned by :func:`subscribe`. + """ + queues = _subscribers.get(pet_id, []) + if q in queues: + queues.remove(q) + if not queues: + task = _worker_tasks.pop(pet_id, None) + if task and not task.done(): + task.cancel() + _subscribers.pop(pet_id, None) diff --git a/standards/mcp/templates/app/init.py b/standards/mcp/templates/app/init.py index 83f3c74..34c5cb6 100644 --- a/standards/mcp/templates/app/init.py +++ b/standards/mcp/templates/app/init.py @@ -2,8 +2,8 @@ Composition root — connects MongoDB (when selected), wires all repos and services, and returns the resources dict. Shared bootstrap for the FastAPI -app, the worker and the seed/drop scripts, guarded against repeated -initialization via a module-level flag. +app and the seed/drop scripts, guarded against repeated initialization via a +module-level flag. --- @@ -14,10 +14,18 @@ external services. from typing import Any from config import settings -from domain.item.ports.db import create_item_repo -from domain.item.ports.service import get_item_service -from domain.job.ports.db import create_job_repo -from domain.job.ports.service import get_job_service +from domain.appointments.ports.db import create_appointment_repo +from domain.appointments.ports.service import get_appointment_service +from domain.jobs.ports.db import create_job_repo +from domain.jobs.ports.service import get_job_service +from domain.parents.ports.db import create_parent_repo +from domain.parents.ports.service import get_parent_service +from domain.pets.ports.db import create_pet_repo +from domain.pets.ports.service import get_pet_service +from domain.treatments.ports.db import create_treatment_repo +from domain.treatments.ports.service import get_treatment_service +from domain.vets.ports.db import create_vet_repo +from domain.vets.ports.service import get_vet_service from infra.db.setup import setup_database, shutdown_database _resources: dict[str, Any] = {} @@ -41,12 +49,24 @@ async def init() -> dict[str, Any]: if settings.repo_backend == "mongo": await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db) - item_repo = create_item_repo() + parent_repo = create_parent_repo() + vet_repo = create_vet_repo() + treatment_repo = create_treatment_repo() + pet_repo = create_pet_repo() + appointment_repo = create_appointment_repo() job_repo = create_job_repo() - _resources["item_repo"] = item_repo + _resources["parent_repo"] = parent_repo + _resources["vet_repo"] = vet_repo + _resources["treatment_repo"] = treatment_repo + _resources["pet_repo"] = pet_repo + _resources["appointment_repo"] = appointment_repo _resources["job_repo"] = job_repo - _resources["item_svc"] = get_item_service(item_repo) + _resources["parent_svc"] = get_parent_service(parent_repo) + _resources["vet_svc"] = get_vet_service(vet_repo) + _resources["treatment_svc"] = get_treatment_service(treatment_repo) + _resources["pet_svc"] = get_pet_service(pet_repo) + _resources["appointment_svc"] = get_appointment_service(appointment_repo) _resources["job_svc"] = get_job_service(job_repo) _initialized = True diff --git a/standards/mcp/templates/app/interfaces/appointments/__init__.py b/standards/mcp/templates/app/interfaces/appointments/__init__.py new file mode 100644 index 0000000..1bbd594 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/appointments/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Appointments interfaces.""" diff --git a/standards/mcp/templates/app/interfaces/appointments/routes.py b/standards/mcp/templates/app/interfaces/appointments/routes.py new file mode 100644 index 0000000..304c434 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/appointments/routes.py @@ -0,0 +1,135 @@ +"""# Summary + +Appointments route handlers — names must match openapi.yaml operationIds. + +Notes: + ``pet``, ``vet`` and ``treatment`` are foreign keys expanded to full + objects on read and normalized from ids on write. The list endpoint + supports date-prefix and pet/vet id filters. +""" +from fastapi import Request, Response + +from interfaces._common import ( + delete_or_404, + dump, + expand_ref, + paginate, + read_or_404, + svc, +) + + +async def list_appointments( + request: Request, + limit: int = 20, + offset: int = 0, + date: str | None = None, + vet: int | None = None, + pet: int | None = None, +) -> dict: + """List appointments (paginated, filterable by date/vet/pet). + + Args: + request (Request): Incoming request. + limit (int): Page size. Defaults to 20. + offset (int): Items to skip. Defaults to 0. + date (str | None): ISO date prefix to match (e.g. ``2025-06-01``). + vet (int | None): Filter by embedded vet id. + pet (int | None): Filter by embedded pet id. + + Returns: + dict: ``{"total": int, "items": list[Appointment]}``. + """ + docs = await svc(request, "appointment").search({}) + items = [] + for d in docs: + row = dump(d) + if date and not str(row.get("date", "")).startswith(date): + continue + if vet is not None and (row.get("vet") or {}).get("id") != vet: + continue + if pet is not None and (row.get("pet") or {}).get("id") != pet: + continue + items.append(row) + return paginate(items, limit, offset) + + +async def create_appointment(request: Request, body: dict, response: Response) -> dict: + """Create an appointment; FK fields may be bare ids or expanded objects. + + Args: + request (Request): Incoming request. + body (dict): Appointment fields (``id`` ignored; FKs normalized). + response (Response): Response object; set to 201 on success. + + Returns: + dict: The created appointment with FKs expanded. + + Raises: + HTTPException: 422 when an FK reference cannot be resolved. + """ + response.status_code = 201 + body.pop("id", None) + body["pet"] = await expand_ref(svc(request, "pet"), body.get("pet"), "pet") + body["vet"] = await expand_ref(svc(request, "vet"), body.get("vet"), "vet") + body["treatment"] = await expand_ref( + svc(request, "treatment"), body.get("treatment"), "treatment" + ) + doc = await svc(request, "appointment").create(body) + return dump(doc) + + +async def get_appointment(request: Request, id: int) -> dict: + """Fetch one appointment by id. + + Args: + request (Request): Incoming request. + id (int): Appointment identifier. + + Returns: + dict: The appointment document. + + Raises: + HTTPException: 404 when the appointment does not exist. + """ + return dump(await read_or_404(svc(request, "appointment"), id, "appointment")) + + +async def update_appointment(request: Request, id: int, body: dict) -> dict: + """Replace an appointment by id; FK fields may be ids or objects. + + Args: + request (Request): Incoming request. + id (int): Appointment identifier. + body (dict): Full replacement payload. + + Returns: + dict: The updated appointment with FKs expanded. + + Raises: + HTTPException: 404 when the appointment does not exist; 422 on bad FKs. + """ + await read_or_404(svc(request, "appointment"), id, "appointment") + body.pop("id", None) + body["pet"] = await expand_ref(svc(request, "pet"), body.get("pet"), "pet") + body["vet"] = await expand_ref(svc(request, "vet"), body.get("vet"), "vet") + body["treatment"] = await expand_ref( + svc(request, "treatment"), body.get("treatment"), "treatment" + ) + doc = await svc(request, "appointment").update(id, body) + return dump(doc) + + +async def delete_appointment(request: Request, id: int, response: Response) -> None: + """Delete an appointment by id. + + Args: + request (Request): Incoming request. + id (int): Appointment identifier. + response (Response): Response object; set to 204 on success. + + Raises: + HTTPException: 404 when the appointment does not exist. + """ + await delete_or_404(svc(request, "appointment"), id, "appointment") + response.status_code = 204 diff --git a/standards/mcp/templates/app/interfaces/item/__init__.py b/standards/mcp/templates/app/interfaces/item/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standards/mcp/templates/app/interfaces/item/routes.py b/standards/mcp/templates/app/interfaces/item/routes.py deleted file mode 100644 index ae067aa..0000000 --- a/standards/mcp/templates/app/interfaces/item/routes.py +++ /dev/null @@ -1,90 +0,0 @@ -"""# Summary - -Item route handlers — names must match openapi.yaml operationIds. -""" -from fastapi import Request, Response - -from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc - - -async def list_items(request: Request, limit: int = 20, offset: int = 0) -> dict: - """List items (paginated). ``limit=0`` returns all remaining items. - - Args: - request (Request): Incoming request. - limit (int): Page size. Defaults to 20. - offset (int): Items to skip. Defaults to 0. - - Returns: - dict: ``{"total": int, "items": list[Item]}``. - """ - docs = await svc(request, "item").search({}) - return paginate([dump(d) for d in docs], limit, offset) - - -async def create_item(request: Request, body: dict, response: Response) -> dict: - """Create an item. - - Args: - request (Request): Incoming request. - body (dict): Item fields (``id`` ignored). - response (Response): Response object; set to 201 on success. - - Returns: - dict: The created item. - """ - response.status_code = 201 - body.pop("id", None) - doc = await svc(request, "item").create(body) - return dump(doc) - - -async def get_item(request: Request, id: int) -> dict: - """Fetch one item by id. - - Args: - request (Request): Incoming request. - id (int): Item identifier. - - Returns: - dict: The item document. - - Raises: - HTTPException: 404 when the item does not exist. - """ - return dump(await read_or_404(svc(request, "item"), id, "item")) - - -async def update_item(request: Request, id: int, body: dict) -> dict: - """Replace an item by id. - - Args: - request (Request): Incoming request. - id (int): Item identifier. - body (dict): Full replacement payload. - - Returns: - dict: The updated item. - - Raises: - HTTPException: 404 when the item does not exist. - """ - await read_or_404(svc(request, "item"), id, "item") - body.pop("id", None) - doc = await svc(request, "item").update(id, body) - return dump(doc) - - -async def delete_item(request: Request, id: int, response: Response) -> None: - """Delete an item by id. - - Args: - request (Request): Incoming request. - id (int): Item identifier. - response (Response): Response object; set to 204 on success. - - Raises: - HTTPException: 404 when the item does not exist. - """ - await delete_or_404(svc(request, "item"), id, "item") - response.status_code = 204 diff --git a/standards/mcp/templates/app/interfaces/job/__init__.py b/standards/mcp/templates/app/interfaces/job/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/standards/mcp/templates/app/domain/job/__init__.py b/standards/mcp/templates/app/interfaces/jobs/__init__.py similarity index 100% rename from standards/mcp/templates/app/domain/job/__init__.py rename to standards/mcp/templates/app/interfaces/jobs/__init__.py diff --git a/standards/mcp/templates/app/interfaces/job/routes.py b/standards/mcp/templates/app/interfaces/jobs/routes.py similarity index 84% rename from standards/mcp/templates/app/interfaces/job/routes.py rename to standards/mcp/templates/app/interfaces/jobs/routes.py index 78aacc8..b052a6c 100644 --- a/standards/mcp/templates/app/interfaces/job/routes.py +++ b/standards/mcp/templates/app/interfaces/jobs/routes.py @@ -1,25 +1,29 @@ """# Summary -Job route handlers — names must match openapi.yaml operationIds. +Jobs route handlers — names must match openapi.yaml operationIds. """ from fastapi import Request, Response from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc -async def list_jobs(request: Request, limit: int = 20, offset: int = 0) -> dict: - """List jobs (paginated). ``limit=0`` returns all remaining items. +async def list_jobs(request: Request, limit: int = 20, offset: int = 0, status: str | None = None) -> dict: + """List jobs (paginated, optionally filtered by status). Args: request (Request): Incoming request. limit (int): Page size. Defaults to 20. offset (int): Items to skip. Defaults to 0. + status (str | None): Filter by status (pending/processing/done/failed). Returns: dict: ``{"total": int, "items": list[Job]}``. """ docs = await svc(request, "job").search({}) - return paginate([dump(d) for d in docs], limit, offset) + items = [dump(d) for d in docs] + if status: + items = [row for row in items if row.get("status") == status] + return paginate(items, limit, offset) async def create_job(request: Request, body: dict, response: Response) -> dict: diff --git a/standards/mcp/templates/app/interfaces/openapi.py b/standards/mcp/templates/app/interfaces/openapi.py index 0c2f6a7..1cc5ed9 100644 --- a/standards/mcp/templates/app/interfaces/openapi.py +++ b/standards/mcp/templates/app/interfaces/openapi.py @@ -5,6 +5,10 @@ OpenAPI-first router resolves each operation to a handler by name, so this modul re-exports every route handler from the subpackages into one flat namespace. """ # ruff: noqa: F403 -from interfaces.item.routes import * -from interfaces.job.routes import * +from interfaces.appointments.routes import * +from interfaces.jobs.routes import * +from interfaces.parents.routes import * +from interfaces.pets.routes import * from interfaces.system.routes import * +from interfaces.treatments.routes import * +from interfaces.vets.routes import * diff --git a/standards/mcp/templates/app/interfaces/parents/__init__.py b/standards/mcp/templates/app/interfaces/parents/__init__.py new file mode 100644 index 0000000..581bd1a --- /dev/null +++ b/standards/mcp/templates/app/interfaces/parents/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Parents interfaces.""" diff --git a/standards/mcp/templates/app/interfaces/parents/routes.py b/standards/mcp/templates/app/interfaces/parents/routes.py new file mode 100644 index 0000000..5841efa --- /dev/null +++ b/standards/mcp/templates/app/interfaces/parents/routes.py @@ -0,0 +1,90 @@ +"""# Summary + +Parents route handlers — names must match openapi.yaml operationIds. +""" +from fastapi import Request, Response + +from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc + + +async def list_parents(request: Request, limit: int = 20, offset: int = 0) -> dict: + """List parents (paginated). ``limit=0`` returns all remaining items. + + Args: + request (Request): Incoming request. + limit (int): Page size. Defaults to 20. + offset (int): Items to skip. Defaults to 0. + + Returns: + dict: ``{"total": int, "items": list[Parent]}``. + """ + docs = await svc(request, "parent").search({}) + return paginate([dump(d) for d in docs], limit, offset) + + +async def create_parent(request: Request, body: dict, response: Response) -> dict: + """Create a parent. + + Args: + request (Request): Incoming request. + body (dict): Parent fields (``id`` ignored). + response (Response): Response object; set to 201 on success. + + Returns: + dict: The created parent. + """ + response.status_code = 201 + body.pop("id", None) + doc = await svc(request, "parent").create(body) + return dump(doc) + + +async def get_parent(request: Request, id: int) -> dict: + """Fetch one parent by id. + + Args: + request (Request): Incoming request. + id (int): Parent identifier. + + Returns: + dict: The parent document. + + Raises: + HTTPException: 404 when the parent does not exist. + """ + return dump(await read_or_404(svc(request, "parent"), id, "parent")) + + +async def update_parent(request: Request, id: int, body: dict) -> dict: + """Replace a parent by id. + + Args: + request (Request): Incoming request. + id (int): Parent identifier. + body (dict): Full replacement payload. + + Returns: + dict: The updated parent. + + Raises: + HTTPException: 404 when the parent does not exist. + """ + await read_or_404(svc(request, "parent"), id, "parent") + body.pop("id", None) + doc = await svc(request, "parent").update(id, body) + return dump(doc) + + +async def delete_parent(request: Request, id: int, response: Response) -> None: + """Delete a parent by id. + + Args: + request (Request): Incoming request. + id (int): Parent identifier. + response (Response): Response object; set to 204 on success. + + Raises: + HTTPException: 404 when the parent does not exist. + """ + await delete_or_404(svc(request, "parent"), id, "parent") + response.status_code = 204 diff --git a/standards/mcp/templates/app/interfaces/pets/__init__.py b/standards/mcp/templates/app/interfaces/pets/__init__.py new file mode 100644 index 0000000..09f7bb0 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/pets/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Pets interfaces.""" diff --git a/standards/mcp/templates/app/interfaces/pets/routes.py b/standards/mcp/templates/app/interfaces/pets/routes.py new file mode 100644 index 0000000..fec51e0 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/pets/routes.py @@ -0,0 +1,172 @@ +"""# Summary + +Pets route handlers — names must match openapi.yaml operationIds. + +Notes: + ``parents`` is a list foreign key expanded to full objects on read and + normalized from ids on write. ``upload_pet_photo`` stores the raw bytes + under ``settings.uploads_dir``; ``stream_actions`` returns an SSE stream + scoped to the pet's species. +""" +import os +from collections.abc import AsyncIterator + +from fastapi import Request, Response, UploadFile +from fastapi.responses import StreamingResponse + +from config import settings +from infra.adapters import actions_stream +from interfaces._common import ( + delete_or_404, + dump, + expand_ref_list, + paginate, + read_or_404, + svc, +) + + +async def list_pets(request: Request, limit: int = 20, offset: int = 0) -> dict: + """List pets (paginated). ``limit=0`` returns all remaining items. + + Args: + request (Request): Incoming request. + limit (int): Page size. Defaults to 20. + offset (int): Items to skip. Defaults to 0. + + Returns: + dict: ``{"total": int, "items": list[Pet]}``. + """ + docs = await svc(request, "pet").search({}) + return paginate([dump(d) for d in docs], limit, offset) + + +async def create_pet(request: Request, body: dict, response: Response) -> dict: + """Create a pet; parents may be bare ids or expanded objects. + + Args: + request (Request): Incoming request. + body (dict): Pet fields (``id`` ignored; ``parents`` normalized). + response (Response): Response object; set to 201 on success. + + Returns: + dict: The created pet with parents expanded. + + Raises: + HTTPException: 422 when a parent reference cannot be resolved. + """ + response.status_code = 201 + body.pop("id", None) + body["parents"] = await expand_ref_list( + svc(request, "parent"), body.get("parents"), "parent" + ) + doc = await svc(request, "pet").create(body) + return dump(doc) + + +async def get_pet(request: Request, id: int) -> dict: + """Fetch one pet by id. + + Args: + request (Request): Incoming request. + id (int): Pet identifier. + + Returns: + dict: The pet document. + + Raises: + HTTPException: 404 when the pet does not exist. + """ + return dump(await read_or_404(svc(request, "pet"), id, "pet")) + + +async def update_pet(request: Request, id: int, body: dict) -> dict: + """Replace a pet by id; parents may be bare ids or expanded objects. + + Args: + request (Request): Incoming request. + id (int): Pet identifier. + body (dict): Full replacement payload. + + Returns: + dict: The updated pet with parents expanded. + + Raises: + HTTPException: 404 when the pet does not exist; 422 on a bad parent ref. + """ + await read_or_404(svc(request, "pet"), id, "pet") + body.pop("id", None) + body["parents"] = await expand_ref_list( + svc(request, "parent"), body.get("parents"), "parent" + ) + doc = await svc(request, "pet").update(id, body) + return dump(doc) + + +async def delete_pet(request: Request, id: int, response: Response) -> None: + """Delete a pet by id. + + Args: + request (Request): Incoming request. + id (int): Pet identifier. + response (Response): Response object; set to 204 on success. + + Raises: + HTTPException: 404 when the pet does not exist. + """ + await delete_or_404(svc(request, "pet"), id, "pet") + response.status_code = 204 + + +async def upload_pet_photo(request: Request, id: int, file: UploadFile) -> dict: + """Store an uploaded photo for a pet and remember its path. + + Args: + request (Request): Incoming request. + id (int): Pet identifier. + file (UploadFile): Multipart upload carrying the photo bytes. + + Returns: + dict: ``{"id": int, "status": "photo_uploaded", "path": str}``. + + Raises: + HTTPException: 404 when the pet does not exist. + """ + pet = await read_or_404(svc(request, "pet"), id, "pet") + os.makedirs(settings.uploads_dir, exist_ok=True) + safe_name = os.path.basename(file.filename or "photo") + path = os.path.join(settings.uploads_dir, f"{id}_{safe_name}") + content = await file.read() + with open(path, "wb") as fh: + fh.write(content) + await svc(request, "pet").update(id, {"photo": path, "parents": pet.parents}) + return {"id": id, "status": "photo_uploaded", "path": path} + + +async def stream_actions(request: Request, id: int) -> StreamingResponse: + """Stream random, species-appropriate pet actions over SSE. + + Args: + request (Request): Incoming request. + id (int): Pet identifier whose species scopes the stream. + + Returns: + StreamingResponse: ``text/event-stream`` emitting ``data: {action}``. + + Raises: + HTTPException: 404 when the pet does not exist. + """ + pet = await read_or_404(svc(request, "pet"), id, "pet") + + async def event_gen() -> AsyncIterator[str]: + queue = await actions_stream.subscribe(id, pet.species) + try: + while True: + if await request.is_disconnected(): + break + data = await queue.get() + yield f"data: {data}\n\n" + finally: + actions_stream.unsubscribe(id, queue) + + return StreamingResponse(event_gen(), media_type="text/event-stream") diff --git a/standards/mcp/templates/app/interfaces/system/routes.py b/standards/mcp/templates/app/interfaces/system/routes.py index 1f64931..cf174fc 100644 --- a/standards/mcp/templates/app/interfaces/system/routes.py +++ b/standards/mcp/templates/app/interfaces/system/routes.py @@ -8,6 +8,6 @@ async def health() -> dict: """Liveness probe — public, unauthenticated. Returns: - dict: ``{"status": "ok", "service": "app-template"}``. + dict: ``{"status": "ok", "service": "vet-server"}``. """ - return {"status": "ok", "service": "app-template"} + return {"status": "ok", "service": "vet-server"} diff --git a/standards/mcp/templates/app/interfaces/treatments/__init__.py b/standards/mcp/templates/app/interfaces/treatments/__init__.py new file mode 100644 index 0000000..a1c6269 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/treatments/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Treatments interfaces.""" diff --git a/standards/mcp/templates/app/interfaces/treatments/routes.py b/standards/mcp/templates/app/interfaces/treatments/routes.py new file mode 100644 index 0000000..7868e58 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/treatments/routes.py @@ -0,0 +1,92 @@ +"""# Summary + +Treatments route handlers — names must match openapi.yaml operationIds. + +Notes: + ``list_treatments`` returns a plain array (the catalogue is small and + unpaginated by contract). +""" +from fastapi import Request, Response + +from interfaces._common import delete_or_404, dump, read_or_404, svc + + +async def list_treatments(request: Request) -> list[dict]: + """List treatments (catalogue, unpaginated). + + Args: + request (Request): Incoming request. + + Returns: + list[dict]: All treatment documents. + """ + docs = await svc(request, "treatment").search({}) + return [dump(d) for d in docs] + + +async def create_treatment(request: Request, body: dict, response: Response) -> dict: + """Add a treatment (admin only). + + Args: + request (Request): Incoming request. + body (dict): Treatment fields (``id`` ignored). + response (Response): Response object; set to 201 on success. + + Returns: + dict: The created treatment. + """ + response.status_code = 201 + body.pop("id", None) + doc = await svc(request, "treatment").create(body) + return dump(doc) + + +async def get_treatment(request: Request, id: int) -> dict: + """Fetch one treatment by id. + + Args: + request (Request): Incoming request. + id (int): Treatment identifier. + + Returns: + dict: The treatment document. + + Raises: + HTTPException: 404 when the treatment does not exist. + """ + return dump(await read_or_404(svc(request, "treatment"), id, "treatment")) + + +async def update_treatment(request: Request, id: int, body: dict) -> dict: + """Replace a treatment by id. + + Args: + request (Request): Incoming request. + id (int): Treatment identifier. + body (dict): Full replacement payload. + + Returns: + dict: The updated treatment. + + Raises: + HTTPException: 404 when the treatment does not exist. + """ + await read_or_404(svc(request, "treatment"), id, "treatment") + body.pop("id", None) + doc = await svc(request, "treatment").update(id, body) + return dump(doc) + + +async def delete_treatment(request: Request, id: int, response: Response) -> None: + """Delete a treatment by id. + + Args: + request (Request): Incoming request. + id (int): Treatment identifier. + response (Response): Response object; set to 204 on success. + + Raises: + HTTPException: 404 when the treatment does not exist. + """ + await delete_or_404(svc(request, "treatment"), id, "treatment") + response.status_code = 204 diff --git a/standards/mcp/templates/app/interfaces/vets/__init__.py b/standards/mcp/templates/app/interfaces/vets/__init__.py new file mode 100644 index 0000000..45feca4 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/vets/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +Vets interfaces.""" diff --git a/standards/mcp/templates/app/interfaces/vets/routes.py b/standards/mcp/templates/app/interfaces/vets/routes.py new file mode 100644 index 0000000..a79d986 --- /dev/null +++ b/standards/mcp/templates/app/interfaces/vets/routes.py @@ -0,0 +1,90 @@ +"""# Summary + +Vets route handlers — names must match openapi.yaml operationIds. +""" +from fastapi import Request, Response + +from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc + + +async def list_vets(request: Request, limit: int = 20, offset: int = 0) -> dict: + """List vets (paginated). ``limit=0`` returns all remaining items. + + Args: + request (Request): Incoming request. + limit (int): Page size. Defaults to 20. + offset (int): Items to skip. Defaults to 0. + + Returns: + dict: ``{"total": int, "items": list[Vet]}``. + """ + docs = await svc(request, "vet").search({}) + return paginate([dump(d) for d in docs], limit, offset) + + +async def create_vet(request: Request, body: dict, response: Response) -> dict: + """Create a vet. + + Args: + request (Request): Incoming request. + body (dict): Vet fields (``id`` ignored). + response (Response): Response object; set to 201 on success. + + Returns: + dict: The created vet. + """ + response.status_code = 201 + body.pop("id", None) + doc = await svc(request, "vet").create(body) + return dump(doc) + + +async def get_vet(request: Request, id: int) -> dict: + """Fetch one vet by id. + + Args: + request (Request): Incoming request. + id (int): Vet identifier. + + Returns: + dict: The vet document. + + Raises: + HTTPException: 404 when the vet does not exist. + """ + return dump(await read_or_404(svc(request, "vet"), id, "vet")) + + +async def update_vet(request: Request, id: int, body: dict) -> dict: + """Replace a vet by id. + + Args: + request (Request): Incoming request. + id (int): Vet identifier. + body (dict): Full replacement payload. + + Returns: + dict: The updated vet. + + Raises: + HTTPException: 404 when the vet does not exist. + """ + await read_or_404(svc(request, "vet"), id, "vet") + body.pop("id", None) + doc = await svc(request, "vet").update(id, body) + return dump(doc) + + +async def delete_vet(request: Request, id: int, response: Response) -> None: + """Delete a vet by id. + + Args: + request (Request): Incoming request. + id (int): Vet identifier. + response (Response): Response object; set to 204 on success. + + Raises: + HTTPException: 404 when the vet does not exist. + """ + await delete_or_404(svc(request, "vet"), id, "vet") + response.status_code = 204 diff --git a/standards/mcp/templates/app/main.py b/standards/mcp/templates/app/main.py index fb95627..9b41147 100644 --- a/standards/mcp/templates/app/main.py +++ b/standards/mcp/templates/app/main.py @@ -52,7 +52,7 @@ async def lifespan(app: OpenAPIFirstApp): app = OpenAPIFirstApp( openapi_path="openapi.yaml", routes_module=openapi_routes, - title="App Template API", + title="Veterinary Clinic API", version="0.1.0", lifespan=lifespan, ) diff --git a/standards/mcp/templates/app/openapi.yaml b/standards/mcp/templates/app/openapi.yaml index 015b716..13704c1 100644 --- a/standards/mcp/templates/app/openapi.yaml +++ b/standards/mcp/templates/app/openapi.yaml @@ -1,18 +1,16 @@ openapi: 3.1.0 info: - title: App Template API + title: Veterinary Clinic API version: 0.1.0 description: | - App template API — OpenAPI-first backend pairing with the client template - and the @aetoskia/react-openapi admin panel renderer. Resources are driven - by OpenAPI x- extension fields; auth is bearer JWT introspected against - the auth-server. Item and Job are example domains — replace them with - your own, keeping the x- conventions documented in the react-openapi MCP - bundle (module react-openapi, object specExtensions). + Veterinary clinic management API — the reference backend pairing with the + react/vet frontend and the react-openapi admin panel renderer. Every + resource is driven by OpenAPI x- extension fields; auth is bearer JWT + introspected against the auth-server. servers: - - url: http://localhost:8000 + - url: http://localhost:8020 security: - bearerAuth: [] @@ -31,12 +29,203 @@ components: x-me-path: "/me" schemas: - Item: + Metadata: + type: object + x-display-format: "created:{createdOn}|updated:{updatedOn}" + properties: + createdOn: + type: string + format: date-time + updatedOn: + type: string + format: date-time + + Procedure: + type: object + x-display-format: "{name} — ${cost}" + properties: + name: + type: string + x-order: 1 + x-label: "Procedure Name" + description: + type: string + x-order: 2 + x-label: "Description" + cost: + type: number + format: float + x-order: 3 + x-label: "Cost" + notes: + $ref: '#/components/schemas/ProcedureNotes' + x-order: 4 + x-label: "Notes" + + ProcedureNoteBase: + type: object + properties: + summary: + type: string + x-order: 1 + x-label: "Summary" + details: + type: string + x-order: 2 + x-label: "Details" + + ProcedureNotes: + x-display-format: "{summary}" + oneOf: + - $ref: '#/components/schemas/BasicNote' + - $ref: '#/components/schemas/HeartRateNote' + - $ref: '#/components/schemas/DentalNote' + - $ref: '#/components/schemas/VaccineNote' + - $ref: '#/components/schemas/PreOpNote' + - $ref: '#/components/schemas/SurgeryNote' + discriminator: + propertyName: noteType + mapping: + basic: BasicNote + heart_rate: HeartRateNote + dental: DentalNote + vaccine: VaccineNote + preop: PreOpNote + surgery: SurgeryNote + + BasicNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [basic] + x-order: 0 + x-label: "Type" + required: [noteType] + + HeartRateNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [heart_rate] + x-order: 0 + x-label: "Type" + bpm: + type: integer + x-order: 3 + x-label: "Heart Rate (bpm)" + required: [noteType, bpm] + + DentalNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [dental] + x-order: 0 + x-label: "Type" + procedureType: + type: string + enum: [scaling, polishing] + x-order: 3 + x-label: "Procedure" + teeth: + type: string + enum: [all, upper_only, lower_only] + x-order: 4 + x-label: "Teeth" + required: [noteType, procedureType] + + VaccineNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [vaccine] + x-order: 0 + x-label: "Type" + medicine: + type: string + enum: [Rabies, DHPP, Tricat, Deworming] + x-order: 3 + x-label: "Medicine" + leg: + type: string + enum: [front_left, front_right, hind_left, hind_right] + x-order: 4 + x-label: "Injection Leg" + required: [noteType, medicine, leg] + + PreOpNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [preop] + x-order: 0 + x-label: "Type" + heartRate: + type: integer + x-order: 3 + x-label: "Heart Rate (bpm)" + temperature: + type: number + format: float + x-order: 4 + x-label: "Temperature (°C)" + required: [noteType, heartRate, temperature] + + SurgeryNote: + allOf: + - $ref: '#/components/schemas/ProcedureNoteBase' + - type: object + properties: + noteType: + type: string + enum: [surgery] + x-order: 0 + x-label: "Type" + surgeryType: + type: string + enum: [neuter, spay] + x-order: 3 + x-label: "Surgery Type" + complications: + type: string + x-order: 4 + x-label: "Complications" + required: [noteType, surgeryType] + + Action: + type: object + x-primary-key: _received_at + x-display-format: "{action}" + x-list-columns: [action] + properties: + action: + type: string + enum: [wagging_tail, blep, stretching, showing_belly, barking, panting, purring, kneading, head_tilt, chirping, wing_flap] + x-label: "Action" + x-order: 1 + x-filterable: false + required: [action] + + Parent: type: object - x-resource: items x-primary-key: id - x-display-format: "{name} × {quantity}" - x-list-columns: [name, quantity] + x-display-format: "{name}" + x-list-columns: [name, email, phone] properties: id: type: integer @@ -48,25 +237,251 @@ components: type: string x-order: 1 x-label: "Name" - x-description: "Item name" + x-description: "Parent's full name" x-filterable: true x-sortable: true x-autocomplete: token - quantity: - type: integer - default: 1 + email: + type: string + format: email x-order: 2 - x-label: "Quantity" - x-description: "How many units" + x-label: "Email" + x-description: "Email address" + x-filterable: true + x-autocomplete: email + phone: + type: string + x-order: 3 + x-label: "Phone" + x-description: "Contact phone number" + x-filterable: true + x-autocomplete: phone + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, name, email] + + Vet: + type: object + x-primary-key: id + x-display-format: "Dr. {name}" + x-list-columns: [name, specialty, email, phone] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Name" + x-description: "Veterinarian's full name" + x-filterable: true x-sortable: true + x-autocomplete: token + specialty: + type: string + x-order: 2 + x-label: "Specialty" + x-description: "Area of specialization" + x-filterable: true + x-autocomplete: token + email: + type: string + format: email + x-order: 3 + x-label: "Email" + x-description: "Email address" + x-filterable: true + x-autocomplete: email + phone: + type: string + x-order: 4 + x-label: "Phone" + x-description: "Contact phone number" + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 5 + x-label: "Metadata" required: [id, name] + Treatment: + type: object + x-primary-key: id + x-display-format: "{label}" + x-list-columns: [label, description] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + label: + type: string + x-order: 1 + x-label: "Treatment" + x-description: "Name of the treatment" + x-filterable: true + x-sortable: true + x-autocomplete: token + description: + type: string + x-order: 2 + x-label: "Description" + x-description: "Detailed description of the treatment" + x-filterable: true + x-autocomplete: text + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, label] + + Pet: + type: object + x-primary-key: id + x-display-format: "{name} – #{id}" + x-list-columns: [name, species, age, weight, birthDate, parents] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Pet Name" + x-description: "Name of the pet" + x-filterable: true + x-sortable: true + x-autocomplete: token + species: + type: string + enum: [dog, cat, bird] + x-order: 2 + x-label: "Species" + x-description: "Type of animal" + x-filterable: true + age: + type: integer + x-order: 3 + x-label: "Age" + x-description: "Age in years" + x-filterable: true + x-sortable: true + weight: + type: number + format: float + x-order: 4 + x-label: "Weight" + x-description: "Weight in kilograms" + birthDate: + type: string + format: date + x-order: 5 + x-label: "Date of Birth" + x-description: "Pet's birth date" + x-filterable: true + photo: + type: string + format: binary + x-ui-type: image + x-upload-url: /pets/{id}/photo + x-order: 6 + x-label: "Photo" + x-description: "Upload a photo of the pet" + parents: + type: array + items: + $ref: '#/components/schemas/Parent' + x-fk: + resource: parents + x-order: 7 + x-label: "Parents" + x-description: "Pet's owners" + x-filterable: true + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 8 + x-label: "Metadata" + required: [id, name, parents] + + Appointment: + type: object + x-primary-key: id + x-display-format: "Appt #{id} – {date}" + x-list-columns: [date, pet, vet, treatment, notes] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + date: + type: string + format: date-time + x-order: 1 + x-label: "Date & Time" + x-description: "Appointment date and time" + x-filterable: true + x-sortable: true + notes: + type: string + x-order: 2 + x-label: "Notes" + x-description: "Any additional notes" + procedures: + type: array + items: + $ref: '#/components/schemas/Procedure' + x-order: 3 + x-label: "Procedures" + x-description: "List of procedures performed during this appointment" + pet: + $ref: '#/components/schemas/Pet' + x-fk: + resource: pets + x-order: 4 + x-label: "Pet" + x-description: "Select a pet" + x-filterable: true + vet: + $ref: '#/components/schemas/Vet' + x-fk: + resource: vets + prefetch: true + x-order: 5 + x-label: "Veterinarian" + x-description: "Select a veterinarian" + x-filterable: true + treatment: + $ref: '#/components/schemas/Treatment' + x-fk: + resource: treatments + prefetch: true + x-order: 6 + x-label: "Treatment" + x-description: "Select a treatment" + x-filterable: true + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 7 + x-label: "Metadata" + required: [id, date, pet, vet, treatment] + Job: type: object - x-resource: jobs x-primary-key: id - x-display-format: "{name} [{status}]" - x-list-columns: [name, status, retry_count] + x-display-format: "Job #{id} – {name}" + x-list-columns: [id, name, kind, status, retry_count, error_message] + x-hidden: false properties: id: type: integer @@ -78,32 +493,50 @@ components: type: string x-order: 1 x-label: "Name" - x-description: "Job name" + x-description: "Human label for the job" + x-sortable: true + kind: + type: string + x-order: 2 + x-label: "Kind" + x-description: "Dispatch key — selects the pipeline family" x-filterable: true - x-autocomplete: token + x-edit-as: select + x-ui-type: enum + enum: [appointment_reminder] status: type: string - enum: [pending, processing, done, failed] - x-order: 2 + x-order: 3 x-label: "Status" x-description: "Lifecycle status" x-filterable: true - retry_count: - type: integer - readOnly: true - x-order: 3 - x-label: "Retries" - error_message: - type: string - readOnly: true - x-order: 4 - x-label: "Last Error" + x-sortable: true + x-edit-as: select + x-ui-type: enum + enum: [pending, processing, done, failed] payload: type: object - x-order: 5 + additionalProperties: true + x-order: 4 x-label: "Payload" - x-description: "Arbitrary job payload" - required: [id, name, status] + x-description: "Job-specific input (e.g. appointment_id)" + retry_count: + type: integer + x-order: 5 + x-label: "Retries" + x-description: "Failed attempts so far" + x-hidden: { form: true } + error_message: + type: string + x-order: 6 + x-label: "Error" + x-description: "Last failure detail" + x-hidden: { form: true, list: true } + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 7 + x-label: "Metadata" + required: [id, name, kind, status] ErrorBody: type: object @@ -184,10 +617,10 @@ paths: service: type: string - /items: + /parents: get: - summary: List items (paginated) - operationId: list_items + summary: List parents (paginated) + operationId: list_parents parameters: - in: query name: limit @@ -197,7 +630,7 @@ paths: schema: {type: integer, default: 0} responses: '200': - description: Paginated list of items + description: Paginated list of parents content: application/json: schema: @@ -208,7 +641,7 @@ paths: items: type: array items: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -218,21 +651,21 @@ paths: '500': $ref: '#/components/responses/InternalServerError' post: - summary: Create an item - operationId: create_item + summary: Create a parent + operationId: create_parent requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' responses: '201': - description: Item created + description: Parent created content: application/json: schema: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -241,9 +674,9 @@ paths: $ref: '#/components/responses/ValidationError' '500': $ref: '#/components/responses/InternalServerError' - /items/{id}: + /parents/{id}: get: - operationId: get_item + operationId: get_parent parameters: - name: id in: path @@ -251,11 +684,11 @@ paths: schema: {type: integer} responses: '200': - description: Single item + description: Single parent content: application/json: schema: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -267,7 +700,7 @@ paths: '500': $ref: '#/components/responses/InternalServerError' put: - operationId: update_item + operationId: update_parent parameters: - name: id in: path @@ -278,14 +711,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' responses: '200': - description: Item updated + description: Parent updated content: application/json: schema: - $ref: '#/components/schemas/Item' + $ref: '#/components/schemas/Parent' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -297,7 +730,7 @@ paths: '500': $ref: '#/components/responses/InternalServerError' delete: - operationId: delete_item + operationId: delete_parent parameters: - name: id in: path @@ -305,7 +738,583 @@ paths: schema: {type: integer} responses: '204': - description: Item deleted + description: Parent deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /vets: + get: + summary: List vets (paginated) + operationId: list_vets + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + responses: + '200': + description: Paginated list of vets + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a vet + operationId: create_vet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + responses: + '201': + description: Vet created + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /vets/{id}: + get: + operationId: get_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single vet + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + responses: + '200': + description: Vet updated + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Vet deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /treatments: + get: + summary: List treatments (catalogue) + operationId: list_treatments + responses: + '200': + description: List of treatments + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Add a treatment (admin only) + operationId: create_treatment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + responses: + '201': + description: Treatment added + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /treatments/{id}: + get: + operationId: get_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single treatment + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + responses: + '200': + description: Treatment updated + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Treatment deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /pets: + get: + summary: List pets (paginated) + operationId: list_pets + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a pet + operationId: create_pet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Pet created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /pets/{id}: + get: + operationId: get_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Pet updated + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Pet deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Upload pet photo + operationId: upload_pet_photo + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Photo uploaded + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /pets/{id}/actions: + get: + summary: Stream animal actions via SSE, scoped to a pet's species + operationId: stream_actions + x-sse: true + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: SSE stream of random animal actions (behaviors) + content: + text/event-stream: + schema: + $ref: '#/components/schemas/Action' + + /appointments: + get: + summary: List appointments (paginated, filterable) + operationId: list_appointments + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + - in: query + name: date + schema: {type: string, format: date} + - in: query + name: vet + schema: {type: integer} + - in: query + name: pet + schema: {type: integer} + responses: + '200': + description: Paginated list of appointments + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create an appointment + operationId: create_appointment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + responses: + '201': + description: Appointment created + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /appointments/{id}: + get: + operationId: get_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single appointment + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + responses: + '200': + description: Appointment updated + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Appointment deleted '401': $ref: '#/components/responses/Unauthorized' '403': @@ -328,6 +1337,11 @@ paths: - in: query name: offset schema: {type: integer, default: 0} + - in: query + name: status + schema: {type: string} + x-label: "Status" + x-filterable: true responses: '200': description: Paginated list of jobs diff --git a/standards/mcp/templates/app/domain/job/ports/__init__.py b/standards/mcp/templates/app/pipelines/appointment_reminder/__init__.py similarity index 100% rename from standards/mcp/templates/app/domain/job/ports/__init__.py rename to standards/mcp/templates/app/pipelines/appointment_reminder/__init__.py diff --git a/standards/mcp/templates/app/pipelines/appointment_reminder/abc.py b/standards/mcp/templates/app/pipelines/appointment_reminder/abc.py new file mode 100644 index 0000000..f38d4bb --- /dev/null +++ b/standards/mcp/templates/app/pipelines/appointment_reminder/abc.py @@ -0,0 +1,50 @@ +"""# Summary + +Appointment-reminder pipeline family — port contracts (stage interfaces + root +interface). The demo pipeline axis of the vet sample: each stage is a small, +testable unit; the DI container wires the tree. +""" +from abc import ABC, abstractmethod +from typing import Any + + +class LoadPort(ABC): + """Load the appointment the job refers to.""" + + @abstractmethod + async def load(self, appointment_id: int) -> dict[str, Any]: + """Return the appointment as a JSON-ready mapping.""" + + +class ComposePort(ABC): + """Compose the reminder message from the appointment context.""" + + @abstractmethod + def compose(self, appointment: dict[str, Any], pet: dict[str, Any] | None, + parent: dict[str, Any] | None) -> dict[str, Any]: + """Return a reminder record ``{to, subject, body}``.""" + + +class DeliverPort(ABC): + """Deliver the composed reminder (log stub today, email/SMS adaptor later).""" + + @abstractmethod + async def deliver(self, reminder: dict[str, Any]) -> dict[str, Any]: + """Deliver the reminder and return it with a delivery marker.""" + + +class AppointmentReminderPipelinePort(ABC): + """Root of the appointment-reminder family. + + Notes: + The slot annotations below are contracts. A concrete subclass re-annotates + them with stage implementations; the DI container instantiates the tree. + """ + + loader: LoadPort + compose: ComposePort + deliver: DeliverPort + + @abstractmethod + async def run(self, appointment_id: int) -> dict[str, Any]: + """Run the full stage chain and return the delivered reminder.""" diff --git a/standards/mcp/templates/app/pipelines/appointment_reminder/impl.py b/standards/mcp/templates/app/pipelines/appointment_reminder/impl.py new file mode 100644 index 0000000..c1a4c2a --- /dev/null +++ b/standards/mcp/templates/app/pipelines/appointment_reminder/impl.py @@ -0,0 +1,112 @@ +"""# Summary + +Appointment-reminder pipeline family — concrete stages, the wired root, and its +registration. The concrete subclass re-annotates each slot with a stage +implementation; the worker wires the tree once per run with the CRUD services +injected by slot name. +""" +import logging +from pathlib import Path +from typing import Any + +from pipelines.appointment_reminder.abc import ( + AppointmentReminderPipelinePort, + ComposePort, + DeliverPort, + LoadPort, +) +from pipelines.shared.loader import build_pipeline +from pipelines.shared.registry import register + +logger = logging.getLogger(__name__) + + +class AppointmentLoader(LoadPort): + """Load an appointment through the appointment CRUD service. + + Notes: + The service instance is injected by the worker (``appointment_svc``). + """ + + appointment_svc: Any + + async def load(self, appointment_id: int) -> dict[str, Any]: + doc = await self.appointment_svc.read(appointment_id) + if doc is None: + raise ValueError(f"appointment {appointment_id} not found") + return doc.model_dump(mode="json", by_alias=True) + + +class ReminderComposer(ComposePort): + """Compose the reminder text from the family's pattern fragments. + + Notes: + ``registry`` is the merged pattern map injected by the shared loader. + """ + + registry: Any + + def compose(self, appointment: dict[str, Any], pet: dict[str, Any] | None, + parent: dict[str, Any] | None) -> dict[str, Any]: + pattern = (self.registry or {}).get("reminder", {}) + pet_name = (pet or {}).get("name", "your pet") + parent_name = (parent or {}).get("name", "there") + to = (parent or {}).get("email", "") + subject = pattern.get("subject", "Appointment reminder") + body = pattern.get( + "body", + "Hi {parent_name}, {pet_name} has an appointment on {date}.", + ).format( + parent_name=parent_name, + pet_name=pet_name, + date=appointment.get("date", ""), + ) + return {"to": to, "subject": subject, "body": body} + + +class LogDeliver(DeliverPort): + """Deliver the reminder by logging it (swap for an email/SMS adaptor).""" + + async def deliver(self, reminder: dict[str, Any]) -> dict[str, Any]: + logger.info("REMINDER to=%s subject=%s body=%s", + reminder.get("to"), reminder.get("subject"), reminder.get("body")) + reminder["delivered"] = "log" + return reminder + + +class AppointmentReminderPipeline(AppointmentReminderPipelinePort): + """Concrete appointment-reminder family — the dependency decisions live here.""" + + loader: AppointmentLoader + compose: ReminderComposer + deliver: LogDeliver + + async def run(self, appointment_id: int) -> dict[str, Any]: + appointment = await self.loader.load(appointment_id) + pet = appointment.get("pet") if isinstance(appointment.get("pet"), dict) else None + parent = None + if pet and pet.get("parents"): + first = pet["parents"][0] + parent = first if isinstance(first, dict) else None + reminder = self.compose.compose(appointment, pet, parent) + return await self.deliver.deliver(reminder) + + +def get_appointment_reminder_pipeline(appointment_svc) -> AppointmentReminderPipeline: + """Wire and return the appointment-reminder pipeline through the shared loader. + + Args: + appointment_svc: Appointment CRUD service injected into the load stage. + + Returns: + AppointmentReminderPipeline: The wired pipeline root. + """ + resources = Path(__file__).parent / "resources" + return build_pipeline( + AppointmentReminderPipeline, + resources_dir=resources, + instances={"appointment_svc": appointment_svc}, + ) + + +register("appointment_reminder", "default", AppointmentReminderPipeline) diff --git a/standards/mcp/templates/app/pipelines/appointment_reminder/pipeline.yaml b/standards/mcp/templates/app/pipelines/appointment_reminder/pipeline.yaml new file mode 100644 index 0000000..ee437d1 --- /dev/null +++ b/standards/mcp/templates/app/pipelines/appointment_reminder/pipeline.yaml @@ -0,0 +1,15 @@ +# Graph spec for the appointment-reminder family: nodes, edges, and the root. +# Mirrors the port tree in abc.py. +schema: Reminder +nodes: + loader: + port: LoadPort + compose: + port: ComposePort + deliver: + port: DeliverPort +edges: + - loader -> compose + - compose -> deliver +roots: + default: AppointmentReminderPipeline diff --git a/standards/mcp/templates/app/pipelines/appointment_reminder/resources/patterns/reminder.yaml b/standards/mcp/templates/app/pipelines/appointment_reminder/resources/patterns/reminder.yaml new file mode 100644 index 0000000..251209e --- /dev/null +++ b/standards/mcp/templates/app/pipelines/appointment_reminder/resources/patterns/reminder.yaml @@ -0,0 +1,5 @@ +# Pattern / rule fragments for the appointment-reminder family. +# The loader merges every patterns/*.yaml into one registry by key. +reminder: + subject: "Appointment reminder" + body: "Hi {parent_name}, {pet_name} has an appointment on {date}." diff --git a/standards/mcp/templates/app/pipelines/example/resources/pipeline.yaml b/standards/mcp/templates/app/pipelines/appointment_reminder/resources/pipeline.yaml similarity index 76% rename from standards/mcp/templates/app/pipelines/example/resources/pipeline.yaml rename to standards/mcp/templates/app/pipelines/appointment_reminder/resources/pipeline.yaml index 8a670be..f5be8f3 100644 --- a/standards/mcp/templates/app/pipelines/example/resources/pipeline.yaml +++ b/standards/mcp/templates/app/pipelines/appointment_reminder/resources/pipeline.yaml @@ -1,5 +1,5 @@ # Runtime spec consumed by the shared loader. -kind: example +kind: appointment_reminder variant: default mode: default patterns: [] diff --git a/standards/mcp/templates/app/pipelines/example/__init__.py b/standards/mcp/templates/app/pipelines/example/__init__.py deleted file mode 100644 index 196d174..0000000 --- a/standards/mcp/templates/app/pipelines/example/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""# Summary - -Example pipeline family — a reusable skeleton, not an app-specific pipeline. - -Replace ``Example*`` names and the three stages with your family's stages. The -shape (abc → impl → pipeline.yaml → resources/) stays the same. -""" diff --git a/standards/mcp/templates/app/pipelines/example/abc.py b/standards/mcp/templates/app/pipelines/example/abc.py deleted file mode 100644 index ebbc570..0000000 --- a/standards/mcp/templates/app/pipelines/example/abc.py +++ /dev/null @@ -1,53 +0,0 @@ -"""# Summary - -Example pipeline family — port contracts (stage interfaces + root interface). - -Ports are declared as a DI tree: the root's annotated slots *are* the dependency -decisions. The concrete subclass in ``impl.py`` pins the stage types. -""" -from abc import ABC, abstractmethod -from typing import Any - -from pipelines.shared.ports import FinalizePort - - -class IngestPort(ABC): - """Obtain the raw input as text/bytes.""" - - @abstractmethod - def materialize(self, source: Any) -> str: - """Return the raw input materialized as text.""" - - -class ReadPort(ABC): - """Parse materialized input into records.""" - - @abstractmethod - def read(self, content: str) -> list[dict[str, Any]]: - """Return parsed records.""" - - -class NormalizePort(ABC): - """Normalize raw records into the target shape.""" - - @abstractmethod - def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return normalized records.""" - - -class ExamplePipelinePort(ABC): - """Root of the example family. - - Notes: - The slot annotations below are contracts. A concrete subclass re-annotates - them with stage implementations; the DI container instantiates the tree. - """ - - ingest: IngestPort - reader: ReadPort - normalize: NormalizePort - finalize: FinalizePort - - @abstractmethod - async def run(self, source: Any) -> list[dict[str, Any]]: - """Run the full stage chain and return the normalized records.""" diff --git a/standards/mcp/templates/app/pipelines/example/impl.py b/standards/mcp/templates/app/pipelines/example/impl.py deleted file mode 100644 index d78c30e..0000000 --- a/standards/mcp/templates/app/pipelines/example/impl.py +++ /dev/null @@ -1,71 +0,0 @@ -"""# Summary - -Example pipeline family — concrete stages, the wired root, and its registration. - -The concrete subclass re-annotates each slot with a stage implementation; the DI -container builds the whole tree. Register the family so the shared registry can -resolve it by ``(kind, variant, mode)``. -""" -from pathlib import Path -from typing import Any - -from pipelines.example.abc import ( - ExamplePipelinePort, - IngestPort, - NormalizePort, - ReadPort, -) -from pipelines.shared.loader import build_pipeline -from pipelines.shared.ports import FinalizePort -from pipelines.shared.registry import register - - -class TextIngest(IngestPort): - """Materialize any source as text.""" - - def materialize(self, source: Any) -> str: - return source if isinstance(source, str) else str(source) - - -class LineReader(ReadPort): - """Read one record per non-empty line.""" - - def read(self, content: str) -> list[dict[str, Any]]: - return [{"line": line} for line in content.splitlines() if line.strip()] - - -class PassthroughNormalize(NormalizePort): - """Return records unchanged (replace with real normalization).""" - - def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]: - return records - - -class NoopFinalize(FinalizePort): - """Drop records (replace with a service-backed finalize stage).""" - - async def finalize(self, records: list[dict[str, Any]]) -> list[Any]: - return records - - -class ExamplePipeline(ExamplePipelinePort): - """Concrete example family — the dependency decisions live here.""" - - ingest: TextIngest - reader: LineReader - normalize: PassthroughNormalize - finalize: NoopFinalize - - async def run(self, source: Any) -> list[dict[str, Any]]: - content = self.ingest.materialize(source) - records = self.reader.read(content) - return self.normalize.build(records) - - -def get_example_pipeline() -> ExamplePipeline: - """Wire and return the example pipeline through the shared loader.""" - resources = Path(__file__).parent / "resources" - return build_pipeline(ExamplePipeline, resources_dir=resources) - - -register("example", "default", ExamplePipeline) diff --git a/standards/mcp/templates/app/pipelines/example/pipeline.yaml b/standards/mcp/templates/app/pipelines/example/pipeline.yaml deleted file mode 100644 index 0ebfe2d..0000000 --- a/standards/mcp/templates/app/pipelines/example/pipeline.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Graph spec for the example family: nodes, edges, and the root. -# Mirrors the port tree in abc.py. Replace with your family's graph. -schema: ExampleRecord -nodes: - ingest: - port: IngestPort - reader: - port: ReadPort - normalize: - port: NormalizePort - finalize: - port: FinalizePort -edges: - - ingest -> reader - - reader -> normalize - - normalize -> finalize -roots: - default: ExamplePipeline diff --git a/standards/mcp/templates/app/pipelines/example/resources/patterns/example.yaml b/standards/mcp/templates/app/pipelines/example/resources/patterns/example.yaml deleted file mode 100644 index 0074eff..0000000 --- a/standards/mcp/templates/app/pipelines/example/resources/patterns/example.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Pattern / rule fragments for the example family. -# The loader merges every patterns/*.yaml into one registry by key. -example: - enabled: true diff --git a/standards/mcp/templates/app/pipelines/item_sync.py b/standards/mcp/templates/app/pipelines/item_sync.py deleted file mode 100644 index 0644e46..0000000 --- a/standards/mcp/templates/app/pipelines/item_sync.py +++ /dev/null @@ -1,32 +0,0 @@ -"""# Summary - -Item-sync pipeline — the demo pipeline axis of the blueprint. - -Runs on the worker, consumes a pending job, and materializes its payload as an -item. Steps are plain async functions here; once the chain branches or needs -deterministic replay, wrap the steps in a dagpipe DAG (see standards -"50_pipeline_scaffold"). -""" -import logging - -logger = logging.getLogger(__name__) - - -async def run_item_sync(job, resources: dict) -> None: - """Materialize a pending job payload as an item. - - Step 1: extract the payload from the job. - Step 2: create the item through the item service. - - Args: - job: The pending job document. - resources (dict): Wired services and adaptors. - """ - payload = job.payload or {} - item_svc = resources["item_svc"] - - await item_svc.create({ - "name": payload.get("name", job.name), - "quantity": payload.get("quantity", 1), - }) - logger.info("Created item from job %s", str(job.id)) \ No newline at end of file diff --git a/standards/mcp/templates/app/scripts/seed.py b/standards/mcp/templates/app/scripts/seed.py index 2a2ad0f..efa0557 100644 --- a/standards/mcp/templates/app/scripts/seed.py +++ b/standards/mcp/templates/app/scripts/seed.py @@ -12,12 +12,19 @@ from init import init, shutdown async def main() -> None: - """Seed one demo item and shut down cleanly.""" + """Seed minimal demo records and shut down cleanly.""" resources = await init() try: - item_svc = resources["item_svc"] - await item_svc.create({"name": "seed", "quantity": 1}) - print("Seeded one item.") + await resources["parent_svc"].create( + {"name": "Alice Parker", "email": "alice@vet.example"} + ) + await resources["vet_svc"].create( + {"name": "Dr. Chen", "specialty": "Canine", "email": "chen@vet.example"} + ) + await resources["treatment_svc"].create( + {"label": "Checkup", "description": "Routine wellness exam"} + ) + print("Seeded demo parents/vets/treatments.") finally: await shutdown() diff --git a/standards/mcp/templates/app/tests/integration/test_item_routes.py b/standards/mcp/templates/app/tests/integration/test_item_routes.py deleted file mode 100644 index e9e6936..0000000 --- a/standards/mcp/templates/app/tests/integration/test_item_routes.py +++ /dev/null @@ -1,32 +0,0 @@ -import importlib - -from openapi_first.app import OpenAPIFirstApp - -import interfaces.openapi as routes_module - - -def test_openapi_app_builds(): - """The spec-first app must build and plant each route handler.""" - module = importlib.import_module(routes_module.__name__) - for operation in ( - "health", - "list_items", - "create_item", - "get_item", - "update_item", - "delete_item", - "list_jobs", - "create_job", - "get_job", - "update_job", - "delete_job", - ): - assert hasattr(module, operation) - - app = OpenAPIFirstApp( - openapi_path="openapi.yaml", - routes_module=module, - title="App Template API", - version="0.1.0", - ) - assert app is not None diff --git a/standards/mcp/templates/app/tests/unit/test_appointment_reminder_pipeline.py b/standards/mcp/templates/app/tests/unit/test_appointment_reminder_pipeline.py new file mode 100644 index 0000000..a191a81 --- /dev/null +++ b/standards/mcp/templates/app/tests/unit/test_appointment_reminder_pipeline.py @@ -0,0 +1,79 @@ +"""Unit tests for the appointment-reminder pipeline family.""" +from pathlib import Path + +from pipelines.appointment_reminder.impl import ( + AppointmentReminderPipeline, + ReminderComposer, + get_appointment_reminder_pipeline, +) +from pipelines.shared.loader import build_pipeline, load_patterns + +RESOURCES = Path(__file__).resolve().parents[2] / "pipelines" / "appointment_reminder" / "resources" + + +class FakeDoc: + def __init__(self, data): + self._data = data + + def model_dump(self, **kwargs): + return self._data + + +class FakeAppointmentSvc: + def __init__(self, data): + self._data = data + + async def read(self, id): + return FakeDoc(self._data) if self._data else None + + +def _appointment(): + return { + "id": 1, + "date": "2026-11-02T10:00:00", + "pet": {"name": "Rex", "species": "dog", "parents": [{"name": "Kim", "email": "kim@example.com"}]}, + "vet": {"name": "Dr. Patel"}, + } + + +def test_load_patterns_merges_fragments(): + registry = load_patterns(RESOURCES) + assert registry["reminder"]["subject"] == "Appointment reminder" + assert "{pet_name}" in registry["reminder"]["body"] + + +def test_composer_uses_pattern_and_context(): + composer = ReminderComposer() + composer.registry = load_patterns(RESOURCES) + reminder = composer.compose(_appointment(), _appointment()["pet"], _appointment()["pet"]["parents"][0]) + assert reminder["to"] == "kim@example.com" + assert reminder["subject"] == "Appointment reminder" + assert "Rex" in reminder["body"] and "Kim" in reminder["body"] + assert "2026-11-02" in reminder["body"] + + +async def test_full_pipeline_delivers_reminder(): + pipeline = get_appointment_reminder_pipeline(FakeAppointmentSvc(_appointment())) + assert isinstance(pipeline, AppointmentReminderPipeline) + result = await pipeline.run(1) + assert result["delivered"] == "log" + assert result["to"] == "kim@example.com" + + +async def test_pipeline_errors_on_missing_appointment(): + pipeline = get_appointment_reminder_pipeline(FakeAppointmentSvc(None)) + try: + await pipeline.run(99) + except ValueError as exc: + assert "not found" in str(exc) + else: + raise AssertionError("expected ValueError for missing appointment") + + +def test_build_pipeline_injects_registry(): + pipeline = build_pipeline( + AppointmentReminderPipeline, + resources_dir=RESOURCES, + instances={"appointment_svc": FakeAppointmentSvc(_appointment())}, + ) + assert pipeline.compose.registry["reminder"]["subject"] == "Appointment reminder" diff --git a/standards/mcp/templates/app/tests/unit/test_item_models.py b/standards/mcp/templates/app/tests/unit/test_item_models.py deleted file mode 100644 index 10b58ab..0000000 --- a/standards/mcp/templates/app/tests/unit/test_item_models.py +++ /dev/null @@ -1,22 +0,0 @@ -from domain.item.models import Item -from domain.job.models import Job - - -def test_item_defaults(): - item = Item(name="widget") - assert item.quantity == 1 - - -def test_job_defaults(): - job = Job(name="sync") - assert job.status == "pending" - assert job.retry_count == 0 - assert job.error_message is None - - -def test_job_rejects_unknown_status(): - try: - Job(name="sync", status="exploded") - except Exception: - return - raise AssertionError("status should be rejected") \ No newline at end of file diff --git a/standards/mcp/templates/app/tests/unit/test_item_sync.py b/standards/mcp/templates/app/tests/unit/test_item_sync.py deleted file mode 100644 index 6bc2d99..0000000 --- a/standards/mcp/templates/app/tests/unit/test_item_sync.py +++ /dev/null @@ -1,28 +0,0 @@ -from types import SimpleNamespace - -from pipelines.item_sync import run_item_sync - - -class FakeItemSvc: - def __init__(self): - self.created = [] - - async def create(self, data: dict): - self.created.append(dict(data)) - - -async def test_run_item_sync_creates_item(): - svc = FakeItemSvc() - job = SimpleNamespace(id="j1", name="widget", payload={"quantity": 4}) - await run_item_sync(job, {"item_svc": svc}) - - assert len(svc.created) == 1 - assert svc.created[0] == {"name": "widget", "quantity": 4} - - -async def test_run_item_sync_uses_payload_name(): - svc = FakeItemSvc() - job = SimpleNamespace(id="j1", name="ignored", payload={"name": "gadget"}) - await run_item_sync(job, {"item_svc": svc}) - - assert svc.created[0]["name"] == "gadget" \ No newline at end of file diff --git a/standards/mcp/templates/app/tests/unit/test_jobs_routes.py b/standards/mcp/templates/app/tests/unit/test_jobs_routes.py new file mode 100644 index 0000000..ea56141 --- /dev/null +++ b/standards/mcp/templates/app/tests/unit/test_jobs_routes.py @@ -0,0 +1,100 @@ +"""Unit tests for the jobs interface helpers and routes (memory backend).""" +from types import SimpleNamespace + +from interfaces.jobs.routes import ( + create_job, + delete_job, + get_job, + list_jobs, + update_job, +) + + +class FakeDoc: + """Minimal document exposing the pydantic dump interface.""" + + def __init__(self, **fields): + self.__dict__.update(fields) + + def model_dump(self, **kwargs): + return dict(self.__dict__) + + +class FakeJobSvc: + """In-memory job service double.""" + + def __init__(self): + self.store: dict[int, FakeDoc] = {} + self._next = 1 + + async def search(self, filter=None): + return list(self.store.values()) + + async def create(self, body): + body.setdefault("status", "pending") + body.setdefault("retry_count", 0) + doc = FakeDoc(id=self._next, **body) + self.store[self._next] = doc + self._next += 1 + return doc + + async def read(self, id): + return self.store.get(id) + + async def update(self, id, body): + doc = FakeDoc(id=id, **body) + self.store[id] = doc + return doc + + async def delete(self, id): + return self.store.pop(id, None) is not None + + +def _request(svc_obj): + state = SimpleNamespace(db_resources={"job_svc": svc_obj}) + return SimpleNamespace(app=SimpleNamespace(state=state)) + + +async def test_create_list_get_update_delete_job(): + svc = FakeJobSvc() + request = _request(svc) + response = SimpleNamespace(status_code=200) + + created = await create_job( + request, {"name": "remind", "kind": "appointment_reminder"}, response + ) + assert response.status_code == 201 + assert created["kind"] == "appointment_reminder" + assert created["status"] == "pending" + + page = await list_jobs(request) + assert page["total"] == 1 + assert page["items"][0]["name"] == "remind" + + one = await get_job(request, created["id"]) + assert one["id"] == created["id"] + + updated = await update_job( + request, created["id"], {"name": "remind-2", "kind": "appointment_reminder"} + ) + assert updated["name"] == "remind-2" + + response = SimpleNamespace(status_code=200) + await delete_job(request, created["id"], response) + assert response.status_code == 204 + assert (await list_jobs(request))["total"] == 0 + + +async def test_list_jobs_filters_by_status(): + svc = FakeJobSvc() + request = _request(svc) + await create_job(request, {"name": "a", "kind": "k"}, SimpleNamespace()) + await create_job(request, {"name": "b", "kind": "k", "status": "done"}, SimpleNamespace()) + + pending = await list_jobs(request, status="pending") + assert pending["total"] == 1 + assert pending["items"][0]["name"] == "a" + + done = await list_jobs(request, status="done") + assert done["total"] == 1 + assert done["items"][0]["name"] == "b" diff --git a/standards/mcp/templates/app/tests/unit/test_worker.py b/standards/mcp/templates/app/tests/unit/test_worker.py new file mode 100644 index 0000000..b4abbe1 --- /dev/null +++ b/standards/mcp/templates/app/tests/unit/test_worker.py @@ -0,0 +1,121 @@ +"""Unit tests for the worker dispatch table and job status transitions.""" +from worker import HANDLERS, poll_jobs + + +class FakeDoc: + def __init__(self, **fields): + self.__dict__.update(fields) + + def model_dump(self, **kwargs): + return dict(self.__dict__) + + +class FakeJobSvc: + def __init__(self, jobs): + self.jobs = {j.id: j for j in jobs} + self.patches: list[tuple[int, dict]] = [] + + async def search(self, filter=None): + filter = filter or {} + return [j for j in self.jobs.values() if all(j.__dict__.get(k) == v for k, v in filter.items())] + + async def patch(self, id, body): + self.patches.append((int(id), body)) + job = self.jobs[int(id)] + for k, v in body.items(): + setattr(job, k, v) + + +class FakeAppointmentSvc: + def __init__(self, data): + self._data = data + + async def read(self, id): + return FakeDoc(**self._data) if self._data else None + + +def _job(id=1, kind="appointment_reminder", payload=None): + return FakeDoc( + id=id, + name="j", + kind=kind, + status="pending", + payload=payload or {"appointment_id": 1}, + retry_count=0, + ) + + +def test_dispatch_table_has_appointment_reminder(): + assert "appointment_reminder" in HANDLERS + + +async def test_poll_jobs_runs_pending_and_marks_done(monkeypatch): + monkeypatch.setattr("worker.settings.worker_poll_seconds", 0) + appointment = { + "id": 1, + "date": "2026-11-02T10:00:00", + "pet": {"name": "Rex", "parents": [{"name": "Kim", "email": "kim@example.com"}]}, + } + job_svc = FakeJobSvc([_job()]) + resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(appointment)} + + import asyncio + + task = asyncio.create_task(poll_jobs(resources)) + for _ in range(50): + await asyncio.sleep(0.01) + if job_svc.patches and job_svc.patches[-1][1].get("status") == "done": + break + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + statuses = [body["status"] for _, body in job_svc.patches] + assert statuses == ["processing", "done"] + + +async def test_poll_jobs_marks_unknown_kind_failed(monkeypatch): + monkeypatch.setattr("worker.settings.worker_poll_seconds", 0) + job_svc = FakeJobSvc([_job(kind="nope")]) + resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(None)} + + import asyncio + + task = asyncio.create_task(poll_jobs(resources)) + for _ in range(50): + await asyncio.sleep(0.01) + if job_svc.patches and job_svc.patches[-1][1].get("status") == "failed": + break + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + last = job_svc.patches[-1][1] + assert last["status"] == "failed" + assert "no handler" in last["error_message"] + assert last["retry_count"] == 1 + + +async def test_poll_jobs_skips_retries_exhausted(monkeypatch): + monkeypatch.setattr("worker.settings.worker_poll_seconds", 0) + monkeypatch.setattr("worker.settings.worker_max_retries", 1) + job = _job() + job.retry_count = 1 + job_svc = FakeJobSvc([job]) + resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(None)} + + import asyncio + + task = asyncio.create_task(poll_jobs(resources)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert job_svc.patches == [] diff --git a/standards/mcp/templates/app/worker.py b/standards/mcp/templates/app/worker.py index 9e1d0d6..9caf56a 100644 --- a/standards/mcp/templates/app/worker.py +++ b/standards/mcp/templates/app/worker.py @@ -1,8 +1,8 @@ """# Summary -Background worker — polls for pending jobs and runs the item-sync pipeline. -The poll loop is the pipeline axis of the blueprint: resources are created by -the API axis, workers consume them on a fixed cadence. +Background worker — polls for pending jobs and runs the matching pipeline +family. The poll loop is the pipeline axis of the sample: resources are created +by the API axis, workers consume them on a fixed cadence. Notes: - This process is separate from the API process. Any in-memory event bus is per @@ -11,9 +11,10 @@ Notes: """ import asyncio import logging +from typing import Any, Awaitable, Callable from config import settings -from pipelines.item_sync import run_item_sync +from pipelines.appointment_reminder.impl import get_appointment_reminder_pipeline logging.basicConfig( level=logging.INFO, @@ -22,9 +23,29 @@ logging.basicConfig( logger = logging.getLogger(__name__) +# job.kind -> async (job, resources) -> None. Add families here as they land. +HANDLERS: dict[str, Callable[..., Awaitable[None]]] = {} -async def poll_jobs(resources: dict): - """Poll for pending jobs and run the item-sync pipeline on each. + +def _register_default_handlers() -> None: + """Populate the dispatch table with the shipped families.""" + + async def run_appointment_reminder(job, resources: dict[str, Any]) -> None: + payload = job.payload or {} + appointment_id = payload.get("appointment_id") + if appointment_id is None: + raise ValueError("appointment_reminder job requires payload.appointment_id") + pipeline = get_appointment_reminder_pipeline(resources["appointment_svc"]) + await pipeline.run(int(appointment_id)) + + HANDLERS["appointment_reminder"] = run_appointment_reminder + + +_register_default_handlers() + + +async def poll_jobs(resources: dict) -> None: + """Poll for pending jobs and dispatch each to its pipeline family. Args: resources (dict): Wired services from :func:`init.init`. @@ -34,8 +55,9 @@ async def poll_jobs(resources: dict): settings.worker_poll_seconds, settings.worker_max_retries) while True: - pending = await job_svc.search(filter={"status": "pending"}, limit=10) - logger.info("Found %d pending job(s)", len(pending) if pending else 0) + pending = await job_svc.search({"status": "pending"}) + pending = pending[:10] + logger.info("Found %d pending job(s)", len(pending)) for job in pending: job_id = str(job.id) @@ -43,13 +65,20 @@ async def poll_jobs(resources: dict): logger.warning("SKIPPING job %s — retries exhausted", job_id) continue + handler = HANDLERS.get(job.kind) await job_svc.patch(job_id, {"status": "processing"}) try: - await run_item_sync(job, resources) + if handler is None: + raise ValueError(f"no handler for job kind '{job.kind}'") + await handler(job, resources) await job_svc.patch(job_id, {"status": "done", "error_message": ""}) except Exception as exc: # noqa: BLE001 logger.exception("job %s FAILED: %s", job_id, exc) - await job_svc.patch(job_id, {"status": "failed", "error_message": str(exc)}) + await job_svc.patch(job_id, { + "status": "failed", + "error_message": str(exc), + "retry_count": (job.retry_count or 0) + 1, + }) await asyncio.sleep(settings.worker_poll_seconds) @@ -64,4 +93,4 @@ if __name__ == "__main__": finally: await shutdown() - asyncio.run(_run()) \ No newline at end of file + asyncio.run(_run()) diff --git a/standards/src/lib/01_build_recipe.md b/standards/src/lib/01_build_recipe.md index 2d41ec0..f243fae 100644 --- a/standards/src/lib/01_build_recipe.md +++ b/standards/src/lib/01_build_recipe.md @@ -69,8 +69,7 @@ Then: - call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs` and note the Tier A libraries that already exist — never reimplement one; - call `list_app_components()` and pull each file you need with - `get_app_component(kind, resource=..., family=...)` instead of writing it from - scratch. + `get_app_component(kind)` instead of writing it from scratch. ## Steps @@ -117,10 +116,11 @@ Then: with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll the job collection, run the pipeline, and publish progress. -8. **Add pipeline families (only if data-driven)** — copy `pipelines/example/` - to `pipelines//` with `abc.py`, `impl.py`, `pipeline.yaml`, - `resources/`, and reuse `pipelines/shared/` (loader + registry). Register the - root. Add fixtures and a parity test. +8. **Add pipeline families (only if data-driven)** — copy the worked family + `pipelines/appointment_reminder/` to `pipelines//` with `abc.py`, + `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/` + (loader + registry). Register the root, add the `job.kind` handler in + `worker.py`. Add fixtures and a parity test. 9. **Add infrastructure only where needed** — Tier A capability: use the library. Tier B capability: copy the template. Never hand-write a Tier A diff --git a/standards/src/lib/40_ui_admin.md b/standards/src/lib/40_ui_admin.md index bb5743d..9b1b880 100644 --- a/standards/src/lib/40_ui_admin.md +++ b/standards/src/lib/40_ui_admin.md @@ -57,7 +57,7 @@ docs://modules/react-openapi # module: specExtensions — the full catalo The app's own spec carries the per-resource values; the catalog explains what each field does. The template's `openapi.yaml` shows the convention on the -example `Item` and `Job` schemas. +example `Job` schema. --- @@ -82,5 +82,7 @@ example `Item` and `Job` schemas. - The server template ships authless by default (see [22 – Auth Integration](22_auth_integration.md)); the client template pairs with it and degrades to "no login" until a security scheme is declared. -- `react/vet` is the full-stack reference implementation: spec with x- - extensions, server, and the consuming client in one tree. +- The standards **app + client templates** are the full-stack reference: the + server spec carries the x- extensions, and the client renders the admin from + it. The wiki's **Reference App** walkthrough (`06_reference_app`) documents + the pairing end to end. diff --git a/standards/src/lib/90_drone_pipelines.md b/standards/src/lib/90_drone_pipelines.md index 1c418ea..a97d358 100644 --- a/standards/src/lib/90_drone_pipelines.md +++ b/standards/src/lib/90_drone_pipelines.md @@ -10,7 +10,7 @@ chosen by what the repository produces: |------|------|----------|-------| | **A — PyPI package** | `templates/drone/python-lib.yaml` | A wheel + sdist on the private index | `dagpipe`, `doc-forge`, `hexa`, `mail-intake`, `mongo-ops`, `omniread`, `openapi-first`, `py-jwt` | | **B — npm package** | `templates/drone/npm-lib.yaml` | A package published to the private npm registry | `doc-weaver`, `react-auth`, `react-openapi` | -| **C — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server`, `vet` | +| **C — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server` | All templates run on `platform: linux / arm64` with the workspace at `/drone/src`. Copy the matching template to `/.drone.yml`; the Type A and diff --git a/standards/src/lib/94_repository_inventory.md b/standards/src/lib/94_repository_inventory.md index 0da0427..bbdb8c0 100644 --- a/standards/src/lib/94_repository_inventory.md +++ b/standards/src/lib/94_repository_inventory.md @@ -45,15 +45,14 @@ source is `package.json`; Drone file is `npm-lib.yaml` (tag-triggered). --- -## Services & Apps (Type C) +## Services (Type C) A container image pushed to the registry and run on the Docker host. Version source is the `VERSION` file; Drone file is `service-docker.yaml`. -| Repo | Path | Type | Trigger | Docs | -|------|------|------|---------|------| -| `auth-server` | `auth-server` | service | tag | api, lib, wiki | -| `vet` | `react/vet` | app | tag | lib, wiki | +| Repo | Path | Trigger | Docs | +|------|------|---------|------| +| `auth-server` | `auth-server` | tag | api, lib, wiki | Tag-triggered and enforces `tag == VERSION`. @@ -65,7 +64,7 @@ Tag-triggered and enforces `tag == VERSION`. |------|----------------|-----------|-------| | package (PyPI) | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` | | package (npm) | `package.json` | `CHANGELOG.md` | `npm-lib.yaml` | -| service / app | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` | +| service | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` | Every covered repository must be able to satisfy `tag == version`: the version file (never a commit id) is bumped in the release MR, the MR is diff --git a/standards/src/repos.json b/standards/src/repos.json index ea475b2..3dbb9f8 100644 --- a/standards/src/repos.json +++ b/standards/src/repos.json @@ -122,16 +122,6 @@ "trigger": "tag", "docs": ["api", "lib", "wiki"], "mcp": null - }, - { - "name": "vet", - "type": "app", - "path": "react/vet", - "version_source": "VERSION", - "drone": "service-docker.yaml", - "trigger": "tag", - "docs": ["lib", "wiki"], - "mcp": null } ] } diff --git a/standards/src/templates/README.md b/standards/src/templates/README.md index ffd7496..ee5bc02 100644 --- a/standards/src/templates/README.md +++ b/standards/src/templates/README.md @@ -1,16 +1,19 @@ # Application Template -A minimal, runnable skeleton of the standardized architecture. It demonstrates -both axes of the blueprint (see `standards`): +A runnable sample of the standardized architecture — the **vet** veterinary +clinic domain, kept as the standards' reference sample. It demonstrates both +axes of the blueprint (see `standards`): -1. **Resource axis** — items and jobs, each a Claude-style generic CRUD slice - (model → document → repo → service → route), all driven from `openapi.yaml`. -2. **Pipeline axis** — a background `worker.py` that polls pending jobs and runs - a pipeline; `pipelines/example/` shows the reusable family skeleton and - `pipelines/item_sync.py` the simple end-to-end form. +1. **Resource axis** — five domain aggregates (parents, pets, treatments, + vets, appointments) plus a **jobs** queue, each a CRUD slice + (model → document → repo → service → route), all driven from + `openapi.yaml`. x- extension fields on the spec drive the admin client. +2. **Pipeline axis** — a background `worker.py` that polls pending jobs and + dispatches by `job.kind`; `pipelines/appointment_reminder/` is the worked + family and `pipelines/shared/` the reusable scaffold. -Everything here is deliberately small so an agent can rewrite each file rather -than delete unused code. The domain is a neutral "item", not an app noun. +Copy a slice, replace the domain with your own — the layer structure, wiring +patterns and tests stay. ## Internal dependencies @@ -38,11 +41,19 @@ The app depends on the internal Aetos libraries (`openapi-first`, `mongo-ops`, cd apps/ 1. `pyproject.toml` — rename the package; keep the internal library pins. -2. `config.py` — replace/add settings consumed by your domain. -3. `openapi.yaml` — replace the `Item`/`Job` resources with your resources. -4. `domain/` — rename `item` and `job` slices to your aggregates. -5. `pipelines/` — replace the demo with your pipeline (see below). -6. `tests/` — keep the two test files as the pattern for your own. +2. `config.py` — replace/add settings consumed by your domain (keep the + worker knobs if you run jobs). +3. `openapi.yaml` — replace the vet resources with yours; keep the x- + extension conventions on schemas and fields. +4. `domain/` — replace each aggregate slice with your own; copy + `domain/parents/` as the simplest pattern, `domain/appointments/` when you + need FK expansion. +5. `interfaces/` — one `routes.py` per resource re-exported from + `openapi.py`; keep `_common.py` as-is. +6. `pipelines/` — replace the appointment_reminder family with yours (see + below); keep `pipelines/shared/` as-is. +7. `tests/` — keep `test_openapi_app_builds.py`; adapt the unit tests as + patterns for your own. Install and run: @@ -53,8 +64,7 @@ Install and run: ## Pipeline families -`pipelines/item_sync.py` is the smallest possible pipeline (plain async steps). -When a family needs its own stages, copy `pipelines/example/`: +Copy `pipelines/appointment_reminder/` as the family pattern: pipelines// abc.py # port contracts @@ -68,7 +78,8 @@ When a family needs its own stages, copy `pipelines/example/`: The shared loader wires the root through the DI library (`hexa.build`) and merges the pattern fragments; the registry resolves a `(kind, variant, mode)` triple to -a root class. See standards "50_pipeline_scaffold". +a root class. The worker maps `job.kind` to a handler in its `HANDLERS` +dispatch table. See standards "50_pipeline_scaffold" and "51_worker_and_progress". ## What to keep, what to replace @@ -79,9 +90,10 @@ a root class. See standards "50_pipeline_scaffold". | openapi-first routing | Paths, schemas, operationIds | | mongo-ops repo factory | Collections, doc models | | generic CRUD adaptor | Nothing (reuse as-is) | +| `interfaces/_common.py` | Nothing (reuse as-is) | | `pipelines/shared/` | Nothing (reuse as-is) | -| `pipelines/example/` | Family name, stages, patterns | -| worker poll + status loop | Job payloads, pipeline steps | +| `pipelines/appointment_reminder/` | Family name, stages, patterns | +| worker poll + dispatch loop | Job kinds, payloads, pipeline steps | | `scripts/` | Task bodies | ## Where the internal libraries slot in @@ -95,4 +107,4 @@ a root class. See standards "50_pipeline_scaffold". Confirm current endpoints and ownership with the `standards` registry MCP tools: `find_library("pipeline")`, `find_library("auth")`, or -`get_app_component("pipeline-abc", family="csv")`. +`get_app_component("pipeline-abc")`. diff --git a/standards/src/wiki/03_start_an_app.md b/standards/src/wiki/03_start_an_app.md index 49c73ed..1ad66d9 100644 --- a/standards/src/wiki/03_start_an_app.md +++ b/standards/src/wiki/03_start_an_app.md @@ -40,8 +40,7 @@ From an MCP client connected to the `standards` server: **`start_app`** to intake-and-build in one call. 3. The prompt hands off to `docs://modules/build_recipe`; the agent scaffolds with `get_app_template()` and pulls exact files with - `get_app_component(kind, resource=..., family=...)`, authoring only the - app-specific parts. + `get_app_component(kind)`, authoring only the app-specific parts. 4. To validate the standard without touching a real app, invoke **`trial`** (`trial(app_name=..., build_path=...)`) to build into a scratch directory, prove it builds, and report findings. diff --git a/standards/wiki/03_start_an_app/index.html b/standards/wiki/03_start_an_app/index.html index 5c3decf..5729637 100644 --- a/standards/wiki/03_start_an_app/index.html +++ b/standards/wiki/03_start_an_app/index.html @@ -708,8 +708,7 @@ machine-facing checklist lives in the reference page start_app to intake-and-build in one call.
  8. The prompt hands off to docs://modules/build_recipe; the agent scaffolds with get_app_template() and pulls exact files with - get_app_component(kind, resource=..., family=...), authoring only the - app-specific parts.
  9. + get_app_component(kind), authoring only the app-specific parts.
  10. To validate the standard without touching a real app, invoke trial (trial(app_name=..., build_path=...)) to build into a scratch directory, prove it builds, and report findings.
  11. diff --git a/standards/wiki/search/search_index.json b/standards/wiki/search/search_index.json index 8eb2d22..e83d27f 100644 --- a/standards/wiki/search/search_index.json +++ b/standards/wiki/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 Standards \u2014 the Aetos App Blueprint","text":"

    One reusable architecture for every backend application in the Aetos ecosystem: a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure layer, and a single composition root.

    Fill in two axes only and a new app is done: your resource model, and (if needed) your pipeline family. The blueprint itself never names a business domain \u2014 expenses, health metrics, inventory, and analytics are all the same shape underneath.

    Doc model: this wiki is written for humans \u2014 how-to guides, the build recipe, and quality gates. The enforceable rules (layering, composition root, CRUD template, pipeline scaffold) and the library registry live in the reference pages, mirrored by the machine-readable standards MCP bundle. Query find_library before writing any infrastructure.

    "},{"location":"#key-features","title":"\ud83d\ude80 Key Features","text":"
    • \ud83e\udded Spec-first routing \u2014 the OpenAPI document, not decorator routers, is the source of truth (operationId lookup)
    • \ud83c\udfd7\ufe0f One-way layering \u2014 interfaces \u2192 domain \u2192 infra, enforced
    • \ud83d\udd0c Single composition root \u2014 every dependency is wired in exactly one place
    • \ud83e\udde9 Generic, not app-specific \u2014 reusable rules versus two authoring axes
    • \ud83d\udcda Library-backed \u2014 DB, DI, pipelines, ingestion, auth, and docs come from internal libraries, never hand-written
    • \ud83d\udea6 Explicit state machines \u2014 durable job statuses, progress, and human-in-the-loop pauses
    "},{"location":"#documentation-structure","title":"\ud83d\udcc1 Documentation Structure","text":"Section Description Overview Philosophy, the one-way dependency rule, architecture App Components The component model and how the pieces wire together Start an App The entry point: gather the two axes, then build Testing & Quality Test layout and acceptance gates

    Reference (separate site, mirrors the MCP bundle): the enforceable anatomy \u2014 start at the Build Recipe, then the pillars: domains (Layering), the OpenAPI contract (Composition Root, Spec-First Routing, CRUD Template, Auth Integration), interfaces & infra (Infra Adapters), UI (Auto-Rendered Admin), the optional pipelines layer (Pipeline Scaffold, Worker & Progress, Human-in-the-Loop), and delivery/meta (Genericity Matrix, Library Registry).

    "},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"
    • MCP bundle \u2014 standards server on :8009: read docs://index, docs://nav, docs://modules/{module}; call find_library(capability)
    • Library registry \u2014 the catalog of internal libraries behind every app (libraries.json, served as registry://libraries)
    • Template \u2014 the runnable skeleton at standards/src/templates/app/
    • Start a build \u2014 invoke the start_app prompt on the standards server, or read the Start an App guide

    \u00a9 Aetoskia Internal \u2014 standards

    "},{"location":"01_overview/","title":"Overview","text":"

    The blueprint is a hexagonal (ports & adapters) application with a spec-first web edge and a single composition root. It favors explicit wiring over implicit magic and existing internal libraries over hand-written infrastructure. Start here to build the mental model; the enforceable details live in the reference pages.

    "},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"
    flowchart LR\n    subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n        OAS[\"openapi.yaml<br/>operationId contract\"]\n        RT[\"<resource>/routes.py<br/>thin handlers\"]\n    end\n\n    subgraph CORE[\"domain/ \u2014 business core\"]\n        MD[\"models\"]\n        PT[\"ports (adapter binding seam)\"]\n        SV[\"services\"]\n    end\n\n    subgraph INFRA[\"infra/ \u2014 adapters\"]\n        RP[\"mongo repos (mongo-ops)\"]\n        AD[\"cache \u00b7 stores \u00b7 llm \u00b7 mail\"]\n        DB[(\"MongoDB\")]\n    end\n\n    CR[\"init.py<br/>composition root\"]\n\n    OAS --> RT\n    RT --> SV\n    SV --> PT\n    SV --> RP\n    PT -. depends on .- RP\n    CR --- RT\n    CR --- SV\n    CR --- RP\n    RP --> DB\n    AD --- CR

    The rule holds at every edge: code in an outer ring may depend on a ring closer to the core, never the reverse. domain/ never imports a web framework; only its ports/ seam reaches into infra/ to bind adapters.

    Legend \u2014 solid arrows are compile-time calls; dotted arrows are the port/adapter binding, performed by the composition root at startup.

    "},{"location":"01_overview/#the-one-way-dependency-rule","title":"\ud83d\udd04 The one-way dependency rule","text":"Ring May import Never imports interfaces/ domain, framework glue (glue only) business logic, infra directly domain/ its own models + its ports/ seam (which binds infra/libraries) web frameworks, interfaces/ infra/ domain ports, drivers, libraries \u2014 init.py everything (the wiring point) \u2014"},{"location":"01_overview/#core-principles","title":"\ud83e\udded Core principles","text":"
    1. One-way dependencies. interfaces -> domain -> infra is allowed; the reverse is forbidden.
    2. Ports in the domain, adapters in infra. A service depends on a port; the port's factory binds the concrete adapter, and the composition root calls those factories.
    3. One composition root. Nothing constructs its own dependencies. All wiring lives in one module (init.py) exposed as a flat resource registry.
    4. Spec-first. The HTTP contract is declared in openapi.yaml; route handlers are looked up by operationId. There are no decorator routers.
    5. Reuse the libraries. Orchestration, DI, Mongo repositories, ingestion, auth, and docs come from internal libraries \u2014 see the Library Registry.
    6. Explicit state machines. Long-running work moves through explicit, durable statuses and can pause for human input.
    "},{"location":"01_overview/#runtime-processes","title":"\u2699\ufe0f Runtime processes","text":"

    A standard app may run as up to three processes that share the same composition root:

    Process Entry point Responsibility API main.py serve HTTP; start/stop resources via lifespan Worker worker.py poll durable jobs, run pipelines, publish progress Scripts python -m scripts.<name> one-shot operator tasks"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"
    • 02 \u2013 App Components \u00b7 03 \u2013 Start an App
    "},{"location":"02_components/","title":"App Components","text":"

    What ships in every standard app, and how the pieces relate. This is the blueprint's component model \u2014 templates/app/ is a runnable instance of it.

    "},{"location":"02_components/#component-model","title":"\ud83d\uddc2\ufe0f Component model","text":"

    The blueprint has two orthogonal views: runtime flow (who calls whom) and composition-root wiring (who constructs whom). Keeping them separate makes the layering visible instead of hiding it behind a hub.

    "},{"location":"02_components/#runtime-flow","title":"\ud83d\udd00 Runtime flow","text":"

    Who calls whom at request and job time:

    %%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n    subgraph REQ[\"Request path \u2014 API process\"]\n        direction LR\n        CL([\"Client<br/>operationId\"]) --> RT[\"interfaces/&lt;resource&gt;/routes.py\"]\n        RT --> SVC[\"&lt;resource&gt;_svc\"]\n        SVC --> REPO[\"&lt;resource&gt;_repo\"]\n    end\n\n    subgraph WRK[\"Background path \u2014 worker process\"]\n        direction LR\n        W[\"worker.py\"] --> JSVC[\"job_svc\"]\n        W --> PIPE[\"pipelines/&lt;family&gt;\"]\n        PIPE --> ISVC[\"item_svc\"]\n        JSVC --> JREPO[\"job_repo\"]\n        ISVC --> IREPO[\"item_repo\"]\n    end\n\n    subgraph INFRA[\"infra/ \u2014 adapters\"]\n        MONGO[(\"MongoDB / mongo_ops\")]\n    end\n\n    REPO --> MONGO\n    JREPO --> MONGO\n    IREPO --> MONGO

    Legend \u2014 solid arrows are runtime calls. Nothing is self-constructed.

    "},{"location":"02_components/#composition-root-wiring","title":"\ud83e\uddf5 Composition-root wiring","text":"

    Who constructs what at startup:

    %%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n    INIT[\"init.py \u2014 composition root\"]\n    SETUP[\"infra/db/setup.py\"]\n    FACT[\"domain/*/ports/*<br/>factory seams\"]\n    REPOS[\"repos<br/>infra/db/repos/mongo.py\"]\n    SVCES[\"services<br/>CrudsServiceAdaptor\"]\n    STATE[\"app.state.db_resources\"]\n    HEXA[\"hexa.build\"]\n    RT[\"routes.py\"]\n    WK[\"worker.py\"]\n\n    INIT --> SETUP\n    INIT --> FACT\n    FACT --> REPOS\n    FACT --> SVCES\n    REPOS --> SVCES\n    HEXA -.-> SVCES\n    INIT --> STATE\n    STATE --> RT\n    INIT --> WK

    Legend \u2014 init.py is the only place that imports concrete adapters. The ports/ factory seams bind infra; the API reads resources from app.state.db_resources, the worker calls init() directly.

    "},{"location":"02_components/#component-roles","title":"\ud83d\udce6 Component roles","text":""},{"location":"02_components/#1-interfaces-the-edge","title":"1. interfaces/ \u2014 the edge","text":"

    Thin handlers bound to openapi.yaml operationIds. They parse the request, call a service, and shape the response. No business rules, no I/O.

    • interfaces/openapi.py re-exports every route module so the OpenAPI-first app can resolve handlers.
    • Routes may mount the cache decorator on read endpoints.
    "},{"location":"02_components/#2-domain-the-core","title":"2. domain/ \u2014 the core","text":"

    Models, ports, and services (use cases). No framework imports. The ports/ package is the one seam allowed to reach into infra/: each port module declares the document shape and a factory that binds the adapter.

    • <aggregate>/models.py \u2014 dataclasses, enums, document shapes.
    • <aggregate>/ports/db.py \u2014 document model + create_<aggregate>_repo().
    • <aggregate>/ports/service.py \u2014 service + get_<aggregate>_service(repo).
    • Services compose ports and enforce the business rules; the port factories bind the implementations, and the composition root calls them.
    "},{"location":"02_components/#3-infra-the-adapters","title":"3. infra/ \u2014 the adapters","text":"

    Concrete implementations of the domain ports plus cross-cutting infrastructure:

    • db/ \u2014 mongo-ops repositories and the connection lifecycle.
    • adapters/ \u2014 cache store, progress bus, storages, fetchers (library-backed).
    • Anything else the domain needs, implemented against its ports.
    "},{"location":"02_components/#4-pipelines-data-driven-families-optional","title":"4. pipelines/ \u2014 data-driven families (optional)","text":"

    Extraction and enrichment families, each a deterministic step chain (abc.py / impl.py / pipeline.yaml / resources/). Roots are registered in the pattern registry and driven either by a worker job or a CLI.

    "},{"location":"02_components/#5-workerpy-the-job-worker-optional","title":"5. worker.py \u2014 the job worker (optional)","text":"

    A separate process that polls durable job collections, runs pipeline roots, and publishes progress to the progress bus. It shares the same composition root as the API.

    "},{"location":"02_components/#6-initpy-the-composition-root","title":"6. init.py \u2014 the composition root","text":"

    The only place anything is constructed: leaf repositories \u2192 dependent repositories \u2192 services \u2192 adapters. Exposes get_svc overloads and starts / stops resources on the app lifespan.

    "},{"location":"02_components/#how-the-request-flows","title":"\ud83d\udd17 How the request flows","text":"
    sequenceDiagram\n    participant C as Client\n    participant R as route (interfaces)\n    participant S as service (port factory / adapter)\n    participant P as repo (infra)\n    participant DB as MongoDB\n\n    C->>R: POST /items (operationId)\n    R->>S: svc.create(payload)\n    S->>P: repo.insert(doc)\n    P->>DB: insert\n    DB-->>P: ack\n    P-->>S: doc\n    S-->>R: result\n    R-->>C: 200 JSON
    "},{"location":"02_components/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 03 \u2013 Start an App \u00b7 04 \u2013 Testing & Quality
    "},{"location":"03_start_an_app/","title":"\ud83d\ude80 Start an App","text":"

    This is the entry point for building a new application from the blueprint. The goal is one deterministic pass: you supply only the two axes, and the agent does the rest by reading the standards MCP, importing Tier A libraries, and copying Tier B template components.

    Doc model: this page is the human entry point. The deterministic, machine-facing checklist lives in the reference page Build Recipe, served as docs://modules/build_recipe.

    "},{"location":"03_start_an_app/#what-the-agent-needs-from-you","title":"\ud83e\uddfe What the agent needs from you","text":"

    Gather these before starting \u2014 the start_app MCP prompt asks for them:

    1. App name + one-line purpose.
    2. Axis 1 \u2014 resources. Each resource, its fields, and relationships. Paste openapi.yaml if you already have it.
    3. Axis 2 \u2014 pipelines. Only if an input needs a multi-step transformation: the families and their input formats (or \"none\").
    4. Auth needed now? Default no. Auth is deferred (py-jwt primitives only), and an authless app is just a spec with no security keys \u2014 see Spec-First Routing \u2192 Authless apps.
    5. Integrations. Mongo, ingestion formats, email, LLM provider.

    If you can state axes 1 and 2, the rest is mechanical.

    "},{"location":"03_start_an_app/#how-to-start","title":"\u25b6\ufe0f How to start","text":"

    From an MCP client connected to the standards server:

    1. Invoke the intake prompt (or start_app with no arguments) to get the intake questions.
    2. Answer them, then invoke build (build(app_name=..., resources=..., pipelines=..., auth=...)), or use start_app to intake-and-build in one call.
    3. The prompt hands off to docs://modules/build_recipe; the agent scaffolds with get_app_template() and pulls exact files with get_app_component(kind, resource=..., family=...), authoring only the app-specific parts.
    4. To validate the standard without touching a real app, invoke trial (trial(app_name=..., build_path=...)) to build into a scratch directory, prove it builds, and report findings.

    Manual alternative \u2014 copy the skeleton and follow the reference recipe:

    cp -r <docs>/standards/src/templates/app apps/<your-app>\n
    "},{"location":"03_start_an_app/#reference","title":"\ud83d\udcda Reference","text":"
    • Build Recipe \u2014 the deterministic checklist
    • Genericity Matrix \u2014 Tier A / B / C
    • Library Registry \u2014 the Tier A catalog
    • App Components \u2014 the component model
    "},{"location":"03_start_an_app/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 02 \u2013 App Components \u00b7 04 \u2013 Testing & Quality
    "},{"location":"05_mcp_setup/","title":"\ud83d\udd0c MCP Setup","text":"

    Every repository ships its documentation as a Model Context Protocol (MCP) server, and the standards registry server ties them together. The docs portal runs each one as a FastMCP streamable-http app bound to 0.0.0.0:<port>, exposing the endpoint at /mcp (see docs/main.py and docs/config.yml).

    Connect your agent to the standards server first: it answers find_library, list_repos, and the start_app prompt, so the agent reaches for an internal library instead of reimplementing one (see the Library Registry).

    "},{"location":"05_mcp_setup/#run-the-servers-locally","title":"\u25b6\ufe0f Run the servers locally","text":"

    From the docs checkout:

    .venv\\Scripts\\python.exe main.py                      # every server\n.venv\\Scripts\\python.exe main.py --servers standards   # just the registry\n

    :8000 is the health endpoint (GET /healthz), not an MCP server.

    "},{"location":"05_mcp_setup/#servers","title":"\ud83e\udded Servers","text":"Slug MCP server Port localhost mcp.aetoskia.com standards standards 8009 http://localhost:8009/mcp https://mcp.aetoskia.com/standards/mcp doc-forge docforge 8001 http://localhost:8001/mcp https://mcp.aetoskia.com/doc-forge/mcp mail-intake mail_intake 8002 http://localhost:8002/mcp https://mcp.aetoskia.com/mail-intake/mcp omniread omniread 8003 http://localhost:8003/mcp https://mcp.aetoskia.com/omniread/mcp openapi-first openapi_first 8004 http://localhost:8004/mcp https://mcp.aetoskia.com/openapi-first/mcp py-jwt jwtlib 8005 http://localhost:8005/mcp https://mcp.aetoskia.com/py-jwt/mcp dagpipe dagpipe 8006 http://localhost:8006/mcp https://mcp.aetoskia.com/dagpipe/mcp mongo-ops mongo_ops 8007 http://localhost:8007/mcp https://mcp.aetoskia.com/mongo-ops/mcp hexa hexa 8008 http://localhost:8008/mcp https://mcp.aetoskia.com/hexa/mcp doc-weaver doc-weaver 8010 http://localhost:8010/mcp https://mcp.aetoskia.com/doc-weaver/mcp react-auth react-auth 8011 http://localhost:8011/mcp https://mcp.aetoskia.com/react-auth/mcp react-openapi react-openapi 8012 http://localhost:8012/mcp https://mcp.aetoskia.com/react-openapi/mcp

    The hosted endpoints are served over HTTPS at https://mcp.aetoskia.com/<slug>/mcp. The tabs below are linked (content.tabs.link): switching one switches all of them. Every snippet registers all twelve servers, so a single paste is enough.

    "},{"location":"05_mcp_setup/#claude-code","title":"\ud83e\udd16 Claude Code","text":"

    Project-scoped .mcp.json at the repo root. The type field is required \u2014 an entry with a url but no type is read as a stdio server and skipped.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"type\": \"http\", \"url\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"type\": \"http\", \"url\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"type\": \"http\", \"url\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"type\": \"http\", \"url\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"type\": \"http\", \"url\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"type\": \"http\", \"url\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"type\": \"http\", \"url\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"type\": \"http\", \"url\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"type\": \"http\", \"url\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"type\": \"http\", \"url\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"type\": \"http\", \"url\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"type\": \"http\", \"url\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n

    Or add them with the CLI: claude mcp add --transport http standards http://localhost:8009/mcp.

    "},{"location":"05_mcp_setup/#codex","title":"\ud83e\udd16 Codex","text":"

    ~/.codex/config.toml (or a project-scoped .codex/config.toml).

    localhostmcp.aetoskia.com
    [mcp_servers.standards]\nurl = \"http://localhost:8009/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"http://localhost:8001/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"http://localhost:8002/mcp\"\n\n[mcp_servers.omniread]\nurl = \"http://localhost:8003/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"http://localhost:8004/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"http://localhost:8005/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"http://localhost:8006/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"http://localhost:8007/mcp\"\n\n[mcp_servers.hexa]\nurl = \"http://localhost:8008/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"http://localhost:8010/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"http://localhost:8011/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"http://localhost:8012/mcp\"\n
    [mcp_servers.standards]\nurl = \"https://mcp.aetoskia.com/standards/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"https://mcp.aetoskia.com/doc-forge/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"https://mcp.aetoskia.com/mail-intake/mcp\"\n\n[mcp_servers.omniread]\nurl = \"https://mcp.aetoskia.com/omniread/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"https://mcp.aetoskia.com/openapi-first/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"https://mcp.aetoskia.com/py-jwt/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"https://mcp.aetoskia.com/dagpipe/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"https://mcp.aetoskia.com/mongo-ops/mcp\"\n\n[mcp_servers.hexa]\nurl = \"https://mcp.aetoskia.com/hexa/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"https://mcp.aetoskia.com/doc-weaver/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"https://mcp.aetoskia.com/react-auth/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"https://mcp.aetoskia.com/react-openapi/mcp\"\n

    Or add them with the CLI: codex mcp add standards --url http://localhost:8009/mcp.

    "},{"location":"05_mcp_setup/#antigravity","title":"\ud83e\udd16 Antigravity","text":"

    ~/.gemini/antigravity/mcp_config.json (IDE) or ~/.gemini/config/mcp_config.json (CLI). Antigravity uses serverUrl, not url, for HTTP endpoints.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"serverUrl\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"serverUrl\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"serverUrl\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"serverUrl\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"serverUrl\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"serverUrl\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"serverUrl\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"serverUrl\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"serverUrl\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"serverUrl\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"serverUrl\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"serverUrl\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"serverUrl\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"serverUrl\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"serverUrl\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"serverUrl\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"serverUrl\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"serverUrl\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"serverUrl\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"serverUrl\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n
    "},{"location":"05_mcp_setup/#opencode","title":"\ud83e\udd16 OpenCode","text":"

    opencode.json (project) or ~/.config/opencode/opencode.json (global).

    localhostmcp.aetoskia.com
    {\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"mcp\": {\n    \"standards\": { \"type\": \"remote\", \"url\": \"http://localhost:8009/mcp\", \"enabled\": true },\n    \"doc-forge\": { \"type\": \"remote\", \"url\": \"http://localhost:8001/mcp\", \"enabled\": true },\n    \"mail-intake\": { \"type\": \"remote\", \"url\": \"http://localhost:8002/mcp\", \"enabled\": true },\n    \"omniread\": { \"type\": \"remote\", \"url\": \"http://localhost:8003/mcp\", \"enabled\": true },\n    \"openapi-first\": { \"type\": \"remote\", \"url\": \"http://localhost:8004/mcp\", \"enabled\": true },\n    \"py-jwt\": { \"type\": \"remote\", \"url\": \"http://localhost:8005/mcp\", \"enabled\": true },\n    \"dagpipe\": { \"type\": \"remote\", \"url\": \"http://localhost:8006/mcp\", \"enabled\": true },\n    \"mongo-ops\": { \"type\": \"remote\", \"url\": \"http://localhost:8007/mcp\", \"enabled\": true },\n    \"hexa\": { \"type\": \"remote\", \"url\": \"http://localhost:8008/mcp\", \"enabled\": true },\n    \"doc-weaver\": { \"type\": \"remote\", \"url\": \"http://localhost:8010/mcp\", \"enabled\": true },\n    \"react-auth\": { \"type\": \"remote\", \"url\": \"http://localhost:8011/mcp\", \"enabled\": true },\n    \"react-openapi\": { \"type\": \"remote\", \"url\": \"http://localhost:8012/mcp\", \"enabled\": true }\n  }\n}\n
    {\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"mcp\": {\n    \"standards\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\", \"enabled\": true },\n    \"doc-forge\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\", \"enabled\": true },\n    \"mail-intake\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\", \"enabled\": true },\n    \"omniread\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\", \"enabled\": true },\n    \"openapi-first\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\", \"enabled\": true },\n    \"py-jwt\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\", \"enabled\": true },\n    \"dagpipe\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\", \"enabled\": true },\n    \"mongo-ops\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\", \"enabled\": true },\n    \"hexa\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\", \"enabled\": true },\n    \"doc-weaver\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\", \"enabled\": true },\n    \"react-auth\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\", \"enabled\": true },\n    \"react-openapi\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\", \"enabled\": true }\n  }\n}\n

    Or add them with the CLI: opencode mcp add standards --url http://localhost:8009/mcp --global.

    "},{"location":"05_mcp_setup/#pycharm-junie-ai-assistant","title":"\ud83e\udd16 PyCharm (Junie / AI Assistant)","text":"

    ~/.junie/mcp/mcp.json (user scope) or .junie/mcp/mcp.json (project scope). The same config is reachable from Settings | Tools | MCP Server \u2192 Manual Client Configuration \u2192 Copy HTTP Stream Config.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"url\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"url\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"url\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"url\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"url\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"url\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"url\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"url\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"url\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"url\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"url\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"url\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n
    "},{"location":"05_mcp_setup/#verify","title":"\u2705 Verify","text":"
    • Health check: curl http://localhost:8000/healthz \u2192 ok.
    • Claude Code: claude mcp list \u2192 each server \u2026 \u2713 Connected.
    • Codex: codex mcp list \u2192 each server \u2026 http.
    • OpenCode: opencode mcp list \u2192 each server \u2026 connected.
    • In-session: run /mcp to list the connected servers and their tools.
    "},{"location":"05_mcp_setup/#reference","title":"\ud83d\udcda Reference","text":"
    • Library Registry \u2014 the catalog each server mirrors, and where find_library resolves
    • Start an App \u2014 connect standards, then invoke start_app
    • App Components \u2014 the component model
    "},{"location":"05_mcp_setup/#related","title":"Related","text":"
    • Start an App \u00b7 Testing & Quality \u00b7 Library Registry
    "},{"location":"04_development/01_testing/","title":"Testing & Quality Gates","text":"

    Two suites run under pytest with asyncio auto mode: integration (the HTTP edge against fakes) and unit (pure domain and pipeline logic). Finish a blueprint app only when every gate below passes.

    "},{"location":"04_development/01_testing/#layout","title":"\ud83d\uddc2\ufe0f Layout","text":"
    tests/\n    integration/     # TestClient(app) + mocked resources\n    unit/            # models, converters, pipeline stages/roots\n    conftest.py      # fixtures\n
    "},{"location":"04_development/01_testing/#integration-tests","title":"\ud83d\ude80 Integration tests","text":"
    • Build the app with TestClient.
    • Replace app.state.db_resources with fake repositories/services so no database is required.
    • Patch the auth introspection call (httpx.AsyncClient.post) to return a canned identity.
    • Assert status codes and bodies for list/get/create/update/delete, plus 400, 404, 204, and the streaming endpoint's fallback.
    client = TestClient(app)\napp.state.db_resources = {\"item_svc\": FakeItemService(), ...}\n\ndef test_create_item():\n    r = client.post(\"/items\", json={\"name\": \"x\"})\n    assert r.status_code == 200\n
    "},{"location":"04_development/01_testing/#unit-tests","title":"\ud83e\uddea Unit tests","text":"
    • Domain models and converters: pure, no I/O.
    • Pipeline stages: feed a fixture state, assert the transformed state.
    • Assembled pipeline roots: fixture parity \u2014 replay a stored fixture input and compare against a golden output file.
    "},{"location":"04_development/01_testing/#config-consistency-tests","title":"\ud83d\udd10 Config-consistency tests","text":"

    For rule-driven pipelines, add a test that cross-references rules against the declared pattern registry: every flag emitted must be defined; every defined flag must be reachable. This catches drift without running the pipeline.

    "},{"location":"04_development/01_testing/#quality-gates","title":"\u2705 Quality gates","text":"

    A definition of done for any standard app:

    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No infrastructure was reimplemented that a library already provides.
    "},{"location":"04_development/01_testing/#conventions","title":"\ud83d\udca1 Conventions","text":"
    • asyncio_mode = \"auto\"; no manual event loops.
    • Fixtures are checked into the repo (inputs + golden outputs).
    • Tests skip (not fail) when a required fixture is absent.
    • Do not mock the domain; mock only the infrastructure edge.
    "},{"location":"04_development/01_testing/#related","title":"Related","text":"
    • 02 \u2013 App Components \u00b7 03 \u2013 Start an App \u00b7 Overview
    "},{"location":"04_development/02_releasing/","title":"Releasing & Versioning","text":"

    How to cut a release of any Aetos repository. The rule lives in the reference page Versioning & Changelog; this is the runbook you follow.

    Every repository is one of two Drone shapes \u2014 a PyPI package or a Docker deployment (Drone Pipelines). Both release the same way: the version bump and changelog promotion go in the merge request, and the tag is created on the merged commit.

    "},{"location":"04_development/02_releasing/#before-you-start","title":"\ud83e\uddfe Before you start","text":"
    • You have an open merge request holding the change, and main is green (the Drone quality gate passed on the last run).
    • You know whether this is a breaking, feature, or fix release.
    • The version file exists:
    • package \u2192 version in pyproject.toml
    • service \u2192 VERSION at the repo root
    "},{"location":"04_development/02_releasing/#pick-the-next-version","title":"\ud83d\udea6 Pick the next version","text":"

    The version file and the tag must be identical. For 0.y.z, 0.y is the major line (see the reference page); once at 1.0.0, normal SemVer applies.

    Change 0.y.z \u2192 1.0.0+ \u2192 Breaking 0.(y+1).0 (x+1).0.0 Feature 0.y.(z+1) x.(y+1).0 Fix 0.y.(z+1) x.y.(z+1)"},{"location":"04_development/02_releasing/#release-in-the-mr","title":"\ud83c\udf3f Release in the MR","text":"

    The version is known while the change is still a merge request, so the bump rides along with it. Do all edits in the MR:

    • Reconcile the changelog first. [Unreleased] is a draft, not the source of truth: diff the change against the last tag (git log <last-tag>..HEAD, git diff --stat <last-tag>..HEAD) and add an entry for every user-facing change that is missing. Always show the proposed additions to the user and wait for approval before promoting \u2014 ask rather than guess when an entry is ambiguous.

    • Promote the changelog in CHANGELOG.md: move the (now reconciled) [Unreleased] content under a new ## [x.y.z] - YYYY-MM-DD heading, leave a fresh empty [Unreleased] on top, and add the compare links \u2014 naming the version/tag, never a commit id:

    [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n[x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n
    • Bump the version file to the intended release version: version = \"x.y.z\" in pyproject.toml (packages) or the VERSION file (services).
    "},{"location":"04_development/02_releasing/#tag-the-merged-commit","title":"\ud83c\udff7\ufe0f Tag the merged commit","text":"
    1. Merge or squash the MR into main.
    2. Create the tag on the resulting commit: git tag x.y.z (lightweight).
    3. Push the tag (git push origin x.y.z) \u2014 Drone builds the release.
    "},{"location":"04_development/02_releasing/#agent-assisted-tagging","title":"\ud83e\udd16 Agent-assisted tagging","text":"

    The agent does not author or merge the MR and never tags before the merge. After you confirm the MR is merged/squashed and tell it to proceed, the agent:

    1. Waits for that explicit go-ahead.
    2. Finds the commit that introduced the bump:
    3. package: git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml
    4. service: git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION
    5. Verifies that commit's version file equals x.y.z, then tags only that commit: git tag x.y.z <sha>.
    6. Does not push \u2014 it asks you to run git push origin x.y.z.
    "},{"location":"04_development/02_releasing/#what-drone-does","title":"\ud83e\udd16 What Drone does","text":"
    • Type A (package): asserts tag == pyproject version, then skips if that version already exists on the index, runs the quality gate, builds, and uploads the wheel + sdist.
    • Type B (service): asserts tag == VERSION, resolves the tag, skips if the image already exists, runs the quality gate, builds, pushes, and restarts the container.

    If the guard fails, nothing is published \u2014 fix the version file, amend the commit, and move the tag only if it was never pushed.

    "},{"location":"04_development/02_releasing/#backfill-recovery","title":"\ud83d\udedf Backfill / recovery","text":"

    If an older tag never published (a past version-file mismatch, an index outage):

    • Package: run the backfill-pypi-from-tags pipeline (custom event). It checks out every tag, verifies tag == pyproject version at that commit, and uploads the ones missing from the index. Already-fixed history is never rewritten.
    • Service: rebuild the image for the missing tag with a fresh commit and a new patch version; do not move a published tag.
    "},{"location":"04_development/02_releasing/#if-a-release-is-wrong","title":"\ud83d\udd01 If a release is wrong","text":"
    • Never reuse or force-move a pushed tag.
    • Fix forward: bump to the next patch, promote the changelog, tag again.
    "},{"location":"04_development/02_releasing/#related","title":"Related","text":"
    • Testing & Quality Gates \u00b7 Versioning & Changelog \u00b7 Drone Pipelines
    "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 Standards \u2014 the Aetos App Blueprint","text":"

    One reusable architecture for every backend application in the Aetos ecosystem: a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure layer, and a single composition root.

    Fill in two axes only and a new app is done: your resource model, and (if needed) your pipeline family. The blueprint itself never names a business domain \u2014 expenses, health metrics, inventory, and analytics are all the same shape underneath.

    Doc model: this wiki is written for humans \u2014 how-to guides, the build recipe, and quality gates. The enforceable rules (layering, composition root, CRUD template, pipeline scaffold) and the library registry live in the reference pages, mirrored by the machine-readable standards MCP bundle. Query find_library before writing any infrastructure.

    "},{"location":"#key-features","title":"\ud83d\ude80 Key Features","text":"
    • \ud83e\udded Spec-first routing \u2014 the OpenAPI document, not decorator routers, is the source of truth (operationId lookup)
    • \ud83c\udfd7\ufe0f One-way layering \u2014 interfaces \u2192 domain \u2192 infra, enforced
    • \ud83d\udd0c Single composition root \u2014 every dependency is wired in exactly one place
    • \ud83e\udde9 Generic, not app-specific \u2014 reusable rules versus two authoring axes
    • \ud83d\udcda Library-backed \u2014 DB, DI, pipelines, ingestion, auth, and docs come from internal libraries, never hand-written
    • \ud83d\udea6 Explicit state machines \u2014 durable job statuses, progress, and human-in-the-loop pauses
    "},{"location":"#documentation-structure","title":"\ud83d\udcc1 Documentation Structure","text":"Section Description Overview Philosophy, the one-way dependency rule, architecture App Components The component model and how the pieces wire together Start an App The entry point: gather the two axes, then build Testing & Quality Test layout and acceptance gates

    Reference (separate site, mirrors the MCP bundle): the enforceable anatomy \u2014 start at the Build Recipe, then the pillars: domains (Layering), the OpenAPI contract (Composition Root, Spec-First Routing, CRUD Template, Auth Integration), interfaces & infra (Infra Adapters), UI (Auto-Rendered Admin), the optional pipelines layer (Pipeline Scaffold, Worker & Progress, Human-in-the-Loop), and delivery/meta (Genericity Matrix, Library Registry).

    "},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"
    • MCP bundle \u2014 standards server on :8009: read docs://index, docs://nav, docs://modules/{module}; call find_library(capability)
    • Library registry \u2014 the catalog of internal libraries behind every app (libraries.json, served as registry://libraries)
    • Template \u2014 the runnable skeleton at standards/src/templates/app/
    • Start a build \u2014 invoke the start_app prompt on the standards server, or read the Start an App guide

    \u00a9 Aetoskia Internal \u2014 standards

    "},{"location":"01_overview/","title":"Overview","text":"

    The blueprint is a hexagonal (ports & adapters) application with a spec-first web edge and a single composition root. It favors explicit wiring over implicit magic and existing internal libraries over hand-written infrastructure. Start here to build the mental model; the enforceable details live in the reference pages.

    "},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"
    flowchart LR\n    subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n        OAS[\"openapi.yaml<br/>operationId contract\"]\n        RT[\"<resource>/routes.py<br/>thin handlers\"]\n    end\n\n    subgraph CORE[\"domain/ \u2014 business core\"]\n        MD[\"models\"]\n        PT[\"ports (adapter binding seam)\"]\n        SV[\"services\"]\n    end\n\n    subgraph INFRA[\"infra/ \u2014 adapters\"]\n        RP[\"mongo repos (mongo-ops)\"]\n        AD[\"cache \u00b7 stores \u00b7 llm \u00b7 mail\"]\n        DB[(\"MongoDB\")]\n    end\n\n    CR[\"init.py<br/>composition root\"]\n\n    OAS --> RT\n    RT --> SV\n    SV --> PT\n    SV --> RP\n    PT -. depends on .- RP\n    CR --- RT\n    CR --- SV\n    CR --- RP\n    RP --> DB\n    AD --- CR

    The rule holds at every edge: code in an outer ring may depend on a ring closer to the core, never the reverse. domain/ never imports a web framework; only its ports/ seam reaches into infra/ to bind adapters.

    Legend \u2014 solid arrows are compile-time calls; dotted arrows are the port/adapter binding, performed by the composition root at startup.

    "},{"location":"01_overview/#the-one-way-dependency-rule","title":"\ud83d\udd04 The one-way dependency rule","text":"Ring May import Never imports interfaces/ domain, framework glue (glue only) business logic, infra directly domain/ its own models + its ports/ seam (which binds infra/libraries) web frameworks, interfaces/ infra/ domain ports, drivers, libraries \u2014 init.py everything (the wiring point) \u2014"},{"location":"01_overview/#core-principles","title":"\ud83e\udded Core principles","text":"
    1. One-way dependencies. interfaces -> domain -> infra is allowed; the reverse is forbidden.
    2. Ports in the domain, adapters in infra. A service depends on a port; the port's factory binds the concrete adapter, and the composition root calls those factories.
    3. One composition root. Nothing constructs its own dependencies. All wiring lives in one module (init.py) exposed as a flat resource registry.
    4. Spec-first. The HTTP contract is declared in openapi.yaml; route handlers are looked up by operationId. There are no decorator routers.
    5. Reuse the libraries. Orchestration, DI, Mongo repositories, ingestion, auth, and docs come from internal libraries \u2014 see the Library Registry.
    6. Explicit state machines. Long-running work moves through explicit, durable statuses and can pause for human input.
    "},{"location":"01_overview/#runtime-processes","title":"\u2699\ufe0f Runtime processes","text":"

    A standard app may run as up to three processes that share the same composition root:

    Process Entry point Responsibility API main.py serve HTTP; start/stop resources via lifespan Worker worker.py poll durable jobs, run pipelines, publish progress Scripts python -m scripts.<name> one-shot operator tasks"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"
    • 02 \u2013 App Components \u00b7 03 \u2013 Start an App
    "},{"location":"02_components/","title":"App Components","text":"

    What ships in every standard app, and how the pieces relate. This is the blueprint's component model \u2014 templates/app/ is a runnable instance of it.

    "},{"location":"02_components/#component-model","title":"\ud83d\uddc2\ufe0f Component model","text":"

    The blueprint has two orthogonal views: runtime flow (who calls whom) and composition-root wiring (who constructs whom). Keeping them separate makes the layering visible instead of hiding it behind a hub.

    "},{"location":"02_components/#runtime-flow","title":"\ud83d\udd00 Runtime flow","text":"

    Who calls whom at request and job time:

    %%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n    subgraph REQ[\"Request path \u2014 API process\"]\n        direction LR\n        CL([\"Client<br/>operationId\"]) --> RT[\"interfaces/&lt;resource&gt;/routes.py\"]\n        RT --> SVC[\"&lt;resource&gt;_svc\"]\n        SVC --> REPO[\"&lt;resource&gt;_repo\"]\n    end\n\n    subgraph WRK[\"Background path \u2014 worker process\"]\n        direction LR\n        W[\"worker.py\"] --> JSVC[\"job_svc\"]\n        W --> PIPE[\"pipelines/&lt;family&gt;\"]\n        PIPE --> ISVC[\"item_svc\"]\n        JSVC --> JREPO[\"job_repo\"]\n        ISVC --> IREPO[\"item_repo\"]\n    end\n\n    subgraph INFRA[\"infra/ \u2014 adapters\"]\n        MONGO[(\"MongoDB / mongo_ops\")]\n    end\n\n    REPO --> MONGO\n    JREPO --> MONGO\n    IREPO --> MONGO

    Legend \u2014 solid arrows are runtime calls. Nothing is self-constructed.

    "},{"location":"02_components/#composition-root-wiring","title":"\ud83e\uddf5 Composition-root wiring","text":"

    Who constructs what at startup:

    %%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n    INIT[\"init.py \u2014 composition root\"]\n    SETUP[\"infra/db/setup.py\"]\n    FACT[\"domain/*/ports/*<br/>factory seams\"]\n    REPOS[\"repos<br/>infra/db/repos/mongo.py\"]\n    SVCES[\"services<br/>CrudsServiceAdaptor\"]\n    STATE[\"app.state.db_resources\"]\n    HEXA[\"hexa.build\"]\n    RT[\"routes.py\"]\n    WK[\"worker.py\"]\n\n    INIT --> SETUP\n    INIT --> FACT\n    FACT --> REPOS\n    FACT --> SVCES\n    REPOS --> SVCES\n    HEXA -.-> SVCES\n    INIT --> STATE\n    STATE --> RT\n    INIT --> WK

    Legend \u2014 init.py is the only place that imports concrete adapters. The ports/ factory seams bind infra; the API reads resources from app.state.db_resources, the worker calls init() directly.

    "},{"location":"02_components/#component-roles","title":"\ud83d\udce6 Component roles","text":""},{"location":"02_components/#1-interfaces-the-edge","title":"1. interfaces/ \u2014 the edge","text":"

    Thin handlers bound to openapi.yaml operationIds. They parse the request, call a service, and shape the response. No business rules, no I/O.

    • interfaces/openapi.py re-exports every route module so the OpenAPI-first app can resolve handlers.
    • Routes may mount the cache decorator on read endpoints.
    "},{"location":"02_components/#2-domain-the-core","title":"2. domain/ \u2014 the core","text":"

    Models, ports, and services (use cases). No framework imports. The ports/ package is the one seam allowed to reach into infra/: each port module declares the document shape and a factory that binds the adapter.

    • <aggregate>/models.py \u2014 dataclasses, enums, document shapes.
    • <aggregate>/ports/db.py \u2014 document model + create_<aggregate>_repo().
    • <aggregate>/ports/service.py \u2014 service + get_<aggregate>_service(repo).
    • Services compose ports and enforce the business rules; the port factories bind the implementations, and the composition root calls them.
    "},{"location":"02_components/#3-infra-the-adapters","title":"3. infra/ \u2014 the adapters","text":"

    Concrete implementations of the domain ports plus cross-cutting infrastructure:

    • db/ \u2014 mongo-ops repositories and the connection lifecycle.
    • adapters/ \u2014 cache store, progress bus, storages, fetchers (library-backed).
    • Anything else the domain needs, implemented against its ports.
    "},{"location":"02_components/#4-pipelines-data-driven-families-optional","title":"4. pipelines/ \u2014 data-driven families (optional)","text":"

    Extraction and enrichment families, each a deterministic step chain (abc.py / impl.py / pipeline.yaml / resources/). Roots are registered in the pattern registry and driven either by a worker job or a CLI.

    "},{"location":"02_components/#5-workerpy-the-job-worker-optional","title":"5. worker.py \u2014 the job worker (optional)","text":"

    A separate process that polls durable job collections, runs pipeline roots, and publishes progress to the progress bus. It shares the same composition root as the API.

    "},{"location":"02_components/#6-initpy-the-composition-root","title":"6. init.py \u2014 the composition root","text":"

    The only place anything is constructed: leaf repositories \u2192 dependent repositories \u2192 services \u2192 adapters. Exposes get_svc overloads and starts / stops resources on the app lifespan.

    "},{"location":"02_components/#how-the-request-flows","title":"\ud83d\udd17 How the request flows","text":"
    sequenceDiagram\n    participant C as Client\n    participant R as route (interfaces)\n    participant S as service (port factory / adapter)\n    participant P as repo (infra)\n    participant DB as MongoDB\n\n    C->>R: POST /items (operationId)\n    R->>S: svc.create(payload)\n    S->>P: repo.insert(doc)\n    P->>DB: insert\n    DB-->>P: ack\n    P-->>S: doc\n    S-->>R: result\n    R-->>C: 200 JSON
    "},{"location":"02_components/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 03 \u2013 Start an App \u00b7 04 \u2013 Testing & Quality
    "},{"location":"03_start_an_app/","title":"\ud83d\ude80 Start an App","text":"

    This is the entry point for building a new application from the blueprint. The goal is one deterministic pass: you supply only the two axes, and the agent does the rest by reading the standards MCP, importing Tier A libraries, and copying Tier B template components.

    Doc model: this page is the human entry point. The deterministic, machine-facing checklist lives in the reference page Build Recipe, served as docs://modules/build_recipe.

    "},{"location":"03_start_an_app/#what-the-agent-needs-from-you","title":"\ud83e\uddfe What the agent needs from you","text":"

    Gather these before starting \u2014 the start_app MCP prompt asks for them:

    1. App name + one-line purpose.
    2. Axis 1 \u2014 resources. Each resource, its fields, and relationships. Paste openapi.yaml if you already have it.
    3. Axis 2 \u2014 pipelines. Only if an input needs a multi-step transformation: the families and their input formats (or \"none\").
    4. Auth needed now? Default no. Auth is deferred (py-jwt primitives only), and an authless app is just a spec with no security keys \u2014 see Spec-First Routing \u2192 Authless apps.
    5. Integrations. Mongo, ingestion formats, email, LLM provider.

    If you can state axes 1 and 2, the rest is mechanical.

    "},{"location":"03_start_an_app/#how-to-start","title":"\u25b6\ufe0f How to start","text":"

    From an MCP client connected to the standards server:

    1. Invoke the intake prompt (or start_app with no arguments) to get the intake questions.
    2. Answer them, then invoke build (build(app_name=..., resources=..., pipelines=..., auth=...)), or use start_app to intake-and-build in one call.
    3. The prompt hands off to docs://modules/build_recipe; the agent scaffolds with get_app_template() and pulls exact files with get_app_component(kind), authoring only the app-specific parts.
    4. To validate the standard without touching a real app, invoke trial (trial(app_name=..., build_path=...)) to build into a scratch directory, prove it builds, and report findings.

    Manual alternative \u2014 copy the skeleton and follow the reference recipe:

    cp -r <docs>/standards/src/templates/app apps/<your-app>\n
    "},{"location":"03_start_an_app/#reference","title":"\ud83d\udcda Reference","text":"
    • Build Recipe \u2014 the deterministic checklist
    • Genericity Matrix \u2014 Tier A / B / C
    • Library Registry \u2014 the Tier A catalog
    • App Components \u2014 the component model
    "},{"location":"03_start_an_app/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 02 \u2013 App Components \u00b7 04 \u2013 Testing & Quality
    "},{"location":"05_mcp_setup/","title":"\ud83d\udd0c MCP Setup","text":"

    Every repository ships its documentation as a Model Context Protocol (MCP) server, and the standards registry server ties them together. The docs portal runs each one as a FastMCP streamable-http app bound to 0.0.0.0:<port>, exposing the endpoint at /mcp (see docs/main.py and docs/config.yml).

    Connect your agent to the standards server first: it answers find_library, list_repos, and the start_app prompt, so the agent reaches for an internal library instead of reimplementing one (see the Library Registry).

    "},{"location":"05_mcp_setup/#run-the-servers-locally","title":"\u25b6\ufe0f Run the servers locally","text":"

    From the docs checkout:

    .venv\\Scripts\\python.exe main.py                      # every server\n.venv\\Scripts\\python.exe main.py --servers standards   # just the registry\n

    :8000 is the health endpoint (GET /healthz), not an MCP server.

    "},{"location":"05_mcp_setup/#servers","title":"\ud83e\udded Servers","text":"Slug MCP server Port localhost mcp.aetoskia.com standards standards 8009 http://localhost:8009/mcp https://mcp.aetoskia.com/standards/mcp doc-forge docforge 8001 http://localhost:8001/mcp https://mcp.aetoskia.com/doc-forge/mcp mail-intake mail_intake 8002 http://localhost:8002/mcp https://mcp.aetoskia.com/mail-intake/mcp omniread omniread 8003 http://localhost:8003/mcp https://mcp.aetoskia.com/omniread/mcp openapi-first openapi_first 8004 http://localhost:8004/mcp https://mcp.aetoskia.com/openapi-first/mcp py-jwt jwtlib 8005 http://localhost:8005/mcp https://mcp.aetoskia.com/py-jwt/mcp dagpipe dagpipe 8006 http://localhost:8006/mcp https://mcp.aetoskia.com/dagpipe/mcp mongo-ops mongo_ops 8007 http://localhost:8007/mcp https://mcp.aetoskia.com/mongo-ops/mcp hexa hexa 8008 http://localhost:8008/mcp https://mcp.aetoskia.com/hexa/mcp doc-weaver doc-weaver 8010 http://localhost:8010/mcp https://mcp.aetoskia.com/doc-weaver/mcp react-auth react-auth 8011 http://localhost:8011/mcp https://mcp.aetoskia.com/react-auth/mcp react-openapi react-openapi 8012 http://localhost:8012/mcp https://mcp.aetoskia.com/react-openapi/mcp

    The hosted endpoints are served over HTTPS at https://mcp.aetoskia.com/<slug>/mcp. The tabs below are linked (content.tabs.link): switching one switches all of them. Every snippet registers all twelve servers, so a single paste is enough.

    "},{"location":"05_mcp_setup/#claude-code","title":"\ud83e\udd16 Claude Code","text":"

    Project-scoped .mcp.json at the repo root. The type field is required \u2014 an entry with a url but no type is read as a stdio server and skipped.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"type\": \"http\", \"url\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"type\": \"http\", \"url\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"type\": \"http\", \"url\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"type\": \"http\", \"url\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"type\": \"http\", \"url\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"type\": \"http\", \"url\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"type\": \"http\", \"url\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"type\": \"http\", \"url\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"type\": \"http\", \"url\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"type\": \"http\", \"url\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"type\": \"http\", \"url\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"type\": \"http\", \"url\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n

    Or add them with the CLI: claude mcp add --transport http standards http://localhost:8009/mcp.

    "},{"location":"05_mcp_setup/#codex","title":"\ud83e\udd16 Codex","text":"

    ~/.codex/config.toml (or a project-scoped .codex/config.toml).

    localhostmcp.aetoskia.com
    [mcp_servers.standards]\nurl = \"http://localhost:8009/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"http://localhost:8001/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"http://localhost:8002/mcp\"\n\n[mcp_servers.omniread]\nurl = \"http://localhost:8003/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"http://localhost:8004/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"http://localhost:8005/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"http://localhost:8006/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"http://localhost:8007/mcp\"\n\n[mcp_servers.hexa]\nurl = \"http://localhost:8008/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"http://localhost:8010/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"http://localhost:8011/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"http://localhost:8012/mcp\"\n
    [mcp_servers.standards]\nurl = \"https://mcp.aetoskia.com/standards/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"https://mcp.aetoskia.com/doc-forge/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"https://mcp.aetoskia.com/mail-intake/mcp\"\n\n[mcp_servers.omniread]\nurl = \"https://mcp.aetoskia.com/omniread/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"https://mcp.aetoskia.com/openapi-first/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"https://mcp.aetoskia.com/py-jwt/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"https://mcp.aetoskia.com/dagpipe/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"https://mcp.aetoskia.com/mongo-ops/mcp\"\n\n[mcp_servers.hexa]\nurl = \"https://mcp.aetoskia.com/hexa/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"https://mcp.aetoskia.com/doc-weaver/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"https://mcp.aetoskia.com/react-auth/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"https://mcp.aetoskia.com/react-openapi/mcp\"\n

    Or add them with the CLI: codex mcp add standards --url http://localhost:8009/mcp.

    "},{"location":"05_mcp_setup/#antigravity","title":"\ud83e\udd16 Antigravity","text":"

    ~/.gemini/antigravity/mcp_config.json (IDE) or ~/.gemini/config/mcp_config.json (CLI). Antigravity uses serverUrl, not url, for HTTP endpoints.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"serverUrl\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"serverUrl\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"serverUrl\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"serverUrl\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"serverUrl\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"serverUrl\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"serverUrl\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"serverUrl\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"serverUrl\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"serverUrl\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"serverUrl\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"serverUrl\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"serverUrl\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"serverUrl\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"serverUrl\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"serverUrl\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"serverUrl\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"serverUrl\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"serverUrl\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"serverUrl\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n
    "},{"location":"05_mcp_setup/#opencode","title":"\ud83e\udd16 OpenCode","text":"

    opencode.json (project) or ~/.config/opencode/opencode.json (global).

    localhostmcp.aetoskia.com
    {\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"mcp\": {\n    \"standards\": { \"type\": \"remote\", \"url\": \"http://localhost:8009/mcp\", \"enabled\": true },\n    \"doc-forge\": { \"type\": \"remote\", \"url\": \"http://localhost:8001/mcp\", \"enabled\": true },\n    \"mail-intake\": { \"type\": \"remote\", \"url\": \"http://localhost:8002/mcp\", \"enabled\": true },\n    \"omniread\": { \"type\": \"remote\", \"url\": \"http://localhost:8003/mcp\", \"enabled\": true },\n    \"openapi-first\": { \"type\": \"remote\", \"url\": \"http://localhost:8004/mcp\", \"enabled\": true },\n    \"py-jwt\": { \"type\": \"remote\", \"url\": \"http://localhost:8005/mcp\", \"enabled\": true },\n    \"dagpipe\": { \"type\": \"remote\", \"url\": \"http://localhost:8006/mcp\", \"enabled\": true },\n    \"mongo-ops\": { \"type\": \"remote\", \"url\": \"http://localhost:8007/mcp\", \"enabled\": true },\n    \"hexa\": { \"type\": \"remote\", \"url\": \"http://localhost:8008/mcp\", \"enabled\": true },\n    \"doc-weaver\": { \"type\": \"remote\", \"url\": \"http://localhost:8010/mcp\", \"enabled\": true },\n    \"react-auth\": { \"type\": \"remote\", \"url\": \"http://localhost:8011/mcp\", \"enabled\": true },\n    \"react-openapi\": { \"type\": \"remote\", \"url\": \"http://localhost:8012/mcp\", \"enabled\": true }\n  }\n}\n
    {\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"mcp\": {\n    \"standards\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\", \"enabled\": true },\n    \"doc-forge\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\", \"enabled\": true },\n    \"mail-intake\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\", \"enabled\": true },\n    \"omniread\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\", \"enabled\": true },\n    \"openapi-first\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\", \"enabled\": true },\n    \"py-jwt\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\", \"enabled\": true },\n    \"dagpipe\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\", \"enabled\": true },\n    \"mongo-ops\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\", \"enabled\": true },\n    \"hexa\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\", \"enabled\": true },\n    \"doc-weaver\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\", \"enabled\": true },\n    \"react-auth\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\", \"enabled\": true },\n    \"react-openapi\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\", \"enabled\": true }\n  }\n}\n

    Or add them with the CLI: opencode mcp add standards --url http://localhost:8009/mcp --global.

    "},{"location":"05_mcp_setup/#pycharm-junie-ai-assistant","title":"\ud83e\udd16 PyCharm (Junie / AI Assistant)","text":"

    ~/.junie/mcp/mcp.json (user scope) or .junie/mcp/mcp.json (project scope). The same config is reachable from Settings | Tools | MCP Server \u2192 Manual Client Configuration \u2192 Copy HTTP Stream Config.

    localhostmcp.aetoskia.com
    {\n  \"mcpServers\": {\n    \"standards\": { \"url\": \"http://localhost:8009/mcp\" },\n    \"doc-forge\": { \"url\": \"http://localhost:8001/mcp\" },\n    \"mail-intake\": { \"url\": \"http://localhost:8002/mcp\" },\n    \"omniread\": { \"url\": \"http://localhost:8003/mcp\" },\n    \"openapi-first\": { \"url\": \"http://localhost:8004/mcp\" },\n    \"py-jwt\": { \"url\": \"http://localhost:8005/mcp\" },\n    \"dagpipe\": { \"url\": \"http://localhost:8006/mcp\" },\n    \"mongo-ops\": { \"url\": \"http://localhost:8007/mcp\" },\n    \"hexa\": { \"url\": \"http://localhost:8008/mcp\" },\n    \"doc-weaver\": { \"url\": \"http://localhost:8010/mcp\" },\n    \"react-auth\": { \"url\": \"http://localhost:8011/mcp\" },\n    \"react-openapi\": { \"url\": \"http://localhost:8012/mcp\" }\n  }\n}\n
    {\n  \"mcpServers\": {\n    \"standards\": { \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n    \"doc-forge\": { \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n    \"mail-intake\": { \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n    \"omniread\": { \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n    \"openapi-first\": { \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n    \"py-jwt\": { \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n    \"dagpipe\": { \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n    \"mongo-ops\": { \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n    \"hexa\": { \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n    \"doc-weaver\": { \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n    \"react-auth\": { \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n    \"react-openapi\": { \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n  }\n}\n
    "},{"location":"05_mcp_setup/#verify","title":"\u2705 Verify","text":"
    • Health check: curl http://localhost:8000/healthz \u2192 ok.
    • Claude Code: claude mcp list \u2192 each server \u2026 \u2713 Connected.
    • Codex: codex mcp list \u2192 each server \u2026 http.
    • OpenCode: opencode mcp list \u2192 each server \u2026 connected.
    • In-session: run /mcp to list the connected servers and their tools.
    "},{"location":"05_mcp_setup/#reference","title":"\ud83d\udcda Reference","text":"
    • Library Registry \u2014 the catalog each server mirrors, and where find_library resolves
    • Start an App \u2014 connect standards, then invoke start_app
    • App Components \u2014 the component model
    "},{"location":"05_mcp_setup/#related","title":"Related","text":"
    • Start an App \u00b7 Testing & Quality \u00b7 Library Registry
    "},{"location":"04_development/01_testing/","title":"Testing & Quality Gates","text":"

    Two suites run under pytest with asyncio auto mode: integration (the HTTP edge against fakes) and unit (pure domain and pipeline logic). Finish a blueprint app only when every gate below passes.

    "},{"location":"04_development/01_testing/#layout","title":"\ud83d\uddc2\ufe0f Layout","text":"
    tests/\n    integration/     # TestClient(app) + mocked resources\n    unit/            # models, converters, pipeline stages/roots\n    conftest.py      # fixtures\n
    "},{"location":"04_development/01_testing/#integration-tests","title":"\ud83d\ude80 Integration tests","text":"
    • Build the app with TestClient.
    • Replace app.state.db_resources with fake repositories/services so no database is required.
    • Patch the auth introspection call (httpx.AsyncClient.post) to return a canned identity.
    • Assert status codes and bodies for list/get/create/update/delete, plus 400, 404, 204, and the streaming endpoint's fallback.
    client = TestClient(app)\napp.state.db_resources = {\"item_svc\": FakeItemService(), ...}\n\ndef test_create_item():\n    r = client.post(\"/items\", json={\"name\": \"x\"})\n    assert r.status_code == 200\n
    "},{"location":"04_development/01_testing/#unit-tests","title":"\ud83e\uddea Unit tests","text":"
    • Domain models and converters: pure, no I/O.
    • Pipeline stages: feed a fixture state, assert the transformed state.
    • Assembled pipeline roots: fixture parity \u2014 replay a stored fixture input and compare against a golden output file.
    "},{"location":"04_development/01_testing/#config-consistency-tests","title":"\ud83d\udd10 Config-consistency tests","text":"

    For rule-driven pipelines, add a test that cross-references rules against the declared pattern registry: every flag emitted must be defined; every defined flag must be reachable. This catches drift without running the pipeline.

    "},{"location":"04_development/01_testing/#quality-gates","title":"\u2705 Quality gates","text":"

    A definition of done for any standard app:

    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No infrastructure was reimplemented that a library already provides.
    "},{"location":"04_development/01_testing/#conventions","title":"\ud83d\udca1 Conventions","text":"
    • asyncio_mode = \"auto\"; no manual event loops.
    • Fixtures are checked into the repo (inputs + golden outputs).
    • Tests skip (not fail) when a required fixture is absent.
    • Do not mock the domain; mock only the infrastructure edge.
    "},{"location":"04_development/01_testing/#related","title":"Related","text":"
    • 02 \u2013 App Components \u00b7 03 \u2013 Start an App \u00b7 Overview
    "},{"location":"04_development/02_releasing/","title":"Releasing & Versioning","text":"

    How to cut a release of any Aetos repository. The rule lives in the reference page Versioning & Changelog; this is the runbook you follow.

    Every repository is one of two Drone shapes \u2014 a PyPI package or a Docker deployment (Drone Pipelines). Both release the same way: the version bump and changelog promotion go in the merge request, and the tag is created on the merged commit.

    "},{"location":"04_development/02_releasing/#before-you-start","title":"\ud83e\uddfe Before you start","text":"
    • You have an open merge request holding the change, and main is green (the Drone quality gate passed on the last run).
    • You know whether this is a breaking, feature, or fix release.
    • The version file exists:
    • package \u2192 version in pyproject.toml
    • service \u2192 VERSION at the repo root
    "},{"location":"04_development/02_releasing/#pick-the-next-version","title":"\ud83d\udea6 Pick the next version","text":"

    The version file and the tag must be identical. For 0.y.z, 0.y is the major line (see the reference page); once at 1.0.0, normal SemVer applies.

    Change 0.y.z \u2192 1.0.0+ \u2192 Breaking 0.(y+1).0 (x+1).0.0 Feature 0.y.(z+1) x.(y+1).0 Fix 0.y.(z+1) x.y.(z+1)"},{"location":"04_development/02_releasing/#release-in-the-mr","title":"\ud83c\udf3f Release in the MR","text":"

    The version is known while the change is still a merge request, so the bump rides along with it. Do all edits in the MR:

    • Reconcile the changelog first. [Unreleased] is a draft, not the source of truth: diff the change against the last tag (git log <last-tag>..HEAD, git diff --stat <last-tag>..HEAD) and add an entry for every user-facing change that is missing. Always show the proposed additions to the user and wait for approval before promoting \u2014 ask rather than guess when an entry is ambiguous.

    • Promote the changelog in CHANGELOG.md: move the (now reconciled) [Unreleased] content under a new ## [x.y.z] - YYYY-MM-DD heading, leave a fresh empty [Unreleased] on top, and add the compare links \u2014 naming the version/tag, never a commit id:

    [Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n[x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n
    • Bump the version file to the intended release version: version = \"x.y.z\" in pyproject.toml (packages) or the VERSION file (services).
    "},{"location":"04_development/02_releasing/#tag-the-merged-commit","title":"\ud83c\udff7\ufe0f Tag the merged commit","text":"
    1. Merge or squash the MR into main.
    2. Create the tag on the resulting commit: git tag x.y.z (lightweight).
    3. Push the tag (git push origin x.y.z) \u2014 Drone builds the release.
    "},{"location":"04_development/02_releasing/#agent-assisted-tagging","title":"\ud83e\udd16 Agent-assisted tagging","text":"

    The agent does not author or merge the MR and never tags before the merge. After you confirm the MR is merged/squashed and tell it to proceed, the agent:

    1. Waits for that explicit go-ahead.
    2. Finds the commit that introduced the bump:
    3. package: git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml
    4. service: git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION
    5. Verifies that commit's version file equals x.y.z, then tags only that commit: git tag x.y.z <sha>.
    6. Does not push \u2014 it asks you to run git push origin x.y.z.
    "},{"location":"04_development/02_releasing/#what-drone-does","title":"\ud83e\udd16 What Drone does","text":"
    • Type A (package): asserts tag == pyproject version, then skips if that version already exists on the index, runs the quality gate, builds, and uploads the wheel + sdist.
    • Type B (service): asserts tag == VERSION, resolves the tag, skips if the image already exists, runs the quality gate, builds, pushes, and restarts the container.

    If the guard fails, nothing is published \u2014 fix the version file, amend the commit, and move the tag only if it was never pushed.

    "},{"location":"04_development/02_releasing/#backfill-recovery","title":"\ud83d\udedf Backfill / recovery","text":"

    If an older tag never published (a past version-file mismatch, an index outage):

    • Package: run the backfill-pypi-from-tags pipeline (custom event). It checks out every tag, verifies tag == pyproject version at that commit, and uploads the ones missing from the index. Already-fixed history is never rewritten.
    • Service: rebuild the image for the missing tag with a fresh commit and a new patch version; do not move a published tag.
    "},{"location":"04_development/02_releasing/#if-a-release-is-wrong","title":"\ud83d\udd01 If a release is wrong","text":"
    • Never reuse or force-move a pushed tag.
    • Fix forward: bump to the next patch, promote the changelog, tag again.
    "},{"location":"04_development/02_releasing/#related","title":"Related","text":"
    • Testing & Quality Gates \u00b7 Versioning & Changelog \u00b7 Drone Pipelines
    "}]} \ No newline at end of file