diff --git a/standards/lib/02_layering_rules/index.html b/standards/lib/02_layering_rules/index.html index b720776..fac1c8d 100644 --- a/standards/lib/02_layering_rules/index.html +++ b/standards/lib/02_layering_rules/index.html @@ -662,12 +662,14 @@ 3 4 5 -6
interfaces/*      ->  domain/*            (services, ports, models)
+6
+7
interfaces/*      ->  domain/*            (services, ports, models)
 interfaces/*      ->  infra/adapters/*    (only read-only value objects)
 domain/*          ->  domain/*            (same layer)
 domain/*          ->  pipelines/*         (call a pipeline via a port)
-infra/adapters/*  ->  domain/ports/*      (implement the interface)
-main.py/init.py   ->  everything          (composition root only)
+domain/*/ports/*  ->  infra/*             (bind adapters + libraries at the seam)
+infra/adapters/*  ->  domain/ports/*      (implement the interface)
+main.py/init.py   ->  everything          (composition root only)
 

Forbidden imports

1
@@ -680,27 +682,32 @@
 

Why

The port/adapter contract

-

A port is an abstract class or protocol declared in domain/<x>/ports/. -An adapter implements it in infra/adapters/. The composition root binds -the two.

+

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 — 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.

1
 2
 3
-4
domain/<resource>/ports/db.py       -> repository port + document model
-domain/<resource>/ports/service.py  -> service + factory
+4
domain/<resource>/ports/db.py       -> document model + create_<resource>_repo()
+domain/<resource>/ports/service.py  -> service + get_<resource>_service()
 infra/db/repos/<vendor>.py          -> repository adapter factory
 infra/adapters/service.py           -> generic CRUD service base
 
+

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

Enforcement

diff --git a/standards/lib/03_composition_root/index.html b/standards/lib/03_composition_root/index.html index a5d12d8..5c67d48 100644 --- a/standards/lib/03_composition_root/index.html +++ b/standards/lib/03_composition_root/index.html @@ -651,7 +651,7 @@ registry and exposes it to the HTTP layer and to scripts.

  • progress_bus — progress/event bus.
  • <vendor>_<kind> — fetchers, clients, stores.
  • -

    The API stores the dict on app.state.resources during lifespan startup. +

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

    Shape

     1
    @@ -747,7 +747,7 @@ typing without importing concrete classes:

    • init() is idempotent.
    • The composition root is the only place that imports concrete adapters.
    • -
    • Handlers never call init(); they read request.app.state.resources.
    • +
    • Handlers never call init(); they read request.app.state.db_resources.
    • Scripts call init() / shutdown() around their work.
    diff --git a/standards/lib/04_spec_first_routing/index.html b/standards/lib/04_spec_first_routing/index.html index 121803d..9770476 100644 --- a/standards/lib/04_spec_first_routing/index.html +++ b/standards/lib/04_spec_first_routing/index.html @@ -711,7 +711,7 @@ Handlers are bound to it by name; there is no decorator router.

    4 5
    @asynccontextmanager
     async def lifespan(app):
    -    app.state.resources = await init()
    +    app.state.db_resources = await init()
         yield
         await shutdown()
     
    @@ -729,7 +729,7 @@ can resolve operationId across the whole app:

    1
     2
     3
    async def create_item(request: Request, body: dict):
    -    svc = request.app.state.resources["item_svc"]
    +    svc = request.app.state.db_resources["item_svc"]
         return await svc.create(Item.model_validate(body))
     

    Conventions

    diff --git a/standards/lib/07_worker_and_progress/index.html b/standards/lib/07_worker_and_progress/index.html index 555e229..dabe4b1 100644 --- a/standards/lib/07_worker_and_progress/index.html +++ b/standards/lib/07_worker_and_progress/index.html @@ -729,7 +729,7 @@ swapped for a durable broker. Emit from the orchestrator at each stage:

    13 14 15
    async def stream_job_events(request, id):
    -    bus = request.app.state.resources.get("progress_bus")
    +    bus = request.app.state.db_resources.get("progress_bus")
         if bus is None:
             return {"events": []}
     
    diff --git a/standards/lib/08_human_in_loop/index.html b/standards/lib/08_human_in_loop/index.html
    index eb9602b..9279a51 100644
    --- a/standards/lib/08_human_in_loop/index.html
    +++ b/standards/lib/08_human_in_loop/index.html
    @@ -734,7 +734,7 @@ injects it into the pipeline so that stage can skip re-raising:

    12 13 14
    async def resolve_question(request, id, body):
    -    svc = request.app.state.resources["question_svc"]
    +    svc = request.app.state.db_resources["question_svc"]
         q = await svc.read(id)
         await svc.update(id, {"chosen": body["chosen"], "status": "resolved",
                               "resolved_at": now()})
    @@ -742,7 +742,7 @@ injects it into the pipeline so that stage can skip re-raising:

    pending = await svc.search(filter={"job_id": q.job_id, "status": "pending"}, limit=1) if not pending: - jobs = request.app.state.resources["job_svc"] + jobs = request.app.state.db_resources["job_svc"] job = await jobs.read(q.job_id) if job and job.status == "paused": await jobs.update_status(q.job_id, "pending") diff --git a/standards/lib/search/search_index.json b/standards/lib/search/search_index.json index 3e37c33..7fd0e5e 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.

    • Layering Rules \u2014 the strict request \u2192 domain \u2192 repository layering and the rules that enforce it.
    • Composition Root \u2014 a single composition point wiring the whole app.
    • Spec-First Routing \u2014 the OpenAPI contract as the routing source of truth.
    • CRUD Template \u2014 the standardized generic resource endpoint scaffold.
    • Pipeline Scaffold \u2014 the deterministic pipeline skeleton for multi-stage jobs.
    • Worker & Progress \u2014 queued work, progress reporting, and visibility.
    • Human-in-the-Loop \u2014 guardrails and approvals inside automations.
    • Infra Adapters \u2014 how infrastructure connects without leaking into domain.
    • Genericity Matrix \u2014 what is generic vs app-specific.
    • Library Registry \u2014 the catalog of internal libraries behind all of it.

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

    "},{"location":"02_layering_rules/","title":"Layering Rules","text":""},{"location":"02_layering_rules/#summary","title":"Summary","text":"

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

    "},{"location":"02_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)\ninfra/adapters/*  ->  domain/ports/*      (implement the interface)\nmain.py/init.py   ->  everything          (composition root only)\n
    "},{"location":"02_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":"02_layering_rules/#why","title":"Why","text":"
    • A pure domain is testable without a server, a database, or an event loop.
    • Adapters can be swapped (in-memory fake, real database) without touching business logic.
    • The HTTP edge stays thin and replaceable.
    "},{"location":"02_layering_rules/#the-portadapter-contract","title":"The port/adapter contract","text":"

    A port is an abstract class or protocol declared in domain/<x>/ports/. An adapter implements it in infra/adapters/. The composition root binds the two.

    domain/<resource>/ports/db.py       -> repository port + document model\ndomain/<resource>/ports/service.py  -> service + factory\ninfra/db/repos/<vendor>.py          -> repository adapter factory\ninfra/adapters/service.py           -> generic CRUD service base\n
    "},{"location":"02_layering_rules/#enforcement","title":"Enforcement","text":"
    • Keep every domain module importable with only the standard library and Pydantic. If a domain import pulls in fastapi, the layering is broken.
    • Reviewers (and CI) reject import fastapi inside domain/ or infra/.
    "},{"location":"03_composition_root/","title":"Composition Root","text":""},{"location":"03_composition_root/#summary","title":"Summary","text":"

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

    "},{"location":"03_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.resources during lifespan startup. Handlers read from it; nothing imports concrete adapters.

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

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

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

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

    "},{"location":"03_composition_root/#rules","title":"Rules","text":"
    • init() is idempotent.
    • The composition root is the only place that imports concrete adapters.
    • Handlers never call init(); they read request.app.state.resources.
    • Scripts call init() / shutdown() around their work.
    "},{"location":"04_spec_first_routing/","title":"Spec-First Routing","text":""},{"location":"04_spec_first_routing/#summary","title":"Summary","text":"

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

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

    lifespan calls the composition root:

    @asynccontextmanager\nasync def lifespan(app):\n    app.state.resources = await init()\n    yield\n    await shutdown()\n
    "},{"location":"04_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":"04_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.resources[\"item_svc\"]\n    return await svc.create(Item.model_validate(body))\n
    "},{"location":"04_spec_first_routing/#conventions","title":"Conventions","text":"
    • operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.
    • Read-only endpoints may use a cache decorator keyed on path + query + user.
    • Validation errors -> 400; not found -> 404; delete -> 204.
    • Security is declared once in the spec and enforced by the framework; handlers contain no auth code.
    "},{"location":"04_spec_first_routing/#codegen","title":"Codegen","text":"

    The openapi_first library can scaffold models and route stubs from the spec, and ships app templates (CRUD app, model app, service). Prefer codegen over hand-writing boilerplate.

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

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

    "},{"location":"05_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":"05_crud_template/#modelspy","title":"models.py","text":"

    A framework-free Pydantic model describing the business shape.

    class Item(BaseModel):\n    name: str\n    quantity: int = 0\n
    "},{"location":"05_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.

    class ItemDocument(Item, BaseDocument):   # BaseDocument adds id/created_at/...\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=\"owner\", collection=\"users\")],\n        cache=None,               # or a CachedBaseRepository config\n    )\n
    "},{"location":"05_crud_template/#portsservicepy","title":"ports/service.py","text":"

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

    class ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n    model = Item\n    schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n    return build(ItemService, instances={\"repo\": repo})\n
    "},{"location":"05_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":"05_crud_template/#optional-files","title":"Optional files","text":"
    • converters.py \u2014 pure functions mapping between domain types.
    • ports/enrichment.py \u2014 adapts a pipeline into an async callable the service can invoke (used when a resource is populated by a pipeline).
    "},{"location":"06_pipeline_scaffold/","title":"Pipeline Scaffold","text":""},{"location":"06_pipeline_scaffold/#summary","title":"Summary","text":"

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

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

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

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

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

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

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

    await self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n                                     status=\"completed\", message={\"count\": n}))\n
    "},{"location":"07_worker_and_progress/#streaming-endpoint","title":"Streaming endpoint","text":"
    async def stream_job_events(request, id):\n    bus = request.app.state.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":"08_human_in_loop/","title":"Human-in-the-Loop","text":""},{"location":"08_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":"08_human_in_loop/#the-mechanism","title":"The mechanism","text":"
    1. During processing, a stage raises a domain exception carrying the context needed to ask the question:
    raise PauseForResolution(\n    job_id=jid,\n    reference=\"<stable key for this decision>\",\n    options=[...],\n    context={...},\n)\n
    1. The orchestrator catches it, records a question document, and re-raises so the worker marks the job paused.
    2. A client lists pending questions and answers one:
    GET  /jobs/{id}/questions            -> list pending questions\nPOST /questions/{qid}/resolve  {choice}  -> record the choice\n
    1. When the last pending question is resolved, the resolve endpoint flips the parent job back to pending.
    2. The worker picks it up again, rebuilds a resolution map from previously answered questions, and seeds it into the pipeline so the same decision is not asked twice.
    "},{"location":"08_human_in_loop/#the-question-document","title":"The question document","text":"
    class Question(BaseDocument):\n    job_id: str\n    reference: str            # stable key: stage|field|value\n    status: str               # pending | resolved\n    options: list[dict]\n    context: dict\n    chosen: dict | None = None\n    resolved_at: str | None = None\n
    "},{"location":"08_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":"08_human_in_loop/#resolve-endpoint","title":"Resolve endpoint","text":"
    async def resolve_question(request, id, body):\n    svc = request.app.state.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.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":"08_human_in_loop/#cli-parity","title":"CLI parity","text":"

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

    "},{"location":"09_infra_adaptors/","title":"Infra Adapters","text":""},{"location":"09_infra_adaptors/#summary","title":"Summary","text":"

    All external systems are reached through adapters in infra/. Prefer an existing internal library over writing a new adapter.

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

    • Referenced documents are described declaratively:

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

    Library: the Mongo operations library (see registry). Do not write raw Motor calls in the domain.

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

    A generic GET cache sits behind a decorator.

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

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

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

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

    Libraries: the ingestion library (formats) and the mail library (email).

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

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

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

    Providers are selected by LLM_PROVIDER and configured by environment.

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

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

    "},{"location":"09_infra_adaptors/#rule","title":"Rule","text":"

    If a capability already exists as a library (DB, DI, ingestion, mail, auth, docs), use it. Writing a private copy of a library is a defect.

    "},{"location":"12_genericity_matrix/","title":"Genericity Matrix","text":""},{"location":"12_genericity_matrix/#summary","title":"Summary","text":"

    This page states precisely what is reusable as-is, what is a template to adapt, and what is app-specific and must be authored. Use it to scope a new build.

    "},{"location":"12_genericity_matrix/#reusable-as-is-do-not-rewrite","title":"Reusable as-is (do not rewrite)","text":"Area Artifact Layering & dependency rule the package layout and import rules Composition root the init.py shape, idempotency, get_svc overloading Web edge OpenAPIFirstApp bootstrap, handler-name = operationId, thin handlers CRUD slice the four-file pattern (model / ports-db / ports-service / converters) Generic CRUD service the base service adaptor Repository factory base / populating / cached selection Worker the poll loop, backoff, retry, status machine Progress bus the bus port + in-memory adapter + streaming endpoint Human-in-the-loop the durable pause + question document + resolve endpoint + resume map Infra adaptors HTTP cache decorator, KV store port, LLM pool scaffold Testing integration harness with fakes, fixture-parity pattern Docs GSDFC + generated stubs + MCP bundle pipeline"},{"location":"12_genericity_matrix/#template-to-adapt-copy-then-change-names","title":"Template to adapt (copy, then change names)","text":"Artifact Change pyproject.toml project name, dependencies Dockerfile / docker-compose.yaml service name, ports, services .env.example connection strings, provider keys openapi.yaml every path/schema/operationId scripts/ seed and operator tasks"},{"location":"12_genericity_matrix/#app-specific-must-be-authored","title":"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 (if any) the transformation graph Pattern/rule resources data-driven policy Fixtures & golden outputs test data"},{"location":"12_genericity_matrix/#the-two-axes","title":"The two axes","text":"

    Everything app-specific reduces to two axes:

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

    If you can specify those two axes, the rest of the app follows mechanically.

    "},{"location":"13_library_registry/","title":"Library Registry","text":""},{"location":"13_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 doc://index, doc://nav, and doc://modules/{module}.

    "},{"location":"13_library_registry/#catalog","title":"Catalog","text":"Library Import Purpose Use instead of MCP DAG Pipe dagpipe Deterministic state-transformation DAG engine hand-rolled orchestration/topological ordering :8006 Hexa hexa Declarative DI / composition-root tooling manual wiring, hand-written factories (see note) 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 Auth Server (service) Central identity service (register/login/me/introspect) building auth endpoints yourself \u2014"},{"location":"13_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"},{"location":"13_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. Query it before writing any infrastructure.

    "},{"location":"13_library_registry/#notes","title":"Notes","text":"
    • Hexa ships an MCP bundle but is not yet assigned a dedicated port in the hub config; it is reachable via the standards registry entry and will be wired to a port.
    • Library MCP servers expose documentation resources (not executable tools): they tell an agent the available API surface so it does not invent code.
    "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Standards Reference","text":"

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

    • Layering Rules \u2014 the strict request \u2192 domain \u2192 repository layering and the rules that enforce it.
    • Composition Root \u2014 a single composition point wiring the whole app.
    • Spec-First Routing \u2014 the OpenAPI contract as the routing source of truth.
    • CRUD Template \u2014 the standardized generic resource endpoint scaffold.
    • Pipeline Scaffold \u2014 the deterministic pipeline skeleton for multi-stage jobs.
    • Worker & Progress \u2014 queued work, progress reporting, and visibility.
    • Human-in-the-Loop \u2014 guardrails and approvals inside automations.
    • Infra Adapters \u2014 how infrastructure connects without leaking into domain.
    • Genericity Matrix \u2014 what is generic vs app-specific.
    • Library Registry \u2014 the catalog of internal libraries behind all of it.

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

    "},{"location":"02_layering_rules/","title":"Layering Rules","text":""},{"location":"02_layering_rules/#summary","title":"Summary","text":"

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

    "},{"location":"02_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":"02_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":"02_layering_rules/#why","title":"Why","text":"
    • The business logic is testable without a server or an event loop.
    • Adapters can be swapped (in-memory fake, real database) without touching business logic.
    • The HTTP edge stays thin and replaceable.
    "},{"location":"02_layering_rules/#the-portadapter-contract","title":"The port/adapter contract","text":"

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

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

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

    "},{"location":"02_layering_rules/#enforcement","title":"Enforcement","text":"
    • domain/ must never import the web framework (fastapi / starlette) or interfaces/. Only the ports/ seam, infra/, and the composition root touch drivers.
    • Reviewers (and CI) reject import fastapi inside domain/ or infra/.
    "},{"location":"03_composition_root/","title":"Composition Root","text":""},{"location":"03_composition_root/#summary","title":"Summary","text":"

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

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

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

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

    Plus named infrastructure adaptors:

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

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

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

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

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

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

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

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

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

    lifespan calls the composition root:

    @asynccontextmanager\nasync def lifespan(app):\n    app.state.db_resources = await init()\n    yield\n    await shutdown()\n
    "},{"location":"04_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":"04_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
    "},{"location":"04_spec_first_routing/#conventions","title":"Conventions","text":"
    • operationId naming: list_<resource>, create_<resource>, get_<resource>, update_<resource>, delete_<resource>.
    • Read-only endpoints may use a cache decorator keyed on path + query + user.
    • Validation errors -> 400; not found -> 404; delete -> 204.
    • Security is declared once in the spec and enforced by the framework; handlers contain no auth code.
    "},{"location":"04_spec_first_routing/#codegen","title":"Codegen","text":"

    The openapi_first library can scaffold models and route stubs from the spec, and ships app templates (CRUD app, model app, service). Prefer codegen over hand-writing boilerplate.

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

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

    "},{"location":"05_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":"05_crud_template/#modelspy","title":"models.py","text":"

    A framework-free Pydantic model describing the business shape.

    class Item(BaseModel):\n    name: str\n    quantity: int = 0\n
    "},{"location":"05_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.

    class ItemDocument(Item, BaseDocument):   # BaseDocument adds id/created_at/...\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=\"owner\", collection=\"users\")],\n        cache=None,               # or a CachedBaseRepository config\n    )\n
    "},{"location":"05_crud_template/#portsservicepy","title":"ports/service.py","text":"

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

    class ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n    model = Item\n    schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n    return build(ItemService, instances={\"repo\": repo})\n
    "},{"location":"05_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":"05_crud_template/#optional-files","title":"Optional files","text":"
    • converters.py \u2014 pure functions mapping between domain types.
    • ports/enrichment.py \u2014 adapts a pipeline into an async callable the service can invoke (used when a resource is populated by a pipeline).
    "},{"location":"06_pipeline_scaffold/","title":"Pipeline Scaffold","text":""},{"location":"06_pipeline_scaffold/#summary","title":"Summary","text":"

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

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

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

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

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

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

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

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

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

    "},{"location":"09_infra_adaptors/","title":"Infra Adapters","text":""},{"location":"09_infra_adaptors/#summary","title":"Summary","text":"

    All external systems are reached through adapters in infra/. Prefer an existing internal library over writing a new adapter.

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

    • Referenced documents are described declaratively:

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

    Library: the Mongo operations library (see registry). Do not write raw Motor calls in the domain.

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

    A generic GET cache sits behind a decorator.

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

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

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

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

    Libraries: the ingestion library (formats) and the mail library (email).

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

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

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

    Providers are selected by LLM_PROVIDER and configured by environment.

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

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

    "},{"location":"09_infra_adaptors/#rule","title":"Rule","text":"

    If a capability already exists as a library (DB, DI, ingestion, mail, auth, docs), use it. Writing a private copy of a library is a defect.

    "},{"location":"12_genericity_matrix/","title":"Genericity Matrix","text":""},{"location":"12_genericity_matrix/#summary","title":"Summary","text":"

    This page states precisely what is reusable as-is, what is a template to adapt, and what is app-specific and must be authored. Use it to scope a new build.

    "},{"location":"12_genericity_matrix/#reusable-as-is-do-not-rewrite","title":"Reusable as-is (do not rewrite)","text":"Area Artifact Layering & dependency rule the package layout and import rules Composition root the init.py shape, idempotency, get_svc overloading Web edge OpenAPIFirstApp bootstrap, handler-name = operationId, thin handlers CRUD slice the four-file pattern (model / ports-db / ports-service / converters) Generic CRUD service the base service adaptor Repository factory base / populating / cached selection Worker the poll loop, backoff, retry, status machine Progress bus the bus port + in-memory adapter + streaming endpoint Human-in-the-loop the durable pause + question document + resolve endpoint + resume map Infra adaptors HTTP cache decorator, KV store port, LLM pool scaffold Testing integration harness with fakes, fixture-parity pattern Docs GSDFC + generated stubs + MCP bundle pipeline"},{"location":"12_genericity_matrix/#template-to-adapt-copy-then-change-names","title":"Template to adapt (copy, then change names)","text":"Artifact Change pyproject.toml project name, dependencies Dockerfile / docker-compose.yaml service name, ports, services .env.example connection strings, provider keys openapi.yaml every path/schema/operationId scripts/ seed and operator tasks"},{"location":"12_genericity_matrix/#app-specific-must-be-authored","title":"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 (if any) the transformation graph Pattern/rule resources data-driven policy Fixtures & golden outputs test data"},{"location":"12_genericity_matrix/#the-two-axes","title":"The two axes","text":"

    Everything app-specific reduces to two axes:

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

    If you can specify those two axes, the rest of the app follows mechanically.

    "},{"location":"13_library_registry/","title":"Library Registry","text":""},{"location":"13_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 doc://index, doc://nav, and doc://modules/{module}.

    "},{"location":"13_library_registry/#catalog","title":"Catalog","text":"Library Import Purpose Use instead of MCP DAG Pipe dagpipe Deterministic state-transformation DAG engine hand-rolled orchestration/topological ordering :8006 Hexa hexa Declarative DI / composition-root tooling manual wiring, hand-written factories (see note) 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 Auth Server (service) Central identity service (register/login/me/introspect) building auth endpoints yourself \u2014"},{"location":"13_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"},{"location":"13_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. Query it before writing any infrastructure.

    "},{"location":"13_library_registry/#notes","title":"Notes","text":"
    • Hexa ships an MCP bundle but is not yet assigned a dedicated port in the hub config; it is reachable via the standards registry entry and will be wired to a port.
    • Library MCP servers expose documentation resources (not executable tools): they tell an agent the available API surface so it does not invent code.
    "}]} \ No newline at end of file diff --git a/standards/mcp/modules/composition_root.json b/standards/mcp/modules/composition_root.json index 0011498..44156b1 100644 --- a/standards/mcp/modules/composition_root.json +++ b/standards/mcp/modules/composition_root.json @@ -2,7 +2,7 @@ "module": "composition_root", "content": { "path": "composition_root", - "docstring": "# Composition Root\n\n## Summary\n\nAll wiring happens in exactly one module. It builds a **flat resource\nregistry** and exposes it to the HTTP layer and to scripts.\n\n---\n\n## The resource registry\n\n`init.py` returns a plain `dict[str, Any]`. Keys follow two conventions:\n\n- `_repo` — a repository adapter for a resource.\n- `_svc` — a service for a resource.\n\nPlus named infrastructure adaptors:\n\n- `http_cache_store` — response cache backend.\n- `progress_bus` — progress/event bus.\n- `_` — fetchers, clients, stores.\n\nThe API stores the dict on `app.state.resources` during lifespan startup.\nHandlers read from it; nothing imports concrete adapters.\n\n## Shape\n\n```python\n_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```\n\n## Typed accessors\n\nExpose a `get_svc(name)` helper with one `@overload` per key so scripts get\ntyping without importing concrete classes:\n\n```python\n@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```\n\nAlso expose `get_db()` (raw database handle) and pure helpers such as\n`compute_fingerprint(payload)` (canonical hash used for deduplication).\n\n## Rules\n\n- `init()` is **idempotent**.\n- The composition root is the only place that imports concrete adapters.\n- Handlers never call `init()`; they read `request.app.state.resources`.\n- Scripts call `init()` / `shutdown()` around their work.\n", + "docstring": "# Composition Root\n\n## Summary\n\nAll wiring happens in exactly one module. It builds a **flat resource\nregistry** and exposes it to the HTTP layer and to scripts.\n\n---\n\n## The resource registry\n\n`init.py` returns a plain `dict[str, Any]`. Keys follow two conventions:\n\n- `_repo` — a repository adapter for a resource.\n- `_svc` — a service for a resource.\n\nPlus named infrastructure adaptors:\n\n- `http_cache_store` — response cache backend.\n- `progress_bus` — progress/event bus.\n- `_` — fetchers, clients, stores.\n\nThe API stores the dict on `app.state.db_resources` during lifespan startup.\nHandlers read from it; nothing imports concrete adapters.\n\n## Shape\n\n```python\n_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```\n\n## Typed accessors\n\nExpose a `get_svc(name)` helper with one `@overload` per key so scripts get\ntyping without importing concrete classes:\n\n```python\n@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```\n\nAlso expose `get_db()` (raw database handle) and pure helpers such as\n`compute_fingerprint(payload)` (canonical hash used for deduplication).\n\n## Rules\n\n- `init()` is **idempotent**.\n- The composition root is the only place that imports concrete adapters.\n- Handlers never call `init()`; they read `request.app.state.db_resources`.\n- Scripts call `init()` / `shutdown()` around their work.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/human_in_loop.json b/standards/mcp/modules/human_in_loop.json index ea11e76..0a6fcaf 100644 --- a/standards/mcp/modules/human_in_loop.json +++ b/standards/mcp/modules/human_in_loop.json @@ -2,7 +2,7 @@ "module": "human_in_loop", "content": { "path": "human_in_loop", - "docstring": "# Human-in-the-Loop\n\n## Summary\n\nSome inputs are ambiguous and must be resolved by a human before processing can\ncontinue. The blueprint models this as a **durable pause**: an exception is\nraised, a question is recorded, the job pauses, and later resumes.\n\n---\n\n## The mechanism\n\n1. During processing, a stage raises a domain exception carrying the context\n needed to ask the question:\n\n ```python\n raise PauseForResolution(\n job_id=jid,\n reference=\"\",\n options=[...],\n context={...},\n )\n ```\n\n2. The orchestrator catches it, records a **question document**, and re-raises\n so the worker marks the job `paused`.\n3. A client lists pending questions and answers one:\n\n ```\n GET /jobs/{id}/questions -> list pending questions\n POST /questions/{qid}/resolve {choice} -> record the choice\n ```\n\n4. When the **last** pending question is resolved, the resolve endpoint flips\n the parent job back to `pending`.\n5. The worker picks it up again, rebuilds a **resolution map** from previously\n answered questions, and seeds it into the pipeline so the same decision is\n not asked twice.\n\n## The question document\n\n```python\nclass 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```\n\n## Resume map\n\nThe worker turns answered questions into a lookup keyed by `reference`, then\ninjects it into the pipeline so that stage can skip re-raising:\n\n```python\nresolved = {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```\n\n## Resolve endpoint\n\n```python\nasync def resolve_question(request, id, body):\n svc = request.app.state.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.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```\n\n## CLI parity\n\nThe same flow is available through scripts/CLI: `list`, `get`, `create`,\n`questions`, `resolve`, `retry`.\n", + "docstring": "# Human-in-the-Loop\n\n## Summary\n\nSome inputs are ambiguous and must be resolved by a human before processing can\ncontinue. The blueprint models this as a **durable pause**: an exception is\nraised, a question is recorded, the job pauses, and later resumes.\n\n---\n\n## The mechanism\n\n1. During processing, a stage raises a domain exception carrying the context\n needed to ask the question:\n\n ```python\n raise PauseForResolution(\n job_id=jid,\n reference=\"\",\n options=[...],\n context={...},\n )\n ```\n\n2. The orchestrator catches it, records a **question document**, and re-raises\n so the worker marks the job `paused`.\n3. A client lists pending questions and answers one:\n\n ```\n GET /jobs/{id}/questions -> list pending questions\n POST /questions/{qid}/resolve {choice} -> record the choice\n ```\n\n4. When the **last** pending question is resolved, the resolve endpoint flips\n the parent job back to `pending`.\n5. The worker picks it up again, rebuilds a **resolution map** from previously\n answered questions, and seeds it into the pipeline so the same decision is\n not asked twice.\n\n## The question document\n\n```python\nclass 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```\n\n## Resume map\n\nThe worker turns answered questions into a lookup keyed by `reference`, then\ninjects it into the pipeline so that stage can skip re-raising:\n\n```python\nresolved = {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```\n\n## Resolve endpoint\n\n```python\nasync 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```\n\n## CLI parity\n\nThe same flow is available through scripts/CLI: `list`, `get`, `create`,\n`questions`, `resolve`, `retry`.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/layering_rules.json b/standards/mcp/modules/layering_rules.json index 594b7e1..f4f94e7 100644 --- a/standards/mcp/modules/layering_rules.json +++ b/standards/mcp/modules/layering_rules.json @@ -2,7 +2,7 @@ "module": "layering_rules", "content": { "path": "layering_rules", - "docstring": "# Layering Rules\n\n## Summary\n\nThe dependency direction is fixed. Violating it is a design defect.\n\n---\n\n## Allowed imports\n\n```\ninterfaces/* -> domain/* (services, ports, models)\ninterfaces/* -> infra/adapters/* (only read-only value objects)\ndomain/* -> domain/* (same layer)\ndomain/* -> pipelines/* (call a pipeline via a port)\ninfra/adapters/* -> domain/ports/* (implement the interface)\nmain.py/init.py -> everything (composition root only)\n```\n\n## Forbidden imports\n\n```\ndomain/* -> interfaces/* (never)\ndomain/* -> fastapi/starlette (never)\ninfra/adapters/* -> interfaces/* (never)\ninterfaces/* -> infra/db/* (state lives in resources, not imports)\n```\n\n## Why\n\n- A pure domain is testable without a server, a database, or an event loop.\n- Adapters can be swapped (in-memory fake, real database) without touching\n business logic.\n- The HTTP edge stays thin and replaceable.\n\n## The port/adapter contract\n\nA **port** is an abstract class or protocol declared in `domain//ports/`.\nAn **adapter** implements it in `infra/adapters/`. The composition root binds\nthe two.\n\n```\ndomain//ports/db.py -> repository port + document model\ndomain//ports/service.py -> service + factory\ninfra/db/repos/.py -> repository adapter factory\ninfra/adapters/service.py -> generic CRUD service base\n```\n\n## Enforcement\n\n- Keep every domain module importable with only the standard library and\n Pydantic. If a domain import pulls in `fastapi`, the layering is broken.\n- Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`.\n", + "docstring": "# Layering Rules\n\n## Summary\n\nThe dependency direction is fixed. Violating it is a design defect.\n\n---\n\n## Allowed imports\n\n```\ninterfaces/* -> 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```\n\n## Forbidden imports\n\n```\ndomain/* -> interfaces/* (never)\ndomain/* -> fastapi/starlette (never)\ninfra/adapters/* -> interfaces/* (never)\ninterfaces/* -> infra/db/* (state lives in resources, not imports)\n```\n\n## Why\n\n- The business logic is testable without a server or an event loop.\n- Adapters can be swapped (in-memory fake, real database) without touching\n business logic.\n- The HTTP edge stays thin and replaceable.\n\n## The port/adapter contract\n\nThe `ports/` package is the **seam** where the domain meets infrastructure.\nEach `domain//ports/` module declares the resource's models/document\nshape and exposes **factory functions** that bind a concrete adapter — so a port\nmodule *is* allowed to import from `infra/` and from the persistence libraries.\nEverything outside `ports/` in `domain/` stays framework- and driver-free.\n\n```\ndomain//ports/db.py -> document model + create__repo()\ndomain//ports/service.py -> service + get__service()\ninfra/db/repos/.py -> repository adapter factory\ninfra/adapters/service.py -> generic CRUD service base\n```\n\nThe composition root calls the port factories; nothing else constructs an\nadapter.\n\n## Enforcement\n\n- `domain/` must never import the web framework (`fastapi` / `starlette`) or\n `interfaces/`. Only the `ports/` seam, `infra/`, and the composition root\n touch drivers.\n- Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/spec_first_routing.json b/standards/mcp/modules/spec_first_routing.json index 9db3023..96a3371 100644 --- a/standards/mcp/modules/spec_first_routing.json +++ b/standards/mcp/modules/spec_first_routing.json @@ -2,7 +2,7 @@ "module": "spec_first_routing", "content": { "path": "spec_first_routing", - "docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# 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=\" API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n## Conventions\n\n- `operationId` naming: `list_`, `create_`,\n `get_`, `update_`, `delete_`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Codegen\n\nThe `openapi_first` library can scaffold models and route stubs from the spec,\nand ships app templates (CRUD app, model app, service). Prefer codegen over\nhand-writing boilerplate.\n", + "docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# 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=\" API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync 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```\n\n## Conventions\n\n- `operationId` naming: `list_`, `create_`,\n `get_`, `update_`, `delete_`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Codegen\n\nThe `openapi_first` library can scaffold models and route stubs from the spec,\nand ships app templates (CRUD app, model app, service). Prefer codegen over\nhand-writing boilerplate.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/worker_and_progress.json b/standards/mcp/modules/worker_and_progress.json index 09d79ef..37635a3 100644 --- a/standards/mcp/modules/worker_and_progress.json +++ b/standards/mcp/modules/worker_and_progress.json @@ -2,7 +2,7 @@ "module": "worker_and_progress", "content": { "path": "worker_and_progress", - "docstring": "# Worker & Progress\n\n## Summary\n\nLong-running work runs in a background worker. Jobs are durable documents that\nmove through an explicit status machine. Progress is streamed to clients.\n\n---\n\n## The status machine\n\n```\npending -> processing -> completed\n \\-> failed (retried up to N times)\n \\-> paused (waiting for human input)\n```\n\n- `pending` — queued, eligible for pickup.\n- `processing` — claimed by a worker.\n- `paused` — blocked on a human-in-the-loop question.\n- `failed` — error; `retry_count` increments until a max, then skipped.\n- `completed` — terminal success.\n\n## The poll loop\n\n```python\nasync 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```\n\nTunables live in environment variables: `WORKER_POLL_MIN`, `WORKER_POLL_MAX`,\n`WORKER_MAX_RETRIES`.\n\n## Progress events\n\nA worker publishes structured progress events to a **bus** (a port). The HTTP\nedge exposes a streaming endpoint that replays events for a given job id.\n\n```python\nclass ProgressEvent(BaseModel):\n job_id: str\n step: str\n status: str # started | progress | completed | skipped | paused\n message: dict | None = None\n```\n\nThe bus is an in-memory adapter by default; because it is a port, it can be\nswapped for a durable broker. Emit from the orchestrator at each stage:\n\n```python\nawait self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n status=\"completed\", message={\"count\": n}))\n```\n\n## Streaming endpoint\n\n```python\nasync def stream_job_events(request, id):\n bus = request.app.state.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```\n\n> **Known limitation.** The default in-memory bus is per-process. If the worker\n> and the API run as separate processes, events published by the worker are not\n> visible to the API. For production, back the bus with a shared broker. The\n> port boundary makes this a one-adapter change.\n", + "docstring": "# Worker & Progress\n\n## Summary\n\nLong-running work runs in a background worker. Jobs are durable documents that\nmove through an explicit status machine. Progress is streamed to clients.\n\n---\n\n## The status machine\n\n```\npending -> processing -> completed\n \\-> failed (retried up to N times)\n \\-> paused (waiting for human input)\n```\n\n- `pending` — queued, eligible for pickup.\n- `processing` — claimed by a worker.\n- `paused` — blocked on a human-in-the-loop question.\n- `failed` — error; `retry_count` increments until a max, then skipped.\n- `completed` — terminal success.\n\n## The poll loop\n\n```python\nasync 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```\n\nTunables live in environment variables: `WORKER_POLL_MIN`, `WORKER_POLL_MAX`,\n`WORKER_MAX_RETRIES`.\n\n## Progress events\n\nA worker publishes structured progress events to a **bus** (a port). The HTTP\nedge exposes a streaming endpoint that replays events for a given job id.\n\n```python\nclass ProgressEvent(BaseModel):\n job_id: str\n step: str\n status: str # started | progress | completed | skipped | paused\n message: dict | None = None\n```\n\nThe bus is an in-memory adapter by default; because it is a port, it can be\nswapped for a durable broker. Emit from the orchestrator at each stage:\n\n```python\nawait self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n status=\"completed\", message={\"count\": n}))\n```\n\n## Streaming endpoint\n\n```python\nasync 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```\n\n> **Known limitation.** The default in-memory bus is per-process. If the worker\n> and the API run as separate processes, events published by the worker are not\n> visible to the API. For production, back the bus with a shared broker. The\n> port boundary makes this a one-adapter change.\n", "objects": {} } } \ No newline at end of file diff --git a/standards/src/lib/02_layering_rules.md b/standards/src/lib/02_layering_rules.md index 580805c..aceb185 100644 --- a/standards/src/lib/02_layering_rules.md +++ b/standards/src/lib/02_layering_rules.md @@ -13,6 +13,7 @@ interfaces/* -> domain/* (services, ports, models) interfaces/* -> infra/adapters/* (only read-only value objects) domain/* -> domain/* (same layer) domain/* -> pipelines/* (call a pipeline via a port) +domain/*/ports/* -> infra/* (bind adapters + libraries at the seam) infra/adapters/* -> domain/ports/* (implement the interface) main.py/init.py -> everything (composition root only) ``` @@ -28,26 +29,32 @@ interfaces/* -> infra/db/* (state lives in resources, not imports ## Why -- A pure domain is testable without a server, a database, or an event loop. +- The business logic is testable without a server or an event loop. - Adapters can be swapped (in-memory fake, real database) without touching business logic. - The HTTP edge stays thin and replaceable. ## The port/adapter contract -A **port** is an abstract class or protocol declared in `domain//ports/`. -An **adapter** implements it in `infra/adapters/`. The composition root binds -the two. +The `ports/` package is the **seam** where the domain meets infrastructure. +Each `domain//ports/` module declares the resource's models/document +shape and exposes **factory functions** that bind a concrete adapter — 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//ports/db.py -> repository port + document model -domain//ports/service.py -> service + factory +domain//ports/db.py -> document model + create__repo() +domain//ports/service.py -> service + get__service() infra/db/repos/.py -> repository adapter factory infra/adapters/service.py -> generic CRUD service base ``` +The composition root calls the port factories; nothing else constructs an +adapter. + ## Enforcement -- Keep every domain module importable with only the standard library and - Pydantic. If a domain import pulls in `fastapi`, the layering is broken. +- `domain/` must never import the web framework (`fastapi` / `starlette`) or + `interfaces/`. Only the `ports/` seam, `infra/`, and the composition root + touch drivers. - Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`. diff --git a/standards/src/lib/03_composition_root.md b/standards/src/lib/03_composition_root.md index 756aaac..81532fc 100644 --- a/standards/src/lib/03_composition_root.md +++ b/standards/src/lib/03_composition_root.md @@ -20,7 +20,7 @@ Plus named infrastructure adaptors: - `progress_bus` — progress/event bus. - `_` — fetchers, clients, stores. -The API stores the dict on `app.state.resources` during lifespan startup. +The API stores the dict on `app.state.db_resources` during lifespan startup. Handlers read from it; nothing imports concrete adapters. ## Shape @@ -85,5 +85,5 @@ Also expose `get_db()` (raw database handle) and pure helpers such as - `init()` is **idempotent**. - The composition root is the only place that imports concrete adapters. -- Handlers never call `init()`; they read `request.app.state.resources`. +- Handlers never call `init()`; they read `request.app.state.db_resources`. - Scripts call `init()` / `shutdown()` around their work. diff --git a/standards/src/lib/04_spec_first_routing.md b/standards/src/lib/04_spec_first_routing.md index aa3e238..acb1a76 100644 --- a/standards/src/lib/04_spec_first_routing.md +++ b/standards/src/lib/04_spec_first_routing.md @@ -35,7 +35,7 @@ app = OpenAPIFirstApp( ```python @asynccontextmanager async def lifespan(app): - app.state.resources = await init() + app.state.db_resources = await init() yield await shutdown() ``` @@ -57,7 +57,7 @@ Handlers are thin and synchronous with respect to business logic: ```python async def create_item(request: Request, body: dict): - svc = request.app.state.resources["item_svc"] + svc = request.app.state.db_resources["item_svc"] return await svc.create(Item.model_validate(body)) ``` diff --git a/standards/src/lib/07_worker_and_progress.md b/standards/src/lib/07_worker_and_progress.md index 56c73fa..7742ec0 100644 --- a/standards/src/lib/07_worker_and_progress.md +++ b/standards/src/lib/07_worker_and_progress.md @@ -73,7 +73,7 @@ await self.bus.publish(ProgressEvent(job_id=jid, step="normalize", ```python async def stream_job_events(request, id): - bus = request.app.state.resources.get("progress_bus") + bus = request.app.state.db_resources.get("progress_bus") if bus is None: return {"events": []} diff --git a/standards/src/lib/08_human_in_loop.md b/standards/src/lib/08_human_in_loop.md index d46d5e5..3e3ee92 100644 --- a/standards/src/lib/08_human_in_loop.md +++ b/standards/src/lib/08_human_in_loop.md @@ -66,7 +66,7 @@ pipeline = build(root, instances={"pause": handler}, config={"resolved": resolve ```python async def resolve_question(request, id, body): - svc = request.app.state.resources["question_svc"] + svc = request.app.state.db_resources["question_svc"] q = await svc.read(id) await svc.update(id, {"chosen": body["chosen"], "status": "resolved", "resolved_at": now()}) @@ -74,7 +74,7 @@ async def resolve_question(request, id, body): pending = await svc.search(filter={"job_id": q.job_id, "status": "pending"}, limit=1) if not pending: - jobs = request.app.state.resources["job_svc"] + jobs = request.app.state.db_resources["job_svc"] job = await jobs.read(q.job_id) if job and job.status == "paused": await jobs.update_status(q.job_id, "pending") diff --git a/standards/src/wiki/01_overview.md b/standards/src/wiki/01_overview.md index 0bb90d2..4c94019 100644 --- a/standards/src/wiki/01_overview.md +++ b/standards/src/wiki/01_overview.md @@ -17,9 +17,9 @@ flowchart LR RT["/routes.py
    thin handlers"] end - subgraph CORE["domain/ — pure business core"] + subgraph CORE["domain/ — business core"] MD["models"] - PT["ports (abstract)"] + PT["ports (adapter binding seam)"] SV["services"] end @@ -44,11 +44,11 @@ flowchart LR ``` 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 or a -database driver. +to the core, never the reverse. `domain/` never imports a web framework; +only its `ports/` seam reaches into `infra/` to bind adapters. > **Legend** — solid arrows are compile-time calls; dotted arrows are the -> port/adapter contract, resolved by the composition root at startup. +> port/adapter binding, performed by the composition root at startup. --- @@ -57,7 +57,7 @@ database driver. | Ring | May import | Never imports | |-------------|------------------------------------|----------------------------------| | `interfaces/` | `domain`, framework glue (glue only) | business logic, `infra` directly | -| `domain/` | nothing but its own ports | web frameworks, drivers, `infra` | +| `domain/` | its own models + its `ports/` seam (which binds `infra`/libraries) | web frameworks, `interfaces/` | | `infra/` | `domain` ports, drivers, libraries | — | | `init.py` | everything (the wiring point) | — | @@ -67,8 +67,9 @@ database driver. 1. **One-way dependencies.** `interfaces -> domain -> infra` is allowed; the reverse is forbidden. -2. **Ports in the domain, adapters in infra.** A service depends on an abstract - port; the composition root injects a concrete adapter. +2. **Ports in the domain, adapters in infra.** A service depends on a port; the + port's factory binds the concrete adapter, and the composition root calls + those factories. 3. **One composition root.** Nothing constructs its own dependencies. All wiring lives in one module (`init.py`) exposed as a flat resource registry. 4. **Spec-first.** The HTTP contract is declared in `openapi.yaml`; route diff --git a/standards/src/wiki/02_components.md b/standards/src/wiki/02_components.md index 622f1ba..5d0fe84 100644 --- a/standards/src/wiki/02_components.md +++ b/standards/src/wiki/02_components.md @@ -7,57 +7,75 @@ blueprint's component model — `templates/app/` is a runnable instance of it. ## 🗂️ Component model +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. + +### 🔀 Runtime flow + +Who calls whom at request and job time: + ```mermaid -%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 120}}}%% +%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%% flowchart TB - subgraph EDGE["interfaces/ — HTTP edge"] - OAS["openapi.yaml"] - RU["resource routes"] - JU["job routes"] + subgraph REQ["Request path — API process"] + direction LR + CL(["Client
    operationId"]) --> RT["interfaces/<resource>/routes.py"] + RT --> SVC["<resource>_svc"] + SVC --> REPO["<resource>_repo"] end - subgraph CORE["domain/ — business core"] - RI[" · models · ports · services"] - JI[" · models · ports · services"] - PI["pipelines/"] + subgraph WRK["Background path — worker process"] + direction LR + W["worker.py"] --> JSVC["job_svc"] + W --> PIPE["pipelines/<family>"] + PIPE --> ISVC["item_svc"] + JSVC --> JREPO["job_repo"] + ISVC --> IREPO["item_repo"] end subgraph INFRA["infra/ — adapters"] - DB[("MongoDB repos")] - CA["redis cache"] - ST["stores · progress bus"] - LL["llm clients"] + MONGO[("MongoDB / mongo_ops")] end - W["worker.py"] - CR["init.py — composition root"] - - OAS --> RU - OAS --> JU - RU --> RI - JU --> JI - JI --> PI - PI --> DB - PI --> LL - RI --> CA - RI --> DB - JI --> ST - W --> JI - W --> PI - - CR === RU - CR === JU - CR === RI - CR === JI - CR === PI - CR === W - CR === DB - CR === CA - CR === ST + REPO --> MONGO + JREPO --> MONGO + IREPO --> MONGO ``` -> **Legend** — solid arrows are direct calls; thick links are wires created by -> the composition root. Every component is injected, never self-constructed. +> **Legend** — solid arrows are runtime calls. Nothing is self-constructed. + +### 🧵 Composition-root wiring + +Who constructs what at startup: + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%% +flowchart TB + INIT["init.py — composition root"] + SETUP["infra/db/setup.py"] + FACT["domain/*/ports/*
    factory seams"] + REPOS["repos
    infra/db/repos/mongo.py"] + SVCES["services
    CrudsServiceAdaptor"] + STATE["app.state.db_resources"] + HEXA["hexa.build"] + RT["routes.py"] + WK["worker.py"] + + INIT --> SETUP + INIT --> FACT + FACT --> REPOS + FACT --> SVCES + REPOS --> SVCES + HEXA -.-> SVCES + INIT --> STATE + STATE --> RT + INIT --> WK +``` + +> **Legend** — `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. --- @@ -74,14 +92,15 @@ call a service, and shape the response. No business rules, no I/O. ### 2. `domain/` — the core -Pure models, **ports** (abstract contracts), and services (use cases). No -framework imports, no drivers. +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. - `/models.py` — dataclasses, enums, document shapes. -- `/ports/db.py` — repo contract; `/ports/service.py` — - service contract. -- Services compose ports and enforce the business rules; the composition root - injects the implementations. +- `/ports/db.py` — document model + `create__repo()`. +- `/ports/service.py` — service + `get__service(repo)`. +- Services compose ports and enforce the business rules; the port factories bind + the implementations, and the composition root calls them. ### 3. `infra/` — the adapters @@ -117,7 +136,7 @@ stops resources on the app lifespan. sequenceDiagram participant C as Client participant R as route (interfaces) - participant S as service (domain) + participant S as service (port factory / adapter) participant P as repo (infra) participant DB as MongoDB diff --git a/standards/src/wiki/04_development/01_testing.md b/standards/src/wiki/04_development/01_testing.md index b6af74a..36fb371 100644 --- a/standards/src/wiki/04_development/01_testing.md +++ b/standards/src/wiki/04_development/01_testing.md @@ -20,7 +20,7 @@ tests/ ## 🚀 Integration tests - Build the app with `TestClient`. -- Replace `app.state.resources` with fake repositories/services so no database +- Replace `app.state.db_resources` with fake repositories/services so no database is required. - Patch the auth introspection call (`httpx.AsyncClient.post`) to return a canned identity. @@ -29,7 +29,7 @@ tests/ ```python client = TestClient(app) -app.state.resources = {"item_svc": FakeItemService(), ...} +app.state.db_resources = {"item_svc": FakeItemService(), ...} def test_create_item(): r = client.post("/items", json={"name": "x"}) diff --git a/standards/src/wiki/index.md b/standards/src/wiki/index.md index b01d3f9..afafd65 100644 --- a/standards/src/wiki/index.md +++ b/standards/src/wiki/index.md @@ -1,8 +1,8 @@ # 🧩 Standards — the Aetos App Blueprint One reusable architecture for every backend application in the Aetos ecosystem: -a spec-first HTTP edge, a pure domain core, a pluggable infrastructure layer, -and a single composition root. +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 diff --git a/standards/wiki/01_overview/index.html b/standards/wiki/01_overview/index.html index 4061168..3428ef7 100644 --- a/standards/wiki/01_overview/index.html +++ b/standards/wiki/01_overview/index.html @@ -553,9 +553,9 @@ live in the reference pages.

    RT["<resource>/routes.py<br/>thin handlers"] end - subgraph CORE["domain/ — pure business core"] + subgraph CORE["domain/ — business core"] MD["models"] - PT["ports (abstract)"] + PT["ports (adapter binding seam)"] SV["services"] end @@ -578,11 +578,11 @@ live in the reference pages.

    RP --> DB 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 or a -database driver.

    +to the core, never the reverse. domain/ never imports a web framework; +only its ports/ seam reaches into infra/ to bind adapters.

    Legend — solid arrows are compile-time calls; dotted arrows are the -port/adapter contract, resolved by the composition root at startup.

    +port/adapter binding, performed by the composition root at startup.


    🔄 The one-way dependency rule

    @@ -602,8 +602,8 @@ port/adapter contract, resolved by the composition root at startup.

    domain/ -nothing but its own ports -web frameworks, drivers, infra +its own models + its ports/ seam (which binds infra/libraries) +web frameworks, interfaces/ infra/ @@ -622,8 +622,9 @@ port/adapter contract, resolved by the composition root at startup.

    1. One-way dependencies. interfaces -> domain -> infra is allowed; the reverse is forbidden.
    2. -
    3. Ports in the domain, adapters in infra. A service depends on an abstract - port; the composition root injects a concrete adapter.
    4. +
    5. Ports in the domain, adapters in infra. A service depends on a port; the + port's factory binds the concrete adapter, and the composition root calls + those factories.
    6. One composition root. Nothing constructs its own dependencies. All wiring lives in one module (init.py) exposed as a flat resource registry.
    7. Spec-first. The HTTP contract is declared in openapi.yaml; route diff --git a/standards/wiki/02_components/index.html b/standards/wiki/02_components/index.html index a9886bd..783d787 100644 --- a/standards/wiki/02_components/index.html +++ b/standards/wiki/02_components/index.html @@ -433,6 +433,30 @@ + +
    8. @@ -595,55 +619,66 @@ blueprint's component model — templates/app/ is a runnable instance of it.


      🗂️ Component model

      -
      %%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 120}}}%%
      +

      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.

      +

      🔀 Runtime flow

      +

      Who calls whom at request and job time:

      +
      %%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%%
       flowchart TB
      -    subgraph EDGE["interfaces/ — HTTP edge"]
      -        OAS["openapi.yaml"]
      -        RU["resource routes"]
      -        JU["job routes"]
      +    subgraph REQ["Request path — API process"]
      +        direction LR
      +        CL(["Client<br/>operationId"]) --> RT["interfaces/&lt;resource&gt;/routes.py"]
      +        RT --> SVC["&lt;resource&gt;_svc"]
      +        SVC --> REPO["&lt;resource&gt;_repo"]
           end
       
      -    subgraph CORE["domain/ — business core"]
      -        RI["<resource> · models · ports · services"]
      -        JI["<job/worker> · models · ports · services"]
      -        PI["pipelines/<family>"]
      +    subgraph WRK["Background path — worker process"]
      +        direction LR
      +        W["worker.py"] --> JSVC["job_svc"]
      +        W --> PIPE["pipelines/&lt;family&gt;"]
      +        PIPE --> ISVC["item_svc"]
      +        JSVC --> JREPO["job_repo"]
      +        ISVC --> IREPO["item_repo"]
           end
       
           subgraph INFRA["infra/ — adapters"]
      -        DB[("MongoDB repos")]
      -        CA["redis cache"]
      -        ST["stores · progress bus"]
      -        LL["llm clients"]
      +        MONGO[("MongoDB / mongo_ops")]
           end
       
      -    W["worker.py"]
      -    CR["init.py — composition root"]
      -
      -    OAS --> RU
      -    OAS --> JU
      -    RU --> RI
      -    JU --> JI
      -    JI --> PI
      -    PI --> DB
      -    PI --> LL
      -    RI --> CA
      -    RI --> DB
      -    JI --> ST
      -    W --> JI
      -    W --> PI
      -
      -    CR === RU
      -    CR === JU
      -    CR === RI
      -    CR === JI
      -    CR === PI
      -    CR === W
      -    CR === DB
      -    CR === CA
      -    CR === ST
      + REPO --> MONGO + JREPO --> MONGO + IREPO --> MONGO
      -

      Legend — solid arrows are direct calls; thick links are wires created by -the composition root. Every component is injected, never self-constructed.

      +

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

      +
      +

      🧵 Composition-root wiring

      +

      Who constructs what at startup:

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

      Legend — 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.


      📦 Component roles

      @@ -656,14 +691,15 @@ call a service, and shape the response. No business rules, no I/O.

    9. Routes may mount the cache decorator on read endpoints.
    10. 2. domain/ — the core

      -

      Pure models, ports (abstract contracts), and services (use cases). No -framework imports, no drivers.

      +

      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 — dataclasses, enums, document shapes.
      • -
      • <aggregate>/ports/db.py — repo contract; <aggregate>/ports/service.py — - service contract.
      • -
      • Services compose ports and enforce the business rules; the composition root - injects the implementations.
      • +
      • <aggregate>/ports/db.py — document model + create_<aggregate>_repo().
      • +
      • <aggregate>/ports/service.py — service + get_<aggregate>_service(repo).
      • +
      • Services compose ports and enforce the business rules; the port factories bind + the implementations, and the composition root calls them.

      3. infra/ — the adapters

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

      @@ -689,7 +725,7 @@ stops resources on the app lifespan.

      sequenceDiagram
           participant C as Client
           participant R as route (interfaces)
      -    participant S as service (domain)
      +    participant S as service (port factory / adapter)
           participant P as repo (infra)
           participant DB as MongoDB
       
      diff --git a/standards/wiki/04_development/01_testing/index.html b/standards/wiki/04_development/01_testing/index.html
      index 7d1dac1..54e6f45 100644
      --- a/standards/wiki/04_development/01_testing/index.html
      +++ b/standards/wiki/04_development/01_testing/index.html
      @@ -573,7 +573,7 @@ blueprint app only when every gate below passes.

      🚀 Integration tests

      • Build the app with TestClient.
      • -
      • Replace app.state.resources with fake repositories/services so no database +
      • Replace app.state.db_resources with fake repositories/services so no database is required.
      • Patch the auth introspection call (httpx.AsyncClient.post) to return a canned identity.
      • @@ -586,7 +586,7 @@ blueprint app only when every gate below passes.

        4 5 6
    client = TestClient(app)
    -app.state.resources = {"item_svc": FakeItemService(), ...}
    +app.state.db_resources = {"item_svc": FakeItemService(), ...}
     
     def test_create_item():
         r = client.post("/items", json={"name": "x"})
    diff --git a/standards/wiki/index.html b/standards/wiki/index.html
    index c4cf97b..e684740 100644
    --- a/standards/wiki/index.html
    +++ b/standards/wiki/index.html
    @@ -521,8 +521,8 @@
     
     

    🧩 Standards — the Aetos App Blueprint

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

    +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 — expenses, health metrics, inventory, and analytics are all diff --git a/standards/wiki/search/search_index.json b/standards/wiki/search/search_index.json index 226a936..b8ab9db 100644 --- a/standards/wiki/search/search_index.json +++ b/standards/wiki/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 Standards \u2014 the Aetos App Blueprint","text":"

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

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

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

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

    Reference (separate site, mirrors the MCP bundle): the enforceable rules \u2014 Layering, Composition Root, Spec-First Routing, CRUD Template, Pipeline Scaffold, Worker & Progress, Human-in-the-Loop, Infra Adapters, Genericity Matrix, Library Registry.

    "},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"
    • MCP bundle \u2014 standards server on :8009: read doc://index, doc://nav, doc://modules/{module}; call find_library(capability)
    • Library registry \u2014 the catalog of internal libraries behind every app (libraries.json, served as registry://libraries)
    • Template \u2014 the runnable skeleton at standards/src/templates/app/
    • Copy the repo's AGENTS.md-style build prompt from Build Recipe

    \u00a9 Aetoskia Internal \u2014 standards

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

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

    "},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"
    flowchart LR\n    subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n        OAS[\"openapi.yaml<br/>operationId contract\"]\n        RT[\"<resource>/routes.py<br/>thin handlers\"]\n    end\n\n    subgraph CORE[\"domain/ \u2014 pure business core\"]\n        MD[\"models\"]\n        PT[\"ports (abstract)\"]\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 or a database driver.

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

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

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

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

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

    "},{"location":"02_components/#component-model","title":"\ud83d\uddc2\ufe0f Component model","text":"
    %%{init: {\"flowchart\": {\"nodeSpacing\": 55, \"rankSpacing\": 120}}}%%\nflowchart TB\n    subgraph EDGE[\"interfaces/ \u2014 HTTP edge\"]\n        OAS[\"openapi.yaml\"]\n        RU[\"resource routes\"]\n        JU[\"job routes\"]\n    end\n\n    subgraph CORE[\"domain/ \u2014 business core\"]\n        RI[\"<resource> \u00b7 models \u00b7 ports \u00b7 services\"]\n        JI[\"<job/worker> \u00b7 models \u00b7 ports \u00b7 services\"]\n        PI[\"pipelines/<family>\"]\n    end\n\n    subgraph INFRA[\"infra/ \u2014 adapters\"]\n        DB[(\"MongoDB repos\")]\n        CA[\"redis cache\"]\n        ST[\"stores \u00b7 progress bus\"]\n        LL[\"llm clients\"]\n    end\n\n    W[\"worker.py\"]\n    CR[\"init.py \u2014 composition root\"]\n\n    OAS --> RU\n    OAS --> JU\n    RU --> RI\n    JU --> JI\n    JI --> PI\n    PI --> DB\n    PI --> LL\n    RI --> CA\n    RI --> DB\n    JI --> ST\n    W --> JI\n    W --> PI\n\n    CR === RU\n    CR === JU\n    CR === RI\n    CR === JI\n    CR === PI\n    CR === W\n    CR === DB\n    CR === CA\n    CR === ST

    Legend \u2014 solid arrows are direct calls; thick links are wires created by the composition root. Every component is injected, never self-constructed.

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

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

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

    Pure models, ports (abstract contracts), and services (use cases). No framework imports, no drivers.

    • <aggregate>/models.py \u2014 dataclasses, enums, document shapes.
    • <aggregate>/ports/db.py \u2014 repo contract; <aggregate>/ports/service.py \u2014 service contract.
    • Services compose ports and enforce the business rules; the composition root injects the implementations.
    "},{"location":"02_components/#3-infra-the-adapters","title":"3. infra/ \u2014 the adapters","text":"

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

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

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

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

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

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

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

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

    The ordered, deterministic checklist for generating a new application of the standard shape. Every step is fixed; only steps 2 and 3 need app-specific authoring. Give an AI agent the copyable prompt below (or read it from the standards MCP bundle as doc://modules/build_recipe) and it can produce a complete app in one pass.

    "},{"location":"03_build_recipe/#quick-start","title":"\ud83d\ude80 Quick start","text":"
    # Copy the runnable skeleton (from the standards docs checkout)\ncp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n

    Then follow the numbered steps in the prompt.

    "},{"location":"03_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 and 3 use the\napp-specific details provided by the user; everything else is deterministic.\n\nBefore writing any code, connect to the `standards` MCP server and read:\n- `doc://nav` (the module list)\n- `doc://modules/layering_rules` (the dependency rules)\n- `doc://modules/genericity_matrix` (what is generic vs app-specific)\nThen call `find_library` with capabilities like `db`, `di`, `routing`,\n`ingestion`, `auth`, `docs`, `pipeline`, and note the libraries that already\nexist. Never reimplement one.\n\n1. **Scaffold** \u2014 copy `src/templates/app/` into `apps/<your-app>/`. Keep the\n   package tree: `domain/`, `infra/`, `interfaces/`, `pipelines/`, `tests/`,\n   `scripts/`, plus `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`,\n   `main.py`, `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.\n\n3. **Write each CRUD slice (app-specific)** \u2014 per resource create the\n   four-file slice: `models.py`, `ports/db.py`, `ports/service.py`, and\n   optional `converters.py`. Follow the CRUD template.\n\n4. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n   dependent repositories, then services; add infra adapters (db, cache,\n   stores, progress bus, fetchers). Expose `get_svc` overloads.\n\n5. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers\n   bound to `operationId`s and re-export them in `interfaces/openapi.py`. Add\n   the cache decorator to read endpoints.\n\n6. **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\n   poll the job collection, run the pipeline, and publish progress.\n\n7. **Add pipeline families (only if data-driven)** \u2014 create\n   `pipelines/<family>/` with `abc.py`, `impl.py`, `pipeline.yaml`,\n   `resources/`, and register the root. Add fixtures and a parity test.\n\n8. **Add infrastructure only where needed** \u2014 prefer a library-backed adapter.\n   Never hand-write what an internal library already provides.\n\n9. **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.\n\n10. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n    `init()` / `get_svc()` / `shutdown()`.\n\n11. **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 infrastructure was reimplemented that a library already provides.\n
    "},{"location":"03_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"

    The steps map onto the layers as follows:

    Step Package / file Depends on 1 \u00b7 Scaffold whole templates/app/ \u2014 2 \u00b7 Spec openapi.yaml \u2014 3 \u00b7 CRUD slices domain/<resource>/ spec, CRUD template 4 \u00b7 Wiring init.py every slice 5 \u00b7 Routes interfaces/<resource>/ services, openapi route registry 6 \u00b7 Bootstrap main.py, worker.py composition root 7 \u00b7 Pipelines pipelines/<family>/ library pipeline engine 8 \u00b7 Infra infra/ domain ports 9 \u00b7 Tests tests/ app + fixtures 10 \u00b7 Scripts scripts/ composition root 11 \u00b7 Docs docstrings + docs build code, Doc Forge"},{"location":"03_build_recipe/#definition-of-done","title":"\u26a0\ufe0f Definition of done","text":"
    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No infrastructure was reimplemented that a library already provides.
    "},{"location":"03_build_recipe/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 02 \u2013 App Components \u00b7 04 \u2013 Testing & Quality
    "},{"location":"04_development/01_testing/","title":"Testing & Quality Gates","text":"

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

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

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

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

    A definition of done for any standard app:

    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No infrastructure was reimplemented that a library already provides.
    "},{"location":"04_development/01_testing/#conventions","title":"\ud83d\udca1 Conventions","text":"
    • asyncio_mode = \"auto\"; no manual event loops.
    • Fixtures are checked into the repo (inputs + golden outputs).
    • Tests skip (not fail) when a required fixture is absent.
    • Do not mock the domain; mock only the infrastructure edge.
    "},{"location":"04_development/01_testing/#related","title":"Related","text":"
    • 02 \u2013 App Components \u00b7 03 \u2013 Build Recipe \u00b7 Overview
    "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 Standards \u2014 the Aetos App Blueprint","text":"

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

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

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

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

    Reference (separate site, mirrors the MCP bundle): the enforceable rules \u2014 Layering, Composition Root, Spec-First Routing, CRUD Template, Pipeline Scaffold, Worker & Progress, Human-in-the-Loop, Infra Adapters, Genericity Matrix, Library Registry.

    "},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"
    • MCP bundle \u2014 standards server on :8009: read doc://index, doc://nav, doc://modules/{module}; call find_library(capability)
    • Library registry \u2014 the catalog of internal libraries behind every app (libraries.json, served as registry://libraries)
    • Template \u2014 the runnable skeleton at standards/src/templates/app/
    • Copy the repo's AGENTS.md-style build prompt from Build Recipe

    \u00a9 Aetoskia Internal \u2014 standards

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

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

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

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

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

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

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

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

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

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

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

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

    Who calls whom at request and job time:

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

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

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

    Who constructs what at startup:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    The ordered, deterministic checklist for generating a new application of the standard shape. Every step is fixed; only steps 2 and 3 need app-specific authoring. Give an AI agent the copyable prompt below (or read it from the standards MCP bundle as doc://modules/build_recipe) and it can produce a complete app in one pass.

    "},{"location":"03_build_recipe/#quick-start","title":"\ud83d\ude80 Quick start","text":"
    # Copy the runnable skeleton (from the standards docs checkout)\ncp -r <docs>/standards/src/templates/app apps/<your-app>\ncd apps/<your-app>\n

    Then follow the numbered steps in the prompt.

    "},{"location":"03_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 and 3 use the\napp-specific details provided by the user; everything else is deterministic.\n\nBefore writing any code, connect to the `standards` MCP server and read:\n- `doc://nav` (the module list)\n- `doc://modules/layering_rules` (the dependency rules)\n- `doc://modules/genericity_matrix` (what is generic vs app-specific)\nThen call `find_library` with capabilities like `db`, `di`, `routing`,\n`ingestion`, `auth`, `docs`, `pipeline`, and note the libraries that already\nexist. Never reimplement one.\n\n1. **Scaffold** \u2014 copy `src/templates/app/` into `apps/<your-app>/`. Keep the\n   package tree: `domain/`, `infra/`, `interfaces/`, `pipelines/`, `tests/`,\n   `scripts/`, plus `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`,\n   `main.py`, `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.\n\n3. **Write each CRUD slice (app-specific)** \u2014 per resource create the\n   four-file slice: `models.py`, `ports/db.py`, `ports/service.py`, and\n   optional `converters.py`. Follow the CRUD template.\n\n4. **Wire the composition root** \u2014 in `init.py`, build leaf repositories, then\n   dependent repositories, then services; add infra adapters (db, cache,\n   stores, progress bus, fetchers). Expose `get_svc` overloads.\n\n5. **Add routes** \u2014 create `interfaces/<resource>/routes.py` thin handlers\n   bound to `operationId`s and re-export them in `interfaces/openapi.py`. Add\n   the cache decorator to read endpoints.\n\n6. **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\n   poll the job collection, run the pipeline, and publish progress.\n\n7. **Add pipeline families (only if data-driven)** \u2014 create\n   `pipelines/<family>/` with `abc.py`, `impl.py`, `pipeline.yaml`,\n   `resources/`, and register the root. Add fixtures and a parity test.\n\n8. **Add infrastructure only where needed** \u2014 prefer a library-backed adapter.\n   Never hand-write what an internal library already provides.\n\n9. **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.\n\n10. **Scripts and seeds** \u2014 operator scripts under `scripts/` using\n    `init()` / `get_svc()` / `shutdown()`.\n\n11. **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 infrastructure was reimplemented that a library already provides.\n
    "},{"location":"03_build_recipe/#where-the-steps-land","title":"\ud83e\udded Where the steps land","text":"

    The steps map onto the layers as follows:

    Step Package / file Depends on 1 \u00b7 Scaffold whole templates/app/ \u2014 2 \u00b7 Spec openapi.yaml \u2014 3 \u00b7 CRUD slices domain/<resource>/ spec, CRUD template 4 \u00b7 Wiring init.py every slice 5 \u00b7 Routes interfaces/<resource>/ services, openapi route registry 6 \u00b7 Bootstrap main.py, worker.py composition root 7 \u00b7 Pipelines pipelines/<family>/ library pipeline engine 8 \u00b7 Infra infra/ domain ports 9 \u00b7 Tests tests/ app + fixtures 10 \u00b7 Scripts scripts/ composition root 11 \u00b7 Docs docstrings + docs build code, Doc Forge"},{"location":"03_build_recipe/#definition-of-done","title":"\u26a0\ufe0f Definition of done","text":"
    • python -c \"import main\" succeeds.
    • pytest passes.
    • The app serves the spec's operations; the worker drains its queue.
    • No domain module imports a web framework.
    • No infrastructure was reimplemented that a library already provides.
    "},{"location":"03_build_recipe/#related","title":"Related","text":"
    • 01 \u2013 Overview \u00b7 02 \u2013 App Components \u00b7 04 \u2013 Testing & Quality
    "},{"location":"04_development/01_testing/","title":"Testing & Quality Gates","text":"

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

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

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

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

    A definition of done for any standard app:

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