From d7c344c9aa0a8a83e67c1359a6401bdb24a85e06 Mon Sep 17 00:00:00 2001
From: Vishesh 'ironeagle' Bangotra Every Aetos repository ships a Drone CI definition at Both templates run on All templates run on Conventions: For internal libraries. The file contains two pipelines: For internal Python libraries. The file contains two pipelines:Drone Pipelines
Summary
.drone.yml. There is
-exactly one standard per repository, and there are two types of file,
+exactly one standard per repository, and there are three types of file,
chosen by what the repository produces:
@@ -675,23 +684,30 @@ chosen by what the repository produces:
-dagpipe, doc-forge, hexa, mail-intake, mongo-ops, omniread, openapi-first, py-jwt
-
+B — Docker deployment
+B — npm package
+
+templates/drone/npm-lib.yamlA package published to the private npm registry
+
+doc-weaver, react-auth, react-openapi
+
C — Docker deployment
templates/drone/service-docker.yamlAn image pushed to the registry and run as a container
-
+auth-serverauth-server, vetplatform: linux / arm64 with the workspace at
-/drone/src. Copy the matching template to <repo>/.drone.yml; the Type A file
-needs no edits, the Type B file needs its placeholders filled in (below).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).
.drone.yml (not .yaml), one file per repo, secrets injected
with from_secret — never commit credentials.
Type A — PyPI package (
-python-lib.yaml)
build-and-publish-pypi — triggered on tag:check-version — guard: fail unless $DRONE_TAG equals the version in
@@ -716,7 +732,28 @@ with from_secret — never commit credentials.
service-docker.yaml)npm-lib.yaml)For internal npm libraries (the React packages). One pipeline,
+build-and-publish-npm, triggered on tag:
check-version — 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 — write .npmrc from the secrets, npm install, then
+ npm run quality-gate.build-package — npm install && npm run build into dist/.publish-to-private-npm — 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 — the committed .npmrc in a repo only points at the registry URL.
package.json, so the same file
+ is reused verbatim across every npm package.service-docker.yaml)For a service that ships a container image — a long-running API/worker, e.g.
auth-server. The file contains one pipeline, build-and-deploy-service,
triggered on tag:
tag:
from_secret.check-version / check-remote-image skip — it guards the
index and the running container against duplicate publishes.pyproject.toml
- version to equal the tag; a service needs a root VERSION file that equals
- the tag. See 91 – Versioning & Changelog.pyproject.toml version to equal the tag; an npm package needs its
+ package.json version; a service needs a root VERSION file. See
+ 91 – Versioning & Changelog.build-and-publish-pypi and build-and-deploy-service.build-and-publish-pypi, build-and-publish-npm, and
+ build-and-deploy-service.
This page pairs with 90 – Drone Pipelines: the version -and tag contract is exactly what those two CI shapes consume.
+and tag contract is exactly what those three CI shapes consume.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) — never hardcode the version |
@@ -838,7 +843,8 @@ bump travels with the change and only the tag is added afterwards:
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 |
+
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.
VERSION file; Drone file is service-docker.yaml
-package
+package (PyPI)
pyproject.toml
CHANGELOG.md
python-lib.yaml
-service
+package (npm)
+package.json
+CHANGELOG.md
+npm-lib.yaml
+
+
+service / app
VERSION
CHANGELOG.md
service-docker.yaml
diff --git a/standards/lib/index.html b/standards/lib/index.html
index a3240b5..46131db 100644
--- a/standards/lib/index.html
+++ b/standards/lib/index.html
@@ -821,8 +821,8 @@ and an optional pipelines layer.
Delivery & Meta
-- Drone Pipelines — the two CI shapes: a PyPI/npm package or a Docker
- deployment.
+- Drone Pipelines — the three CI shapes: a PyPI package, an npm package,
+ or a Docker deployment.
- Versioning & Changelog — SemVer, the tag↔version contract, and the
release changelog.
- Repository Inventory — every covered repo, its type, version source, and
diff --git a/standards/lib/search/search_index.json b/standards/lib/search/search_index.json
index d5d2d7a..70f8289 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 two CI shapes: a PyPI/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:
- Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required
id only on the wire model. - Order the bases so the document wins:
class ItemDocument(BaseDocument, Item). - 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":" - Never trust an unverified token. Decode through the generated dependency or
jwtlib before handling the request. - Treat
active: false as unauthenticated, even if the JWT decodes. - Do not implement your own JWT parsing. Revocation and issuer changes must stay centralized in the auth-server.
- 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":" - The spec owns the UI. Add a resource or rename a column in
openapi.yaml; the admin follows. Never fork the renderer per app. - 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.
- 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. - 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":" - 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
- The orchestrator catches it, records a question document, and re-raises so the worker marks the job
paused. - A client lists pending questions and answers one:
GET /jobs/{id}/questions -> list pending questions\nPOST /questions/{qid}/resolve {choice} -> record the choice\n
- When the last pending question is resolved, the resolve endpoint flips the parent job back to
pending. - 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 two 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 Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container auth-server Both templates run on platform: linux / arm64 with the workspace at /drone/src. Copy the matching template to <repo>/.drone.yml; the Type A file needs no edits, the Type B 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 libraries. The file contains two pipelines:
build-and-publish-pypi \u2014 triggered on tag: 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). quality-gate \u2014 install the package with its [dev] extras from the private index, then black --check, ruff check, mypy, pydoclint, pytest. build-package \u2014 python -m build into dist/. upload-to-private-pypi \u2014 twine upload to $PIP_REPO_URL. backfill-pypi-from-tags \u2014 triggered on custom: fetch-tags \u2014 git fetch --tags --force. 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-docker-deployment-service-dockeryaml","title":"Type B \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 third shape. A repo is either Type A or Type B, never both.
- 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 package needs its
pyproject.toml version to equal the tag; a service needs a root VERSION file that equals the tag. See 91 \u2013 Versioning & Changelog. - Do not rename the pipelines; tooling and runbooks key off
build-and-publish-pypi 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 two 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 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:
- One version per repository. No per-module versions.
- 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. - 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.
- 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:
- List what actually changed since the last release:
git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD. - Compare that against
[Unreleased]; 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. 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:
- 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 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. - Merge or squash the MR into
main. - Create the tag
x.y.z on the resulting commit (the merge/squash commit). - 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:
- Wait for that explicit go-ahead.
- Find the commit that introduced the version bump:
- packages:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml - services:
git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION - Confirm that commit's version file equals
x.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>. - 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":" - Confirm the quality gate is green and
main is up to date. - 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. - In the MR: add the two compare-link references at the bottom of the changelog, naming the version/tag (never a commit id).
- In the MR: set the version file \u2014
pyproject.toml (version = \"x.y.z\") or VERSION \u2014 to the intended release version. - Merge or squash the MR into
main. - Create the tag on the resulting commit: lightweight
git tag x.y.z. - Push the tag (
git push origin x.y.z). Watch Drone: Type A publishes the wheel/sdist, Type B builds and deploys the image; both first assert tag == version. - 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:
- Resource model \u2014 the set of resources, their fields, relationships, and the OpenAPI contract.
- 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 two 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","title":"Packages (Type A)","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/#services-type-b","title":"Services (Type B)","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 pyproject.toml CHANGELOG.md python-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
+{"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:
- Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required
id only on the wire model. - Order the bases so the document wins:
class ItemDocument(BaseDocument, Item). - 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":" - Never trust an unverified token. Decode through the generated dependency or
jwtlib before handling the request. - Treat
active: false as unauthenticated, even if the JWT decodes. - Do not implement your own JWT parsing. Revocation and issuer changes must stay centralized in the auth-server.
- 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":" - The spec owns the UI. Add a resource or rename a column in
openapi.yaml; the admin follows. Never fork the renderer per app. - 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.
- 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. - 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":" - 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
- The orchestrator catches it, records a question document, and re-raises so the worker marks the job
paused. - A client lists pending questions and answers one:
GET /jobs/{id}/questions -> list pending questions\nPOST /questions/{qid}/resolve {choice} -> record the choice\n
- When the last pending question is resolved, the resolve endpoint flips the parent job back to
pending. - 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:
build-and-publish-pypi \u2014 triggered on tag: 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). quality-gate \u2014 install the package with its [dev] extras from the private index, then black --check, ruff check, mypy, pydoclint, pytest. build-package \u2014 python -m build into dist/. upload-to-private-pypi \u2014 twine upload to $PIP_REPO_URL. backfill-pypi-from-tags \u2014 triggered on custom: fetch-tags \u2014 git fetch --tags --force. 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:
- One version per repository. No per-module versions.
- 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. - 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.
- 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:
- List what actually changed since the last release:
git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD. - Compare that against
[Unreleased]; 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. 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:
- 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. - Merge or squash the MR into
main. - Create the tag
x.y.z on the resulting commit (the merge/squash commit). - 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:
- Wait for that explicit go-ahead.
- Find the commit that introduced the version bump:
- Python packages:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml - npm packages:
git log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.json - services:
git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION - Confirm that commit's version file equals
x.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>. - 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":" - Confirm the quality gate is green and
main is up to date. - 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. - In the MR: add the two compare-link references at the bottom of the changelog, naming the version/tag (never a commit id).
- 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. - Merge or squash the MR into
main. - Create the tag on the resulting commit: lightweight
git tag x.y.z. - 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. - 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:
- Resource model \u2014 the set of resources, their fields, relationships, and the OpenAPI contract.
- 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
diff --git a/standards/lib/sitemap.xml.gz b/standards/lib/sitemap.xml.gz
index 7140084f63cdc7ac094f9b92e2c304376b6ad4a8..4a88da2ba663d03469325173240c2b00a79c04ce 100644
GIT binary patch
delta 13
Ucmb=gXP58h;9yvPZ6bRG031#PRR910
delta 13
Ucmb=gXP58h;An`wI+48s03EOdf&c&j
diff --git a/standards/mcp/client.json b/standards/mcp/client.json
new file mode 100644
index 0000000..dcf6cf7
--- /dev/null
+++ b/standards/mcp/client.json
@@ -0,0 +1,102 @@
+{
+ "project": "standards",
+ "template": "templates/client",
+ "components": [
+ {
+ "kind": "client-package",
+ "path": "package.json",
+ "placeholder": "",
+ "description": "npm manifest: @aetoskia deps, scripts (dev/build/preview)."
+ },
+ {
+ "kind": "client-npmrc",
+ "path": ".npmrc",
+ "placeholder": "",
+ "description": "Private registry auth pointing at npm.aetoskia.com."
+ },
+ {
+ "kind": "client-env",
+ "path": ".env.example",
+ "placeholder": "",
+ "description": "VITE_API_BASE_URL / VITE_AUTH_BASE_URL seed."
+ },
+ {
+ "kind": "client-vite-config",
+ "path": "vite.config.ts",
+ "placeholder": "",
+ "description": "Vite config (dev server, build)."
+ },
+ {
+ "kind": "client-tsconfig",
+ "path": "tsconfig.json",
+ "placeholder": "",
+ "description": "TypeScript config for the client."
+ },
+ {
+ "kind": "client-index-html",
+ "path": "index.html",
+ "placeholder": "",
+ "description": "HTML entry point."
+ },
+ {
+ "kind": "client-entry",
+ "path": "src/main.jsx",
+ "placeholder": "",
+ "description": "React entry: router + providers + theme."
+ },
+ {
+ "kind": "client-openapi-config",
+ "path": "src/openapi-config.ts",
+ "placeholder": "",
+ "description": "Points the admin renderer at the app spec + auth-server spec."
+ },
+ {
+ "kind": "client-home",
+ "path": "src/Home.tsx",
+ "placeholder": "",
+ "description": "Auto-listing home page (ResourceCard + LiveTable per resource)."
+ },
+ {
+ "kind": "client-header",
+ "path": "src/Header.tsx",
+ "placeholder": "",
+ "description": "App header bar (brand + nav)."
+ },
+ {
+ "kind": "client-footer",
+ "path": "src/Footer.tsx",
+ "placeholder": "",
+ "description": "App footer."
+ },
+ {
+ "kind": "client-vite-env",
+ "path": "src/vite-env.d.ts",
+ "placeholder": "",
+ "description": "Vite client type declarations."
+ },
+ {
+ "kind": "client-theme",
+ "path": "src/shared-theme/AppTheme.tsx",
+ "placeholder": "",
+ "description": "Theme provider wrapper (light/dark)."
+ },
+ {
+ "kind": "client-theme-config",
+ "path": "src/shared-theme/themeConfig.ts",
+ "placeholder": "",
+ "description": "Palette / typography tokens."
+ },
+ {
+ "kind": "client-theme-primitives",
+ "path": "src/shared-theme/themePrimitives.ts",
+ "placeholder": "",
+ "description": "MUI theme primitives (shadows, accents)."
+ },
+ {
+ "kind": "client-theme-shim",
+ "path": "src/shared-theme/mui.d.ts",
+ "placeholder": "",
+ "description": "MUI v5 type shims (Theme.vars, SelectClasses.focused)."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/standards/mcp/drone.json b/standards/mcp/drone.json
index 88e1bea..630934a 100644
--- a/standards/mcp/drone.json
+++ b/standards/mcp/drone.json
@@ -3,6 +3,7 @@
"template": "templates/drone",
"types": [
"pypi",
+ "npm",
"docker"
],
"components": [
@@ -12,6 +13,12 @@
"placeholder": "",
"description": "Drone pipeline: build + publish a Python package to the private PyPI index."
},
+ {
+ "kind": "drone-npm",
+ "path": "npm-lib.yaml",
+ "placeholder": "",
+ "description": "Drone pipeline: build + publish an npm package to the private npm registry."
+ },
{
"kind": "drone-service",
"path": "service-docker.yaml",
diff --git a/standards/mcp/modules/drone_pipelines.json b/standards/mcp/modules/drone_pipelines.json
index 9cba6e0..0e5bf50 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 **two 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 — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server` |\n\nBoth templates run on `platform: linux / arm64` with the workspace at\n`/drone/src`. Copy the matching template to `/.drone.yml`; the Type A file\nneeds no edits, the Type B file needs its placeholders filled in (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 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 — 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 third shape. A repo is either Type A or Type B, never both.\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 package needs its `pyproject.toml`\n version to equal the tag; a service needs a root `VERSION` file that equals\n the tag. See [91 – Versioning & Changelog](91_versioning_and_changelog.md).\n- Do not rename the pipelines; tooling and runbooks key off\n `build-and-publish-pypi` and `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`, `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",
"objects": {}
}
}
\ No newline at end of file
diff --git a/standards/mcp/modules/index.json b/standards/mcp/modules/index.json
index 92cb6ff..4bd5073 100644
--- a/standards/mcp/modules/index.json
+++ b/standards/mcp/modules/index.json
@@ -2,7 +2,7 @@
"module": "index",
"content": {
"path": "index",
- "docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\nThe reference is organized as an **anatomy** — one entry checklist and four\npillars describing what an application is made of, plus delivery/meta pages\nand an optional pipelines layer.\n\n## Anatomy\n\n- **Build Recipe** — the ordered, agentic checklist for a new app; the entry\n point that walks the whole anatomy.\n\n## Pillar 1 — Domains\n\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n\n## Pillar 2 — OpenAPI Spec (the contract)\n\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n\n## Pillar 3 — Interfaces & Infra\n\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n\n## Pillar 4 — UI\n\n- **UI: Auto-Rendered Admin & Custom Pages** — the `@aetoskia/react-openapi`\n admin rendered from the spec's x- extensions, and `@aetoskia/react-auth`\n for auth pages; the client template scaffold.\n\n## Pipelines (optional layer)\n\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n\n## Delivery & Meta\n\n- **Drone Pipelines** — the two CI shapes: a PyPI/npm package or a Docker\n deployment.\n- **Versioning & Changelog** — SemVer, the tag↔version contract, and the\n release changelog.\n- **Repository Inventory** — every covered repo, its type, version source, and\n Drone shape.\n- **Genericity Matrix** — what is library-backed, template-provided, or\n authored.\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\n",
+ "docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\nThe reference is organized as an **anatomy** — one entry checklist and four\npillars describing what an application is made of, plus delivery/meta pages\nand an optional pipelines layer.\n\n## Anatomy\n\n- **Build Recipe** — the ordered, agentic checklist for a new app; the entry\n point that walks the whole anatomy.\n\n## Pillar 1 — Domains\n\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n\n## Pillar 2 — OpenAPI Spec (the contract)\n\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n\n## Pillar 3 — Interfaces & Infra\n\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n\n## Pillar 4 — UI\n\n- **UI: Auto-Rendered Admin & Custom Pages** — the `@aetoskia/react-openapi`\n admin rendered from the spec's x- extensions, and `@aetoskia/react-auth`\n for auth pages; the client template scaffold.\n\n## Pipelines (optional layer)\n\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n\n## Delivery & Meta\n\n- **Drone Pipelines** — the three CI shapes: a PyPI package, an npm package,\n or a Docker deployment.\n- **Versioning & Changelog** — SemVer, the tag↔version contract, and the\n release changelog.\n- **Repository Inventory** — every covered repo, its type, version source, and\n Drone shape.\n- **Genericity Matrix** — what is library-backed, template-provided, or\n authored.\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\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 fbd3b0e..2131e3b 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 two 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)\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## Services (Type B)\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 | `pyproject.toml` | `CHANGELOG.md` | `python-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",
+ "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",
"objects": {}
}
}
\ No newline at end of file
diff --git a/standards/mcp/modules/versioning_and_changelog.json b/standards/mcp/modules/versioning_and_changelog.json
index 1739932..7df1be0 100644
--- a/standards/mcp/modules/versioning_and_changelog.json
+++ b/standards/mcp/modules/versioning_and_changelog.json
@@ -2,7 +2,7 @@
"module": "versioning_and_changelog",
"content": {
"path": "versioning_and_changelog",
- "docstring": "# Versioning & Changelog\n\n## Summary\n\nEvery Aetos artifact — library or service — is versioned with\n[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and documents its\nreleases in a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) file.\nThe **version file is the source of truth**, the **git tag must equal it**, and\na Drone guard enforces that equality before anything is published. Releases are\ncut manually; a tag is what triggers the pipeline.\n\nThis page pairs with [90 – Drone Pipelines](90_drone_pipelines.md): the version\nand tag contract is exactly what those two CI shapes consume.\n\n---\n\n## The version contract\n\n| Artifact | Source of truth | Notes |\n|----------|-----------------|-------|\n| Library (PyPI package) | `version` in `pyproject.toml` | `check-version` also reads it to skip duplicates |\n| Service (Docker) | `VERSION` file at the repo root | one line, `x.y.z`; the service **reads** it (build-arg) — never hardcode the version |\n\nRules:\n\n1. **One version per repository.** No per-module versions.\n2. **Tag = version.** The git tag is bare SemVer (`0.0.6`), no `v` prefix, and\n it MUST match the version file byte-for-byte. The Drone guard fails the\n build otherwise — this is what stops a tag from silently publishing nothing.\n3. **A release is a tag, created after the change is merged.** The version bump\n and the changelog promotion travel inside the merge request (MR); the tag is\n added on the merged commit and pushed. See [Tagging strategy](#tagging-strategy-mr-based).\n4. **Never reuse a tag.** If a release is wrong, cut a new patch version.\n\n### Pre-1.0 (0.y.z)\n\nWhile a package is `0.y.z`, **`0.y` is treated as the major line**:\n\n- breaking change → `0.(y+1).0`\n- new feature → `0.y.(z+1)`\n- bug fix → `0.y.(z+1)`\n\nOnce a package reaches `1.0.0`, normal SemVer applies. Do not ship `1.0.0`\nuntil the public API is considered stable.\n\n---\n\n## The changelog\n\n`CHANGELOG.md` at the repo root, in Keep a Changelog form. It always has exactly\none open `[Unreleased]` section with the change categories that apply:\n\n```markdown\n## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n```\n\nOn release you **promote** it — never delete history:\n\n```markdown\n## [0.0.6] - 2026-03-07\n\n### Added\n- ...\n```\n\nand add a link reference at the bottom:\n\n```markdown\n[Unreleased]: https://git.aetoskia.com/aetos//compare/0.0.6...HEAD\n[0.0.6]: https://git.aetoskia.com/aetos//compare/0.0.5...0.0.6\n```\n\nConventions:\n\n- Write entries for humans: one line each, imperative, referencing the user\n impact, not the commit hash.\n- Categories: **Added**, **Changed**, **Deprecated**, **Removed**, **Fixed**,\n **Security**. Omit empty categories.\n- Security fixes get their own entry even if the change is otherwise a bug fix.\n- Keep `[Unreleased]` current: add an entry in the same change that introduces\n the behaviour, not later.\n- The `[Unreleased]` heading is never renamed; copy its content into the new\n version heading and clear it.\n\n### Reconcile before releasing\n\n`[Unreleased]` is a **draft, not the source of truth**. It is only trustworthy\nwhen it was maintained alongside each change; a long-running branch or a change\nthat skipped the changelog leaves it stale, so it can silently under-report a\nrelease. Verify it against the change before you promote:\n\n1. List what actually changed since the last release:\n `git log ..HEAD` and `git diff --stat ..HEAD`.\n2. Compare that against `[Unreleased]`; add an entry for every user-facing change\n that is missing.\n3. **Always show the proposed additions to the user and wait for approval**\n before promoting. If an entry is ambiguous, ask rather than guess.\n\nOnly then promote. Never cut a release with a known gap between the diff and the\nchangelog.\n\n---\n\n## Tagging strategy (MR-based)\n\nThe release version is known while the change is still a merge request, so the\nbump travels with the change and only the tag is added afterwards:\n\n1. **In the MR** — reconcile `[Unreleased]` against the change (see\n [Reconcile before releasing](#reconcile-before-releasing)) and get the user's\n approval for any additions. Then set the intended release version: `version`\n in `pyproject.toml` (or the `VERSION` file) to `x.y.z`, and promote the\n changelog (below). Changelog entries and link references name the\n **version/tag**, never a commit id.\n2. **Merge or squash** the MR into `main`.\n3. **Create the tag** `x.y.z` on the resulting commit (the merge/squash commit).\n4. **Push the tag** (`git push origin x.y.z`) — Drone builds the release.\n\nBecause the version bump is inside the tagged commit, the guard's\n`tag == version` check can only fail if the wrong commit was tagged.\n\n### Agent-assisted tagging\n\nThe agent neither authors nor merges the MR, and never tags before the merge.\nAfter the user confirms the MR is merged/squashed and says to proceed:\n\n1. **Wait** for that explicit go-ahead.\n2. Find the commit that introduced the version bump:\n - packages:\n `git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml`\n - services:\n `git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`\n3. Confirm that commit's version file equals `x.y.z`, then tag **only** that\n commit, lightweight and bare: `git tag x.y.z `.\n4. **Do not push.** Ask the user to push the tag: `git push origin x.y.z`.\n\n---\n\n## Release checklist\n\n1. Confirm the quality gate is green and `main` is up to date.\n2. In the MR: reconcile `[Unreleased]` against the change being released —\n `git log ..HEAD` and `git diff --stat ..HEAD` — add any\n missing user-facing entries, and get the user's approval for the additions.\n Then move every `[Unreleased]` entry under the new version heading with the\n target date (`## [x.y.z] - YYYY-MM-DD`) and restore an empty `[Unreleased]`\n on top.\n3. In the MR: add the two compare-link references at the bottom of the\n changelog, naming the version/tag (never a commit id).\n4. In the MR: set the version file — `pyproject.toml` (`version = \"x.y.z\"`) or\n `VERSION` — to the intended release version.\n5. Merge or squash the MR into `main`.\n6. Create the tag on the resulting commit: lightweight `git tag x.y.z`.\n7. Push the tag (`git push origin x.y.z`). Watch Drone: Type A publishes the\n wheel/sdist, Type B builds and deploys the image; both first assert\n `tag == version`.\n8. Do not reuse the tag if the run fails — fix and cut `x.y.(z+1)`.\n\n---\n\n## Why the guard matters\n\nThe version file and the tag are two facts that must agree; nothing in git\nenforces that by itself. A mismatch is not a theoretical risk — a package can be\ntagged `0.0.2` while its `pyproject.toml` still says `0.0.1`, and the publish\nstep (which compares the **file** against the index) then skips the build\nentirely, so `0.0.2` never reaches the index while looking like a release. The\nDrone guard turns that silent no-op into a hard failure.\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [94 – Repository Inventory](94_repository_inventory.md) · [01 – Build Recipe](01_build_recipe.md) · [93 – Library Registry](93_library_registry.md)\n",
+ "docstring": "# Versioning & Changelog\n\n## Summary\n\nEvery Aetos artifact — library or service — is versioned with\n[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and documents its\nreleases in a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) file.\nThe **version file is the source of truth**, the **git tag must equal it**, and\na Drone guard enforces that equality before anything is published. Releases are\ncut manually; a tag is what triggers the pipeline.\n\nThis page pairs with [90 – Drone Pipelines](90_drone_pipelines.md): the version\nand tag contract is exactly what those three CI shapes consume.\n\n---\n\n## The version contract\n\n| Artifact | Source of truth | Notes |\n|----------|-----------------|-------|\n| Library (PyPI package) | `version` in `pyproject.toml` | `check-version` also reads it to skip duplicates |\n| Library (npm package) | `version` in `package.json` | `check-version` also queries the registry to skip duplicates |\n| Service (Docker) | `VERSION` file at the repo root | one line, `x.y.z`; the service **reads** it (build-arg) — never hardcode the version |\n\nRules:\n\n1. **One version per repository.** No per-module versions.\n2. **Tag = version.** The git tag is bare SemVer (`0.0.6`), no `v` prefix, and\n it MUST match the version file byte-for-byte. The Drone guard fails the\n build otherwise — this is what stops a tag from silently publishing nothing.\n3. **A release is a tag, created after the change is merged.** The version bump\n and the changelog promotion travel inside the merge request (MR); the tag is\n added on the merged commit and pushed. See [Tagging strategy](#tagging-strategy-mr-based).\n4. **Never reuse a tag.** If a release is wrong, cut a new patch version.\n\n### Pre-1.0 (0.y.z)\n\nWhile a package is `0.y.z`, **`0.y` is treated as the major line**:\n\n- breaking change → `0.(y+1).0`\n- new feature → `0.y.(z+1)`\n- bug fix → `0.y.(z+1)`\n\nOnce a package reaches `1.0.0`, normal SemVer applies. Do not ship `1.0.0`\nuntil the public API is considered stable.\n\n---\n\n## The changelog\n\n`CHANGELOG.md` at the repo root, in Keep a Changelog form. It always has exactly\none open `[Unreleased]` section with the change categories that apply:\n\n```markdown\n## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n```\n\nOn release you **promote** it — never delete history:\n\n```markdown\n## [0.0.6] - 2026-03-07\n\n### Added\n- ...\n```\n\nand add a link reference at the bottom:\n\n```markdown\n[Unreleased]: https://git.aetoskia.com/aetos//compare/0.0.6...HEAD\n[0.0.6]: https://git.aetoskia.com/aetos//compare/0.0.5...0.0.6\n```\n\nConventions:\n\n- Write entries for humans: one line each, imperative, referencing the user\n impact, not the commit hash.\n- Categories: **Added**, **Changed**, **Deprecated**, **Removed**, **Fixed**,\n **Security**. Omit empty categories.\n- Security fixes get their own entry even if the change is otherwise a bug fix.\n- Keep `[Unreleased]` current: add an entry in the same change that introduces\n the behaviour, not later.\n- The `[Unreleased]` heading is never renamed; copy its content into the new\n version heading and clear it.\n\n### Reconcile before releasing\n\n`[Unreleased]` is a **draft, not the source of truth**. It is only trustworthy\nwhen it was maintained alongside each change; a long-running branch or a change\nthat skipped the changelog leaves it stale, so it can silently under-report a\nrelease. Verify it against the change before you promote:\n\n1. List what actually changed since the last release:\n `git log ..HEAD` and `git diff --stat ..HEAD`.\n2. Compare that against `[Unreleased]`; add an entry for every user-facing change\n that is missing.\n3. **Always show the proposed additions to the user and wait for approval**\n before promoting. If an entry is ambiguous, ask rather than guess.\n\nOnly then promote. Never cut a release with a known gap between the diff and the\nchangelog.\n\n---\n\n## Tagging strategy (MR-based)\n\nThe release version is known while the change is still a merge request, so the\nbump travels with the change and only the tag is added afterwards:\n\n1. **In the MR** — reconcile `[Unreleased]` against the change (see\n [Reconcile before releasing](#reconcile-before-releasing)) and get the user's\n approval for any additions. Then set the intended release version: `version`\n in `pyproject.toml` or `package.json` (or the `VERSION` file) to `x.y.z`,\n and promote the\n changelog (below). Changelog entries and link references name the\n **version/tag**, never a commit id.\n2. **Merge or squash** the MR into `main`.\n3. **Create the tag** `x.y.z` on the resulting commit (the merge/squash commit).\n4. **Push the tag** (`git push origin x.y.z`) — Drone builds the release.\n\nBecause the version bump is inside the tagged commit, the guard's\n`tag == version` check can only fail if the wrong commit was tagged.\n\n### Agent-assisted tagging\n\nThe agent neither authors nor merges the MR, and never tags before the merge.\nAfter the user confirms the MR is merged/squashed and says to proceed:\n\n1. **Wait** for that explicit go-ahead.\n2. Find the commit that introduced the version bump:\n - Python packages:\n `git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.toml`\n - npm packages:\n `git log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.json`\n - services:\n `git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`\n3. Confirm that commit's version file equals `x.y.z`, then tag **only** that\n commit, lightweight and bare: `git tag x.y.z `.\n4. **Do not push.** Ask the user to push the tag: `git push origin x.y.z`.\n\n---\n\n## Release checklist\n\n1. Confirm the quality gate is green and `main` is up to date.\n2. In the MR: reconcile `[Unreleased]` against the change being released —\n `git log ..HEAD` and `git diff --stat ..HEAD` — add any\n missing user-facing entries, and get the user's approval for the additions.\n Then move every `[Unreleased]` entry under the new version heading with the\n target date (`## [x.y.z] - YYYY-MM-DD`) and restore an empty `[Unreleased]`\n on top.\n3. In the MR: add the two compare-link references at the bottom of the\n changelog, naming the version/tag (never a commit id).\n4. In the MR: set the version file — `pyproject.toml` (`version = \"x.y.z\"`),\n `package.json` (`\"version\": \"x.y.z\"`), or `VERSION` — to the intended\n release version.\n5. Merge or squash the MR into `main`.\n6. Create the tag on the resulting commit: lightweight `git tag x.y.z`.\n7. Push the tag (`git push origin x.y.z`). Watch Drone: Type A publishes the\n wheel/sdist, Type B publishes the npm package, Type C builds and deploys\n the image; all three first assert `tag == version`.\n8. Do not reuse the tag if the run fails — fix and cut `x.y.(z+1)`.\n\n---\n\n## Why the guard matters\n\nThe version file and the tag are two facts that must agree; nothing in git\nenforces that by itself. A mismatch is not a theoretical risk — a package can be\ntagged `0.0.2` while its `pyproject.toml` still says `0.0.1`, and the publish\nstep (which compares the **file** against the index) then skips the build\nentirely, so `0.0.2` never reaches the index while looking like a release. The\nDrone guard turns that silent no-op into a hard failure.\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [94 – Repository Inventory](94_repository_inventory.md) · [01 – Build Recipe](01_build_recipe.md) · [93 – Library Registry](93_library_registry.md)\n",
"objects": {}
}
}
\ No newline at end of file
diff --git a/standards/mcp/repos.json b/standards/mcp/repos.json
index 9456f1c..8f0361d 100644
--- a/standards/mcp/repos.json
+++ b/standards/mcp/repos.json
@@ -1,6 +1,6 @@
{
"project": "standards",
- "note": "Machine-readable repository inventory. Mirrors standards/src/lib/15_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
+ "note": "Machine-readable repository inventory. Mirrors standards/src/lib/94_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
"path_convention": "relative to the aetos/ workspace root",
"repos": [
{
@@ -35,6 +35,22 @@
"port": 8001
}
},
+ {
+ "name": "doc-weaver",
+ "type": "package",
+ "path": "doc-weaver",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": [
+ "lib",
+ "wiki"
+ ],
+ "mcp": {
+ "server": "doc-weaver",
+ "port": 8010
+ }
+ },
{
"name": "hexa",
"type": "package",
@@ -131,6 +147,38 @@
"port": 8005
}
},
+ {
+ "name": "react-auth",
+ "type": "package",
+ "path": "react/react-auth",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": [
+ "lib",
+ "wiki"
+ ],
+ "mcp": {
+ "server": "react-auth",
+ "port": 8011
+ }
+ },
+ {
+ "name": "react-openapi",
+ "type": "package",
+ "path": "react/react-openapi",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": [
+ "lib",
+ "wiki"
+ ],
+ "mcp": {
+ "server": "react-openapi",
+ "port": 8012
+ }
+ },
{
"name": "auth-server",
"type": "service",
@@ -144,6 +192,19 @@
"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/client/.env.example b/standards/mcp/templates/client/.env.example
new file mode 100644
index 0000000..996e6f8
--- /dev/null
+++ b/standards/mcp/templates/client/.env.example
@@ -0,0 +1,2 @@
+VITE_API_BASE_URL=http://localhost:8000
+VITE_AUTH_BASE_URL=http://localhost:8000
diff --git a/standards/mcp/templates/client/.gitignore b/standards/mcp/templates/client/.gitignore
new file mode 100644
index 0000000..434c113
--- /dev/null
+++ b/standards/mcp/templates/client/.gitignore
@@ -0,0 +1,21 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+.env
+# dependencies
+node_modules
+
+# React Router
+.react-router
+build
+
+# misc
+.DS_Store
+dist
+dist-ssr
+*.local
+.idea
+tsconfig.tsbuildinfo
+
+# generated documentation
+docs/mcp
+site/
diff --git a/standards/mcp/templates/client/.npmrc b/standards/mcp/templates/client/.npmrc
new file mode 100644
index 0000000..a412fd6
--- /dev/null
+++ b/standards/mcp/templates/client/.npmrc
@@ -0,0 +1 @@
+registry=https://npm.aetoskia.com
diff --git a/standards/mcp/templates/client/CHANGELOG.md b/standards/mcp/templates/client/CHANGELOG.md
new file mode 100644
index 0000000..3973a5a
--- /dev/null
+++ b/standards/mcp/templates/client/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+All notable changes to this project are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+
+
+## [Unreleased]
diff --git a/standards/mcp/templates/client/index.html b/standards/mcp/templates/client/index.html
new file mode 100644
index 0000000..51be3f3
--- /dev/null
+++ b/standards/mcp/templates/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ App Title
+
+
+
+
+
+
diff --git a/standards/mcp/templates/client/package.json b/standards/mcp/templates/client/package.json
new file mode 100644
index 0000000..02fee5a
--- /dev/null
+++ b/standards/mcp/templates/client/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "client-template",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "quality-gate": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@aetoskia/react-auth": "^0.1.0",
+ "@aetoskia/react-openapi": "^0.1.0",
+ "@emotion/react": "^11.13.3",
+ "@emotion/styled": "^11.13.0",
+ "@mui/icons-material": "^5.16.7",
+ "@mui/material": "^5.16.7",
+ "axios": "^1.7.7",
+ "js-yaml": "^4.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@types/js-yaml": "^4.0.9",
+ "@types/node": "^25.9.3",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "~5.6.2",
+ "vite": "^6.0.1"
+ }
+}
diff --git a/standards/mcp/templates/client/src/Footer.tsx b/standards/mcp/templates/client/src/Footer.tsx
new file mode 100644
index 0000000..564c0b5
--- /dev/null
+++ b/standards/mcp/templates/client/src/Footer.tsx
@@ -0,0 +1,66 @@
+/**
+ * Site footer.
+ *
+ * A fixed bottom `AppBar` (pinned above the drawer via
+ * `zIndex.drawer + 1`) with a blurred translucent background and a top
+ * divider, rendering the `Copyright` line. Rendered once by `main.jsx` below
+ * the routed content.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import Container from '@mui/material/Container';
+import Link from '@mui/material/Link';
+import Typography from '@mui/material/Typography';
+import { AppBar, Toolbar } from "@mui/material";
+
+/** Copyright line linking to aetoskia.com with the current year. */
+function Copyright() {
+ return (
+
+
+ {'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
+
+
+ {new Date().getFullYear()}
+
+ );
+}
+
+/**
+ * Fixed bottom app bar showing the site copyright.
+ *
+ * @returns The footer `AppBar` element.
+ */
+export default function Footer() {
+ return (
+ theme.zIndex.drawer + 1,
+ backdropFilter: 'blur(8px)',
+ boxShadow: 'none',
+ borderTop: '1px solid',
+ borderColor: 'divider',
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/standards/mcp/templates/client/src/Header.tsx b/standards/mcp/templates/client/src/Header.tsx
new file mode 100644
index 0000000..81e9e79
--- /dev/null
+++ b/standards/mcp/templates/client/src/Header.tsx
@@ -0,0 +1,83 @@
+/**
+ * Top navigation bar.
+ *
+ * A fixed `AppBar` with blurred translucent background and bottom divider,
+ * showing the app title/logo, the signed-in user (or a login button), and
+ * an Admin button. Rendered once by `main.jsx` above the routed content;
+ * the title navigates to `/` and the Admin button to `/admin`.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from "react";
+import { AppBar, Toolbar, Typography, Button, Chip, IconButton, Tooltip } from "@mui/material";
+import { useNavigate } from "react-router-dom";
+import DashboardIcon from "@mui/icons-material/Dashboard";
+import LogoutIcon from "@mui/icons-material/Logout";
+import { useAuth } from "@aetoskia/react-auth";
+
+/**
+ * Fixed top app bar with the title/logo, current user, and an Admin button.
+ *
+ * Uses `useNavigate` and `useAuth`, so it must be rendered inside both a
+ * router and an `AuthProvider`. Clicking the title navigates to `/`; the
+ * Admin button navigates to `/admin`; the logout icon clears the session.
+ *
+ * @returns The header `AppBar` element.
+ */
+export default function Header() {
+ const navigate = useNavigate();
+ const { currentUser, logout } = useAuth();
+
+ return (
+ theme.zIndex.drawer + 1,
+ backdropFilter: "blur(8px)",
+ boxShadow: "none",
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ }}
+ >
+
+
+ navigate("/")}
+ >
+ App Title
+
+
+
+
+ {currentUser && (
+
+ )}
+
+
+
+ {currentUser && (
+
+ logout()} aria-label="Log out">
+
+
+
+ )}
+
+
+ );
+}
diff --git a/standards/mcp/templates/client/src/Home.tsx b/standards/mcp/templates/client/src/Home.tsx
new file mode 100644
index 0000000..09430b6
--- /dev/null
+++ b/standards/mcp/templates/client/src/Home.tsx
@@ -0,0 +1,214 @@
+/**
+ * Landing page: a grid of resource cards and a live preview table.
+ *
+ * Exercises the `@aetoskia/react-openapi` building blocks end-to-end:
+ * - `ResourceCard` - a grid of every top-level resource with a live record
+ * count (or SSE connection status for streaming resources).
+ * - `LiveTable` - preview of the first 5 records of the first non-streaming
+ * resource.
+ *
+ * Add custom pages as sibling routes in `main.jsx`; the auto-generated
+ * admin lives at `/admin/*` regardless.
+ *
+ * @packageDocumentation
+ */
+
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ Box, Container, Typography, Paper, Grid, Chip,
+ Table, TableBody, TableCell, TableContainer, TableHead,
+ TableRow, CircularProgress,
+} from "@mui/material";
+
+import { useNavigate } from "react-router-dom";
+import { useAppContext, useResource, ListCellRenderer, SseConnectionStatus, applyDisplayFormat } from "@aetoskia/react-openapi";
+import type { ResourceConfig, FieldConfig } from "@aetoskia/react-openapi";
+
+/**
+ * Clickable card summarizing one resource.
+ *
+ * Shows the display name, field count and either a record-count chip (loaded
+ * via `useResource().list({ limit: 0 })`) or the SSE connection status for
+ * streaming resources; clicking navigates to `/admin/`.
+ *
+ * Props:
+ * - `resource` - the `ResourceConfig` to summarize.
+ */
+function ResourceCard({ resource }: { resource: ResourceConfig }) {
+ const navigate = useNavigate();
+ const { list } = useResource(resource.name);
+ const [count, setCount] = useState(null);
+
+ useEffect(() => {
+ if (resource.streaming) return;
+ list({ limit: 0 }).then((res) => setCount(res.total ?? res.items?.length ?? 0));
+ }, [resource.name]);
+
+ return (
+ navigate(`/admin/${resource.name}`)}
+ sx={{
+ p: 3,
+ borderRadius: 2,
+ border: "1px solid",
+ borderColor: "divider",
+ cursor: "pointer",
+ transition: "all 0.2s",
+ "&:hover": { borderColor: "primary.main", boxShadow: 1 },
+ }}
+ >
+
+ {resource.displayName}
+
+
+ {resource.fields.length} fields
+
+
+ {resource.streaming ? (
+
+ ) : (
+ 0 ? "primary" : "default"}
+ variant="outlined"
+ />
+ )}
+
+
+
+ );
+}
+
+/**
+ * Preview table of the first 5 records of a resource.
+ *
+ * Columns come from `resource.listColumns`; each cell renders through
+ * `ListCellRenderer` using the resource display format (or the FK target's
+ * format for foreign-key columns). Shows a spinner while loading and a
+ * "No records found." message when empty.
+ *
+ * Props:
+ * - `resource` - the `ResourceConfig` to preview.
+ */
+function LiveTable({ resource }: { resource: ResourceConfig }) {
+ const { list } = useResource(resource.name);
+ const { resources: allResources } = useAppContext();
+ const [rows, setRows] = useState(null);
+ const columns = resource.listColumns
+ .map((n) => resource.fields.find((f) => f.name === n))
+ .filter(Boolean) as FieldConfig[];
+
+ useEffect(() => {
+ list({ limit: 5 }).then((res) => setRows(res.items ?? []));
+ }, [resource.name]);
+
+ if (!rows) {
+ return (
+
+
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return No records found. ;
+ }
+
+ return (
+
+
+
+
+ {columns.map((col) => (
+
+ {col.label}
+
+ ))}
+
+
+
+ {rows.map((row, i) => (
+
+ {columns.map((col) => {
+ const displayFormat = col.fk
+ ? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? resource.displayFormat)
+ : resource.displayFormat;
+ return (
+
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * Render the landing page: resource grid and live preview.
+ *
+ * @returns The page container, or a "no resources" notice when the spec
+ * exposes none.
+ */
+export default function Home() {
+ const { resources } = useAppContext();
+ const topLevel = useMemo(() => resources.filter((r) => !r.parent), [resources]);
+ const previewResource = useMemo(() => topLevel.find((r) => !r.streaming) ?? topLevel[0], [topLevel]);
+
+ if (resources.length === 0) {
+ return (
+
+
+ No resources found in the OpenAPI spec.
+
+
+ );
+ }
+
+ return (
+
+
+
+ App Title
+
+
+ Auto-generated CRUD from an OpenAPI 3.1 spec using{" "}
+
+ @aetoskia/react-openapi
+
+ .
+
+
+
+
+ Resources
+
+
+ {topLevel.map((r) => (
+
+
+
+ ))}
+
+
+
+ Live Preview
+ {previewResource && (
+
+ — first 5 records from {previewResource.displayName}
+
+ )}
+
+ {previewResource && }
+
+ );
+}
diff --git a/standards/mcp/templates/client/src/main.jsx b/standards/mcp/templates/client/src/main.jsx
new file mode 100644
index 0000000..d0a3229
--- /dev/null
+++ b/standards/mcp/templates/client/src/main.jsx
@@ -0,0 +1,94 @@
+/**
+ * Application entry point.
+ *
+ * Creates the React root from `#root` and mounts the app: `AuthProvider`
+ * (JWT session against `VITE_AUTH_BASE_URL`) wraps a `LoginGate` (renders
+ * `AuthPage` until authenticated) which wraps `AppProvider` (fed by
+ * `specConfiguration`) → `BrowserRouter` → `AppTheme` → `Header` → routed
+ * content (`Home` at `/`, `Admin` at `/admin/*`) → `Footer`.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { Box, CssBaseline, Toolbar } from "@mui/material";
+import { AuthProvider, AuthPage, useAuth } from "@aetoskia/react-auth";
+import Home from './Home';
+import { Admin, AppProvider } from '@aetoskia/react-openapi';
+import { specConfiguration } from './openapi-config';
+import Header from './Header';
+import Footer from './Footer';
+import AppTheme from './shared-theme/AppTheme';
+
+const authServer = import.meta.env.VITE_AUTH_BASE_URL;
+
+const authConfig = {
+ serverUrl: authServer,
+ loginPath: "/login",
+ registerPath: "/register",
+ logoutPath: "/logout",
+ mePath: "/me",
+ introspectPath: "/introspect",
+};
+
+/**
+ * Route guard: renders the split-screen `AuthPage` until a user is
+ * authenticated, then renders the full application shell.
+ *
+ * @returns The login gate or the authenticated app.
+ */
+function LoginGate() {
+ const auth = useAuth();
+ const [mode, setMode] = React.useState("login");
+
+ if (!auth.currentUser) {
+ return (
+
+
+ {}}
+ onSwitchMode={() => setMode((m) => (m === "login" ? "register" : "login"))}
+ login={auth.login}
+ register={auth.register}
+ loading={auth.loading}
+ error={auth.error}
+ currentUser={auth.currentUser}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ } />
+ } />
+
+
+
+
+
+
+
+ );
+}
+
+const rootElement = document.getElementById('root');
+const root = createRoot(rootElement);
+
+root.render(
+
+
+
+);
diff --git a/standards/mcp/templates/client/src/openapi-config.ts b/standards/mcp/templates/client/src/openapi-config.ts
new file mode 100644
index 0000000..240e34b
--- /dev/null
+++ b/standards/mcp/templates/client/src/openapi-config.ts
@@ -0,0 +1,29 @@
+/**
+ * Runtime OpenAPI configuration.
+ *
+ * Reads the API base URL from `import.meta.env.VITE_API_BASE_URL` and points
+ * `@aetoskia/react-openapi` at `${base}/openapi.json`, sets the app title,
+ * and wires the auth token from `@aetoskia/react-auth` into every API call.
+ *
+ * @packageDocumentation
+ */
+
+import type { SpecConfiguration } from "@aetoskia/react-openapi";
+import { tokenStore } from "@aetoskia/react-auth";
+
+const apiBase = import.meta.env.VITE_API_BASE_URL;
+
+/**
+ * The `SpecConfiguration` passed to `AppProvider`.
+ *
+ * - `specUrl` / `baseApiUrl` - derived from `VITE_API_BASE_URL`.
+ * - `title` - replace "App Title" with your application's name.
+ * - `getToken` - supplies the JWT from `@aetoskia/react-auth` to every
+ * request made through `getApi()` / `useResource()`.
+ */
+export const specConfiguration: SpecConfiguration = {
+ specUrl: `${apiBase}/openapi.json`,
+ baseApiUrl: apiBase,
+ title: "App Title",
+ getToken: () => tokenStore.get(),
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/AppTheme.tsx b/standards/mcp/templates/client/src/shared-theme/AppTheme.tsx
new file mode 100644
index 0000000..27ee7c5
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/AppTheme.tsx
@@ -0,0 +1,145 @@
+/**
+ * App theme provider and color-mode context.
+ *
+ * Wraps the MUI `ThemeProvider` with the design tokens (`getDesignTokens`),
+ * semantic colors (`getSemanticColors`) and component customizations
+ * (`inputsCustomizations`, `dataDisplayCustomizations`,
+ * `feedbackCustomizations`, `navigationCustomizations`,
+ * `surfacesCustomizations`). Exposes a light/dark `ColorModeContext` and
+ * publishes the semantic colors as CSS custom properties (`--bg-page`,
+ * `--bg-card`, `--text-primary`, ...) for the app subtree.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from "react";
+import {
+ ThemeProvider,
+ createTheme,
+ CssBaseline,
+ Box,
+} from "@mui/material";
+
+import { getDesignTokens } from "./themePrimitives";
+import { getSemanticColors } from "./themeConfig";
+
+import { inputsCustomizations } from "./customizations/inputs";
+import { dataDisplayCustomizations } from "./customizations/dataDisplay";
+import { feedbackCustomizations } from "./customizations/feedback";
+import { navigationCustomizations } from "./customizations/navigation";
+import { surfacesCustomizations } from "./customizations/surfaces";
+
+/** Color mode of the app theme. */
+export type ColorMode = "light" | "dark";
+
+/** Shape of the color-mode context value. */
+type ColorModeContextValue = {
+ /** The current color mode. */
+ mode: ColorMode;
+ /** Set the color mode explicitly. */
+ setMode: (mode: ColorMode) => void;
+ /** Toggle between light and dark. */
+ toggleColorMode: () => void;
+};
+
+/** Context giving the current color mode and its setters. */
+export const ColorModeContext =
+ React.createContext({
+ mode: "light",
+ setMode: () => {},
+ toggleColorMode: () => {},
+ });
+
+/** Props for `AppTheme`. */
+type AppThemeProps = {
+ /** Subtree that receives the theme and color-mode context. */
+ children: React.ReactNode;
+ /** Initial color mode (defaults to `"light"`). */
+ defaultMode?: ColorMode;
+};
+
+/**
+ * Provide the MUI theme, color-mode context and CSS variables to `children`.
+ *
+ * Builds the theme from `getDesignTokens(mode)` + `getSemanticColors(mode)` +
+ * the component customizations, and re-creates it whenever the mode changes.
+ * Also sets `--bg-page`, `--bg-card`, `--bg-elevated`, `--border-default`,
+ * `--border-subtle`, `--text-primary`, `--text-secondary` and `--text-muted`
+ * on a wrapper `Box`.
+ *
+ * Props: `children` and `defaultMode` (see `AppThemeProps`).
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export default function AppTheme({
+ children,
+ defaultMode = "light",
+}: AppThemeProps) {
+ const [mode, setMode] =
+ React.useState(defaultMode);
+
+ const toggleColorMode = React.useCallback(() => {
+ setMode((prev) =>
+ prev === "light" ? "dark" : "light"
+ );
+ }, []);
+
+ const contextValue = React.useMemo(
+ () => ({
+ mode,
+ setMode,
+ toggleColorMode,
+ }),
+ [mode, toggleColorMode]
+ );
+
+ const semantic = React.useMemo(
+ () => getSemanticColors(mode),
+ [mode]
+ );
+
+ const theme = React.useMemo(
+ () =>
+ createTheme({
+ ...getDesignTokens(mode),
+ semantic,
+
+ components: {
+ ...inputsCustomizations,
+ ...dataDisplayCustomizations,
+ ...feedbackCustomizations,
+ ...navigationCustomizations,
+ ...surfacesCustomizations,
+ },
+ }),
+ [mode, semantic]
+ );
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/standards/mcp/templates/client/src/shared-theme/customizations/dataDisplay.tsx b/standards/mcp/templates/client/src/shared-theme/customizations/dataDisplay.tsx
new file mode 100644
index 0000000..e300922
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/customizations/dataDisplay.tsx
@@ -0,0 +1,243 @@
+/**
+ * MUI style overrides for data-display components.
+ *
+ * Exports `dataDisplayCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering lists, chips, table pagination and icons.
+ *
+ * @packageDocumentation
+ */
+
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { svgIconClasses } from '@mui/material/SvgIcon';
+import { typographyClasses } from '@mui/material/Typography';
+import { buttonBaseClasses } from '@mui/material/ButtonBase';
+import { chipClasses } from '@mui/material/Chip';
+import { iconButtonClasses } from '@mui/material/IconButton';
+import { gray, red, green } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for lists, chips, table pagination and icons. */
+export const dataDisplayCustomizations: Components = {
+ MuiList: {
+ styleOverrides: {
+ root: {
+ padding: '8px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 0,
+ },
+ },
+ },
+ MuiListItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ [`& .${svgIconClasses.root}`]: {
+ width: '1rem',
+ height: '1rem',
+ color: (theme.vars || theme).palette.text.secondary,
+ },
+ [`& .${typographyClasses.root}`]: {
+ fontWeight: 500,
+ },
+ [`& .${buttonBaseClasses.root}`]: {
+ display: 'flex',
+ gap: 8,
+ padding: '2px 8px',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ opacity: 0.7,
+ '&.Mui-selected': {
+ opacity: 1,
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ [`& .${svgIconClasses.root}`]: {
+ color: (theme.vars || theme).palette.text.primary,
+ },
+ '&:focus-visible': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ '&:hover': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.5),
+ },
+ },
+ '&:focus-visible': {
+ backgroundColor: 'transparent',
+ },
+ },
+ }),
+ },
+ },
+ MuiListItemText: {
+ styleOverrides: {
+ primary: ({ theme }) => ({
+ fontSize: theme.typography.body2.fontSize,
+ fontWeight: 500,
+ lineHeight: theme.typography.body2.lineHeight,
+ }),
+ secondary: ({ theme }) => ({
+ fontSize: theme.typography.caption.fontSize,
+ lineHeight: theme.typography.caption.lineHeight,
+ }),
+ },
+ },
+ MuiListSubheader: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ backgroundColor: 'transparent',
+ padding: '4px 8px',
+ fontSize: theme.typography.caption.fontSize,
+ fontWeight: 500,
+ lineHeight: theme.typography.caption.lineHeight,
+ }),
+ },
+ },
+ MuiListItemIcon: {
+ styleOverrides: {
+ root: {
+ minWidth: 0,
+ },
+ },
+ },
+ MuiChip: {
+ defaultProps: {
+ size: 'small',
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ border: '1px solid',
+ borderRadius: '999px',
+ [`& .${chipClasses.label}`]: {
+ fontWeight: 600,
+ },
+ variants: [
+ {
+ props: {
+ color: 'default',
+ },
+ style: {
+ borderColor: gray[200],
+ backgroundColor: gray[100],
+ [`& .${chipClasses.label}`]: {
+ color: gray[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: gray[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: gray[700],
+ backgroundColor: gray[800],
+ [`& .${chipClasses.label}`]: {
+ color: gray[300],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: gray[300],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'success',
+ },
+ style: {
+ borderColor: green[200],
+ backgroundColor: green[50],
+ [`& .${chipClasses.label}`]: {
+ color: green[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: green[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: green[800],
+ backgroundColor: green[900],
+ [`& .${chipClasses.label}`]: {
+ color: green[300],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: green[300],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'error',
+ },
+ style: {
+ borderColor: red[100],
+ backgroundColor: red[50],
+ [`& .${chipClasses.label}`]: {
+ color: red[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: red[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: red[800],
+ backgroundColor: red[900],
+ [`& .${chipClasses.label}`]: {
+ color: red[200],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: red[300],
+ },
+ }),
+ },
+ },
+ {
+ props: { size: 'small' },
+ style: {
+ maxHeight: 20,
+ [`& .${chipClasses.label}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ [`& .${svgIconClasses.root}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ },
+ },
+ {
+ props: { size: 'medium' },
+ style: {
+ [`& .${chipClasses.label}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiTablePagination: {
+ styleOverrides: {
+ actions: {
+ display: 'flex',
+ gap: 8,
+ marginRight: 6,
+ [`& .${iconButtonClasses.root}`]: {
+ minWidth: 0,
+ width: 36,
+ height: 36,
+ },
+ },
+ },
+ },
+ MuiIcon: {
+ defaultProps: {
+ fontSize: 'small',
+ },
+ styleOverrides: {
+ root: {
+ variants: [
+ {
+ props: {
+ fontSize: 'small',
+ },
+ style: {
+ fontSize: '1rem',
+ },
+ },
+ ],
+ },
+ },
+ },
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/customizations/feedback.tsx b/standards/mcp/templates/client/src/shared-theme/customizations/feedback.tsx
new file mode 100644
index 0000000..48c4401
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/customizations/feedback.tsx
@@ -0,0 +1,56 @@
+/**
+ * MUI style overrides for feedback components.
+ *
+ * Exports `feedbackCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering alerts, dialogs and linear progress.
+ *
+ * @packageDocumentation
+ */
+
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { gray, orange } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for alerts, dialogs and linear progress. */
+export const feedbackCustomizations: Components = {
+ MuiAlert: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: 10,
+ backgroundColor: orange[100],
+ color: (theme.vars || theme).palette.text.primary,
+ border: `1px solid ${alpha(orange[300], 0.5)}`,
+ '& .MuiAlert-icon': {
+ color: orange[500],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: alpha(orange[900], 0.35),
+ border: `1px solid ${alpha(orange[800], 0.3)}`,
+ }),
+ }),
+ },
+ },
+ MuiDialog: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ '& .MuiDialog-paper': {
+ borderRadius: '10px',
+ border: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ },
+ }),
+ },
+ },
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ height: 8,
+ borderRadius: 8,
+ backgroundColor: gray[200],
+ ...theme.applyStyles('dark', {
+ backgroundColor: gray[800],
+ }),
+ }),
+ },
+ },
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/customizations/inputs.tsx b/standards/mcp/templates/client/src/shared-theme/customizations/inputs.tsx
new file mode 100644
index 0000000..757f4e9
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/customizations/inputs.tsx
@@ -0,0 +1,456 @@
+/**
+ * MUI style overrides for form inputs and buttons.
+ *
+ * Exports `inputsCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering buttons, icon buttons, toggles, checkbox, outlined
+ * inputs and form labels with light/dark variants.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { alpha, Theme, Components } from '@mui/material/styles';
+import { outlinedInputClasses } from '@mui/material/OutlinedInput';
+import { svgIconClasses } from '@mui/material/SvgIcon';
+import { toggleButtonGroupClasses } from '@mui/material/ToggleButtonGroup';
+import { toggleButtonClasses } from '@mui/material/ToggleButton';
+import CheckBoxOutlineBlankRoundedIcon from '@mui/icons-material/CheckBoxOutlineBlankRounded';
+import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
+import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
+import { gray, brand } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for buttons, toggles, inputs and labels. */
+export const inputsCustomizations: Components = {
+ MuiButtonBase: {
+ defaultProps: {
+ disableTouchRipple: true,
+ disableRipple: true,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxSizing: 'border-box',
+ transition: 'all 100ms ease-in',
+ '&:focus-visible': {
+ outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
+ outlineOffset: '2px',
+ },
+ }),
+ },
+ },
+ MuiButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxShadow: 'none',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ textTransform: 'none',
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ height: '2.25rem',
+ padding: '8px 12px',
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ height: '2.5rem', // 40px
+ },
+ },
+ {
+ props: {
+ color: 'primary',
+ variant: 'contained',
+ },
+ style: {
+ color: 'white',
+ backgroundColor: gray[900],
+ backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
+ boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
+ border: `1px solid ${gray[700]}`,
+ '&:hover': {
+ backgroundImage: 'none',
+ backgroundColor: gray[700],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: gray[800],
+ },
+ ...theme.applyStyles('dark', {
+ color: 'black',
+ backgroundColor: gray[50],
+ backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
+ boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
+ border: `1px solid ${gray[50]}`,
+ '&:hover': {
+ backgroundImage: 'none',
+ backgroundColor: gray[300],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: gray[400],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'contained',
+ },
+ style: {
+ color: 'white',
+ backgroundColor: brand[300],
+ backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
+ boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
+ border: `1px solid ${brand[500]}`,
+ '&:hover': {
+ backgroundColor: brand[700],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: brand[700],
+ backgroundImage: 'none',
+ },
+ },
+ },
+ {
+ props: {
+ variant: 'outlined',
+ },
+ style: {
+ color: (theme.vars || theme).palette.text.primary,
+ border: '1px solid',
+ borderColor: gray[200],
+ backgroundColor: alpha(gray[50], 0.3),
+ '&:hover': {
+ backgroundColor: gray[100],
+ borderColor: gray[300],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
+ borderColor: (theme.vars || theme).palette.divider,
+
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'outlined',
+ },
+ style: {
+ color: brand[700],
+ border: '1px solid',
+ borderColor: brand[200],
+ backgroundColor: brand[50],
+ '&:hover': {
+ backgroundColor: brand[100],
+ borderColor: brand[400],
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[200], 0.7),
+ },
+ ...theme.applyStyles('dark', {
+ color: brand[50],
+ border: '1px solid',
+ borderColor: brand[900],
+ backgroundColor: alpha(brand[900], 0.3),
+ '&:hover': {
+ borderColor: brand[700],
+ backgroundColor: alpha(brand[900], 0.6),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[900], 0.5),
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ variant: 'text',
+ },
+ style: {
+ color: gray[600],
+ '&:hover': {
+ backgroundColor: gray[100],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ color: 'hsl(0, 0%, 92%)',
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.08)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.12)',
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'text',
+ },
+ style: {
+ color: brand[700],
+ '&:hover': {
+ backgroundColor: alpha(brand[100], 0.5),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[200], 0.7),
+ },
+ ...theme.applyStyles('dark', {
+ color: brand[100],
+ '&:hover': {
+ backgroundColor: alpha(brand[900], 0.5),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[900], 0.3),
+ },
+ }),
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiIconButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxShadow: 'none',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ textTransform: 'none',
+ fontWeight: theme.typography.fontWeightMedium,
+ letterSpacing: 0,
+ color: (theme.vars || theme).palette.text.primary,
+ border: '1px solid ',
+ borderColor: gray[200],
+ backgroundColor: alpha(gray[50], 0.3),
+ '&:hover': {
+ backgroundColor: gray[100],
+ borderColor: gray[300],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
+ borderColor: (theme.vars || theme).palette.divider,
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ }),
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ width: '2.25rem',
+ height: '2.25rem',
+ padding: '0.25rem',
+ [`& .${svgIconClasses.root}`]: { fontSize: '1rem' },
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ width: '2.5rem',
+ height: '2.5rem',
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiToggleButtonGroup: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: '10px',
+ boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
+ [`& .${toggleButtonGroupClasses.selected}`]: {
+ color: brand[500],
+ },
+ ...theme.applyStyles('dark', {
+ [`& .${toggleButtonGroupClasses.selected}`]: {
+ color: '#fff',
+ },
+ boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`,
+ }),
+ }),
+ },
+ },
+ MuiToggleButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: '12px 16px',
+ textTransform: 'none',
+ borderRadius: '10px',
+ fontWeight: 500,
+ ...theme.applyStyles('dark', {
+ color: gray[400],
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)',
+ [`&.${toggleButtonClasses.selected}`]: {
+ color: brand[300],
+ },
+ }),
+ }),
+ },
+ },
+ MuiCheckbox: {
+ defaultProps: {
+ disableRipple: true,
+ icon: (
+
+ ),
+ checkedIcon: ,
+ indeterminateIcon: ,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ margin: 10,
+ height: 16,
+ width: 16,
+ borderRadius: 5,
+ border: '1px solid ',
+ borderColor: alpha(gray[300], 0.8),
+ boxShadow: '0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset',
+ backgroundColor: alpha(gray[100], 0.4),
+ transition: 'border-color, background-color, 120ms ease-in',
+ '&:hover': {
+ borderColor: brand[300],
+ },
+ '&.Mui-focusVisible': {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '2px',
+ borderColor: brand[400],
+ },
+ '&.Mui-checked': {
+ color: 'white',
+ backgroundColor: brand[500],
+ borderColor: brand[500],
+ boxShadow: `none`,
+ '&:hover': {
+ backgroundColor: brand[600],
+ },
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: alpha(gray[700], 0.8),
+ boxShadow: '0 0 0 1.5px hsl(210, 0%, 0%) inset',
+ backgroundColor: alpha(gray[900], 0.8),
+ '&:hover': {
+ borderColor: brand[300],
+ },
+ '&.Mui-focusVisible': {
+ borderColor: brand[400],
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '2px',
+ },
+ }),
+ }),
+ },
+ },
+ MuiInputBase: {
+ styleOverrides: {
+ root: {
+ border: 'none',
+ },
+ input: {
+ '&::placeholder': {
+ opacity: 0.7,
+ color: gray[500],
+ },
+ },
+ },
+ },
+ MuiOutlinedInput: {
+ styleOverrides: {
+ input: {
+ padding: 0,
+ },
+ root: ({ theme }) => ({
+ padding: '8px 12px',
+ color: (theme.vars || theme).palette.text.primary,
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ transition: 'border 120ms ease-in',
+ '&:hover': {
+ borderColor: gray[400],
+ },
+ [`&.${outlinedInputClasses.focused}`]: {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ borderColor: brand[400],
+ },
+ ...theme.applyStyles('dark', {
+ '&:hover': {
+ borderColor: gray[500],
+ },
+ }),
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ height: '2.25rem',
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ height: '2.5rem',
+ },
+ },
+ ],
+ }),
+ notchedOutline: {
+ border: 'none',
+ },
+ },
+ },
+ MuiInputAdornment: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: (theme.vars || theme).palette.grey[500],
+ ...theme.applyStyles('dark', {
+ color: (theme.vars || theme).palette.grey[400],
+ }),
+ }),
+ },
+ },
+ MuiFormLabel: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ typography: theme.typography.caption,
+ marginBottom: 8,
+ }),
+ },
+ },
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/customizations/navigation.tsx b/standards/mcp/templates/client/src/shared-theme/customizations/navigation.tsx
new file mode 100644
index 0000000..d516e3f
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/customizations/navigation.tsx
@@ -0,0 +1,290 @@
+/**
+ * MUI style overrides for navigation components.
+ *
+ * Exports `navigationCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering menus, selects, links, drawers, pagination, tabs and
+ * steppers.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { SvgIconProps } from '@mui/material/SvgIcon';
+import { buttonBaseClasses } from '@mui/material/ButtonBase';
+import { dividerClasses } from '@mui/material/Divider';
+import { menuItemClasses } from '@mui/material/MenuItem';
+import { selectClasses } from '@mui/material/Select';
+import { tabClasses } from '@mui/material/Tab';
+import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
+import { gray, brand } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for menus, selects, links, drawers, pagination and tabs. */
+export const navigationCustomizations: Components = {
+ MuiMenuItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ padding: '6px 8px',
+ [`&.${menuItemClasses.focusVisible}`]: {
+ backgroundColor: 'transparent',
+ },
+ [`&.${menuItemClasses.selected}`]: {
+ [`&.${menuItemClasses.focusVisible}`]: {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ },
+ }),
+ },
+ },
+ MuiMenu: {
+ styleOverrides: {
+ list: {
+ gap: '0px',
+ [`&.${dividerClasses.root}`]: {
+ margin: '0 -8px',
+ },
+ },
+ paper: ({ theme }) => ({
+ marginTop: '4px',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ backgroundImage: 'none',
+ background: 'hsl(0, 0%, 100%)',
+ boxShadow:
+ 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
+ [`& .${buttonBaseClasses.root}`]: {
+ '&.Mui-selected': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ },
+ ...theme.applyStyles('dark', {
+ background: (theme.vars || theme).palette.background.paper,
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
+ }),
+ }),
+ },
+ },
+ MuiSelect: {
+ defaultProps: {
+ IconComponent: React.forwardRef((props, ref) => (
+
+ )),
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: '1px solid',
+ borderColor: gray[200],
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
+ '&:hover': {
+ borderColor: gray[300],
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'none',
+ },
+ [`&.${selectClasses.focused}`]: {
+ outlineOffset: 0,
+ borderColor: gray[400],
+ },
+ '&:before, &:after': {
+ display: 'none',
+ },
+
+ ...theme.applyStyles('dark', {
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ borderColor: (theme.vars || theme).palette.divider,
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)',
+ '&:hover': {
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'none',
+ },
+ [`&.${selectClasses.focused}`]: {
+ outlineOffset: 0,
+ borderColor: 'hsl(210, 55%, 55%)',
+ },
+ '&:before, &:after': {
+ display: 'none',
+ },
+ }),
+ }),
+ select: ({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ ...theme.applyStyles('dark', {
+ display: 'flex',
+ alignItems: 'center',
+ '&:focus-visible': {
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ },
+ }),
+ }),
+ },
+ },
+ MuiLink: {
+ defaultProps: {
+ underline: 'none',
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: (theme.vars || theme).palette.text.primary,
+ fontWeight: 500,
+ position: 'relative',
+ textDecoration: 'none',
+ width: 'fit-content',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ width: '100%',
+ height: '1px',
+ bottom: 0,
+ left: 0,
+ backgroundColor: (theme.vars || theme).palette.text.secondary,
+ opacity: 0.3,
+ transition: 'width 0.3s ease, opacity 0.3s ease',
+ },
+ '&:hover::before': {
+ width: 0,
+ },
+ '&:focus-visible': {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '4px',
+ borderRadius: '2px',
+ },
+ }),
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: ({ theme }) => ({
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ borderRight: `1px solid ${(theme.vars || theme).palette.divider}`,
+ }),
+ },
+ },
+ MuiPaginationItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ '&.Mui-selected': {
+ color: 'white',
+ backgroundColor: (theme.vars || theme).palette.grey[900],
+ },
+ ...theme.applyStyles('dark', {
+ '&.Mui-selected': {
+ color: 'black',
+ backgroundColor: (theme.vars || theme).palette.grey[50],
+ },
+ }),
+ }),
+ },
+ },
+ MuiTabs: {
+ styleOverrides: {
+ root: { minHeight: 'fit-content' },
+ indicator: ({ theme }) => ({
+ backgroundColor: (theme.vars || theme).palette.grey[800],
+ ...theme.applyStyles('dark', {
+ backgroundColor: (theme.vars || theme).palette.grey[200],
+ }),
+ }),
+ },
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: '6px 8px',
+ marginBottom: '8px',
+ textTransform: 'none',
+ minWidth: 'fit-content',
+ minHeight: 'fit-content',
+ color: (theme.vars || theme).palette.text.secondary,
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: '1px solid',
+ borderColor: 'transparent',
+ ':hover': {
+ color: (theme.vars || theme).palette.text.primary,
+ backgroundColor: gray[100],
+ borderColor: gray[200],
+ },
+ [`&.${tabClasses.selected}`]: {
+ color: gray[900],
+ },
+ ...theme.applyStyles('dark', {
+ ':hover': {
+ color: (theme.vars || theme).palette.text.primary,
+ backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08),
+ borderColor: (theme.vars || theme).palette.divider,
+ },
+ [`&.${tabClasses.selected}`]: {
+ color: '#fff',
+ },
+ }),
+ }),
+ },
+ },
+ MuiStepConnector: {
+ styleOverrides: {
+ line: ({ theme }) => ({
+ borderTop: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ flex: 1,
+ borderRadius: '99px',
+ }),
+ },
+ },
+ MuiStepIcon: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: 'transparent',
+ border: `1px solid ${gray[400]}`,
+ width: 12,
+ height: 12,
+ borderRadius: '50%',
+ '& text': {
+ display: 'none',
+ },
+ '&.Mui-active': {
+ border: 'none',
+ color: (theme.vars || theme).palette.primary.main,
+ },
+ '&.Mui-completed': {
+ border: 'none',
+ color: (theme.vars || theme).palette.success.main,
+ },
+ ...theme.applyStyles('dark', {
+ border: `1px solid ${gray[700]}`,
+ '&.Mui-active': {
+ border: 'none',
+ color: (theme.vars || theme).palette.primary.light,
+ },
+ '&.Mui-completed': {
+ border: 'none',
+ color: (theme.vars || theme).palette.success.light,
+ },
+ }),
+ variants: [
+ {
+ props: { completed: true },
+ style: {
+ width: 12,
+ height: 12,
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiStepLabel: {
+ styleOverrides: {
+ label: ({ theme }) => ({
+ '&.Mui-completed': {
+ opacity: 0.6,
+ ...theme.applyStyles('dark', { opacity: 0.5 }),
+ },
+ }),
+ },
+ },
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/customizations/surfaces.ts b/standards/mcp/templates/client/src/shared-theme/customizations/surfaces.ts
new file mode 100644
index 0000000..fb9296c
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/customizations/surfaces.ts
@@ -0,0 +1,123 @@
+/**
+ * MUI style overrides for surface components.
+ *
+ * Exports `surfacesCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering accordions, paper, cards and card sections.
+ *
+ * @packageDocumentation
+ */
+
+import { alpha, Theme, Components } from '@mui/material/styles';
+import { gray } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for accordions, paper and cards. */
+export const surfacesCustomizations: Components = {
+ MuiAccordion: {
+ defaultProps: {
+ elevation: 0,
+ disableGutters: true,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: 4,
+ overflow: 'clip',
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ border: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ ':before': {
+ backgroundColor: 'transparent',
+ },
+ '&:not(:last-of-type)': {
+ borderBottom: 'none',
+ },
+ '&:first-of-type': {
+ borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
+ borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
+ },
+ '&:last-of-type': {
+ borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
+ borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
+ },
+ }),
+ },
+ },
+ MuiAccordionSummary: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ border: 'none',
+ borderRadius: 8,
+ '&:hover': { backgroundColor: gray[50] },
+ '&:focus-visible': { backgroundColor: 'transparent' },
+ ...theme.applyStyles('dark', {
+ '&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) },
+ }),
+ }),
+ },
+ },
+ MuiAccordionDetails: {
+ styleOverrides: {
+ root: { mb: 20, border: 'none' },
+ },
+ },
+ MuiPaper: {
+ defaultProps: {
+ elevation: 0,
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: ({ theme }) => {
+ return {
+ padding: 16,
+ gap: 16,
+ transition: 'all 100ms ease',
+ backgroundColor: gray[50],
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ boxShadow: 'none',
+ ...theme.applyStyles('dark', {
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ }),
+ variants: [
+ {
+ props: {
+ variant: 'outlined',
+ },
+ style: {
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ boxShadow: 'none',
+ background: 'hsl(0, 0%, 100%)',
+ ...theme.applyStyles('dark', {
+ background: alpha((theme.vars || theme).palette.background.paper, 0.6),
+ }),
+ },
+ },
+ ],
+ };
+ },
+ },
+ },
+ MuiCardContent: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ '&:last-child': { paddingBottom: 0 },
+ },
+ },
+ },
+ MuiCardHeader: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ },
+ },
+ },
+ MuiCardActions: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ },
+ },
+ },
+};
diff --git a/standards/mcp/templates/client/src/shared-theme/mui.d.ts b/standards/mcp/templates/client/src/shared-theme/mui.d.ts
new file mode 100644
index 0000000..46ca2c3
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/mui.d.ts
@@ -0,0 +1,27 @@
+/**
+ * MUI v5 type shims for the shared theme.
+ *
+ * The component customizations use the MUI v6 CSS-variable style
+ * (`theme.vars || theme`), which is runtime-safe on v5 (falls back to the
+ * plain theme) but not covered by the v5 type definitions. These
+ * augmentations teach the v5 types about that pattern so `tsc` passes on
+ * both majors.
+ *
+ * @packageDocumentation
+ */
+
+import "@mui/material/styles";
+
+declare module "@mui/material/styles" {
+ interface Theme {
+ /** CSS-variable slice; undefined on v5 at runtime — always use `(theme.vars || theme)`. */
+ vars: Theme;
+ }
+}
+
+declare module "@mui/material/Select" {
+ interface SelectClasses {
+ /** Present in MUI v6+; undefined on v5 (dead selector, harmless). */
+ focused: string;
+ }
+}
diff --git a/standards/mcp/templates/client/src/shared-theme/themeConfig.ts b/standards/mcp/templates/client/src/shared-theme/themeConfig.ts
new file mode 100644
index 0000000..f78daf2
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/themeConfig.ts
@@ -0,0 +1,112 @@
+/**
+ * Semantic (mode-aware) color tokens.
+ *
+ * Adds a `semantic` slice to the MUI theme (augmenting `@mui/material/styles`)
+ * with surface/border/text colors, resolved per light/dark mode by
+ * `getSemanticColors`. `AppTheme` publishes these as CSS custom properties.
+ *
+ * @packageDocumentation
+ */
+
+import { gray } from "./themePrimitives";
+import { alpha } from "@mui/material/styles";
+
+declare module "@mui/material/styles" {
+ interface Theme {
+ semantic: SemanticColors;
+ }
+ interface ThemeOptions {
+ semantic?: SemanticColors;
+ }
+}
+
+/** Color mode supported by the semantic tokens. */
+export type SemanticColorMode = "light" | "dark";
+
+/**
+ * Mode-aware semantic color groups attached to the MUI theme as
+ * `theme.semantic` and exposed as CSS custom properties by `AppTheme`.
+ */
+export interface SemanticColors {
+ /** Background surface colors. */
+ surface: {
+ /** Page background (`--bg-page`). */
+ page: string;
+ /** Card background (`--bg-card`). */
+ card: string;
+ /** Elevated surface, e.g. popovers (`--bg-elevated`). */
+ elevated: string;
+ };
+ /** Border colors. */
+ border: {
+ /** Default border (`--border-default`). */
+ default: string;
+ /** Subtle border (`--border-subtle`). */
+ subtle: string;
+ };
+ /** Text colors. */
+ text: {
+ /** Primary text (`--text-primary`). */
+ primary: string;
+ /** Secondary text (`--text-secondary`). */
+ secondary: string;
+ /** Muted text (`--text-muted`). */
+ muted: string;
+ };
+}
+
+const darkBg = 'hsl(0, 0%, 9%)';
+const darkPaper = 'hsl(0, 0%, 14%)';
+const darkElevated = 'hsl(0, 0%, 19%)';
+
+/**
+ * Resolve the semantic color groups for a color mode.
+ *
+ * Dark mode uses fixed HSL values; light mode derives borders/text from the
+ * `gray` ramp (with `alpha`).
+ *
+ * @param mode - `"light"` or `"dark"`.
+ * @returns The `SemanticColors` for that mode.
+ * @example
+ * ```ts
+ * const colors = getSemanticColors("dark");
+ * colors.surface.page; // "hsl(0, 0%, 9%)"
+ * ```
+ */
+export function getSemanticColors(mode: SemanticColorMode): SemanticColors {
+ if (mode === "dark") {
+ return {
+ surface: {
+ page: darkBg,
+ card: darkPaper,
+ elevated: darkElevated,
+ },
+ border: {
+ default: 'hsla(0, 0%, 100%, 0.08)',
+ subtle: 'hsla(0, 0%, 100%, 0.04)',
+ },
+ text: {
+ primary: 'hsl(0, 0%, 92%)',
+ secondary: 'hsl(0, 0%, 60%)',
+ muted: 'hsl(0, 0%, 45%)',
+ },
+ };
+ }
+
+ return {
+ surface: {
+ page: "hsl(0, 0%, 99%)",
+ card: "hsl(220, 35%, 97%)",
+ elevated: gray[100],
+ },
+ border: {
+ default: alpha(gray[300], 0.4),
+ subtle: alpha(gray[200], 0.3),
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ muted: gray[500],
+ },
+ };
+}
diff --git a/standards/mcp/templates/client/src/shared-theme/themePrimitives.ts b/standards/mcp/templates/client/src/shared-theme/themePrimitives.ts
new file mode 100644
index 0000000..a1bba19
--- /dev/null
+++ b/standards/mcp/templates/client/src/shared-theme/themePrimitives.ts
@@ -0,0 +1,480 @@
+/**
+ * Base design tokens and palettes for the app theme.
+ *
+ * Defines the raw color ramps (`brand`, `gray`, `green`, `orange`, `red`), the
+ * light/dark design tokens (`getDesignTokens`), and the `colorSchemes`,
+ * `typography`, `shape` and `shadows` primitives. Also augments MUI's theme
+ * types with the `ColorRange`, `flows` and `baseShadow` extensions.
+ *
+ * @packageDocumentation
+ */
+
+import { createTheme, alpha, Shadows } from '@mui/material/styles';
+import type { PaletteMode } from '@mui/material';
+
+declare module '@mui/material/Paper' {
+ interface PaperPropsVariantOverrides {
+ highlighted: true;
+ }
+}
+declare module '@mui/material/styles' {
+ interface ColorRange {
+ 50: string;
+ 100: string;
+ 200: string;
+ 300: string;
+ 400: string;
+ 500: string;
+ 600: string;
+ 700: string;
+ 800: string;
+ 900: string;
+ }
+
+ interface PaletteColor extends ColorRange {}
+
+ interface Palette {
+ baseShadow: string;
+ flows: {
+ outflows: { primary: string; surface: string; text: string };
+ inflows: { primary: string; surface: string; text: string };
+ };
+ }
+}
+
+const defaultTheme = createTheme();
+
+const customShadows: Shadows = [...defaultTheme.shadows];
+
+/** Brand blue color ramp (50-900). */
+export const brand = {
+ 50: 'hsl(210, 100%, 95%)',
+ 100: 'hsl(210, 100%, 92%)',
+ 200: 'hsl(210, 100%, 80%)',
+ 300: 'hsl(210, 100%, 65%)',
+ 400: 'hsl(210, 98%, 48%)',
+ 500: 'hsl(210, 98%, 42%)',
+ 600: 'hsl(210, 98%, 55%)',
+ 700: 'hsl(210, 100%, 35%)',
+ 800: 'hsl(210, 100%, 16%)',
+ 900: 'hsl(210, 100%, 21%)',
+};
+
+/** Neutral gray color ramp (50-900, plus 750/850). */
+export const gray = {
+ 50: 'hsl(220, 35%, 97%)',
+ 100: 'hsl(220, 30%, 94%)',
+ 200: 'hsl(220, 20%, 88%)',
+ 300: 'hsl(220, 20%, 80%)',
+ 400: 'hsl(220, 20%, 65%)',
+ 500: 'hsl(220, 20%, 42%)',
+ 600: 'hsl(220, 20%, 35%)',
+ 700: 'hsl(220, 20%, 25%)',
+ 750: 'hsl(220, 20%, 18%)',
+ 800: 'hsl(220, 30%, 6%)',
+ 850: 'hsl(220, 22%, 11%)',
+ 900: 'hsl(220, 35%, 3%)',
+};
+
+/** Green (success) color ramp (50-900). */
+export const green = {
+ 50: 'hsl(120, 80%, 98%)',
+ 100: 'hsl(120, 75%, 94%)',
+ 200: 'hsl(120, 75%, 87%)',
+ 300: 'hsl(120, 61%, 77%)',
+ 400: 'hsl(120, 44%, 53%)',
+ 500: 'hsl(120, 59%, 30%)',
+ 600: 'hsl(120, 70%, 25%)',
+ 700: 'hsl(120, 75%, 16%)',
+ 800: 'hsl(120, 84%, 10%)',
+ 900: 'hsl(120, 87%, 6%)',
+};
+
+/** Orange (warning) color ramp (50-900). */
+export const orange = {
+ 50: 'hsl(45, 100%, 97%)',
+ 100: 'hsl(45, 92%, 90%)',
+ 200: 'hsl(45, 94%, 80%)',
+ 300: 'hsl(45, 90%, 65%)',
+ 400: 'hsl(45, 90%, 40%)',
+ 500: 'hsl(45, 90%, 35%)',
+ 600: 'hsl(45, 91%, 25%)',
+ 700: 'hsl(45, 94%, 20%)',
+ 800: 'hsl(45, 95%, 16%)',
+ 900: 'hsl(45, 93%, 12%)',
+};
+
+/** Red (error) color ramp (50-900). */
+export const red = {
+ 50: 'hsl(0, 100%, 97%)',
+ 100: 'hsl(0, 92%, 90%)',
+ 200: 'hsl(0, 94%, 80%)',
+ 300: 'hsl(0, 90%, 65%)',
+ 400: 'hsl(0, 90%, 40%)',
+ 500: 'hsl(0, 90%, 30%)',
+ 600: 'hsl(0, 91%, 25%)',
+ 700: 'hsl(0, 94%, 18%)',
+ 800: 'hsl(0, 95%, 12%)',
+ 900: 'hsl(0, 93%, 6%)',
+};
+
+const darkBg = 'hsl(0, 0%, 9%)';
+const darkPaper = 'hsl(0, 0%, 14%)';
+const darkElevated = 'hsl(0, 0%, 19%)';
+
+/**
+ * Build the MUI design tokens (palette, typography, shape, shadows) for a mode.
+ *
+ * Light and dark palettes share the ramps but override specific slots in dark
+ * mode. Also sets `shadows[1]` (the "baseShadow") per mode. The returned object
+ * is spread into `createTheme` by `AppTheme`.
+ *
+ * @param mode - `"light"` or `"dark"`.
+ * @returns A MUI `ThemeOptions`-shaped object (palette, typography, shape,
+ * shadows).
+ */
+export const getDesignTokens = (mode: PaletteMode) => {
+ customShadows[1] =
+ mode === 'dark'
+ ? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)'
+ : 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
+
+ return {
+ palette: {
+ mode,
+ primary: {
+ light: brand[200],
+ main: brand[400],
+ dark: brand[700],
+ contrastText: brand[50],
+ ...(mode === 'dark' && {
+ contrastText: brand[50],
+ light: 'hsl(210, 50%, 65%)',
+ main: 'hsl(210, 55%, 55%)',
+ dark: 'hsl(210, 50%, 35%)',
+ }),
+ },
+ info: {
+ light: brand[100],
+ main: brand[300],
+ dark: brand[600],
+ contrastText: gray[50],
+ ...(mode === 'dark' && {
+ contrastText: 'hsl(210, 30%, 80%)',
+ light: 'hsl(210, 40%, 50%)',
+ main: 'hsl(210, 35%, 40%)',
+ dark: 'hsl(210, 30%, 25%)',
+ }),
+ },
+ warning: {
+ light: orange[300],
+ main: orange[400],
+ dark: orange[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(45, 60%, 55%)',
+ main: 'hsl(45, 55%, 45%)',
+ dark: 'hsl(45, 50%, 30%)',
+ }),
+ },
+ error: {
+ light: red[300],
+ main: red[400],
+ dark: red[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(0, 55%, 60%)',
+ main: 'hsl(0, 55%, 50%)',
+ dark: 'hsl(0, 50%, 35%)',
+ }),
+ },
+ success: {
+ light: green[300],
+ main: green[400],
+ dark: green[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(120, 40%, 55%)',
+ main: 'hsl(120, 40%, 45%)',
+ dark: 'hsl(120, 35%, 30%)',
+ }),
+ },
+ grey: {
+ ...gray,
+ },
+ divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4),
+ background: {
+ default: 'hsl(0, 0%, 99%)',
+ paper: 'hsl(220, 35%, 97%)',
+ ...(mode === 'dark' && { default: darkBg, paper: darkPaper }),
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ warning: orange[400],
+ ...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }),
+ },
+ action: {
+ hover: alpha(gray[200], 0.2),
+ selected: `${alpha(gray[200], 0.3)}`,
+ ...(mode === 'dark' && {
+ hover: 'hsla(0, 0%, 100%, 0.06)',
+ selected: 'hsla(0, 0%, 100%, 0.1)',
+ }),
+ },
+ flows: {
+ outflows: {
+ primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f',
+ surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea',
+ text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c',
+ },
+ inflows: {
+ primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32',
+ surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9',
+ text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20',
+ },
+ },
+ },
+ typography: {
+ fontFamily: 'Inter, sans-serif',
+ h1: {
+ fontSize: defaultTheme.typography.pxToRem(48),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ letterSpacing: -0.5,
+ },
+ h2: {
+ fontSize: defaultTheme.typography.pxToRem(36),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h3: {
+ fontSize: defaultTheme.typography.pxToRem(30),
+ lineHeight: 1.2,
+ },
+ h4: {
+ fontSize: defaultTheme.typography.pxToRem(24),
+ fontWeight: 600,
+ lineHeight: 1.5,
+ },
+ h5: {
+ fontSize: defaultTheme.typography.pxToRem(20),
+ fontWeight: 600,
+ },
+ h6: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ fontWeight: 600,
+ },
+ subtitle1: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ },
+ subtitle2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 500,
+ },
+ body1: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ },
+ body2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 400,
+ },
+ caption: {
+ fontSize: defaultTheme.typography.pxToRem(12),
+ fontWeight: 400,
+ },
+ },
+ shape: {
+ borderRadius: 8,
+ },
+ shadows: customShadows,
+ };
+};
+
+/** Explicit light/dark `colorSchemes` palette definitions. */
+export const colorSchemes = {
+ light: {
+ palette: {
+ primary: {
+ light: brand[200],
+ main: brand[400],
+ dark: brand[700],
+ contrastText: brand[50],
+ },
+ info: {
+ light: brand[100],
+ main: brand[300],
+ dark: brand[600],
+ contrastText: gray[50],
+ },
+ warning: {
+ light: orange[300],
+ main: orange[400],
+ dark: orange[800],
+ },
+ error: {
+ light: red[300],
+ main: red[400],
+ dark: red[800],
+ },
+ success: {
+ light: green[300],
+ main: green[400],
+ dark: green[800],
+ },
+ grey: {
+ ...gray,
+ },
+ divider: alpha(gray[300], 0.4),
+ background: {
+ default: 'hsl(0, 0%, 99%)',
+ paper: 'hsl(220, 35%, 97%)',
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ warning: orange[400],
+ },
+ action: {
+ hover: alpha(gray[200], 0.2),
+ selected: `${alpha(gray[200], 0.3)}`,
+ },
+ flows: {
+ outflows: {
+ primary: '#d32f2f',
+ surface: '#fdecea',
+ text: '#b71c1c',
+ },
+ inflows: {
+ primary: '#2e7d32',
+ surface: '#e8f5e9',
+ text: '#1b5e20',
+ },
+ },
+ baseShadow:
+ 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
+ },
+ },
+ dark: {
+ palette: {
+ primary: {
+ contrastText: brand[50],
+ light: 'hsl(210, 50%, 65%)',
+ main: 'hsl(210, 55%, 55%)',
+ dark: 'hsl(210, 50%, 35%)',
+ },
+ info: {
+ contrastText: 'hsl(210, 30%, 80%)',
+ light: 'hsl(210, 40%, 50%)',
+ main: 'hsl(210, 35%, 40%)',
+ dark: 'hsl(210, 30%, 25%)',
+ },
+ warning: {
+ light: 'hsl(45, 60%, 55%)',
+ main: 'hsl(45, 55%, 45%)',
+ dark: 'hsl(45, 50%, 30%)',
+ },
+ error: {
+ light: 'hsl(0, 55%, 60%)',
+ main: 'hsl(0, 55%, 50%)',
+ dark: 'hsl(0, 50%, 35%)',
+ },
+ success: {
+ light: 'hsl(120, 40%, 55%)',
+ main: 'hsl(120, 40%, 45%)',
+ dark: 'hsl(120, 35%, 30%)',
+ },
+ grey: {
+ ...gray,
+ },
+ divider: 'hsla(0, 0%, 100%, 0.08)',
+ background: {
+ default: darkBg,
+ paper: darkPaper,
+ },
+ text: {
+ primary: 'hsl(0, 0%, 92%)',
+ secondary: 'hsl(0, 0%, 60%)',
+ },
+ action: {
+ hover: 'hsla(0, 0%, 100%, 0.06)',
+ selected: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ flows: {
+ outflows: {
+ primary: 'hsl(0, 55%, 60%)',
+ surface: 'hsla(0, 35%, 25%, 0.6)',
+ text: 'hsl(0, 60%, 80%)',
+ },
+ inflows: {
+ primary: 'hsl(120, 40%, 55%)',
+ surface: 'hsla(120, 25%, 22%, 0.6)',
+ text: 'hsl(120, 40%, 78%)',
+ },
+ },
+ baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
+ },
+ },
+};
+
+/** Typography scale (Inter) used by the theme. */
+export const typography = {
+ fontFamily: 'Inter, sans-serif',
+ h1: {
+ fontSize: defaultTheme.typography.pxToRem(48),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ letterSpacing: -0.5,
+ },
+ h2: {
+ fontSize: defaultTheme.typography.pxToRem(36),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h3: {
+ fontSize: defaultTheme.typography.pxToRem(30),
+ lineHeight: 1.2,
+ },
+ h4: {
+ fontSize: defaultTheme.typography.pxToRem(24),
+ fontWeight: 600,
+ lineHeight: 1.5,
+ },
+ h5: {
+ fontSize: defaultTheme.typography.pxToRem(20),
+ fontWeight: 600,
+ },
+ h6: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ fontWeight: 600,
+ },
+ subtitle1: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ },
+ subtitle2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 500,
+ },
+ body1: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ },
+ body2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 400,
+ },
+ caption: {
+ fontSize: defaultTheme.typography.pxToRem(12),
+ fontWeight: 400,
+ },
+};
+
+/** Shape tokens (global border radius). */
+export const shape = {
+ borderRadius: 8,
+};
+
+// @ts-ignore
+const defaultShadows: Shadows = [
+ 'none',
+ 'var(--template-palette-baseShadow)',
+ ...defaultTheme.shadows.slice(2),
+];
+/** Shadow scale (index 0 `none`, index 1 the template `baseShadow`). */
+export const shadows = defaultShadows;
diff --git a/standards/mcp/templates/client/src/vite-env.d.ts b/standards/mcp/templates/client/src/vite-env.d.ts
new file mode 100644
index 0000000..9ce06ff
--- /dev/null
+++ b/standards/mcp/templates/client/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string;
+ readonly VITE_AUTH_BASE_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/standards/mcp/templates/client/tsconfig.json b/standards/mcp/templates/client/tsconfig.json
new file mode 100644
index 0000000..f6e9b6f
--- /dev/null
+++ b/standards/mcp/templates/client/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "allowJs": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/standards/mcp/templates/client/vite.config.ts b/standards/mcp/templates/client/vite.config.ts
new file mode 100644
index 0000000..0466183
--- /dev/null
+++ b/standards/mcp/templates/client/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+});
diff --git a/standards/src/lib/90_drone_pipelines.md b/standards/src/lib/90_drone_pipelines.md
index bcca08e..1c418ea 100644
--- a/standards/src/lib/90_drone_pipelines.md
+++ b/standards/src/lib/90_drone_pipelines.md
@@ -3,17 +3,19 @@
## Summary
Every Aetos repository ships a Drone CI definition at `.drone.yml`. There is
-exactly **one standard per repository**, and there are **two types of file**,
+exactly **one standard per repository**, and there are **three types of file**,
chosen by what the repository produces:
| Type | File | Produces | Repos |
|------|------|----------|-------|
| **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 — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server` |
+| **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` |
-Both templates run on `platform: linux / arm64` with the workspace at
-`/drone/src`. Copy the matching template to `/.drone.yml`; the Type A file
-needs no edits, the Type B file needs its placeholders filled in (below).
+All templates run on `platform: linux / arm64` with the workspace at
+`/drone/src`. Copy the matching template to `/.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` — never commit credentials.
@@ -22,7 +24,7 @@ needs no edits, the Type B file needs its placeholders filled in (below).
## Type A — PyPI package (`python-lib.yaml`)
-For internal libraries. The file contains **two pipelines**:
+For internal Python libraries. The file contains **two pipelines**:
1. **`build-and-publish-pypi`** — triggered on `tag`:
- `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in
@@ -48,7 +50,30 @@ Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.
---
-## Type B — Docker deployment (`service-docker.yaml`)
+## Type B — npm package (`npm-lib.yaml`)
+
+For internal npm libraries (the React packages). One pipeline,
+**`build-and-publish-npm`**, triggered on `tag`:
+
+- `check-version` — **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` — write `.npmrc` from the secrets, `npm install`, then
+ `npm run quality-gate`.
+- `build-package` — `npm install && npm run build` into `dist/`.
+- `publish-to-private-npm` — `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 — 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.
+
+---
+
+## Type C — Docker deployment (`service-docker.yaml`)
For a service that ships a container image — a long-running API/worker, e.g.
`auth-server`. The file contains **one pipeline**, `build-and-deploy-service`,
@@ -86,15 +111,18 @@ Placeholders to fill in per repo:
## What not to do
-- Do not hand-roll a third shape. A repo is either Type A or Type B, never both.
+- 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 — it guards the
index and the running container against duplicate publishes.
-- Do not drop the tag↔version guard. A package needs its `pyproject.toml`
- version to equal the tag; a service needs a root `VERSION` file that equals
- the tag. See [91 – Versioning & Changelog](91_versioning_and_changelog.md).
+- Do not drop the tag↔version 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 – Versioning & Changelog](91_versioning_and_changelog.md).
- Do not rename the pipelines; tooling and runbooks key off
- `build-and-publish-pypi` and `build-and-deploy-service`.
+ `build-and-publish-pypi`, `build-and-publish-npm`, and
+ `build-and-deploy-service`.
---
diff --git a/standards/src/lib/91_versioning_and_changelog.md b/standards/src/lib/91_versioning_and_changelog.md
index 9503db8..3f557a3 100644
--- a/standards/src/lib/91_versioning_and_changelog.md
+++ b/standards/src/lib/91_versioning_and_changelog.md
@@ -10,7 +10,7 @@ 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 – Drone Pipelines](90_drone_pipelines.md): the version
-and tag contract is exactly what those two CI shapes consume.
+and tag contract is exactly what those three CI shapes consume.
---
@@ -19,6 +19,7 @@ and tag contract is exactly what those two CI shapes consume.
| 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) — never hardcode the version |
Rules:
@@ -116,7 +117,8 @@ bump travels with the change and only the tag is added afterwards:
1. **In the MR** — reconcile `[Unreleased]` against the change (see
[Reconcile before releasing](#reconcile-before-releasing)) and get the user's
approval for any additions. Then set the intended release version: `version`
- in `pyproject.toml` (or the `VERSION` file) to `x.y.z`, and promote the
+ 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`.
@@ -133,8 +135,10 @@ 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:
- - packages:
+ - Python packages:
`git log -S 'version = "x.y.z"' -n 1 --format='%H %s' -- pyproject.toml`
+ - npm packages:
+ `git log -S '"version": "x.y.z"' -n 1 --format='%H %s' -- package.json`
- services:
`git log -S 'x.y.z' -n 1 --format='%H %s' -- VERSION`
3. Confirm that commit's version file equals `x.y.z`, then tag **only** that
@@ -154,13 +158,14 @@ After the user confirms the MR is merged/squashed and says to proceed:
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 — `pyproject.toml` (`version = "x.y.z"`) or
- `VERSION` — to the intended release version.
+4. In the MR: set the version file — `pyproject.toml` (`version = "x.y.z"`),
+ `package.json` (`"version": "x.y.z"`), or `VERSION` — 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 builds and deploys the image; both first assert
- `tag == version`.
+ 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 — fix and cut `x.y.(z+1)`.
---
diff --git a/standards/src/lib/94_repository_inventory.md b/standards/src/lib/94_repository_inventory.md
index b6f0989..0da0427 100644
--- a/standards/src/lib/94_repository_inventory.md
+++ b/standards/src/lib/94_repository_inventory.md
@@ -5,7 +5,7 @@
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 – Versioning & Changelog](91_versioning_and_changelog.md) and the two CI
+[91 – Versioning & Changelog](91_versioning_and_changelog.md) and the three CI
files in [90 – Drone Pipelines](90_drone_pipelines.md).
The same data is published machine-readably as the `registry://repos` MCP
@@ -14,7 +14,7 @@ Paths are relative to the `aetos/` workspace root.
---
-## Packages (Type A)
+## Packages (Type A — PyPI)
A wheel + sdist published to the private index. Version source is
`pyproject.toml`; Drone file is `python-lib.yaml` (tag-triggered).
@@ -32,14 +32,28 @@ A wheel + sdist published to the private index. Version source is
---
-## Services (Type B)
+## Packages (Type B — npm)
+
+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` |
+
+---
+
+## Services & Apps (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 | Trigger | Docs |
-|------|------|---------|------|
-| `auth-server` | `auth-server` | tag | api, lib, wiki |
+| 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`.
@@ -49,8 +63,9 @@ Tag-triggered and enforces `tag == VERSION`.
| Type | Version source | Changelog | Drone |
|------|----------------|-----------|-------|
-| package | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` |
-| service | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` |
+| 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
diff --git a/standards/src/lib/index.md b/standards/src/lib/index.md
index 8bd29c9..e20058a 100644
--- a/standards/src/lib/index.md
+++ b/standards/src/lib/index.md
@@ -44,8 +44,8 @@ and an optional pipelines layer.
## Delivery & Meta
-- **Drone Pipelines** — the two CI shapes: a PyPI/npm package or a Docker
- deployment.
+- **Drone Pipelines** — the three CI shapes: a PyPI package, an npm package,
+ or a Docker deployment.
- **Versioning & Changelog** — SemVer, the tag↔version contract, and the
release changelog.
- **Repository Inventory** — every covered repo, its type, version source, and
diff --git a/standards/src/repos.json b/standards/src/repos.json
index b65c33b..ea475b2 100644
--- a/standards/src/repos.json
+++ b/standards/src/repos.json
@@ -1,6 +1,6 @@
{
"project": "standards",
- "note": "Machine-readable repository inventory. Mirrors standards/src/lib/15_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
+ "note": "Machine-readable repository inventory. Mirrors standards/src/lib/94_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
"path_convention": "relative to the aetos/ workspace root",
"repos": [
{
@@ -23,6 +23,16 @@
"docs": ["lib", "wiki"],
"mcp": { "server": "docforge", "port": 8001 }
},
+ {
+ "name": "doc-weaver",
+ "type": "package",
+ "path": "doc-weaver",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": ["lib", "wiki"],
+ "mcp": { "server": "doc-weaver", "port": 8010 }
+ },
{
"name": "hexa",
"type": "package",
@@ -83,6 +93,26 @@
"docs": ["lib", "wiki"],
"mcp": { "server": "jwtlib", "port": 8005 }
},
+ {
+ "name": "react-auth",
+ "type": "package",
+ "path": "react/react-auth",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": ["lib", "wiki"],
+ "mcp": { "server": "react-auth", "port": 8011 }
+ },
+ {
+ "name": "react-openapi",
+ "type": "package",
+ "path": "react/react-openapi",
+ "version_source": "package.json",
+ "drone": "npm-lib.yaml",
+ "trigger": "tag",
+ "docs": ["lib", "wiki"],
+ "mcp": { "server": "react-openapi", "port": 8012 }
+ },
{
"name": "auth-server",
"type": "service",
@@ -92,6 +122,16 @@
"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/wiki/sitemap.xml.gz b/standards/wiki/sitemap.xml.gz
index 7140084f63cdc7ac094f9b92e2c304376b6ad4a8..4a88da2ba663d03469325173240c2b00a79c04ce 100644
GIT binary patch
delta 13
Ucmb=gXP58h;9yvPZ6bRG031#PRR910
delta 13
Ucmb=gXP58h;An`wI+48s03EOdf&c&j