Step 01 / 28

Action and the pipeline

Everything in AOA revolves around a single figure — the Action. It is a business operation cast as a class: one typed input (Params), one output (Result), and between them a straight chain of steps you read top to bottom, like a page. Each section below maps to a real file in examples/step_01_Action_and_pipeline/ — press Run to see its actual captured output, or open it in Colab to run it yourself.

Hello, world!

Let's start with an Action that does nothing useful — it prints a line and returns an empty stub. Its value is elsewhere: it shows the minimum of declarations AOA requires before it will run anything at all.

01_hello_world.py
class GreetingDomain(BaseDomain):    name = "greeting"    description = "Greetings domain"@meta(description="Say hello to the world", domain=GreetingDomain)@check_roles(GuestRole)class SayHelloAction(BaseAction[ParamsStub, ResultStub]):    @summary_aspect("Print greeting and return stub")    async def output_summary(self, params, state, box, connections):        print("Hello, world!")        return ResultStub()
$ uv run python examples/step_01_Action_and_pipeline/01_hello_world.py
output

That is a fair number of lines for a plain greeting — but none of them is a ritual: each declares something without which an Action in AOA is not considered complete. It all begins with a domain. @meta is the Action's passport; @check_roles(GuestRole) declares access out loud, not by default. And @summary_aspect is the single exit point — skip any of the three and the machine refuses to run the Action, before the first call.

Params, Result, and box

Stubs are fine for introductions; a real Action works with data. Let's give it an input and an output — and swap print for the instrument Actions in AOA use to speak with the outside world.

02_params_result_and_box.py
class GreetParams(BaseParams):    name: str = Field(description="Name of the person to greet")class GreetResult(BaseResult):    message: str = Field(description="Assembled greeting message")@meta(description="Greet a person by name", domain=GreetingDomain)@check_roles(GuestRole)class GreetPersonAction(BaseAction[GreetParams, GreetResult]):    @summary_aspect("Build greeting and return result")    async def greet_summary(self, params, state, box, connections):        await box.info(            Channel.business,            "Greeting: Hello, {%var.name|cyan}!",            name=params.name,        )        return GreetResult(message=f"Hello, {params.name}!")
$ uv run python examples/step_01_Action_and_pipeline/02_params_result_and_box.py
output

box is more interesting than print — it is a structured logger bound to the current step, carrying channel, level, domain, action and aspect name, so it lends itself to filtering, routing and export. Where the event goes is not the Action's decision — that is the machine's concern, wired in from the outside.

Multiple aspects

One step is rarely enough. AOA leaves no freedom for intermediate data to hide in local variables or object fields — it flows through the pipeline in an explicit state, while the Action itself stays empty between calls.

03_multiple_aspects.py
@meta(description="Process input string through multiple steps", domain=ProcessingDomain)@check_roles(GuestRole)class ProcessInputAction(BaseAction[ProcessParams, ProcessResult]):    @regular_aspect("Step 1: Strip whitespace and lowercase")    @result_string("cleaned", required=True)    async def validate_aspect(self, params, state, box, connections):        cleaned = params.raw_input.strip().lower()        return {"cleaned": cleaned}    @regular_aspect("Step 2: Enrich data")    @result_string("cleaned", required=True)    @result_string("enriched", required=True)    async def enrich_aspect(self, params, state, box, connections):        enriched = f"enriched::{state['cleaned']}"        return {"cleaned": state["cleaned"], "enriched": enriched}    @summary_aspect("Step 3: Assemble final result")    async def assemble_summary(self, params, state, box, connections):        return ProcessResult(            cleaned=state["cleaned"],            enriched=state["enriched"],            final=f"{state['cleaned']} → {state['enriched']}",        )
$ uv run python examples/step_01_Action_and_pipeline/03_multiple_aspects.py
output

The returned dictionary replaces state entirely — it does not extend it. @result_string("cleaned", required=True) is a checker: a verifiable contract on an aspect's output. Break the promise and the machine stops the aspect on the spot, never letting spoiled state pass further.

Inheritance

Actions inherit like ordinary classes — with one deliberate exception: aspects are not inherited into the pipeline. The machine builds the pipeline only from what is declared in the class itself.

04_inheritance.py
# Parent: two aspects in the pipelineclass BaseOrderAction(BaseAction[OrderParams, OrderResult]):    @regular_aspect("Validate order")    async def validate_aspect(self, ...): ...    @summary_aspect("Base result")    async def base_summary(self, ...): ...# Child: declares only its own summary — validate_aspect will NOT runclass ChildOrderAction(BaseOrderAction):    @summary_aspect("Child result")    async def child_summary(self, ...): ...# The right way: declare the aspect explicitly and call super()class ExtendedOrderAction(BaseOrderAction):    @regular_aspect("Validate order")    @result_instance("steps", list, required=True)    async def validate_aspect(self, params, state, box, connections):        result = await super().validate_aspect(params, state, box, connections)        return {**result, "extended": True}    @summary_aspect("Extended result")    async def extended_summary(self, ...): ...
$ uv run python examples/step_01_Action_and_pipeline/04_inheritance.py
output

ChildOrderAction runs fine but its pipeline contains only child_summary — the parent's validate_aspect never executes. ExtendedOrderAction does it right: it re-declares the aspect and calls super() to build on the ancestor's logic.


Experiments

This chapter has accumulated a fair number of rules — but they share one property: almost all of them are checked at class declaration, at module import, not at the Action's call. Code here is an executable specification; a contract violation surfaces before the first run, not one night in production. Try it — pick a way to break SayHelloAction from the first example and press Run.

01_hello_world.py
class GreetingDomain(BaseDomain):    name = "greeting"    description = "Greetings domain"@meta(description="Say hello to the world", domain=GreetingDomain)@check_roles(GuestRole)class SayHelloAction(BaseAction[ParamsStub, ResultStub]):    @summary_aspect("Print greeting and return stub")    async def output_summary(self, params, state, box, connections):        print("Hello, world!")        return ResultStub()
$ uv run python examples/step_01_Action_and_pipeline/01_hello_world.py
output

Summary

The core of the model is already in hand: an operation is an Action with a typed boundary (Params and Result), a linear pipeline of aspects, and contracts that are mostly checked at initialization. Behavior is expressed in the structure of the code, not in documentation lying next to it.


Review questions

  1. Which invariant secures an Action's "from one class" readability, and at what moment is it checked?
  2. Why is the absence of @check_roles an error and not a silent "open to everyone"?
  3. The AOA pipeline is linear — no branches, no side exits. What does this constraint buy, and at what cost?
  4. Why are aspects not inherited into the pipeline automatically? Compare with ordinary method inheritance in OOP.
  5. What does "code is an executable specification" mean, and why are most contracts checked at initialization rather than at runtime?
step 01 — Action and the pipeline · aoa.run