From 5a1204fbcb2a6c2ea9426ae13d79a41eaa6cc0a2 Mon Sep 17 00:00:00 2001
From: Vishesh 'ironeagle' Bangotra from_secret — never commit credentials.
npm-lib.yaml)For internal npm libraries (the React packages). One pipeline,
-build-and-publish-npm, triggered on tag:
For internal npm libraries (the React packages). The file contains two +pipelines:
+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.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.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.$NPM_REPO_URL.
+backfill-npm-from-tags — triggered on custom:fetch-tags — git fetch --tags --force.build-and-publish-missing — walk every tag in version order, skip the
+ ones already on the registry, git checkout the rest, verify
+ tag == package.json version at that commit, build, and publish. This is
+ the disaster-recovery / replay path.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.
.npmrc in a repo only points at the regis
package.json, so the same file
is reused verbatim across every npm package.+Registry credentials are shared. The private npm registry (Verdaccio at +
+npm.aetoskia.com) authenticates against the samehtpasswdfile as the +private PyPI index and the Docker registry (/auth/htpasswd), with +registration disabled (max_users: -1).NPM_USERNAME/NPM_PASSWORDare +therefore the same credentials asPIP_USERNAME/PIP_PASSWORD— no +separate npm account exists. Verdaccio's htpasswd plugin accepts bcrypt +hashes ($2y$…); non-bcrypt entries that pypiserver tolerates will not +authenticate against npm. See 93 – Library Registry.
service-docker.yaml)For a service that ships a container image — a long-running API/worker, e.g.
@@ -821,8 +838,8 @@ triggered on tag:
package.json version; a service needs a root VERSION file. See
91 – Versioning & Changelog.
build-and-publish-pypi, build-and-publish-npm, and
- build-and-deploy-service.build-and-publish-pypi, build-and-publish-npm,
+ build-and-deploy-service, and their backfill-*-from-tags companions.
docs://spec. Query it before writing
any infrastructure.
+Python packages publish to the private index (pip.aetoskia.com, pypiserver)
+and npm packages to the private registry (npm.aetoskia.com, Verdaccio). Both
+authenticate against the same htpasswd file (/auth/htpasswd) used by the
+Docker registry and the Traefik basic-auth middleware; registration is disabled,
+so the CI credentials (PIP_USERNAME / PIP_PASSWORD, NPM_USERNAME /
+NPM_PASSWORD) are one and the same account — there is no separate npm user.
+Verdaccio requires bcrypt htpasswd entries; see
+90 – Drone Pipelines for the CI wiring.
:8008: read
diff --git a/standards/lib/search/search_index.json b/standards/lib/search/search_index.json
index d05c6ac..aae5e33 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":"@aetoskia/react-openapi admin rendered from the spec's x- extensions, and @aetoskia/react-auth for auth pages; the client template scaffold.Read the Wiki for the philosophy and rationale; these pages are the reference that mirrors the standards MCP bundle.
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.
From the standards docs checkout:
cp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n Pair it with the React client template when a UI is needed (auto-rendered admin from the spec \u2014 see 40 \u2013 UI):
cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client && npm install\n Install the internal libraries \u2014 editable local checkouts (preferred in development) or the private index (CI / fresh machines). Credentials are environment-only; never commit them.
# local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n"},{"location":"01_build_recipe/#agent-prompt-copyable","title":"\ud83d\udccb Agent prompt (copyable)","text":"# Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2\u20134 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n and note the Tier A libraries that already exist \u2014 never reimplement one;\n- call `list_app_components()` and pull each file you need with\n `get_app_component(kind)` instead of writing it from scratch.\n\n## Steps\n\n1. **Scaffold** \u2014 copy `standards/src/templates/app/` into `apps/<your-app>/`\n (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** \u2014 write `openapi.yaml`: resources,\n schemas, `operationId`s, security, and extension metadata. This is the\n contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** \u2014 from the spec, generate the wire\n models and the exact handler signatures:\n ```bash\n openapi-first models openapi.yaml -o _gen/models.py\n openapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n ```\n `_gen/models.py` is the **wire contract** (Pydantic, one class per\n `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every\n `operationId` with its exact parameter signature. `--template-style` makes\n those stubs `async def` with a `body` parameter, matching the handlers you\n keep in `interfaces/` (without it they are sync `def` stubs taking\n `payload`; both bind the same way). Treat `_gen/` as generated\n (never hand-edit it) \u2014 the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** \u2014 per resource create the four-file\n slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n (`class XDocument(BaseDocument, X)` \u2014 id comes from `BaseDocument`),\n `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n dependent repositories, then services; add infra adapters (db, cache, stores,\n progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers whose\n names match the `operationId`s (use the signatures from step 3 as your\n checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n decorator to read endpoints.\n\n7. **Bootstrap app and worker** \u2014 confirm `main.py` runs the OpenAPI-first app\n with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** \u2014 copy the worked family\n `pipelines/appointment_reminder/` to `pipelines/<family>/` with `abc.py`,\n `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/`\n (loader + registry). Register the root, add the `job.kind` handler in\n `worker.py`. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** \u2014 Tier A capability: use the\n library. Tier B capability: copy the template. Never hand-write a Tier A\n library.\n\n10. **Tests** \u2014 integration tests with fakes at the HTTP edge; unit tests for\n domain and pipeline logic; a config-consistency test for rule-driven\n pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.<name>`.\n\n12. **Docs** \u2014 author GSDFC docstrings, generate `.pyi` stubs, mark the package\n `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n"},{"location":"01_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"The steps map onto the layers as follows:
Step Package / file Depends on 1 \u00b7 Scaffold wholetemplates/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:
_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:
_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.--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./fetch-requests \u2192 fetch_requests.py.UploadFile = File(...) / Form(...) params._gen/ is disposable. Regenerate on every spec change; git diff shows exactly what moved.python -c \"import main\" succeeds.pytest passes.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.
_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).
init() is idempotent.init(); they read request.app.state.db_resources.init() / shutdown() around their work.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 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.import fastapi inside domain/ or infra/.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.operationId names the handler function.openapi_first) validates requests/responses against the spec and dispatches to the handler whose name equals operationId.# 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/.
operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.400; not found -> 404; delete -> 204.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:
security: block.components.securitySchemes.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: [].
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.
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 Hasid? 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:
id only on the wire model.class ItemDocument(BaseDocument, Item).ports/converters.py.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:
dict / list[dict] on the document; the API returns it verbatim. No populate wiring.PopulateRule; mongo-ops expands it on read. The referenced repo must be registered with the PopulationEngine.For populate-backed repositories change a reference with update, not patch: patch rejects changes to populated FK fields by design.
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).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.
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 PurposePOST /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.
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.
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).
jwtlib before handling the request.active: false as unauthenticated, even if the JWT decodes.securitySchemes block above only when auth is required. Pull the shared slice with get_app_component(\"auth\").jwtlib for the low-level path.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.
setup_database(uri, db_name) / shutdown_database().repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n 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 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 invalidate(*resources) bumps a generation counter for write-triggered invalidation.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).
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.
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":"find_library first.templates/app rather than searching the registry.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).
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);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>.
The admin renderer reads a catalog of OpenAPI extension fields (x-resource, x-list-columns, x-display-format, x-label, x-order, x-hidden, x-sortable, x-filterable, x-autocomplete, x-fk, x-ui-type, x-upload, x-sse, x-profile, and the auth wiring x-server-url / x-login-path / x-me-path / \u2026) declared on schemas and properties.
The catalog is canonical in the react-openapi source, not in this doc. Read it from the MCP bundle:
docs://modules/react-openapi # module: specExtensions \u2014 the full catalog\n The app's own spec carries the per-resource values; the catalog explains what each field does. The template's openapi.yaml shows the convention on the example Job schema.
openapi.yaml; the admin follows. Never fork the renderer per app.@aetoskia/react-auth holds tokens and calls the auth-server endpoints declared by the x- wiring; pages read the context.@aetoskia/react-openapi or @aetoskia/react-auth (SemVer, tag-triggered npm release), not copy-pasted into app trees.06_reference_app) documents the pairing end to end.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.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.
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":"raise PauseForResolution(\n job_id=jid,\n reference=\"<stable key for this decision>\",\n options=[...],\n context={...},\n)\n paused.GET /jobs/{id}/questions -> list pending questions\nPOST /questions/{qid}/resolve {choice} -> record the choice\n pending.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.
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:
templates/drone/python-lib.yaml A wheel + sdist on the private index dagpipe, doc-forge, hexa, mail-intake, mongo-ops, omniread, openapi-first, py-jwt B \u2014 npm package templates/drone/npm-lib.yaml A package published to the private npm registry doc-weaver, react-auth, react-openapi C \u2014 Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container auth-server All templates run on platform: linux / arm64 with the workspace at /drone/src. Copy the matching template to <repo>/.drone.yml; the Type A and Type B files need no edits, the Type C file needs its placeholders filled in (below).
Conventions: .drone.yml (not .yaml), one file per repo, secrets injected with from_secret \u2014 never commit credentials.
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.
pyproject.toml, so the same file is reused verbatim across every library.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.
package.json, so the same file is reused verbatim across every npm package.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":"from_secret.check-version / check-remote-image skip \u2014 it guards the index and the running container against duplicate publishes.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.build-and-publish-pypi, build-and-publish-npm, and build-and-deploy-service.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:
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.While a package is 0.y.z, 0.y is treated as the major line:
0.(y+1).00.y.(z+1)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.
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:
[Unreleased] current: add an entry in the same change that introduces the behaviour, not later.[Unreleased] heading is never renamed; copy its content into the new version heading and clear it.[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:
git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD.[Unreleased]; add an entry for every user-facing change that is missing.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:
[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.main.x.y.z on the resulting commit (the merge/squash commit).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.
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:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.tomlgit log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.jsongit log -S 'x.y.z' -n 1 --format='%H %s' -- VERSIONx.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>.git push origin x.y.z.main is up to date.[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.pyproject.toml (version = \"x.y.z\"), package.json (\"version\": \"x.y.z\"), or VERSION \u2014 to the intended release version.main.git tag x.y.z.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.x.y.(z+1).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.
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.
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.
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:
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}.
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.
: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.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.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.
A wheel + sdist published to the private index. Version source is pyproject.toml; Drone file is python-lib.yaml (tag-triggered).
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).
doc-weaver doc-weaver lib, wiki doc-weaver:8010 react-auth react/react-auth lib, wiki react-auth:8011 react-openapi react/react-openapi lib, wiki react-openapi:8012"},{"location":"94_repository_inventory/#services-type-c","title":"Services (Type C)","text":"A container image pushed to the registry and run on the Docker host. Version source is the VERSION file; Drone file is service-docker.yaml.
auth-server auth-server tag api, lib, wiki Tag-triggered and enforces tag == VERSION.
pyproject.toml CHANGELOG.md python-lib.yaml package (npm) package.json CHANGELOG.md npm-lib.yaml service VERSION CHANGELOG.md service-docker.yaml Every covered repository must be able to satisfy tag == version: the version file (never a commit id) is bumped in the release MR, the MR is merged, and the tag is created on the merged commit.
registry://reposlist_repos()standards/src/repos.json).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":"@aetoskia/react-openapi admin rendered from the spec's x- extensions, and @aetoskia/react-auth for auth pages; the client template scaffold.Read the Wiki for the philosophy and rationale; these pages are the reference that mirrors the standards MCP bundle.
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.
From the standards docs checkout:
cp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n Pair it with the React client template when a UI is needed (auto-rendered admin from the spec \u2014 see 40 \u2013 UI):
cp -r <docs>/standards/src/templates/client apps/<your-app>-client\ncd apps/<your-app>-client && npm install\n Install the internal libraries \u2014 editable local checkouts (preferred in development) or the private index (CI / fresh machines). Credentials are environment-only; never commit them.
# local checkouts\npip install -e ../aetos/openapi-first -e ../aetos/mongo-ops -e ../aetos/hexa \\\n -e ../aetos/dagpipe -e ../aetos/doc-forge\n# private index\npip install --extra-index-url \\\n \"https://$PIP_REGISTRY_USERNAME:$PIP_REGISTRY_PASSWORD@pip.aetoskia.com/simple\" .\n"},{"location":"01_build_recipe/#agent-prompt-copyable","title":"\ud83d\udccb Agent prompt (copyable)","text":"# Build a new application from the Aetos Standards blueprint\n\nYou are coding a complete new backend application conforming to the Aetos\nstandard app blueprint. Execute every step in order. Only steps 2\u20134 use the\napp-specific details provided by the user; everything else is deterministic.\n\n## Before writing any code\n\nConnect to the `standards` MCP server and read:\n- `docs://nav` (the module list)\n- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)\n- `docs://modules/layering_rules` (the dependency rules)\n\nThen:\n- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`\n and note the Tier A libraries that already exist \u2014 never reimplement one;\n- call `list_app_components()` and pull each file you need with\n `get_app_component(kind)` instead of writing it from scratch.\n\n## Steps\n\n1. **Scaffold** \u2014 copy `standards/src/templates/app/` into `apps/<your-app>/`\n (or pull the same tree via `get_app_template`). Keep the tree: `domain/`,\n `infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus\n `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,\n `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.\n\n2. **Author the spec (app-specific)** \u2014 write `openapi.yaml`: resources,\n schemas, `operationId`s, security, and extension metadata. This is the\n contract and drives all routing. Every operation MUST have an `operationId`.\n\n3. **Generate the scaffold (codegen)** \u2014 from the spec, generate the wire\n models and the exact handler signatures:\n ```bash\n openapi-first models openapi.yaml -o _gen/models.py\n openapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n ```\n `_gen/models.py` is the **wire contract** (Pydantic, one class per\n `components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every\n `operationId` with its exact parameter signature. `--template-style` makes\n those stubs `async def` with a `body` parameter, matching the handlers you\n keep in `interfaces/` (without it they are sync `def` stubs taking\n `payload`; both bind the same way). Treat `_gen/` as generated\n (never hand-edit it) \u2014 the canonical handlers live in `interfaces/`.\n\n4. **Write each CRUD slice (app-specific)** \u2014 per resource create the four-file\n slice: `models.py` (domain model, **no `id`**), `ports/db.py`\n (`class XDocument(BaseDocument, X)` \u2014 id comes from `BaseDocument`),\n `ports/service.py`, and optional `converters.py`. Follow the CRUD template.\n\n5. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n dependent repositories, then services; add infra adapters (db, cache, stores,\n progress bus, fetchers). Expose `get_svc` overloads.\n\n6. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers whose\n names match the `operationId`s (use the signatures from step 3 as your\n checklist) and re-export them in `interfaces/openapi.py`. Add the cache\n decorator to read endpoints.\n\n7. **Bootstrap app and worker** \u2014 confirm `main.py` runs the OpenAPI-first app\n with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll\n the job collection, run the pipeline, and publish progress.\n\n8. **Add pipeline families (only if data-driven)** \u2014 copy the worked family\n `pipelines/appointment_reminder/` to `pipelines/<family>/` with `abc.py`,\n `impl.py`, `pipeline.yaml`, `resources/`, and reuse `pipelines/shared/`\n (loader + registry). Register the root, add the `job.kind` handler in\n `worker.py`. Add fixtures and a parity test.\n\n9. **Add infrastructure only where needed** \u2014 Tier A capability: use the\n library. Tier B capability: copy the template. Never hand-write a Tier A\n library.\n\n10. **Tests** \u2014 integration tests with fakes at the HTTP edge; unit tests for\n domain and pipeline logic; a config-consistency test for rule-driven\n pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports\n the app, asserts `import main` binds every operation).\n\n11. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n `init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.<name>`.\n\n12. **Docs** \u2014 author GSDFC docstrings, generate `.pyi` stubs, mark the package\n `py.typed`, and build the docs.\n\n**Definition of done:**\n- `python -c \"import main\"` succeeds.\n- `pytest` passes.\n- The app serves every operation in the spec; the worker drains its queue.\n- No domain module imports a web framework.\n- No Tier A library was reimplemented.\n"},{"location":"01_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"The steps map onto the layers as follows:
Step Package / file Depends on 1 \u00b7 Scaffold wholetemplates/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:
_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:
_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.--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./fetch-requests \u2192 fetch_requests.py.UploadFile = File(...) / Form(...) params._gen/ is disposable. Regenerate on every spec change; git diff shows exactly what moved.python -c \"import main\" succeeds.pytest passes.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.
_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).
init() is idempotent.init(); they read request.app.state.db_resources.init() / shutdown() around their work.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 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.import fastapi inside domain/ or infra/.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.operationId names the handler function.openapi_first) validates requests/responses against the spec and dispatches to the handler whose name equals operationId.# 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/.
operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.400; not found -> 404; delete -> 204.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:
security: block.components.securitySchemes.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: [].
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.
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 Hasid? 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:
id only on the wire model.class ItemDocument(BaseDocument, Item).ports/converters.py.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:
dict / list[dict] on the document; the API returns it verbatim. No populate wiring.PopulateRule; mongo-ops expands it on read. The referenced repo must be registered with the PopulationEngine.For populate-backed repositories change a reference with update, not patch: patch rejects changes to populated FK fields by design.
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).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.
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 PurposePOST /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.
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.
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).
jwtlib before handling the request.active: false as unauthenticated, even if the JWT decodes.securitySchemes block above only when auth is required. Pull the shared slice with get_app_component(\"auth\").jwtlib for the low-level path.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.
setup_database(uri, db_name) / shutdown_database().repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n 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 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 invalidate(*resources) bumps a generation counter for write-triggered invalidation.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).
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.
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":"find_library first.templates/app rather than searching the registry.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).
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);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>.
The admin renderer reads a catalog of OpenAPI extension fields (x-resource, x-list-columns, x-display-format, x-label, x-order, x-hidden, x-sortable, x-filterable, x-autocomplete, x-fk, x-ui-type, x-upload, x-sse, x-profile, and the auth wiring x-server-url / x-login-path / x-me-path / \u2026) declared on schemas and properties.
The catalog is canonical in the react-openapi source, not in this doc. Read it from the MCP bundle:
docs://modules/react-openapi # module: specExtensions \u2014 the full catalog\n The app's own spec carries the per-resource values; the catalog explains what each field does. The template's openapi.yaml shows the convention on the example Job schema.
openapi.yaml; the admin follows. Never fork the renderer per app.@aetoskia/react-auth holds tokens and calls the auth-server endpoints declared by the x- wiring; pages read the context.@aetoskia/react-openapi or @aetoskia/react-auth (SemVer, tag-triggered npm release), not copy-pasted into app trees.06_reference_app) documents the pairing end to end.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.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.
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":"raise PauseForResolution(\n job_id=jid,\n reference=\"<stable key for this decision>\",\n options=[...],\n context={...},\n)\n paused.GET /jobs/{id}/questions -> list pending questions\nPOST /questions/{qid}/resolve {choice} -> record the choice\n pending.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.
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:
templates/drone/python-lib.yaml A wheel + sdist on the private index dagpipe, doc-forge, hexa, mail-intake, mongo-ops, omniread, openapi-first, py-jwt B \u2014 npm package templates/drone/npm-lib.yaml A package published to the private npm registry doc-weaver, react-auth, react-openapi C \u2014 Docker deployment templates/drone/service-docker.yaml An image pushed to the registry and run as a container auth-server All templates run on platform: linux / arm64 with the workspace at /drone/src. Copy the matching template to <repo>/.drone.yml; the Type A and Type B files need no edits, the Type C file needs its placeholders filled in (below).
Conventions: .drone.yml (not .yaml), one file per repo, secrets injected with from_secret \u2014 never commit credentials.
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.
pyproject.toml, so the same file is reused verbatim across every library.npm-lib.yaml)","text":"For internal npm libraries (the React packages). The file contains two pipelines:
build-and-publish-npm \u2014 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.backfill-npm-from-tags \u2014 triggered on custom:fetch-tags \u2014 git fetch --tags --force.build-and-publish-missing \u2014 walk every tag in version order, skip the ones already on the registry, git checkout the rest, verify tag == package.json version at that commit, build, and publish. This is the disaster-recovery / replay path.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.
package.json, so the same file is reused verbatim across every npm package.Registry credentials are shared. The private npm registry (Verdaccio at npm.aetoskia.com) authenticates against the same htpasswd file as the private PyPI index and the Docker registry (/auth/htpasswd), with registration disabled (max_users: -1). NPM_USERNAME / NPM_PASSWORD are therefore the same credentials as PIP_USERNAME / PIP_PASSWORD \u2014 no separate npm account exists. Verdaccio's htpasswd plugin accepts bcrypt hashes ($2y$\u2026); non-bcrypt entries that pypiserver tolerates will not authenticate against npm. See 93 \u2013 Library Registry.
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":"from_secret.check-version / check-remote-image skip \u2014 it guards the index and the running container against duplicate publishes.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.build-and-publish-pypi, build-and-publish-npm, build-and-deploy-service, and their backfill-*-from-tags companions.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:
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.While a package is 0.y.z, 0.y is treated as the major line:
0.(y+1).00.y.(z+1)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.
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:
[Unreleased] current: add an entry in the same change that introduces the behaviour, not later.[Unreleased] heading is never renamed; copy its content into the new version heading and clear it.[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:
git log <last-tag>..HEAD and git diff --stat <last-tag>..HEAD.[Unreleased]; add an entry for every user-facing change that is missing.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:
[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.main.x.y.z on the resulting commit (the merge/squash commit).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.
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:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.tomlgit log -S '\"version\": \"x.y.z\"' -n 1 --format='%H %s' -- package.jsongit log -S 'x.y.z' -n 1 --format='%H %s' -- VERSIONx.y.z, then tag only that commit, lightweight and bare: git tag x.y.z <sha>.git push origin x.y.z.main is up to date.[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.pyproject.toml (version = \"x.y.z\"), package.json (\"version\": \"x.y.z\"), or VERSION \u2014 to the intended release version.main.git tag x.y.z.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.x.y.(z+1).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.
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.
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.
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:
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}.
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.
Python packages publish to the private index (pip.aetoskia.com, pypiserver) and npm packages to the private registry (npm.aetoskia.com, Verdaccio). Both authenticate against the same htpasswd file (/auth/htpasswd) used by the Docker registry and the Traefik basic-auth middleware; registration is disabled, so the CI credentials (PIP_USERNAME / PIP_PASSWORD, NPM_USERNAME / NPM_PASSWORD) are one and the same account \u2014 there is no separate npm user. Verdaccio requires bcrypt htpasswd entries; see 90 \u2013 Drone Pipelines for the CI wiring.
: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.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.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.
A wheel + sdist published to the private index. Version source is pyproject.toml; Drone file is python-lib.yaml (tag-triggered).
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).
doc-weaver doc-weaver lib, wiki doc-weaver:8010 react-auth react/react-auth lib, wiki react-auth:8011 react-openapi react/react-openapi lib, wiki react-openapi:8012"},{"location":"94_repository_inventory/#services-type-c","title":"Services (Type C)","text":"A container image pushed to the registry and run on the Docker host. Version source is the VERSION file; Drone file is service-docker.yaml.
auth-server auth-server tag api, lib, wiki Tag-triggered and enforces tag == VERSION.
pyproject.toml CHANGELOG.md python-lib.yaml package (npm) package.json CHANGELOG.md npm-lib.yaml service VERSION CHANGELOG.md service-docker.yaml Every covered repository must be able to satisfy tag == version: the version file (never a commit id) is bumped in the release MR, the MR is merged, and the tag is created on the merged commit.
registry://reposlist_repos()standards/src/repos.json).If an older tag never published (a past version-file mismatch, an index outage):
backfill-pypi-from-tags pipeline (custom event). It
- checks out every tag, verifies tag == pyproject version at that commit,
- and uploads the ones missing from the index. Already-fixed history is never
+backfill-*-from-tags pipeline (custom event) —
+ backfill-pypi-from-tags for Python, backfill-npm-from-tags for npm. It
+ checks out every tag, verifies tag == the version file at that commit, and
+ uploads the ones missing from the index. Already-fixed history is never
rewritten.One reusable architecture for every backend application in the Aetos ecosystem: a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure layer, and a single composition root.
Fill in two axes only and a new app is done: your resource model, and (if needed) your pipeline family. The blueprint itself never names a business domain \u2014 expenses, health metrics, inventory, and analytics are all the same shape underneath.
Doc model: this wiki is written for humans \u2014 how-to guides, the build recipe, and quality gates. The enforceable rules (layering, composition root, CRUD template, pipeline scaffold) and the library registry live in the reference pages, mirrored by the machine-readable standards MCP bundle. Query find_library before writing any infrastructure.
interfaces \u2192 domain \u2192 infra, enforcedReference (separate site, mirrors the MCP bundle): the enforceable anatomy \u2014 start at the Build Recipe, then the pillars: domains (Layering), the OpenAPI contract (Composition Root, Spec-First Routing, CRUD Template, Auth Integration), interfaces & infra (Infra Adapters), UI (Auto-Rendered Admin), the optional pipelines layer (Pipeline Scaffold, Worker & Progress, Human-in-the-Loop), and delivery/meta (Genericity Matrix, Library Registry).
"},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"standards server on :8009: read docs://index, docs://nav, docs://modules/{module}; call find_library(capability)libraries.json, served as registry://libraries)standards/src/templates/app/ (server) and standards/src/templates/client/ (React admin); walkthrough in the Reference Appstart_app prompt on the standards server, or read the Start an App guide\u00a9 Aetoskia Internal \u2014 standards
The blueprint is a hexagonal (ports & adapters) application with a spec-first web edge and a single composition root. It favors explicit wiring over implicit magic and existing internal libraries over hand-written infrastructure. Start here to build the mental model; the enforceable details live in the reference pages.
"},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"flowchart LR\n subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n OAS[\"openapi.yaml<br/>operationId contract\"]\n RT[\"<resource>/routes.py<br/>thin handlers\"]\n end\n\n subgraph CORE[\"domain/ \u2014 business core\"]\n MD[\"models\"]\n PT[\"ports (adapter binding seam)\"]\n SV[\"services\"]\n end\n\n subgraph INFRA[\"infra/ \u2014 adapters\"]\n RP[\"mongo repos (mongo-ops)\"]\n AD[\"cache \u00b7 stores \u00b7 llm \u00b7 mail\"]\n DB[(\"MongoDB\")]\n end\n\n CR[\"init.py<br/>composition root\"]\n\n OAS --> RT\n RT --> SV\n SV --> PT\n SV --> RP\n PT -. depends on .- RP\n CR --- RT\n CR --- SV\n CR --- RP\n RP --> DB\n AD --- CR The rule holds at every edge: code in an outer ring may depend on a ring closer to the core, never the reverse. domain/ never imports a web framework; only its ports/ seam reaches into infra/ to bind adapters.
Legend \u2014 solid arrows are compile-time calls; dotted arrows are the port/adapter binding, performed by the composition root at startup.
"},{"location":"01_overview/#the-one-way-dependency-rule","title":"\ud83d\udd04 The one-way dependency rule","text":"Ring May import Never importsinterfaces/ domain, framework glue (glue only) business logic, infra directly domain/ its own models + its ports/ seam (which binds infra/libraries) web frameworks, interfaces/ infra/ domain ports, drivers, libraries \u2014 init.py everything (the wiring point) \u2014"},{"location":"01_overview/#core-principles","title":"\ud83e\udded Core principles","text":"interfaces -> domain -> infra is allowed; the reverse is forbidden.init.py) exposed as a flat resource registry.openapi.yaml; route handlers are looked up by operationId. There are no decorator routers.A standard app may run as up to three processes that share the same composition root:
Process Entry point Responsibility APImain.py serve HTTP; start/stop resources via lifespan Worker worker.py poll durable jobs, run pipelines, publish progress Scripts python -m scripts.<name> one-shot operator tasks"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"What ships in every standard app, and how the pieces relate. This is the blueprint's component model \u2014 templates/app/ is a runnable instance of it.
The blueprint has two orthogonal views: runtime flow (who calls whom) and composition-root wiring (who constructs whom). Keeping them separate makes the layering visible instead of hiding it behind a hub.
"},{"location":"02_components/#runtime-flow","title":"\ud83d\udd00 Runtime flow","text":"Who calls whom at request and job time:
%%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n subgraph REQ[\"Request path \u2014 API process\"]\n direction LR\n CL([\"Client<br/>operationId\"]) --> RT[\"interfaces/<resource>/routes.py\"]\n RT --> SVC[\"<resource>_svc\"]\n SVC --> REPO[\"<resource>_repo\"]\n end\n\n subgraph WRK[\"Background path \u2014 worker process\"]\n direction LR\n W[\"worker.py\"] --> JSVC[\"job_svc\"]\n W --> PIPE[\"pipelines/<family>\"]\n PIPE --> ISVC[\"item_svc\"]\n JSVC --> JREPO[\"job_repo\"]\n ISVC --> IREPO[\"item_repo\"]\n end\n\n subgraph INFRA[\"infra/ \u2014 adapters\"]\n MONGO[(\"MongoDB / mongo_ops\")]\n end\n\n REPO --> MONGO\n JREPO --> MONGO\n IREPO --> MONGO Legend \u2014 solid arrows are runtime calls. Nothing is self-constructed.
"},{"location":"02_components/#composition-root-wiring","title":"\ud83e\uddf5 Composition-root wiring","text":"Who constructs what at startup:
%%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n INIT[\"init.py \u2014 composition root\"]\n SETUP[\"infra/db/setup.py\"]\n FACT[\"domain/*/ports/*<br/>factory seams\"]\n REPOS[\"repos<br/>infra/db/repos/mongo.py\"]\n SVCES[\"services<br/>CrudsServiceAdaptor\"]\n STATE[\"app.state.db_resources\"]\n HEXA[\"hexa.build\"]\n RT[\"routes.py\"]\n WK[\"worker.py\"]\n\n INIT --> SETUP\n INIT --> FACT\n FACT --> REPOS\n FACT --> SVCES\n REPOS --> SVCES\n HEXA -.-> SVCES\n INIT --> STATE\n STATE --> RT\n INIT --> WK Legend \u2014 init.py is the only place that imports concrete adapters. The ports/ factory seams bind infra; the API reads resources from app.state.db_resources, the worker calls init() directly.
interfaces/ \u2014 the edge","text":"Thin handlers bound to openapi.yaml operationIds. They parse the request, call a service, and shape the response. No business rules, no I/O.
interfaces/openapi.py re-exports every route module so the OpenAPI-first app can resolve handlers.cache decorator on read endpoints.domain/ \u2014 the core","text":"Models, ports, and services (use cases). No framework imports. The ports/ package is the one seam allowed to reach into infra/: each port module declares the document shape and a factory that binds the adapter.
<aggregate>/models.py \u2014 dataclasses, enums, document shapes.<aggregate>/ports/db.py \u2014 document model + create_<aggregate>_repo().<aggregate>/ports/service.py \u2014 service + get_<aggregate>_service(repo).infra/ \u2014 the adapters","text":"Concrete implementations of the domain ports plus cross-cutting infrastructure:
db/ \u2014 mongo-ops repositories and the connection lifecycle.adapters/ \u2014 cache store, progress bus, storages, fetchers (library-backed).pipelines/ \u2014 data-driven families (optional)","text":"Extraction and enrichment families, each a deterministic step chain (abc.py / impl.py / pipeline.yaml / resources/). Roots are registered in the pattern registry and driven either by a worker job or a CLI.
worker.py \u2014 the job worker (optional)","text":"A separate process that polls durable job collections, runs pipeline roots, and publishes progress to the progress bus. It shares the same composition root as the API.
"},{"location":"02_components/#6-initpy-the-composition-root","title":"6.init.py \u2014 the composition root","text":"The only place anything is constructed: leaf repositories \u2192 dependent repositories \u2192 services \u2192 adapters. Exposes get_svc overloads and starts / stops resources on the app lifespan.
sequenceDiagram\n participant C as Client\n participant R as route (interfaces)\n participant S as service (port factory / adapter)\n participant P as repo (infra)\n participant DB as MongoDB\n\n C->>R: POST /items (operationId)\n R->>S: svc.create(payload)\n S->>P: repo.insert(doc)\n P->>DB: insert\n DB-->>P: ack\n P-->>S: doc\n S-->>R: result\n R-->>C: 200 JSON"},{"location":"02_components/#related","title":"Related","text":"This is the entry point for building a new application from the blueprint. The goal is one deterministic pass: you supply only the two axes, and the agent does the rest by reading the standards MCP, importing Tier A libraries, and copying Tier B template components.
Doc model: this page is the human entry point. The deterministic, machine-facing checklist lives in the reference page Build Recipe, served as docs://modules/build_recipe.
Gather these before starting \u2014 the start_app MCP prompt asks for them:
openapi.yaml if you already have it.py-jwt primitives only), and an authless app is just a spec with no security keys \u2014 see Spec-First Routing \u2192 Authless apps.If you can state axes 1 and 2, the rest is mechanical.
"},{"location":"03_start_an_app/#how-to-start","title":"\u25b6\ufe0f How to start","text":"From an MCP client connected to the standards server:
intake prompt (or start_app with no arguments) to get the intake questions.build (build(app_name=..., resources=..., pipelines=..., auth=...)), or use start_app to intake-and-build in one call.docs://modules/build_recipe; the agent scaffolds with get_app_template() and pulls exact files with get_app_component(kind), authoring only the app-specific parts.trial (trial(app_name=..., build_path=...)) to build into a scratch directory, prove it builds, and report findings.Manual alternative \u2014 copy the skeleton and follow the reference recipe:
cp -r <docs>/standards/src/templates/app apps/<your-app>\n"},{"location":"03_start_an_app/#reference","title":"\ud83d\udcda Reference","text":"Every repository ships its documentation as a Model Context Protocol (MCP) server, and the standards registry server ties them together. The docs portal runs each one as a FastMCP streamable-http app bound to 0.0.0.0:<port>, exposing the endpoint at /mcp (see docs/main.py and docs/config.yml).
Connect your agent to the standards server first: it answers find_library, list_repos, and the start_app prompt, so the agent reaches for an internal library instead of reimplementing one (see the Library Registry).
From the docs checkout:
.venv\\Scripts\\python.exe main.py # every server\n.venv\\Scripts\\python.exe main.py --servers standards # just the registry\n :8000 is the health endpoint (GET /healthz), not an MCP server.
standards standards 8009 http://localhost:8009/mcp https://mcp.aetoskia.com/standards/mcp doc-forge docforge 8001 http://localhost:8001/mcp https://mcp.aetoskia.com/doc-forge/mcp mail-intake mail_intake 8002 http://localhost:8002/mcp https://mcp.aetoskia.com/mail-intake/mcp omniread omniread 8003 http://localhost:8003/mcp https://mcp.aetoskia.com/omniread/mcp openapi-first openapi_first 8004 http://localhost:8004/mcp https://mcp.aetoskia.com/openapi-first/mcp py-jwt jwtlib 8005 http://localhost:8005/mcp https://mcp.aetoskia.com/py-jwt/mcp dagpipe dagpipe 8006 http://localhost:8006/mcp https://mcp.aetoskia.com/dagpipe/mcp mongo-ops mongo_ops 8007 http://localhost:8007/mcp https://mcp.aetoskia.com/mongo-ops/mcp hexa hexa 8008 http://localhost:8008/mcp https://mcp.aetoskia.com/hexa/mcp doc-weaver doc-weaver 8010 http://localhost:8010/mcp https://mcp.aetoskia.com/doc-weaver/mcp react-auth react-auth 8011 http://localhost:8011/mcp https://mcp.aetoskia.com/react-auth/mcp react-openapi react-openapi 8012 http://localhost:8012/mcp https://mcp.aetoskia.com/react-openapi/mcp The hosted endpoints are served over HTTPS at https://mcp.aetoskia.com/<slug>/mcp. The tabs below are linked (content.tabs.link): switching one switches all of them. Every snippet registers all twelve servers, so a single paste is enough.
Project-scoped .mcp.json at the repo root. The type field is required \u2014 an entry with a url but no type is read as a stdio server and skipped.
{\n \"mcpServers\": {\n \"standards\": { \"type\": \"http\", \"url\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"type\": \"http\", \"url\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"type\": \"http\", \"url\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"type\": \"http\", \"url\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"type\": \"http\", \"url\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"type\": \"http\", \"url\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"type\": \"http\", \"url\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"type\": \"http\", \"url\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"type\": \"http\", \"url\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"type\": \"http\", \"url\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"type\": \"http\", \"url\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"type\": \"http\", \"url\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n Or add them with the CLI: claude mcp add --transport http standards http://localhost:8009/mcp.
~/.codex/config.toml (or a project-scoped .codex/config.toml).
[mcp_servers.standards]\nurl = \"http://localhost:8009/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"http://localhost:8001/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"http://localhost:8002/mcp\"\n\n[mcp_servers.omniread]\nurl = \"http://localhost:8003/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"http://localhost:8004/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"http://localhost:8005/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"http://localhost:8006/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"http://localhost:8007/mcp\"\n\n[mcp_servers.hexa]\nurl = \"http://localhost:8008/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"http://localhost:8010/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"http://localhost:8011/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"http://localhost:8012/mcp\"\n [mcp_servers.standards]\nurl = \"https://mcp.aetoskia.com/standards/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"https://mcp.aetoskia.com/doc-forge/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"https://mcp.aetoskia.com/mail-intake/mcp\"\n\n[mcp_servers.omniread]\nurl = \"https://mcp.aetoskia.com/omniread/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"https://mcp.aetoskia.com/openapi-first/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"https://mcp.aetoskia.com/py-jwt/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"https://mcp.aetoskia.com/dagpipe/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"https://mcp.aetoskia.com/mongo-ops/mcp\"\n\n[mcp_servers.hexa]\nurl = \"https://mcp.aetoskia.com/hexa/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"https://mcp.aetoskia.com/doc-weaver/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"https://mcp.aetoskia.com/react-auth/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"https://mcp.aetoskia.com/react-openapi/mcp\"\n Or add them with the CLI: codex mcp add standards --url http://localhost:8009/mcp.
~/.gemini/antigravity/mcp_config.json (IDE) or ~/.gemini/config/mcp_config.json (CLI). Antigravity uses serverUrl, not url, for HTTP endpoints.
{\n \"mcpServers\": {\n \"standards\": { \"serverUrl\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"serverUrl\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"serverUrl\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"serverUrl\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"serverUrl\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"serverUrl\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"serverUrl\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"serverUrl\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"serverUrl\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"serverUrl\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"serverUrl\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"serverUrl\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"serverUrl\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"serverUrl\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"serverUrl\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"serverUrl\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"serverUrl\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"serverUrl\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"serverUrl\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"serverUrl\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n"},{"location":"05_mcp_setup/#opencode","title":"\ud83e\udd16 OpenCode","text":"opencode.json (project) or ~/.config/opencode/opencode.json (global).
{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {\n \"standards\": { \"type\": \"remote\", \"url\": \"http://localhost:8009/mcp\", \"enabled\": true },\n \"doc-forge\": { \"type\": \"remote\", \"url\": \"http://localhost:8001/mcp\", \"enabled\": true },\n \"mail-intake\": { \"type\": \"remote\", \"url\": \"http://localhost:8002/mcp\", \"enabled\": true },\n \"omniread\": { \"type\": \"remote\", \"url\": \"http://localhost:8003/mcp\", \"enabled\": true },\n \"openapi-first\": { \"type\": \"remote\", \"url\": \"http://localhost:8004/mcp\", \"enabled\": true },\n \"py-jwt\": { \"type\": \"remote\", \"url\": \"http://localhost:8005/mcp\", \"enabled\": true },\n \"dagpipe\": { \"type\": \"remote\", \"url\": \"http://localhost:8006/mcp\", \"enabled\": true },\n \"mongo-ops\": { \"type\": \"remote\", \"url\": \"http://localhost:8007/mcp\", \"enabled\": true },\n \"hexa\": { \"type\": \"remote\", \"url\": \"http://localhost:8008/mcp\", \"enabled\": true },\n \"doc-weaver\": { \"type\": \"remote\", \"url\": \"http://localhost:8010/mcp\", \"enabled\": true },\n \"react-auth\": { \"type\": \"remote\", \"url\": \"http://localhost:8011/mcp\", \"enabled\": true },\n \"react-openapi\": { \"type\": \"remote\", \"url\": \"http://localhost:8012/mcp\", \"enabled\": true }\n }\n}\n {\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {\n \"standards\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\", \"enabled\": true },\n \"doc-forge\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\", \"enabled\": true },\n \"mail-intake\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\", \"enabled\": true },\n \"omniread\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\", \"enabled\": true },\n \"openapi-first\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\", \"enabled\": true },\n \"py-jwt\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\", \"enabled\": true },\n \"dagpipe\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\", \"enabled\": true },\n \"mongo-ops\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\", \"enabled\": true },\n \"hexa\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\", \"enabled\": true },\n \"doc-weaver\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\", \"enabled\": true },\n \"react-auth\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\", \"enabled\": true },\n \"react-openapi\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\", \"enabled\": true }\n }\n}\n Or add them with the CLI: opencode mcp add standards --url http://localhost:8009/mcp --global.
~/.junie/mcp/mcp.json (user scope) or .junie/mcp/mcp.json (project scope). The same config is reachable from Settings | Tools | MCP Server \u2192 Manual Client Configuration \u2192 Copy HTTP Stream Config.
{\n \"mcpServers\": {\n \"standards\": { \"url\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"url\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"url\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"url\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"url\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"url\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"url\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"url\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"url\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"url\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"url\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"url\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n"},{"location":"05_mcp_setup/#verify","title":"\u2705 Verify","text":"curl http://localhost:8000/healthz \u2192 ok.claude mcp list \u2192 each server \u2026 \u2713 Connected.codex mcp list \u2192 each server \u2026 http.opencode mcp list \u2192 each server \u2026 connected./mcp to list the connected servers and their tools.find_library resolvesstandards, then invoke start_appTwo suites run under pytest with asyncio auto mode: integration (the HTTP edge against fakes) and unit (pure domain and pipeline logic). Finish a blueprint app only when every gate below passes.
"},{"location":"04_development/01_testing/#layout","title":"\ud83d\uddc2\ufe0f Layout","text":"tests/\n integration/ # TestClient(app) + mocked resources\n unit/ # models, converters, pipeline stages/roots\n conftest.py # fixtures\n"},{"location":"04_development/01_testing/#integration-tests","title":"\ud83d\ude80 Integration tests","text":"TestClient.app.state.db_resources with fake repositories/services so no database is required.httpx.AsyncClient.post) to return a canned identity.400, 404, 204, and the streaming endpoint's fallback.client = TestClient(app)\napp.state.db_resources = {\"item_svc\": FakeItemService(), ...}\n\ndef test_create_item():\n r = client.post(\"/items\", json={\"name\": \"x\"})\n assert r.status_code == 200\n"},{"location":"04_development/01_testing/#unit-tests","title":"\ud83e\uddea Unit tests","text":"For rule-driven pipelines, add a test that cross-references rules against the declared pattern registry: every flag emitted must be defined; every defined flag must be reachable. This catches drift without running the pipeline.
"},{"location":"04_development/01_testing/#quality-gates","title":"\u2705 Quality gates","text":"A definition of done for any standard app:
python -c \"import main\" succeeds.pytest passes.asyncio_mode = \"auto\"; no manual event loops.How to cut a release of any Aetos repository. The rule lives in the reference page Versioning & Changelog; this is the runbook you follow.
Every repository is one of two Drone shapes \u2014 a PyPI package or a Docker deployment (Drone Pipelines). Both release the same way: the version bump and changelog promotion go in the merge request, and the tag is created on the merged commit.
"},{"location":"04_development/02_releasing/#before-you-start","title":"\ud83e\uddfe Before you start","text":"main is green (the Drone quality gate passed on the last run).version in pyproject.tomlVERSION at the repo rootThe version file and the tag must be identical. For 0.y.z, 0.y is the major line (see the reference page); once at 1.0.0, normal SemVer applies.
0.y.z \u2192 1.0.0+ \u2192 Breaking 0.(y+1).0 (x+1).0.0 Feature 0.y.(z+1) x.(y+1).0 Fix 0.y.(z+1) x.y.(z+1)"},{"location":"04_development/02_releasing/#release-in-the-mr","title":"\ud83c\udf3f Release in the MR","text":"The version is known while the change is still a merge request, so the bump rides along with it. Do all edits in the MR:
Reconcile the changelog first. [Unreleased] is a draft, not the source of truth: diff the change against the last tag (git log <last-tag>..HEAD, git diff --stat <last-tag>..HEAD) and add an entry for every user-facing change that is missing. Always show the proposed additions to the user and wait for approval before promoting \u2014 ask rather than guess when an entry is ambiguous.
Promote the changelog in CHANGELOG.md: move the (now reconciled) [Unreleased] content under a new ## [x.y.z] - YYYY-MM-DD heading, leave a fresh empty [Unreleased] on top, and add the compare links \u2014 naming the version/tag, never a commit id:
[Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n[x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n version = \"x.y.z\" in pyproject.toml (packages) or the VERSION file (services).main.git tag x.y.z (lightweight).git push origin x.y.z) \u2014 Drone builds the release.The agent does not author or merge the MR and never tags before the merge. After you confirm the MR is merged/squashed and tell it to proceed, the agent:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.tomlgit log -S 'x.y.z' -n 1 --format='%H %s' -- VERSIONx.y.z, then tags only that commit: git tag x.y.z <sha>.git push origin x.y.z.tag == pyproject version, then skips if that version already exists on the index, runs the quality gate, builds, and uploads the wheel + sdist.tag == VERSION, resolves the tag, skips if the image already exists, runs the quality gate, builds, pushes, and restarts the container.If the guard fails, nothing is published \u2014 fix the version file, amend the commit, and move the tag only if it was never pushed.
"},{"location":"04_development/02_releasing/#backfill-recovery","title":"\ud83d\udedf Backfill / recovery","text":"If an older tag never published (a past version-file mismatch, an index outage):
backfill-pypi-from-tags pipeline (custom event). It checks out every tag, verifies tag == pyproject version at that commit, and uploads the ones missing from the index. Already-fixed history is never rewritten.The standards ship a runnable sample of the whole blueprint: the vet veterinary-clinic application. It exists to be copied \u2014 it is the reference for how a real domain sits on the architecture, and the code an agent pulls through the MCP tools (get_app_template, get_app_component).
One sample, two templates
The server sample lives at standards/src/templates/app/ (FastAPI + openapi-first + mongo-ops + hexa). The client sample lives at standards/src/templates/client/ (Vite + MUI + @aetoskia/react-openapi + @aetoskia/react-auth). Together they are the full-stack reference.
parents[]), file upload, enum fields treatments Plain resource with pricing fields vets Plain resource; target of prefetch FKs appointments Single FKs (pet, vet, treatment) expanded on read, filtered list jobs The worker's queue \u2014 durable status machine The pipeline axis ships one worked family, appointment_reminder, plus the reusable pipelines/shared/ scaffold and a worker.py that dispatches jobs by job.kind.
cp -r <docs>/standards/src/templates/app apps/vet-sample\ncd apps/vet-sample\npip install -e \".[dev]\"\n\nuvicorn main:app --reload # API on :8020 (memory backend, zero services)\npython worker.py # background worker (polls jobs)\npython -m scripts.seed # optional: seed demo data (mongo backend)\n The default REPO_BACKEND=memory means the API boots with no external services \u2014 repositories are process-local dictionaries. Switch to REPO_BACKEND=mongo for persistence (see .env.example).
The client pairs with it:
cp -r <docs>/standards/src/templates/client apps/vet-sample-client\ncd apps/vet-sample-client\necho \"VITE_API_BASE_URL=http://localhost:8020\" > .env.local\necho \"VITE_AUTH_BASE_URL=http://localhost:8020\" >> .env.local\nnpm install\nnpm run dev # Vite dev server\n Auth
The server ships with the bearerAuth security scheme declared ({AUTH_SERVER} introspection). Without a reachable auth-server the API answers 401 on resource routes; /health stays public. For a local no-auth demo, remove the security block from openapi.yaml and the global security entry \u2014 see Auth Integration.
Every aggregate follows the same four-file slice (CRUD Template); walk parents first, then appointments when you need foreign keys.
domain/parents/\n models.py # framework-free Pydantic model (id optional)\n ports/\n db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()\n service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()\ninterfaces/parents/\n routes.py # list/create/get/update/delete \u2014 names == operationIds\n Wiring happens in exactly one place \u2014 the composition root init.py:
parent_repo = create_parent_repo()\n_resources[\"parent_svc\"] = get_parent_service(parent_repo)\n Routes resolve the service from app state through the shared helper (interfaces/_common.py):
async def list_parents(request: Request, limit: int = 20, offset: int = 0) -> dict:\n docs = await svc(request, \"parent\").search({})\n return paginate([dump(d) for d in docs], limit, offset)\n"},{"location":"06_reference_app/02_domain_and_api/#the-spec-is-the-contract","title":"The spec is the contract","text":"openapi.yaml is OpenAPI 3.1 and the single source of truth: main.py builds an OpenAPIFirstApp that binds each operationId to a function of the same name in interfaces/openapi.py (a flat re-export of every routes module). There are no decorator routers.
The x- extension fields on schemas and properties are what make the admin client render itself \u2014 see Auto-Rendered Admin and the worked Job schema (enum selects, hidden fields, filterable columns).
Appointment declares pet, vet, treatment as dict | int | None. The write path normalizes bare ids through expand_ref; the read path returns fully expanded objects:
pet = await expand_ref(svc(request, \"pet\"), body.get(\"pet\"), \"pet\")\n The list endpoint additionally filters by date prefix and embedded fk ids \u2014 client-side over the repository result, the pattern for small reference data.
"},{"location":"06_reference_app/02_domain_and_api/#ids-on-the-wire","title":"Ids on the wire","text":"The domain models declare id: int | None = None and documents use the integer-id repository variants (infra/db/repos/mongo_int.py, create_repo() in domain/*/ports/db.py, REPO_BACKEND=memory|mongo). This keeps the OpenAPI id: integer contract, the in-memory demo store, and the Mongo persistence layer consistent.
tests/integration/test_openapi_app_builds.py \u2014 generic gate: every operationId resolves and the app builds. Copy it verbatim.tests/unit/test_jobs_routes.py \u2014 the pattern for route unit tests with a service double (no HTTP client needed).The sample proves the full loop: API creates a job document \u2192 worker polls \u2192 pipeline family runs \u2192 job reaches a terminal status.
"},{"location":"06_reference_app/03_pipelines_and_worker/#the-jobs-slice","title":"The jobs slice","text":"domain/jobs/ is an ordinary CRUD slice; its model is the durable state machine the worker drives:
class Job(BaseModel):\n name: str\n kind: str # dispatch key\n status: Literal[\"pending\", \"processing\", \"done\", \"failed\"] = \"pending\"\n payload: dict | None = None\n retry_count: int = 0\n error_message: str | None = None\n POST /jobs with {\"name\": \"...\", \"kind\": \"appointment_reminder\", \"payload\": {\"appointment_id\": 1}} enqueues work; GET /jobs?status=... inspects the queue.
pipelines/appointment_reminder/\n abc.py # LoadPort / ComposePort / DeliverPort + root interface\n impl.py # concrete stages, wired root, register(\"appointment_reminder\", ...)\n pipeline.yaml # graph spec (nodes, edges, roots)\n resources/\n pipeline.yaml # runtime spec consumed by the loader\n patterns/reminder.yaml # message template fragments\npipelines/shared/\n ports.py loader.py registry.py # reused by every family\n Each stage is a small, independently testable unit. The loader (pipelines/shared/loader.py) wires the root through hexa.build and merges resources/patterns/*.yaml into the stage-injected registry:
pipeline = get_appointment_reminder_pipeline(appointment_svc)\nreminder = await pipeline.run(appointment_id)\n To add a family: copy appointment_reminder/, replace the stages, call register(kind, variant, root_cls) \u2014 see Pipeline Scaffold.
worker.py is the process that turns documents into execution:
job_svc.search({\"status\": \"pending\"}) on WORKER_POLL_SECONDSstatus \u2192 processingHANDLERS table \u2014 HANDLERS[job.kind](job, resources)status \u2192 done; on exception status \u2192 failed, retry_count += 1, error_message captured; jobs at WORKER_MAX_RETRIES are skippedHANDLERS: dict[str, Callable] = {\n \"appointment_reminder\": run_appointment_reminder,\n # add yours here\n}\n The worker shares the composition root: from init import init gives it the same wired services the API uses \u2014 one place to add a dependency.
Same process model in production
API and worker are separate processes (see docker-compose.yaml: api, worker, mongo). Any in-memory progress bus is per-process; to surface live progress through the API, swap the bus adaptor for a broker-backed one \u2014 see Worker & Progress.
tests/unit/test_appointment_reminder_pipeline.py \u2014 stage units + the wired root against a fake service.tests/unit/test_worker.py \u2014 dispatch, done/failed transitions, retry exhaustion, using an in-memory job service double and a cancelled poll task.The client sample (standards/src/templates/client/) renders a full admin \u2014 auth pages, resource lists, forms, detail views \u2014 from the server's openapi.yaml, driven entirely by the x- extension fields. There are no hand-written screens per resource.
A Vite + React + MUI app wiring the two Aetos packages:
@aetoskia/react-openapi \u2014 fetches ${VITE_API_BASE_URL}/openapi.json, builds the router and the resource admin from the spec's schemas and x- fields (list columns, autocomplete, fk pickers, enum selects, hidden fields\u2026)@aetoskia/react-auth \u2014 login/register/logout against the auth-server paths declared in the spec's securitySchemes.bearerAuth, and attaches the JWT to every API callsrc/shared-theme/) and the shell (Header / Footer / Home)src/openapi-config.ts is the only configuration:
export const specConfiguration: SpecConfiguration = {\n specUrl: `${apiBase}/openapi.json`,\n baseApiUrl: apiBase,\n title: \"Vet Admin\",\n getToken: () => tokenStore.get(),\n};\n"},{"location":"06_reference_app/04_client_admin/#pair-it-with-the-sample-server","title":"Pair it with the sample server","text":"cd apps/vet-sample-client\nprintf 'VITE_API_BASE_URL=http://localhost:8020\\nVITE_AUTH_BASE_URL=http://localhost:8020\\n' > .env.local\nnpm install\nnpm run dev\n Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets, Appointments and Jobs straight from the vet spec. Edit openapi.yaml on the server and the client follows on reload \u2014 the spec is the contract on both sides.
The canonical catalog lives in the react-openapi package (spec-extensions.ts, served to agents as the specExtensions module of the react-openapi MCP bundle). The worked examples on the server side are the Job schema (enum selects via x-edit-as: select + x-ui-type: enum, x-hidden field masks, x-list-columns) and the FK fields on Appointment (x-fk: {resource: pets, prefetch: true}).
The client ships a drone-npm shape (Type B in Drone Pipelines) and follows the Versioning standard (package.json + CHANGELOG.md).
One reusable architecture for every backend application in the Aetos ecosystem: a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure layer, and a single composition root.
Fill in two axes only and a new app is done: your resource model, and (if needed) your pipeline family. The blueprint itself never names a business domain \u2014 expenses, health metrics, inventory, and analytics are all the same shape underneath.
Doc model: this wiki is written for humans \u2014 how-to guides, the build recipe, and quality gates. The enforceable rules (layering, composition root, CRUD template, pipeline scaffold) and the library registry live in the reference pages, mirrored by the machine-readable standards MCP bundle. Query find_library before writing any infrastructure.
interfaces \u2192 domain \u2192 infra, enforcedReference (separate site, mirrors the MCP bundle): the enforceable anatomy \u2014 start at the Build Recipe, then the pillars: domains (Layering), the OpenAPI contract (Composition Root, Spec-First Routing, CRUD Template, Auth Integration), interfaces & infra (Infra Adapters), UI (Auto-Rendered Admin), the optional pipelines layer (Pipeline Scaffold, Worker & Progress, Human-in-the-Loop), and delivery/meta (Genericity Matrix, Library Registry).
"},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"standards server on :8009: read docs://index, docs://nav, docs://modules/{module}; call find_library(capability)libraries.json, served as registry://libraries)standards/src/templates/app/ (server) and standards/src/templates/client/ (React admin); walkthrough in the Reference Appstart_app prompt on the standards server, or read the Start an App guide\u00a9 Aetoskia Internal \u2014 standards
The blueprint is a hexagonal (ports & adapters) application with a spec-first web edge and a single composition root. It favors explicit wiring over implicit magic and existing internal libraries over hand-written infrastructure. Start here to build the mental model; the enforceable details live in the reference pages.
"},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"flowchart LR\n subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n OAS[\"openapi.yaml<br/>operationId contract\"]\n RT[\"<resource>/routes.py<br/>thin handlers\"]\n end\n\n subgraph CORE[\"domain/ \u2014 business core\"]\n MD[\"models\"]\n PT[\"ports (adapter binding seam)\"]\n SV[\"services\"]\n end\n\n subgraph INFRA[\"infra/ \u2014 adapters\"]\n RP[\"mongo repos (mongo-ops)\"]\n AD[\"cache \u00b7 stores \u00b7 llm \u00b7 mail\"]\n DB[(\"MongoDB\")]\n end\n\n CR[\"init.py<br/>composition root\"]\n\n OAS --> RT\n RT --> SV\n SV --> PT\n SV --> RP\n PT -. depends on .- RP\n CR --- RT\n CR --- SV\n CR --- RP\n RP --> DB\n AD --- CR The rule holds at every edge: code in an outer ring may depend on a ring closer to the core, never the reverse. domain/ never imports a web framework; only its ports/ seam reaches into infra/ to bind adapters.
Legend \u2014 solid arrows are compile-time calls; dotted arrows are the port/adapter binding, performed by the composition root at startup.
"},{"location":"01_overview/#the-one-way-dependency-rule","title":"\ud83d\udd04 The one-way dependency rule","text":"Ring May import Never importsinterfaces/ domain, framework glue (glue only) business logic, infra directly domain/ its own models + its ports/ seam (which binds infra/libraries) web frameworks, interfaces/ infra/ domain ports, drivers, libraries \u2014 init.py everything (the wiring point) \u2014"},{"location":"01_overview/#core-principles","title":"\ud83e\udded Core principles","text":"interfaces -> domain -> infra is allowed; the reverse is forbidden.init.py) exposed as a flat resource registry.openapi.yaml; route handlers are looked up by operationId. There are no decorator routers.A standard app may run as up to three processes that share the same composition root:
Process Entry point Responsibility APImain.py serve HTTP; start/stop resources via lifespan Worker worker.py poll durable jobs, run pipelines, publish progress Scripts python -m scripts.<name> one-shot operator tasks"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"What ships in every standard app, and how the pieces relate. This is the blueprint's component model \u2014 templates/app/ is a runnable instance of it.
The blueprint has two orthogonal views: runtime flow (who calls whom) and composition-root wiring (who constructs whom). Keeping them separate makes the layering visible instead of hiding it behind a hub.
"},{"location":"02_components/#runtime-flow","title":"\ud83d\udd00 Runtime flow","text":"Who calls whom at request and job time:
%%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n subgraph REQ[\"Request path \u2014 API process\"]\n direction LR\n CL([\"Client<br/>operationId\"]) --> RT[\"interfaces/<resource>/routes.py\"]\n RT --> SVC[\"<resource>_svc\"]\n SVC --> REPO[\"<resource>_repo\"]\n end\n\n subgraph WRK[\"Background path \u2014 worker process\"]\n direction LR\n W[\"worker.py\"] --> JSVC[\"job_svc\"]\n W --> PIPE[\"pipelines/<family>\"]\n PIPE --> ISVC[\"item_svc\"]\n JSVC --> JREPO[\"job_repo\"]\n ISVC --> IREPO[\"item_repo\"]\n end\n\n subgraph INFRA[\"infra/ \u2014 adapters\"]\n MONGO[(\"MongoDB / mongo_ops\")]\n end\n\n REPO --> MONGO\n JREPO --> MONGO\n IREPO --> MONGO Legend \u2014 solid arrows are runtime calls. Nothing is self-constructed.
"},{"location":"02_components/#composition-root-wiring","title":"\ud83e\uddf5 Composition-root wiring","text":"Who constructs what at startup:
%%{init: {\"flowchart\": {\"nodeSpacing\": 50, \"rankSpacing\": 80}}}%%\nflowchart TB\n INIT[\"init.py \u2014 composition root\"]\n SETUP[\"infra/db/setup.py\"]\n FACT[\"domain/*/ports/*<br/>factory seams\"]\n REPOS[\"repos<br/>infra/db/repos/mongo.py\"]\n SVCES[\"services<br/>CrudsServiceAdaptor\"]\n STATE[\"app.state.db_resources\"]\n HEXA[\"hexa.build\"]\n RT[\"routes.py\"]\n WK[\"worker.py\"]\n\n INIT --> SETUP\n INIT --> FACT\n FACT --> REPOS\n FACT --> SVCES\n REPOS --> SVCES\n HEXA -.-> SVCES\n INIT --> STATE\n STATE --> RT\n INIT --> WK Legend \u2014 init.py is the only place that imports concrete adapters. The ports/ factory seams bind infra; the API reads resources from app.state.db_resources, the worker calls init() directly.
interfaces/ \u2014 the edge","text":"Thin handlers bound to openapi.yaml operationIds. They parse the request, call a service, and shape the response. No business rules, no I/O.
interfaces/openapi.py re-exports every route module so the OpenAPI-first app can resolve handlers.cache decorator on read endpoints.domain/ \u2014 the core","text":"Models, ports, and services (use cases). No framework imports. The ports/ package is the one seam allowed to reach into infra/: each port module declares the document shape and a factory that binds the adapter.
<aggregate>/models.py \u2014 dataclasses, enums, document shapes.<aggregate>/ports/db.py \u2014 document model + create_<aggregate>_repo().<aggregate>/ports/service.py \u2014 service + get_<aggregate>_service(repo).infra/ \u2014 the adapters","text":"Concrete implementations of the domain ports plus cross-cutting infrastructure:
db/ \u2014 mongo-ops repositories and the connection lifecycle.adapters/ \u2014 cache store, progress bus, storages, fetchers (library-backed).pipelines/ \u2014 data-driven families (optional)","text":"Extraction and enrichment families, each a deterministic step chain (abc.py / impl.py / pipeline.yaml / resources/). Roots are registered in the pattern registry and driven either by a worker job or a CLI.
worker.py \u2014 the job worker (optional)","text":"A separate process that polls durable job collections, runs pipeline roots, and publishes progress to the progress bus. It shares the same composition root as the API.
"},{"location":"02_components/#6-initpy-the-composition-root","title":"6.init.py \u2014 the composition root","text":"The only place anything is constructed: leaf repositories \u2192 dependent repositories \u2192 services \u2192 adapters. Exposes get_svc overloads and starts / stops resources on the app lifespan.
sequenceDiagram\n participant C as Client\n participant R as route (interfaces)\n participant S as service (port factory / adapter)\n participant P as repo (infra)\n participant DB as MongoDB\n\n C->>R: POST /items (operationId)\n R->>S: svc.create(payload)\n S->>P: repo.insert(doc)\n P->>DB: insert\n DB-->>P: ack\n P-->>S: doc\n S-->>R: result\n R-->>C: 200 JSON"},{"location":"02_components/#related","title":"Related","text":"This is the entry point for building a new application from the blueprint. The goal is one deterministic pass: you supply only the two axes, and the agent does the rest by reading the standards MCP, importing Tier A libraries, and copying Tier B template components.
Doc model: this page is the human entry point. The deterministic, machine-facing checklist lives in the reference page Build Recipe, served as docs://modules/build_recipe.
Gather these before starting \u2014 the start_app MCP prompt asks for them:
openapi.yaml if you already have it.py-jwt primitives only), and an authless app is just a spec with no security keys \u2014 see Spec-First Routing \u2192 Authless apps.If you can state axes 1 and 2, the rest is mechanical.
"},{"location":"03_start_an_app/#how-to-start","title":"\u25b6\ufe0f How to start","text":"From an MCP client connected to the standards server:
intake prompt (or start_app with no arguments) to get the intake questions.build (build(app_name=..., resources=..., pipelines=..., auth=...)), or use start_app to intake-and-build in one call.docs://modules/build_recipe; the agent scaffolds with get_app_template() and pulls exact files with get_app_component(kind), authoring only the app-specific parts.trial (trial(app_name=..., build_path=...)) to build into a scratch directory, prove it builds, and report findings.Manual alternative \u2014 copy the skeleton and follow the reference recipe:
cp -r <docs>/standards/src/templates/app apps/<your-app>\n"},{"location":"03_start_an_app/#reference","title":"\ud83d\udcda Reference","text":"Every repository ships its documentation as a Model Context Protocol (MCP) server, and the standards registry server ties them together. The docs portal runs each one as a FastMCP streamable-http app bound to 0.0.0.0:<port>, exposing the endpoint at /mcp (see docs/main.py and docs/config.yml).
Connect your agent to the standards server first: it answers find_library, list_repos, and the start_app prompt, so the agent reaches for an internal library instead of reimplementing one (see the Library Registry).
From the docs checkout:
.venv\\Scripts\\python.exe main.py # every server\n.venv\\Scripts\\python.exe main.py --servers standards # just the registry\n :8000 is the health endpoint (GET /healthz), not an MCP server.
standards standards 8009 http://localhost:8009/mcp https://mcp.aetoskia.com/standards/mcp doc-forge docforge 8001 http://localhost:8001/mcp https://mcp.aetoskia.com/doc-forge/mcp mail-intake mail_intake 8002 http://localhost:8002/mcp https://mcp.aetoskia.com/mail-intake/mcp omniread omniread 8003 http://localhost:8003/mcp https://mcp.aetoskia.com/omniread/mcp openapi-first openapi_first 8004 http://localhost:8004/mcp https://mcp.aetoskia.com/openapi-first/mcp py-jwt jwtlib 8005 http://localhost:8005/mcp https://mcp.aetoskia.com/py-jwt/mcp dagpipe dagpipe 8006 http://localhost:8006/mcp https://mcp.aetoskia.com/dagpipe/mcp mongo-ops mongo_ops 8007 http://localhost:8007/mcp https://mcp.aetoskia.com/mongo-ops/mcp hexa hexa 8008 http://localhost:8008/mcp https://mcp.aetoskia.com/hexa/mcp doc-weaver doc-weaver 8010 http://localhost:8010/mcp https://mcp.aetoskia.com/doc-weaver/mcp react-auth react-auth 8011 http://localhost:8011/mcp https://mcp.aetoskia.com/react-auth/mcp react-openapi react-openapi 8012 http://localhost:8012/mcp https://mcp.aetoskia.com/react-openapi/mcp The hosted endpoints are served over HTTPS at https://mcp.aetoskia.com/<slug>/mcp. The tabs below are linked (content.tabs.link): switching one switches all of them. Every snippet registers all twelve servers, so a single paste is enough.
Project-scoped .mcp.json at the repo root. The type field is required \u2014 an entry with a url but no type is read as a stdio server and skipped.
{\n \"mcpServers\": {\n \"standards\": { \"type\": \"http\", \"url\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"type\": \"http\", \"url\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"type\": \"http\", \"url\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"type\": \"http\", \"url\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"type\": \"http\", \"url\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"type\": \"http\", \"url\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"type\": \"http\", \"url\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"type\": \"http\", \"url\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"type\": \"http\", \"url\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"type\": \"http\", \"url\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"type\": \"http\", \"url\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"type\": \"http\", \"url\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"type\": \"http\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n Or add them with the CLI: claude mcp add --transport http standards http://localhost:8009/mcp.
~/.codex/config.toml (or a project-scoped .codex/config.toml).
[mcp_servers.standards]\nurl = \"http://localhost:8009/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"http://localhost:8001/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"http://localhost:8002/mcp\"\n\n[mcp_servers.omniread]\nurl = \"http://localhost:8003/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"http://localhost:8004/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"http://localhost:8005/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"http://localhost:8006/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"http://localhost:8007/mcp\"\n\n[mcp_servers.hexa]\nurl = \"http://localhost:8008/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"http://localhost:8010/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"http://localhost:8011/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"http://localhost:8012/mcp\"\n [mcp_servers.standards]\nurl = \"https://mcp.aetoskia.com/standards/mcp\"\n\n[mcp_servers.doc-forge]\nurl = \"https://mcp.aetoskia.com/doc-forge/mcp\"\n\n[mcp_servers.mail-intake]\nurl = \"https://mcp.aetoskia.com/mail-intake/mcp\"\n\n[mcp_servers.omniread]\nurl = \"https://mcp.aetoskia.com/omniread/mcp\"\n\n[mcp_servers.openapi-first]\nurl = \"https://mcp.aetoskia.com/openapi-first/mcp\"\n\n[mcp_servers.py-jwt]\nurl = \"https://mcp.aetoskia.com/py-jwt/mcp\"\n\n[mcp_servers.dagpipe]\nurl = \"https://mcp.aetoskia.com/dagpipe/mcp\"\n\n[mcp_servers.mongo-ops]\nurl = \"https://mcp.aetoskia.com/mongo-ops/mcp\"\n\n[mcp_servers.hexa]\nurl = \"https://mcp.aetoskia.com/hexa/mcp\"\n\n[mcp_servers.doc-weaver]\nurl = \"https://mcp.aetoskia.com/doc-weaver/mcp\"\n\n[mcp_servers.react-auth]\nurl = \"https://mcp.aetoskia.com/react-auth/mcp\"\n\n[mcp_servers.react-openapi]\nurl = \"https://mcp.aetoskia.com/react-openapi/mcp\"\n Or add them with the CLI: codex mcp add standards --url http://localhost:8009/mcp.
~/.gemini/antigravity/mcp_config.json (IDE) or ~/.gemini/config/mcp_config.json (CLI). Antigravity uses serverUrl, not url, for HTTP endpoints.
{\n \"mcpServers\": {\n \"standards\": { \"serverUrl\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"serverUrl\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"serverUrl\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"serverUrl\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"serverUrl\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"serverUrl\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"serverUrl\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"serverUrl\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"serverUrl\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"serverUrl\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"serverUrl\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"serverUrl\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"serverUrl\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"serverUrl\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"serverUrl\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"serverUrl\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"serverUrl\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"serverUrl\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"serverUrl\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"serverUrl\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"serverUrl\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"serverUrl\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n"},{"location":"05_mcp_setup/#opencode","title":"\ud83e\udd16 OpenCode","text":"opencode.json (project) or ~/.config/opencode/opencode.json (global).
{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {\n \"standards\": { \"type\": \"remote\", \"url\": \"http://localhost:8009/mcp\", \"enabled\": true },\n \"doc-forge\": { \"type\": \"remote\", \"url\": \"http://localhost:8001/mcp\", \"enabled\": true },\n \"mail-intake\": { \"type\": \"remote\", \"url\": \"http://localhost:8002/mcp\", \"enabled\": true },\n \"omniread\": { \"type\": \"remote\", \"url\": \"http://localhost:8003/mcp\", \"enabled\": true },\n \"openapi-first\": { \"type\": \"remote\", \"url\": \"http://localhost:8004/mcp\", \"enabled\": true },\n \"py-jwt\": { \"type\": \"remote\", \"url\": \"http://localhost:8005/mcp\", \"enabled\": true },\n \"dagpipe\": { \"type\": \"remote\", \"url\": \"http://localhost:8006/mcp\", \"enabled\": true },\n \"mongo-ops\": { \"type\": \"remote\", \"url\": \"http://localhost:8007/mcp\", \"enabled\": true },\n \"hexa\": { \"type\": \"remote\", \"url\": \"http://localhost:8008/mcp\", \"enabled\": true },\n \"doc-weaver\": { \"type\": \"remote\", \"url\": \"http://localhost:8010/mcp\", \"enabled\": true },\n \"react-auth\": { \"type\": \"remote\", \"url\": \"http://localhost:8011/mcp\", \"enabled\": true },\n \"react-openapi\": { \"type\": \"remote\", \"url\": \"http://localhost:8012/mcp\", \"enabled\": true }\n }\n}\n {\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {\n \"standards\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/standards/mcp\", \"enabled\": true },\n \"doc-forge\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\", \"enabled\": true },\n \"mail-intake\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\", \"enabled\": true },\n \"omniread\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/omniread/mcp\", \"enabled\": true },\n \"openapi-first\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\", \"enabled\": true },\n \"py-jwt\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\", \"enabled\": true },\n \"dagpipe\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\", \"enabled\": true },\n \"mongo-ops\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\", \"enabled\": true },\n \"hexa\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/hexa/mcp\", \"enabled\": true },\n \"doc-weaver\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\", \"enabled\": true },\n \"react-auth\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\", \"enabled\": true },\n \"react-openapi\": { \"type\": \"remote\", \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\", \"enabled\": true }\n }\n}\n Or add them with the CLI: opencode mcp add standards --url http://localhost:8009/mcp --global.
~/.junie/mcp/mcp.json (user scope) or .junie/mcp/mcp.json (project scope). The same config is reachable from Settings | Tools | MCP Server \u2192 Manual Client Configuration \u2192 Copy HTTP Stream Config.
{\n \"mcpServers\": {\n \"standards\": { \"url\": \"http://localhost:8009/mcp\" },\n \"doc-forge\": { \"url\": \"http://localhost:8001/mcp\" },\n \"mail-intake\": { \"url\": \"http://localhost:8002/mcp\" },\n \"omniread\": { \"url\": \"http://localhost:8003/mcp\" },\n \"openapi-first\": { \"url\": \"http://localhost:8004/mcp\" },\n \"py-jwt\": { \"url\": \"http://localhost:8005/mcp\" },\n \"dagpipe\": { \"url\": \"http://localhost:8006/mcp\" },\n \"mongo-ops\": { \"url\": \"http://localhost:8007/mcp\" },\n \"hexa\": { \"url\": \"http://localhost:8008/mcp\" },\n \"doc-weaver\": { \"url\": \"http://localhost:8010/mcp\" },\n \"react-auth\": { \"url\": \"http://localhost:8011/mcp\" },\n \"react-openapi\": { \"url\": \"http://localhost:8012/mcp\" }\n }\n}\n {\n \"mcpServers\": {\n \"standards\": { \"url\": \"https://mcp.aetoskia.com/standards/mcp\" },\n \"doc-forge\": { \"url\": \"https://mcp.aetoskia.com/doc-forge/mcp\" },\n \"mail-intake\": { \"url\": \"https://mcp.aetoskia.com/mail-intake/mcp\" },\n \"omniread\": { \"url\": \"https://mcp.aetoskia.com/omniread/mcp\" },\n \"openapi-first\": { \"url\": \"https://mcp.aetoskia.com/openapi-first/mcp\" },\n \"py-jwt\": { \"url\": \"https://mcp.aetoskia.com/py-jwt/mcp\" },\n \"dagpipe\": { \"url\": \"https://mcp.aetoskia.com/dagpipe/mcp\" },\n \"mongo-ops\": { \"url\": \"https://mcp.aetoskia.com/mongo-ops/mcp\" },\n \"hexa\": { \"url\": \"https://mcp.aetoskia.com/hexa/mcp\" },\n \"doc-weaver\": { \"url\": \"https://mcp.aetoskia.com/doc-weaver/mcp\" },\n \"react-auth\": { \"url\": \"https://mcp.aetoskia.com/react-auth/mcp\" },\n \"react-openapi\": { \"url\": \"https://mcp.aetoskia.com/react-openapi/mcp\" }\n }\n}\n"},{"location":"05_mcp_setup/#verify","title":"\u2705 Verify","text":"curl http://localhost:8000/healthz \u2192 ok.claude mcp list \u2192 each server \u2026 \u2713 Connected.codex mcp list \u2192 each server \u2026 http.opencode mcp list \u2192 each server \u2026 connected./mcp to list the connected servers and their tools.find_library resolvesstandards, then invoke start_appTwo suites run under pytest with asyncio auto mode: integration (the HTTP edge against fakes) and unit (pure domain and pipeline logic). Finish a blueprint app only when every gate below passes.
"},{"location":"04_development/01_testing/#layout","title":"\ud83d\uddc2\ufe0f Layout","text":"tests/\n integration/ # TestClient(app) + mocked resources\n unit/ # models, converters, pipeline stages/roots\n conftest.py # fixtures\n"},{"location":"04_development/01_testing/#integration-tests","title":"\ud83d\ude80 Integration tests","text":"TestClient.app.state.db_resources with fake repositories/services so no database is required.httpx.AsyncClient.post) to return a canned identity.400, 404, 204, and the streaming endpoint's fallback.client = TestClient(app)\napp.state.db_resources = {\"item_svc\": FakeItemService(), ...}\n\ndef test_create_item():\n r = client.post(\"/items\", json={\"name\": \"x\"})\n assert r.status_code == 200\n"},{"location":"04_development/01_testing/#unit-tests","title":"\ud83e\uddea Unit tests","text":"For rule-driven pipelines, add a test that cross-references rules against the declared pattern registry: every flag emitted must be defined; every defined flag must be reachable. This catches drift without running the pipeline.
"},{"location":"04_development/01_testing/#quality-gates","title":"\u2705 Quality gates","text":"A definition of done for any standard app:
python -c \"import main\" succeeds.pytest passes.asyncio_mode = \"auto\"; no manual event loops.How to cut a release of any Aetos repository. The rule lives in the reference page Versioning & Changelog; this is the runbook you follow.
Every repository is one of two Drone shapes \u2014 a PyPI package or a Docker deployment (Drone Pipelines). Both release the same way: the version bump and changelog promotion go in the merge request, and the tag is created on the merged commit.
"},{"location":"04_development/02_releasing/#before-you-start","title":"\ud83e\uddfe Before you start","text":"main is green (the Drone quality gate passed on the last run).version in pyproject.tomlVERSION at the repo rootThe version file and the tag must be identical. For 0.y.z, 0.y is the major line (see the reference page); once at 1.0.0, normal SemVer applies.
0.y.z \u2192 1.0.0+ \u2192 Breaking 0.(y+1).0 (x+1).0.0 Feature 0.y.(z+1) x.(y+1).0 Fix 0.y.(z+1) x.y.(z+1)"},{"location":"04_development/02_releasing/#release-in-the-mr","title":"\ud83c\udf3f Release in the MR","text":"The version is known while the change is still a merge request, so the bump rides along with it. Do all edits in the MR:
Reconcile the changelog first. [Unreleased] is a draft, not the source of truth: diff the change against the last tag (git log <last-tag>..HEAD, git diff --stat <last-tag>..HEAD) and add an entry for every user-facing change that is missing. Always show the proposed additions to the user and wait for approval before promoting \u2014 ask rather than guess when an entry is ambiguous.
Promote the changelog in CHANGELOG.md: move the (now reconciled) [Unreleased] content under a new ## [x.y.z] - YYYY-MM-DD heading, leave a fresh empty [Unreleased] on top, and add the compare links \u2014 naming the version/tag, never a commit id:
[Unreleased]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.z...HEAD\n[x.y.z]: https://git.aetoskia.com/aetos/<repo>/compare/x.y.(z-1)...x.y.z\n version = \"x.y.z\" in pyproject.toml (packages) or the VERSION file (services).main.git tag x.y.z (lightweight).git push origin x.y.z) \u2014 Drone builds the release.The agent does not author or merge the MR and never tags before the merge. After you confirm the MR is merged/squashed and tell it to proceed, the agent:
git log -S 'version = \"x.y.z\"' -n 1 --format='%H %s' -- pyproject.tomlgit log -S 'x.y.z' -n 1 --format='%H %s' -- VERSIONx.y.z, then tags only that commit: git tag x.y.z <sha>.git push origin x.y.z.tag == pyproject version, then skips if that version already exists on the index, runs the quality gate, builds, and uploads the wheel + sdist.tag == VERSION, resolves the tag, skips if the image already exists, runs the quality gate, builds, pushes, and restarts the container.If the guard fails, nothing is published \u2014 fix the version file, amend the commit, and move the tag only if it was never pushed.
"},{"location":"04_development/02_releasing/#backfill-recovery","title":"\ud83d\udedf Backfill / recovery","text":"If an older tag never published (a past version-file mismatch, an index outage):
backfill-*-from-tags pipeline (custom event) \u2014 backfill-pypi-from-tags for Python, backfill-npm-from-tags for npm. It checks out every tag, verifies tag == the version file at that commit, and uploads the ones missing from the index. Already-fixed history is never rewritten.The standards ship a runnable sample of the whole blueprint: the vet veterinary-clinic application. It exists to be copied \u2014 it is the reference for how a real domain sits on the architecture, and the code an agent pulls through the MCP tools (get_app_template, get_app_component).
One sample, two templates
The server sample lives at standards/src/templates/app/ (FastAPI + openapi-first + mongo-ops + hexa). The client sample lives at standards/src/templates/client/ (Vite + MUI + @aetoskia/react-openapi + @aetoskia/react-auth). Together they are the full-stack reference.
parents[]), file upload, enum fields treatments Plain resource with pricing fields vets Plain resource; target of prefetch FKs appointments Single FKs (pet, vet, treatment) expanded on read, filtered list jobs The worker's queue \u2014 durable status machine The pipeline axis ships one worked family, appointment_reminder, plus the reusable pipelines/shared/ scaffold and a worker.py that dispatches jobs by job.kind.
cp -r <docs>/standards/src/templates/app apps/vet-sample\ncd apps/vet-sample\npip install -e \".[dev]\"\n\nuvicorn main:app --reload # API on :8020 (memory backend, zero services)\npython worker.py # background worker (polls jobs)\npython -m scripts.seed # optional: seed demo data (mongo backend)\n The default REPO_BACKEND=memory means the API boots with no external services \u2014 repositories are process-local dictionaries. Switch to REPO_BACKEND=mongo for persistence (see .env.example).
The client pairs with it:
cp -r <docs>/standards/src/templates/client apps/vet-sample-client\ncd apps/vet-sample-client\necho \"VITE_API_BASE_URL=http://localhost:8020\" > .env.local\necho \"VITE_AUTH_BASE_URL=http://localhost:8020\" >> .env.local\nnpm install\nnpm run dev # Vite dev server\n Auth
The server ships with the bearerAuth security scheme declared ({AUTH_SERVER} introspection). Without a reachable auth-server the API answers 401 on resource routes; /health stays public. For a local no-auth demo, remove the security block from openapi.yaml and the global security entry \u2014 see Auth Integration.
Every aggregate follows the same four-file slice (CRUD Template); walk parents first, then appointments when you need foreign keys.
domain/parents/\n models.py # framework-free Pydantic model (id optional)\n ports/\n db.py # ParentDocument(Parent, BaseDocument) + create_parent_repo()\n service.py # ParentCrudsService(CrudsServiceAdaptor[...]) + get_parent_service()\ninterfaces/parents/\n routes.py # list/create/get/update/delete \u2014 names == operationIds\n Wiring happens in exactly one place \u2014 the composition root init.py:
parent_repo = create_parent_repo()\n_resources[\"parent_svc\"] = get_parent_service(parent_repo)\n Routes resolve the service from app state through the shared helper (interfaces/_common.py):
async def list_parents(request: Request, limit: int = 20, offset: int = 0) -> dict:\n docs = await svc(request, \"parent\").search({})\n return paginate([dump(d) for d in docs], limit, offset)\n"},{"location":"06_reference_app/02_domain_and_api/#the-spec-is-the-contract","title":"The spec is the contract","text":"openapi.yaml is OpenAPI 3.1 and the single source of truth: main.py builds an OpenAPIFirstApp that binds each operationId to a function of the same name in interfaces/openapi.py (a flat re-export of every routes module). There are no decorator routers.
The x- extension fields on schemas and properties are what make the admin client render itself \u2014 see Auto-Rendered Admin and the worked Job schema (enum selects, hidden fields, filterable columns).
Appointment declares pet, vet, treatment as dict | int | None. The write path normalizes bare ids through expand_ref; the read path returns fully expanded objects:
pet = await expand_ref(svc(request, \"pet\"), body.get(\"pet\"), \"pet\")\n The list endpoint additionally filters by date prefix and embedded fk ids \u2014 client-side over the repository result, the pattern for small reference data.
"},{"location":"06_reference_app/02_domain_and_api/#ids-on-the-wire","title":"Ids on the wire","text":"The domain models declare id: int | None = None and documents use the integer-id repository variants (infra/db/repos/mongo_int.py, create_repo() in domain/*/ports/db.py, REPO_BACKEND=memory|mongo). This keeps the OpenAPI id: integer contract, the in-memory demo store, and the Mongo persistence layer consistent.
tests/integration/test_openapi_app_builds.py \u2014 generic gate: every operationId resolves and the app builds. Copy it verbatim.tests/unit/test_jobs_routes.py \u2014 the pattern for route unit tests with a service double (no HTTP client needed).The sample proves the full loop: API creates a job document \u2192 worker polls \u2192 pipeline family runs \u2192 job reaches a terminal status.
"},{"location":"06_reference_app/03_pipelines_and_worker/#the-jobs-slice","title":"The jobs slice","text":"domain/jobs/ is an ordinary CRUD slice; its model is the durable state machine the worker drives:
class Job(BaseModel):\n name: str\n kind: str # dispatch key\n status: Literal[\"pending\", \"processing\", \"done\", \"failed\"] = \"pending\"\n payload: dict | None = None\n retry_count: int = 0\n error_message: str | None = None\n POST /jobs with {\"name\": \"...\", \"kind\": \"appointment_reminder\", \"payload\": {\"appointment_id\": 1}} enqueues work; GET /jobs?status=... inspects the queue.
pipelines/appointment_reminder/\n abc.py # LoadPort / ComposePort / DeliverPort + root interface\n impl.py # concrete stages, wired root, register(\"appointment_reminder\", ...)\n pipeline.yaml # graph spec (nodes, edges, roots)\n resources/\n pipeline.yaml # runtime spec consumed by the loader\n patterns/reminder.yaml # message template fragments\npipelines/shared/\n ports.py loader.py registry.py # reused by every family\n Each stage is a small, independently testable unit. The loader (pipelines/shared/loader.py) wires the root through hexa.build and merges resources/patterns/*.yaml into the stage-injected registry:
pipeline = get_appointment_reminder_pipeline(appointment_svc)\nreminder = await pipeline.run(appointment_id)\n To add a family: copy appointment_reminder/, replace the stages, call register(kind, variant, root_cls) \u2014 see Pipeline Scaffold.
worker.py is the process that turns documents into execution:
job_svc.search({\"status\": \"pending\"}) on WORKER_POLL_SECONDSstatus \u2192 processingHANDLERS table \u2014 HANDLERS[job.kind](job, resources)status \u2192 done; on exception status \u2192 failed, retry_count += 1, error_message captured; jobs at WORKER_MAX_RETRIES are skippedHANDLERS: dict[str, Callable] = {\n \"appointment_reminder\": run_appointment_reminder,\n # add yours here\n}\n The worker shares the composition root: from init import init gives it the same wired services the API uses \u2014 one place to add a dependency.
Same process model in production
API and worker are separate processes (see docker-compose.yaml: api, worker, mongo). Any in-memory progress bus is per-process; to surface live progress through the API, swap the bus adaptor for a broker-backed one \u2014 see Worker & Progress.
tests/unit/test_appointment_reminder_pipeline.py \u2014 stage units + the wired root against a fake service.tests/unit/test_worker.py \u2014 dispatch, done/failed transitions, retry exhaustion, using an in-memory job service double and a cancelled poll task.The client sample (standards/src/templates/client/) renders a full admin \u2014 auth pages, resource lists, forms, detail views \u2014 from the server's openapi.yaml, driven entirely by the x- extension fields. There are no hand-written screens per resource.
A Vite + React + MUI app wiring the two Aetos packages:
@aetoskia/react-openapi \u2014 fetches ${VITE_API_BASE_URL}/openapi.json, builds the router and the resource admin from the spec's schemas and x- fields (list columns, autocomplete, fk pickers, enum selects, hidden fields\u2026)@aetoskia/react-auth \u2014 login/register/logout against the auth-server paths declared in the spec's securitySchemes.bearerAuth, and attaches the JWT to every API callsrc/shared-theme/) and the shell (Header / Footer / Home)src/openapi-config.ts is the only configuration:
export const specConfiguration: SpecConfiguration = {\n specUrl: `${apiBase}/openapi.json`,\n baseApiUrl: apiBase,\n title: \"Vet Admin\",\n getToken: () => tokenStore.get(),\n};\n"},{"location":"06_reference_app/04_client_admin/#pair-it-with-the-sample-server","title":"Pair it with the sample server","text":"cd apps/vet-sample-client\nprintf 'VITE_API_BASE_URL=http://localhost:8020\\nVITE_AUTH_BASE_URL=http://localhost:8020\\n' > .env.local\nnpm install\nnpm run dev\n Open the dev-server URL; the admin lists Parents, Pets, Treatments, Vets, Appointments and Jobs straight from the vet spec. Edit openapi.yaml on the server and the client follows on reload \u2014 the spec is the contract on both sides.
The canonical catalog lives in the react-openapi package (spec-extensions.ts, served to agents as the specExtensions module of the react-openapi MCP bundle). The worked examples on the server side are the Job schema (enum selects via x-edit-as: select + x-ui-type: enum, x-hidden field masks, x-list-columns) and the FK fields on Appointment (x-fk: {resource: pets, prefetch: true}).
The client ships a drone-npm shape (Type B in Drone Pipelines) and follows the Versioning standard (package.json + CHANGELOG.md).