# Code Craft AI books

> The Code Craft AI books published at https://code-craft.ai/references/codecraftai.


# The Code Craft AI Books

A series of nine volumes and an appendix on building durable automation software in Python, drawn from the `codecraftai-*` packages.  The
series teaches a discipline (debuggability, explicitness, contracts that cut across packages) that works whether or not you keep using
these packages, and every claim in it is anchored to a file and line in the public source.

The text is kept in the public [codecraftai-books repository](https://github.com/code-craft-ai-company/codecraftai-books) and
published at <https://code-craft.ai/references/codecraftai>.  The two are the same text.

## The volumes

The recommended reading order is Volume 0 through Volume 8, then the appendix.  Each title opens that volume's contents page.

| Vol | Title | Subtitle | Chapters |
|----:|-------|----------|--------:|
| **0** | [**Foundations of Automation Engineering with Python**](volumes/00-foundations/README.md) | The CodeCraftX Discipline and the Foundation Tier | 10 |
| **1** | [**Building a Test Framework**](volumes/01-test-framework/README.md) | testbase, arcli, and Single-Host Test Infrastructure | 10 |
| **2** | [**Distributed Automation**](volumes/02-distributed-automation/README.md) | The TestJob Inheritance Chain and Multi-Host Test Execution | 16 |
| **3** | [**Integration Automation and CI Orchestration**](volumes/03-integration-and-orchestration/README.md) | Typed Configurations as the Contract Between Orchestrations and Jobs | 28 |
| **4** | [**Cross-Platform System Management**](volumes/04-cross-platform-sysman/README.md) | 52 Cross-OS Contracts and 5 Reference Implementations | 10 |
| **5** | [**Interop and Protocols**](volumes/05-interop-and-protocols/README.md) | The Aspects Pattern, the Wire, and the Extension Model | 10 |
| **6** | [**Topology, Provisioning, and UI Automation**](volumes/06-topology-provisioning-ui/README.md) | Modelling the Environment Under Automation | 15 |
| **7** | [**Building an AI Agent SDK**](volumes/07-ai-agent-sdk/README.md) | Lifecycle, Tools, Memory, and Observability for Production Agents | 9 |
| **8** | [**Time-Travel Debugging in Production**](volumes/08-timetravel-debugging/README.md) | Pod Instrumentation, Server Ingestion, and the Replay-Driven Debugging Workflow | 8 |
| App. | Operating a Code Craft AI Subscription Platform | From Git-SHA Pinning to a Published-Package Ecosystem | planned |

## Who should read which volume

| Profile | Volumes most relevant |
|---|---|
| Junior Python engineer entering automation work | 0, 1, 3 |
| SDET / test-infra engineer | 0, 1, 2, 3 |
| Platform / DevOps engineer building CI pipelines | 0, 3, 4, 6 |
| SRE running cross-OS automation at scale | 0, 3, 4, 6 |
| Integration engineer building protocol implementations | 0, 5 |
| AI / agent developer shipping production agents | 0, 5, 7 |
| Senior engineer / architect needing observability for long-lived services | 0, 8 |
| Internal-tool maintainer running a package distribution | Appendix |

## How the books are written

- The text is Markdown.  Code is in fenced blocks with a language tag.
- Every claim is anchored to a concrete file and line in one of the `codecraftai-*` repositories.  Line numbers move as the code does, so
  treat them as a pointer to where to look.
- The series is narrated in the voice of CodeCraftX, the engineering persona used across the portfolio.
- Each volume lives in `volumes/<NN-name>/`: a `README.md` that is its contents page, and one file per chapter under `chapters/`.

## Status

Volumes 0 to 8 are drafted in full.  The appendix is planned.  These are working drafts, and a draft has errors: where a chapter is wrong,
unclear or out of date with the code, we want to know.

## Errata, questions and suggestions

Open an issue in the [codecraftai-books repository](https://github.com/code-craft-ai-company/codecraftai-books/issues), and say which
chapter and passage you mean.  Issues are the only contribution we accept;
see [CONTRIBUTING.md](https://github.com/code-craft-ai-company/codecraftai-books/blob/main/CONTRIBUTING.md).

## Copyright

Copyright © 2026 Code Craft AI LLC.  All rights reserved.  The software the books describe is licensed separately, in each `codecraftai-*`
repository.


<!-- https://code-craft.ai/references/codecraftai/00-foundations -->
# Volume 0 — Foundations of Automation Engineering with Python

*The CodeCraftX Discipline and the Foundation Tier*

## About this volume

Volume 0 is **prerequisite reading** for the rest of the series.
Every later volume references the CodeCraftX persona, the error
vocabulary, the Aspects pattern, the explicit-`return` convention,
the configuration model, and the application-identity machinery
introduced here.  Without Volume 0, every later volume would have
to re-explain the foundations.

## Who it is for

Any Python engineer building a serious automation platform —
SDETs, platform engineers, SREs, integration engineers.  This is
the book a senior engineer hands a new hire on day one.

## Source material

The volume draws from the foundation tier of the `codecraftai-*`
portfolio:

- `codecraftai-foundations` — one distribution holding the contract layer (`ccai.interfaces`),
  the exception vocabulary and enhanced tracebacks (`ccai.errors`), the waiting primitives
  (`ccai.waiting`), the minimal `Aspects` carrier (`ccai.aspects`) and base classes such as
  `Freezable` (`ccai.bases`). Until 2026-09-02 these were the separate `codecraftai-interfaces`,
  `codecraftai-errors` and `codecraftai-waiting` packages; import paths did not change, and the
  chapters below still describe them by their subpackage names.
- `codecraftai-this-app` — application identity + persona variables
- `codecraftai-extension` — factory + plug-in machinery
- `codecraftai-runtime` — runtime variables + well-known paths
- `codecraftai-configuration` — config loading + multi-source
- `codecraftai-credentials` — typed credential vocabulary
- `codecraftai-dataprofiles` — connection profile classes
- `codecraftai-storage-database` — DB scaffolding
- `codecraftai-xmodules` — xformatting, ximport, xcollections, etc.

## Contents

- [Chapter 1 — The CodeCraftX Philosophy](chapters/01-the-codecraftai-x-philosophy.md)
- [Chapter 2 — Coding Standards as a Tool, Not a Style Guide](chapters/02-coding-standards-as-a-tool.md)
- [Chapter 3 — The Error Vocabulary](chapters/03-the-error-vocabulary.md)
- [Chapter 4 — Interfaces and Contracts](chapters/04-interfaces-and-contracts.md)
- [Chapter 5 — Waiting with Rich Diagnostics](chapters/05-waiting-with-rich-diagnostics.md)
- [Chapter 6 — Application Identity and Presence](chapters/06-application-identity-and-presence.md)
- [Chapter 7 — Extension and Factory Machinery](chapters/07-extension-and-factory-machinery.md)
- [Chapter 8 — Runtime Variables and Well-Known Paths](chapters/08-runtime-variables-and-well-known-paths.md)
- [Chapter 9 — Configuration Loading](chapters/09-configuration-loading.md)
- [Chapter 10 — Credentials, Data Profiles, Storage Scaffolding](chapters/10-credentials-data-profiles-storage.md)

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/00-foundations/01-the-codecraftai-x-philosophy -->
# Chapter 1 — The CodeCraftX Philosophy

> *Software for automation is software that runs unattended, in
> environments you can't directly observe, against systems whose
> failures you can't always reproduce.  The discipline that makes
> automation software durable is not the same as the discipline
> that makes application software ship.  This chapter is about
> what's different.*

This is the first chapter of the first volume in the series.
Everything that follows — across this volume and the eight
volumes after it — rests on the philosophy established here.
The philosophy is concrete, not abstract: a small set of
engineering principles that have specific consequences in every
line of code the `codecraftai-*` portfolio ships.

The principles are summarised in a single block of text that
opens every repository's `AGENTS.md` file across the portfolio:

```
# Core Philosophy

- Readability is important
- Debuggability is paramount
- Scalability and maintainability are critical
- Flat is better than nested
- Sparse is better than dense
- Explicit is better than implicit
- Simple is better than complex
- Stability is more important than performance
- Errors should never pass silently unless purposefully handled and silenced
- If the implementation is hard to explain, follow, or maintain, try another way
```

Ten lines.  Each one is the result of a tradeoff someone made
deliberately, with consequences across thousands of lines of code.
This chapter unpacks what each line *means in code*, so that the
rest of the series can use them as shorthand.

## Who is CodeCraftX?

Throughout this series, the narrator is **CodeCraftX** — a
Principal Software Engineer and Architect at Code Craft AI
LLC.  Every repository in the portfolio's `AGENTS.md` opens with
a description of this persona:

> *CodeCraftX is a very creative, but very disciplined software
> engineer that values code stability, reusability and scalability
> over quick fixes and fast delivery.  CodeCraftX always keeps
> in mind the company's goal of building a comprehensive suite of
> foundation Python packages that are well designed, cohesive, and
> easy to use and that provide rapid but stable results.*

The persona is not a marketing voice.  It is a *constraint
satisfier* — a fictional engineer who consistently applies the
philosophy above and reaches the same conclusions every time.
When two engineers on the same team would have disagreed about a
design choice, CodeCraftX is the imagined third engineer who
applies the philosophy, lands the choice, and moves on.  The
persona shows up in the portfolio's `AGENTS.md` files because
the philosophy needs a *consistent* voice to apply it; the same
discipline ten people apply produces ten subtly different
codebases unless an authority figure resolves the disagreements.

The series's prose is in the voice of CodeCraftX as well, for
the same reason.  An automation platform built by ten teams
applying their own preferences produces a platform that's hard to
learn.  An automation platform built by ten teams applying *the
same* philosophy produces a platform whose conventions transfer.
The persona is the carrier of the conventions.

## Debuggability is paramount

Of the ten principles, this is the one with the most concrete
consequences.  *Debuggability is paramount* means: when the code
is failing in production, on a machine you can't SSH into, on a
day when you have an hour to find the bug — every other
consideration loses to the consideration of getting useful
information out of the failure.

Debuggability has *cost*.  Every choice the philosophy makes for
debuggability is a choice that's slower, more verbose, less
"clever" than the alternative.  A short example.

The non-debuggable form:

```python
def compute_total(items):
    return sum(item.price * (1 - item.discount_rate) for item in items if item.in_stock)
```

The debuggable form:

```python
def compute_total(items):
    in_stock_items = [item for item in items if item.in_stock]

    item_totals = []
    for item in in_stock_items:
        discounted_price = item.price * (1 - item.discount_rate)
        item_totals.append(discounted_price)

    total = sum(item_totals)
    return total
```

Three times as many lines.  Three intermediate variables.  An
explicit `return` even though the value could be returned
inline.  All of this is *cost*.

The benefit shows up at debug time.  When the production failure
is "the total is wrong for customer A," the debugger or the log
can show the values of `in_stock_items`, of `item_totals`, of the
final `total` — each at a known location in the function.  The
non-debuggable form requires re-running with print statements
inserted, or carefully stepping through a generator expression
in a way most debuggers handle poorly.

The cost of the debuggable form is paid *once* per function
written.  The benefit is paid every time the function is debugged.
Across a codebase that runs unattended for months and is debugged
when something breaks, the math always favours debuggability.

This is the philosophy's most important principle, and it shapes
the others.  When two principles seem to conflict, debuggability
wins.

## Explicit is better than implicit

Python's *Zen* names this; the philosophy turns it into specific
rules.  Three of those rules show up across the portfolio.

### No mutable defaults

```python
# Forbidden
def add_item(item, items=[]):
    items.append(item)
    return items
```

The `items=[]` default is a *single list shared across every
invocation* — the canonical Python footgun.  Every codebase in
the foundation tier checks for this; the CodeCraftX persona
catches it during code review every time.  The right form:

```python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
```

More verbose.  Explicit about the default.  No shared mutable
state across calls.  The discipline appears in
`CLAUDE.md` under the "Default Values"
heading and is enforced in code review.

### Named parameter passing

```python
# Discouraged
result = compute_total(catalog, region, currency)
```

Three positional arguments; the reader has to remember the
order.  The discipline:

```python
# Preferred
result = compute_total(catalog=catalog, region=region, currency=currency)
```

Every parameter named at the call site.  When the function adds a
fourth parameter — say, `tax_jurisdiction` — the existing call
sites are unambiguous about which arguments they're passing.
When parameters are reordered, no call site is affected.

The cost: a few characters per call.  The benefit: refactor
safety and readability.  This is the sort of small, recurring
trade-off the philosophy is consistent about.

### Explicit `return` statements

Volume 3's chapter on the CodeCraftX coding-standards
discussion (chapter 2 of this volume) develops this in detail.
The short version: every function ends in an explicit `return`,
even when the function's "natural" return is `None`.

```python
def configure_logger(logger):
    logger.setLevel(logging.INFO)
    formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
    handler = logging.StreamHandler()
    handler.setFormatter(formatter)
    logger.addHandler(handler)
    return    # <-- explicit, even when None
```

The trailing `return` exists because the Python interpreter
*generates* a return-bytecode whether or not the source has the
keyword.  When the keyword is present, a debugger can break at
that line and inspect every local variable in the function before
exit.  When the keyword is absent, the breakpoint has to attach to
the closing brace's invisible bytecode location, which most
debuggers handle awkwardly.

There are three further reasons covered in chapter 2: refactor
safety (orphaned code becomes unreachable rather than blending
into the next function), function-end visibility (the closing
`return` is a clear delimiter), and consistency (every function
ends the same way).  None of these is the *biggest* reason on its
own; together they justify the verbosity.

## Errors should never pass silently unless purposefully handled and silenced

The exception-handling discipline in the foundation tier has six
specific rules, all derived from this principle.  Chapter 3 of
this volume develops them in full.  The summary version:

1. **Do not handle exceptions unless you can recover and continue
   the intended purpose.**  A bare `except:` that swallows the
   exception and returns is a bug.
2. **Catch and reclassify** to add specific contextual
   information when you have it.
3. **Catch and reclassify** to improve code context in failure
   reports.
4. **Catch and add extra contextual information** about unique
   circumstances.
5. **`SemanticError` must NEVER be caught** — always let it
   bubble.  This is the API-misuse signal; catching it would mask
   the misuse.
6. **In all other cases, let exceptions bubble.**

The third special exception type — `ConfigurationError` — has
similar must-bubble semantics in most contexts; chapter 3
catalogues all three.

The principle is *not* "exceptions are good."  The principle is
*errors that fail loudly are easier to debug than errors that
fail silently*.  When the code can't recover from an exception,
silently absorbing it just makes the failure harder to find later.
When the code *can* recover, it's the code's responsibility to do
so explicitly — and to log what it recovered from.

## Stability is more important than performance

Most automation code is not in a hot loop.  Most automation code
is glue: it runs a step, waits for the step to finish, processes
the result, runs the next step.  The CPU is bored; the network
or the subprocess is the bottleneck.

When automation code is the hot loop — when a property test runs
a thousand cases, when a packet inspector evaluates millions of
rules, when a deserialiser parses a multi-gigabyte JSOS stream —
*then* performance matters.  But for most code in the portfolio,
performance is *not* the constraint.  Stability is.

Stability has specific concrete consequences:

### No clever one-liners

A list comprehension that produces three values in three nested
generators saves a few lines of code.  The cost shows up the
first time someone has to debug why a particular item didn't
make it through the chain.  Expanding to intermediate variables
costs nothing at runtime; it costs a small amount of
readability *gain* over the lifetime of the codebase.

### No micro-optimisation that costs explicitness

Premature optimisation is the canonical example.  In the
portfolio, the principle goes further: *even when the
optimisation is correct*, if it costs explicitness, the
philosophy chooses explicitness.  A `dict[key]` that could be a
`getattr(obj, key)` to save a hashtable lookup is not the right
trade.

### Stability across versions

When a function's signature changes between releases, every
caller has to update.  When the change is a *parameter rename* or
a *parameter reorder*, the named-parameter-passing discipline
above turns the change into a one-line update at each call site
plus a clear deprecation warning.  When the change is the
function's *return type*, the explicit return-and-assign
discipline (return values to local variables before returning)
makes the change visible in code review.

The portfolio's git history shows this discipline at work.  When
`codecraftai-errors` renamed `NotOverloadedError` to
`AbstractMethodError`, the impact across the portfolio was
mechanical: every import was updated, every raise site was
updated, no semantic change in any caller.  The named-import
discipline kept the change small.

## If the implementation is hard to explain, follow, or maintain, try another way

The last principle is the *meta-principle*: when applying the
others doesn't produce code that's clear, the answer is to back
up and try a different design rather than to push through.

This is the principle that catches *clever architecture*.  A
design with five layers of indirection where two would do.  A
factory that produces factories that produce factory-protocol
adapters.  An API that's "elegant" by some abstract metric but
that nobody can use without reading the source.

Each of these is the symptom of an engineering team falling in
love with a pattern at the expense of its users.  The philosophy
catches the pattern in code review, *before* it ships, by asking
the simple question: *can a new engineer read this and understand
what it does in five minutes?*

If the answer is no, the design is wrong.  Not partially wrong;
not "elegant but hard to learn"; not "powerful with a steep
curve" — *wrong*, in the sense of failing to serve the people who
will maintain it.  The right response is to back up and try
again.

## What this philosophy is *not*

Three things the philosophy explicitly does *not* say.

### Not "Python's PEP 8 verbatim"

PEP 8 is a style guide.  The philosophy is a *design discipline*.
PEP 8's rules about line length, naming, and whitespace are
enforced through linting; the discipline above is enforced through
code review.  The two layers complement each other; neither
replaces the other.

The portfolio uses standard Python tooling for PEP 8 — no special
formatter, no custom linter for whitespace.  The discipline above
is what's *additional*.

### Not "the Zen of Python verbatim"

The Zen of Python is a poem.  Several of its lines appear (in
spirit) in the philosophy above — *flat is better than nested*,
*sparse is better than dense*, *explicit is better than implicit*.
But the philosophy adds rules the Zen doesn't have (debuggability
paramount, errors-never-silent, stability-over-performance) and
omits Zen lines that don't apply to automation code (*beautiful is
better than ugly* — automation code is rarely beautiful, and
trying to make it so is usually a mistake).

The Zen is foundational; the philosophy is *operational* — the
day-to-day rules that apply when writing automation code.

### Not "agree with everything or you can't contribute"

The philosophy is opinionated.  Reasonable engineers disagree
with parts of it — the explicit-`return` rule and the named-
parameter-passing rule both have detractors.  The philosophy is
not a litmus test; it's a *consistent set of choices that produce
consistent outcomes*.  An engineer who disagrees with one rule
but accepts the rest can absolutely contribute; the project's
code review process resolves the local disagreement against the
project's chosen direction.

What the philosophy *won't* tolerate is *inconsistent application*.
A codebase where some functions use named parameters and others
don't, where some functions end with `return` and others don't,
where some exceptions are swallowed and others aren't — that's a
codebase where the discipline has been broken without being
replaced.  Either follow the philosophy or replace it; don't half-
follow it.

## The meta-thesis: framework as skeleton and patterns

Beyond the ten engineering principles there is a single
load-bearing design choice that shapes the entire portfolio.
Every later volume references it and every chapter that says
"this is left to the user" is an instance of it:

> *The test framework just sets up the skeleton and patterns;
> the organization customises and refines them to meet the needs
> of the automation scenarios.*

This is the *meta-thesis* of the `codecraftai-*` portfolio.  It
explains why so much of the framework's surface is "left to the
organisation":

- Topology schemas — the framework supplies the loader and one
  canonical shape (`clusters` with `name` / `group` / `nodes` /
  `spares`); the organisation supplies the rest by subclassing
  `Topology`.
- Feature handling on devices — the framework dispatches a small
  set of built-in Features; the organisation adds the rest by
  subclassing `Landscape`.
- Verification logic — the framework supplies hooks
  (`validate_topology` and friends); the organisation
  implements the project-specific verification.
- Runtime profiles — the framework supplies the composition
  machinery (the `MergeMap` overlay covered in chapter 9); the
  organisation defines the actual profiles (`Normal`, `Debug`,
  `UPNP Diagnostics`, `Bluetooth Diagnostics`, etc.).
- Coordinators / Agents / ProtocolExtensions — the framework
  supplies built-ins for common protocols (SSH, UPnP, Serial,
  Power) and the *pattern* for adding more; the organisation
  adds the protocols their automation actually needs.
- Tandem validators — the framework supplies the `Validator`
  base class (in
  `codecraftai-testbase/source/packages/ccai/testbase/tandem/`)
  and the attachment mechanism; the organisation writes the
  validators that watch the system states their tests care
  about.

What the framework *is* opinionated about (the skeleton):

- The `TestJob` lifecycle and its five `fire_*` hooks.
- The single-actor + multi-observer determinism model
  (Volume 1 chapter 1; Volume 2 chapter 2).
- The Pod-as-unit-of-parallelism principle (Volume 2 chapter 3).
- The six configuration categories (Specifications, Landscapes,
  Topologies, Runtimes, Credentials, Data Profiles), all
  uniformly composable via `MergeMap` (chapter 9 of this volume,
  Volume 3 part II).
- The CI hand-off as a *verified contract* — Landscape
  reachability + Topology configured-state checked before the
  first test runs (Volume 3 chapter 21).
- The JSOS streaming results format and the `agents` CLI shape
  (Volume 1 chapters 5 and 6).
- The `arcli` decorator surface for command-line tooling
  (Volume 1 chapter 8).
- The Aspects pattern for behaviour parameterisation (Volume 5
  chapter 1).

What the framework is *not* opinionated about (left to the
organisation):

- The shape of any individual Topology beyond the canonical
  `clusters` form.
- Which Features any given device has.
- What the organisation's validators check for.
- What the organisation's runtime profiles do.
- What protocols the organisation needs Agents for.
- What the organisation's Specifications declare to provision.

### How the customisation actually plugs in

The concrete mechanism: the user subclasses `TestJob` (or one
of its descendants — `InteropLibraryTestJob`,
`DistributedTestJob`).  The subclass overrides the template
methods that return the user's `Landscape` subclass, the user's
`Topology` subclass, the user's sequencer, and so on.  The
framework's entry points (`generic_test_entrypoint(job_type=...)`
and `distributed_test_entrypoint(job_type=...)`) accept the
user's job class via the `job_type=` parameter.

That single chain — `TestJob` subclass → custom `Landscape` /
`Topology` returned by template methods → custom Features and
schemas dispatched inside those subclasses — is the entire
customisation contract.  Volume 2 chapter 4 walks the
inheritance hierarchy in detail; Volume 6 walks Landscape and
Topology subclassing as it applies to the environment model.

### Why the meta-thesis matters

Without this framing, every "left to the user" note in every
chapter reads as *gap*.  With it, every "left to the user" note
reads as *intentional design*.  The framework is *opinionated
about patterns and unopinionated about domain*, and that
distinction is the design choice that makes the portfolio
coherent across automation scenarios as different as a
single-host unit suite, a 12-host distributed integration suite,
a cloud-provisioned reference platform, and an embedded
firmware test rig.

This series treats every "left to the user" as a feature.  When
a chapter says "users implement this by subclassing X," the
implication is *this is exactly where the framework expects you
to specialise*, not *this is where the framework stops short*.

## How the philosophy shapes the rest of the series

Every later chapter of this volume — and every later volume —
applies the philosophy's principles to a specific area of code.
A few preview connections:

- **Chapter 3 (Error vocabulary)** is the philosophy's *errors-
  never-silent* principle made operational with specific
  exception classes.
- **Chapter 4 (Interfaces and contracts)** is the philosophy's
  *explicit-is-better-than-implicit* principle made operational
  through abstract base classes that name what implementers must
  provide.
- **Chapter 6 (Application identity and presence)** is the
  philosophy's *debuggability-paramount* principle made
  operational through the `ThisApplication` machinery that gives
  every running process a structured identity.
- **Volume 3 chapter 7 (Runtime files vs environment variables)**
  is the philosophy's *explicit-is-better-than-implicit* principle
  applied at the orchestration-to-job seam.
- **Volume 5 chapter 1 (The Aspects pattern)** is the
  philosophy's *stability-over-performance* and *if-it's-hard-to-
  follow-try-another-way* principles applied to behaviour-knob
  parameterisation.

The principles are not used as decoration.  They drive the
specific design decisions in every chapter that follows.  When
you find yourself reading a later chapter and wondering "why is
the code shaped this way?" — the answer is in this chapter.

## Summary

- The CodeCraftX philosophy is **ten engineering principles**
  that govern the `codecraftai-*` portfolio.  Each principle has
  concrete code-level consequences enforced through code review.
- **Debuggability is paramount** is the most important principle.
  When two principles conflict, debuggability wins.  Every choice
  the philosophy makes for debuggability has a cost paid in
  verbosity; the cost is paid once per function written, and the
  benefit is paid every time the function is debugged.
- **Explicit is better than implicit** turns into specific rules:
  no mutable defaults, named parameter passing, explicit `return`
  statements.  Each rule trades a few characters of verbosity for
  refactor safety and readability.
- **Errors should never pass silently** turns into the six-rule
  exception-handling discipline of chapter 3.  The principle is
  not "exceptions are good"; it's "errors that fail loudly are
  easier to debug than errors that fail silently."
- **Stability is more important than performance** for code that
  isn't in a hot loop.  Most automation code isn't.  When the
  code is the hot loop, the principle inverts; the philosophy
  acknowledges this.
- **The meta-principle**: if applying the others doesn't produce
  clear code, back up and try a different design.  This catches
  clever architecture before it ships.
- The philosophy is not PEP 8, not the Zen of Python verbatim,
  and not a litmus test.  It is a *consistent set of opinionated
  choices*; the consistency is what produces a portfolio whose
  conventions transfer across packages.
- The narrator of the series — **CodeCraftX** — is the persona
  that consistently applies the philosophy.  The persona's voice
  carries the conventions; the conventions carry the platform.

The next chapter takes the philosophy's principles and turns them
into the *specific coding standards* every package in the
portfolio enforces.  Where this chapter said *what the philosophy
is*, the next chapter says *what the philosophy looks like in
code that ships*.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/02-coding-standards-as-a-tool -->
# Chapter 2 — Coding Standards as a Tool, Not a Style Guide

> *A style guide tells you how to format your code.  A coding
> standard tells you how to engineer it.  This chapter is about
> the second.*

The previous chapter named ten engineering principles.  This
chapter takes those principles and turns them into the specific
*rules* every package in the foundation tier follows.  Each rule
has a *reason*.  The reason is the chapter's central concern;
the rules without the reasons are arbitrary, and arbitrary rules
get worked around.

The canonical source of these rules is the
`CLAUDE.md` file kept at the root of the engineering
workspace, outside any one repository.  The file's job is to tell automated coding
assistants what the project's conventions are; the same file
serves equally well as the human-readable reference for the
team.  This chapter unpacks each rule with the rationale and a
worked example.

## Formatting rules with engineering reasons

The first family of rules looks like style preferences but isn't.
Each one buys something specific.

### No semicolons to terminate or join lines

Python permits semicolons; the convention forbids them.  The
reason is debuggability: a line that uses semicolons to chain
statements (`x = 1; y = 2; z = x + y`) is one source-line that
hits multiple bytecode instructions.  When the debugger breaks at
that line, it stops at the first instruction; stepping doesn't
take you to the next statement on the same line — it takes you to
*the next line*.

Splitting `x = 1; y = 2; z = x + y` into three separate lines
gives the debugger three breakpoint anchors.  This is a small
example of debuggability-paramount; the cost is one newline per
statement, the benefit is one breakpoint per statement.

### `snake_case` for functions and methods

The convention is consistent with PEP 8.  The reason is *machine
readability* — when the codebase is grepped for symbol names
across packages, the convention tells the reader whether a name is
a function (`run_command`), a class (`RunCommand`), or a module
(`run_command.py`).  No string can be ambiguous about its kind.

### Type hints on every parameter and return

```python
# Required
def parse_workflow(workflow_stream: TextIOWrapper) -> Workflow:
    ...

# Forbidden
def parse_workflow(workflow_stream):
    ...
```

The discipline is universal: every parameter and every return,
including for private helpers.  The reason is twofold:

- **Static analysis** catches type mismatches before they reach
  runtime.  The IDE flags the call site that passes the wrong
  type; the linter flags the function that returns the wrong
  shape.
- **Documentation** comes for free.  A reader of an unfamiliar
  function knows what it expects and what it produces from the
  signature alone, without having to read the body.

### `self` and `cls` discipline

Instance methods take `self` as the first argument; class methods
take `cls`.  The convention is consistent with Python's standard
naming.  The reason is the *constraint satisfier* role from
chapter 1 — when the codebase is uniform on this point, a method's
binding is unambiguous from the first parameter.

## Return statement rules

The `return` discipline is one of the most distinctive parts of
the convention.  It has four specific rules.

### Every function and method ends with `return`

```python
def configure_logger(logger: Logger) -> None:
    logger.setLevel(logging.INFO)
    logger.addHandler(StreamHandler())
    return
```

The `return` is required even when the function returns `None`.
Three reasons (the chapter-1 preview develops these in detail):

1. **Debugger breakpoint anchor.**  The Python interpreter
   generates a return-bytecode whether or not the source has the
   keyword.  An explicit `return` lets the debugger attach a
   breakpoint at a *named line*, which inspects every local
   variable in the function before exit.  Without the keyword,
   the breakpoint lands on the closing brace's invisible
   bytecode location, which most debuggers handle awkwardly.
2. **Refactor safety.**  When a function is deleted (or a
   `def` line is removed during refactoring), its trailing
   `return` becomes *unreachable code* in the next function down.
   Linters flag unreachable code; the bug is caught.  Without the
   trailing `return`, the orphaned function body silently merges
   with the next function's body, and the bug ships.
3. **Function-end visibility.**  `return` at column zero
   (after dedent) is a clear delimiter — the reader knows the
   function ends here.

### Assign return values to a local variable before returning

```python
# Discouraged
def get_user_count(query: Query) -> int:
    return query.execute().scalar() + 1

# Preferred
def get_user_count(query: Query) -> int:
    raw_count = query.execute().scalar()
    user_count = raw_count + 1
    return user_count
```

The local variable serves two purposes.  At debug time, the
debugger can break on the `return` and inspect `user_count` to
see what's about to be returned.  At maintenance time, the
intermediate name documents the calculation — `raw_count + 1` is
opaque without the variable name; `user_count = raw_count + 1`
explains the offset's meaning.

### No compound `return` statements

```python
# Forbidden
def find_or_default(key: str, registry: Dict[str, Any]) -> Any:
    return registry.get(key, default_for_key(key))

# Required
def find_or_default(key: str, registry: Dict[str, Any]) -> Any:
    if key in registry:
        return registry[key]

    default_value = default_for_key(key)
    return default_value
```

The forbidden form has *two* function calls and a conditional all
on one line.  Setting a breakpoint on it gives no fine-grained
control.  The required form gives the engineer three breakpoint
anchors — the `if`, the first `return`, and the second `return` —
and makes the conditional logic explicit.

### Standalone `return` for void functions

```python
def install_logger(logger: Logger) -> None:
    logger.addHandler(StreamHandler())
    return     # not `return None`; just `return`
```

When the function returns nothing meaningful, the `return`
statement is bare.  This is a convention rather than a
correctness issue (the two forms are equivalent), but the bare
form distinguishes void functions visually from functions that
happen to return `None` as a sentinel.

## No mutable defaults

```python
# Forbidden — every call shares the same list
def add_item(item: str, items: List[str] = []) -> List[str]:
    items.append(item)
    return items

# Required
def add_item(item: str, items: Optional[List[str]] = None) -> List[str]:
    if items is None:
        items = []
    items.append(item)
    return items
```

The forbidden form has a default argument that is *evaluated
once at function definition time*.  Every call that doesn't
override `items` shares the same list — an action-at-a-distance
bug that's easy to introduce and hard to catch.

The required form takes `None` as the default and creates the
mutable inside the body.  Every call gets a fresh list when no
list is passed.  The verbosity is two extra lines; the safety is
permanent.

## Class and file naming

The portfolio's class-and-file-naming convention is opinionated:

```
# File: someclass.py
class SomeClass:
    pass
```

The class name is `PascalCase` with no underscores.  The file
name is the class name lowercased, also without underscores.  One
class per file, where the class is the file's reason for existing.

The reason: navigation.  When an engineer reads `from
ccai.taskplus.tasks.runcommand import RunCommand`, the path
`ccai/taskplus/tasks/runcommand.py` is the file's location
mechanically.  No mental translation, no searching, no
case-folding rules to remember.

The convention has consequences.  When a module needs five small
classes that belong together, they don't all live in one file —
they live in five files in the same package, with `__init__.py`
re-exporting them as needed.  This is *more* files than a less
disciplined codebase, by design; the navigation gain is worth the
file count.

## Class-variable initialisation in `__init__`

```python
class Workflow:

    def __init__(self, environment: Dict[str, str], parameters: Dict[str, Any], flow: List[FlowNode]):
        self._environment = environment
        self._parameters = parameters
        self._flow = flow
        return
```

Every instance variable is initialised in `__init__`, and *only*
in `__init__`.  Adding a `self.foo = bar` in any other method
violates the rule.

The reason: when the constructor lists every instance variable in
one place, a reader can see the class's full state in one view.
When variables are introduced in random methods, the reader has
to walk the whole class to know what state exists.  The first
pattern is *predictable*; the second is *archaeology*.

## Decision expressions

Two specific rules govern conditional expressions.

### No shortcut truthy/falsy checks

```python
# Forbidden
if some_list:
    # ...

# Required
if some_list is not None and len(some_list) > 0:
    # ...
```

The forbidden form mashes two conditions ("not None" and "not
empty") into one.  When the variable is `None`, the test is
False; when the variable is `[]`, the test is also False; when
the variable is `[0]`, the test is True.  These three cases have
different semantics and probably want different handling — but
the shortcut hides them.

The required form makes the two checks explicit.  When the
variable is `None`, the first clause short-circuits.  When the
variable is empty, the second clause does.  When the variable has
content, both pass.  Each branch can have its own error message,
its own logging, its own recovery path.

### Separate error conditions, separate handling

```python
# Forbidden
if name is None or len(name) == 0 or name in BLACKLIST:
    raise ConfigurationError("Name is invalid.")

# Required
if name is None:
    raise ConfigurationError("The 'name' parameter is required.")
if len(name) == 0:
    raise ConfigurationError("The 'name' parameter cannot be empty.")
if name in BLACKLIST:
    raise ConfigurationError("The name '%s' is blacklisted." % name)
```

The forbidden form gives the same error message regardless of
which condition triggered.  When the user receives "Name is
invalid," they don't know whether to fix a missing parameter, an
empty string, or a blacklist violation.

The required form gives a *specific* error per condition.  The
caller's mental model snaps to the right fix immediately.  This
is the *errors-never-silent* principle from chapter 1 made
operational at the validation layer.

## Decorator usage

Decorators are *only* allowed for two purposes:

1. **Adding metadata.**  `@priority(1)`, `@categorize("integration")`,
   `@deprecated(since="2.0")`.  The decorator attaches a value or
   tag to the function; the function's behaviour is unchanged.
2. **Registering with a mapping.**  `@app.route("/")`,
   `@click.command()`, `@register_handler("user_login")`.  The
   decorator adds the function to a registry; the function's body
   is otherwise untouched.

Decorators are *not* allowed for:

- **Behaviour modification.**  No `@retry`, no `@timeout`, no
  `@cache`, no `@log_calls`.  Behaviour modification belongs in
  parameters or in wrapping objects (see Volume 5's Aspects
  pattern), not in decorators.
- **Cross-cutting concerns.**  No `@requires_auth`, no
  `@validate_input`, no `@audit_log`.  These belong in explicit
  middleware or in the function's body.

The reason: behaviour-modifying decorators *hide* control flow.
A function decorated with `@retry(3)` doesn't run once when the
caller invokes it; it runs up to three times, with delays in
between, with exception swallowing built in.  The caller reading
the call site has no visual cue that any of this is happening.
The same behaviour expressed as an explicit `aspects=Aspects(retry=3)`
parameter is *visible* at the call site; the reader knows what
they're getting.

This is the *explicit-is-better-than-implicit* principle from
chapter 1 made operational at the decorator boundary.  Volume 5
develops the Aspects pattern as the affirmative answer for
behaviour-knob parameterisation.

## Documentation comments

The convention is Sphinx-style triple-quoted docstrings:

```python
def some_function(a: int, b: str) -> str:
    """
        This is a test function.

        :param a: The value for 'a'.
        :param b: The value for 'b'.

        :returns: The word 'blah' in a string.
        :raises: ValueError
    """
    if a <= 0:
        raise ValueError("'a' must be greater than 0")

    rtnval = "blah"
    return rtnval
```

The shape:

- Multi-line triple-quote, even for single-line descriptions.
- Description starts on the *second* line.  The first line is
  whitespace.
- Body indented four spaces inside the opening triple quote.
- `:param name:` for parameters.  No type in the docstring (the
  type hint carries it).
- `:returns:` for the return.  No type in the docstring.
- `:raises:` for the exception types the function can raise.

The shape produces parseable docstrings — Sphinx's `autodoc` and
the IDE's hover-help both render these correctly.  The
type-information-in-hints discipline avoids duplicating the type
in the docstring (where it would drift from the signature).

## Exception handling

The exception-handling discipline has six rules covered briefly
in chapter 1; chapter 3 develops them in full.  As a preview:

1. Don't handle exceptions you can't recover from.
2. Catch and reclassify to add specific contextual information.
3. Catch and reclassify to improve code-context in failure
   reports.
4. Catch and add extra contextual information about unique
   circumstances.
5. `SemanticError` must NEVER be caught.  Same for
   `ConfigurationError` in most contexts.
6. In all other cases, let exceptions bubble.

The discipline is *not* "exceptions are good" or "exceptions are
bad."  It is *errors that fail loudly are easier to debug than
errors that fail silently*, applied at the catch boundary.

## Multi-line error messages

Long error messages are constructed as a list of lines, then
joined:

```python
err_msg_lines = ["Failed to find expected devices."]
err_msg_lines.append("EXPECTED: ({})".format(len(expected)))
for key in expected:
    err_msg_lines.append("    {}:".format(key))

err_msg = os.linesep.join(err_msg_lines)
raise TimeoutError(err_msg) from None
```

Three reasons:

- **Cross-platform line endings.**  `os.linesep` is `\r\n` on
  Windows and `\n` on Unix.  Joining with the constant
  produces correct line endings on every platform without the
  author having to think about it.
- **Composability.**  Building the list with conditionals —
  appending different sections based on what context is
  available — is straightforward.  Building the same string with
  `+=` and embedded newlines is brittle.
- **Readability.**  The list-of-lines form makes the error
  message's *structure* visible in code: a header line, a
  count, an indented per-item block, a footer.  The string-
  concatenation form hides the structure.

## InterOp API design — the Aspects pattern

For APIs that interact with remote systems (SSH, REST,
filesystem operations), the discipline forbids behaviour-modifying
decorators (rule above) and forbids hardcoded retry/timeout
behaviour.  Instead, the API takes an `Aspects` object as a
parameter:

```python
def run_command(command: str, *, aspects: Aspects = None) -> CommandResult:
    if aspects is None:
        aspects = DEFAULT_ASPECTS
    # ... use aspects.timeout, aspects.retry_count, aspects.retry_backoff_ms ...
```

The `Aspects` object packages every behaviour parameter the API
might want — timeout, retry count, retry backoff, logging level,
sync vs async — into one scalable parameter.  Adding a new
behaviour knob is one field in the `Aspects` class; existing
callers don't need to know about it.

Volume 5 chapter 1 develops the Aspects pattern in full,
including the read-only-vs-state-modifying API design heuristic
(read-only APIs typically don't need Aspects; state-modifying
APIs typically do).  Chapter 1 of this volume previewed the
pattern; chapter 23 of Volume 3 used it for the Retry wrapper
task.  The discipline is the same across volumes.

## Properties as simple data accessors

```python
# Required
@property
def host(self) -> str:
    return self._host

# Forbidden
@property
def host(self) -> str:
    if self._host is None:
        self._host = self._connection.lookup_host()
    return self._host
```

A property is a typed read of a stored value.  It is *not* a
hook for lazy initialisation, side-effecting computation, or any
operation that might block.

The reason: properties look like attribute access at the call
site.  When `obj.host` triggers a 30-second SSH connection, the
caller has no visual cue that any work is happening.  Worse,
when `obj.host` is read inside an `__init__` of a related class,
the lazy initialisation can produce circular dependencies that
are nearly impossible to debug.

When lazy initialisation is genuinely needed, the right shape is
an explicit method (`get_host()`) with the work named.  When
caching is needed, a method called `lookup_host()` that caches
internally is fine — the call site sees a method invocation and
knows that work might be happening.

## Coding standards across volumes

The standards in this chapter apply to every volume in the
series.  Specific applications:

- **Volume 1 (Testing)** uses the `assert_*` family of
  verification functions instead of bare Python `assert`
  statements; the explicit-is-better discipline shows up in test
  assertions.
- **Volume 3 (Orchestration)** uses the no-mutable-defaults rule
  in workflow YAML loading; the `parse` classmethod doesn't have
  `Workflow=Workflow()` defaults.
- **Volume 5 (Interop)** is the affirmative-Aspects volume; the
  decorator discipline of this chapter justifies the Aspects
  pattern's existence.

When you read code in any volume of the series and find yourself
asking "why does it look like this?" — chapter 1 is the
philosophy; this chapter is the rules; the next chapter is the
error vocabulary that makes the *errors-never-silent* principle
operational.

## Summary

- The standards in this chapter are **rules with reasons**.  Each
  rule has a debuggability, refactor-safety, or
  explicit-is-better justification.  The reasons matter; rules
  without reasons get worked around.
- **Formatting rules**: no semicolons, snake_case functions, type
  hints everywhere, `self`/`cls` discipline.  Each one buys
  something specific.
- **Return statement rules**: every function ends with `return`,
  return values assigned to local variables before returning, no
  compound returns, bare `return` for void functions.  The
  rationale is debuggability + refactor safety + function-end
  visibility.
- **No mutable defaults**: prevents action-at-a-distance bugs
  from shared mutable state across calls.  Two extra lines per
  function with a default; permanent safety.
- **Class-and-file naming**: PascalCase classes; lowercase-no-
  underscores file names matching the class.  One class per file.
  More files than a less-disciplined codebase, by design.
- **No shortcut truthy/falsy checks**.  `if some_list is not None
  and len(some_list) > 0` makes both conditions explicit; each
  can have its own error message and recovery path.
- **Decorators only for metadata or registration**.  Behaviour-
  modifying decorators are forbidden; behaviour parameters live
  in `Aspects` objects.
- **Sphinx-style docstrings** on every public function, with
  `:param:` / `:returns:` / `:raises:` and no type duplication.
- **Properties as simple data accessors only**.  Lazy init,
  side-effecting computation, and blocking operations belong in
  explicit methods, not properties.
- **Multi-line error messages** built from a list joined with
  `os.linesep`.  Cross-platform, composable, readable.

The next chapter develops the error vocabulary that makes the
*errors-never-silent* principle operational.  Three special
exception types — `SemanticError`, `ConfigurationError`,
`AssertionError` — have specific must-bubble or must-catch
semantics that the rest of the portfolio depends on.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/03-the-error-vocabulary -->
# Chapter 3 — The Error Vocabulary

> *Errors that fail loudly are easier to debug than errors that
> fail silently.  An error vocabulary turns that principle into a
> menu of typed exceptions, each with a specific meaning and a
> specific must-bubble or must-handle status.*

The previous chapter established the coding standards.  This
chapter develops one of them in depth: the exception-handling
discipline.  The discipline rests on
[`ccai.errors`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/errors/exceptions.py)
— a small subpackage of `codecraftai-foundations` whose job is to
provide the **typed exception
vocabulary** every other ccai package uses.

The package's contents are simple in shape but consequential in
meaning.  Eighty exception classes; most are RuntimeError
subclasses; three are deliberately *not* — `SemanticError` and
`ConfigurationError` inherit from `BaseException` directly, and
`AbstractMethodError` from `RuntimeError`.  The inheritance
choices encode the must-bubble and must-handle decisions that
the rest of the portfolio relies on.

## The three special types

Three exception types in the vocabulary have semantics that
matter universally across the portfolio.  Every package
documents them; every package's exception handling respects
them.  This section is the canonical statement of what each
means.

### `SemanticError(BaseException)` — must always bubble

```python
class SemanticError(BaseException):
    """
        The base error object for errors that indicate that there is an issue with
        a piece of automation code and with the way the Automation Kit code is being
        utilized.
    """
```

`SemanticError` signals **API misuse**.  When code raises it, the
caller has invoked the API in a way the API can't make sense of —
passed contradictory parameters, called a method out of order,
constructed an object with mutually-exclusive options.  These are
*the caller's bugs*, and they need to surface to the caller.

The inheritance is deliberate: `BaseException`, not `Exception`.
This means `except Exception:` *does not catch*
`SemanticError`.  A bare `except Exception:` block — even a
broad recovery handler in well-meaning library code — does not
silently absorb a SemanticError.  The error bubbles up through
every Exception-targeted handler and reaches the top of the
stack, where the developer who made the API-misuse mistake sees
the failure with full context.

The discipline:

- **Code may not catch `SemanticError`.**  Ever.  The only
  legitimate handler is the *outermost* layer of the
  application, where the misuse is logged and the process exits.
- **Code may raise `SemanticError`** when the caller has
  violated the API's contract in a way the API can detect.

Concrete example.  Consider a credential class that requires
*either* a password or a keyfile but not both:

```python
class SshCredential(BaseCredential):

    def __init__(self, *, identifier: str, password: Optional[str] = None,
                 keyfile: Optional[str] = None):
        super().__init__(identifier=identifier)

        if password is not None and keyfile is not None:
            raise SemanticError(
                "SshCredential accepts password OR keyfile, not both."
            )

        if password is None and keyfile is None:
            raise SemanticError(
                "SshCredential requires one of: password, keyfile."
            )

        self._password = password
        self._keyfile = keyfile
        return
```

A caller who constructs `SshCredential(identifier="x",
password="pw", keyfile="/path")` has misused the API.  The
SemanticError fires; the constructor's caller sees the failure;
the caller fixes the misuse.  No layer of recovery code in
between gets the chance to swallow it.

### `ConfigurationError(BaseException)` — must bubble in most contexts

```python
class ConfigurationError(BaseException):
    """
        The base error object for errors that indicate that there is an issue related
        to improper configuration.
    """
```

`ConfigurationError` signals **configuration problems** — a
landscape file that's missing a required field, a credential
identifier that doesn't resolve, a runtime YAML with a
malformed entry.  Like `SemanticError`, it inherits from
`BaseException` so generic exception handlers don't silently
absorb it.

The semantics are slightly more nuanced than SemanticError's:

- **Library code may catch `ConfigurationError`** to *augment*
  the message and re-raise.  When a configuration problem
  surfaces three layers deep in a loader, the layer that knows
  about the source URI can catch the error, append its
  context, and re-raise:

  ```python
  try:
      payload = self._parse_yaml(content)
  except ConfigurationError as err:
      errmsg = "Error parsing %s: %s" % (self._source_uri, err)
      raise ConfigurationError(errmsg) from err
  ```

  The original exception is preserved as the cause (`from err`);
  the new message adds the source URI; the caller receives a
  more informative error.

- **Library code may not catch `ConfigurationError` to absorb
  it.**  When the library can't recover from the configuration
  problem, the error bubbles.  The application's top-level
  handler reports the configuration issue and exits cleanly.

The two specific subclasses in the package make finer
distinctions:

- `MissingConfigurationError` — a required field or file is
  missing.
- `InvalidConfigurationError` — a field is present but holds an
  unusable value.

Both inherit from `ConfigurationError`, so the must-bubble
semantics apply to both.  Library code that needs to react to
"missing vs invalid" specifically can catch the subclass; library
code that just wants to know "configuration is wrong" catches
the base.

### `AssertionError` — built-in, but with discipline

`AssertionError` is Python's built-in.  The convention treats it
as the *condition or state failure in the target under test* —
the check that fires when the system isn't behaving as expected.

The discipline:

- **Test code raises `AssertionError`** through the
  `verification.assert_*` family or through bare `assert`
  statements.  The error propagates to the test runner, which
  reports the failure.
- **Library code does *not* raise `AssertionError`** for
  invariant violations.  Use `SemanticError` for "the caller
  used the API wrong" or `RuntimeError` (or a more specific
  subclass) for "something unexpected happened."  Reserving
  `AssertionError` for tests keeps the test framework's
  reporting clean.

## The other categories

The package's remaining ~80 exception classes fall into seven
operational categories.  Each is a `RuntimeError` subclass (or
inherits from a more specific built-in like `TimeoutError`),
which means generic `except Exception:` handlers *do* catch
them when appropriate.

### Command and process

- **`CommandError`** — a wrapped subprocess command returned a
  non-zero exit code.  The exception carries the `status`,
  `stdout`, and `stderr` of the failed command, so the handler
  has the operational context to diagnose.
- **`UnExpectedSessionClosureError`** — an SSH or similar
  session closed without explicit close.

### Resource lifecycle

- **`CheckinError`** — a resource checkin failed.
- **`CheckoutError`** — a resource checkout failed (chapter 6 of
  Volume 6 develops this in the landscape context).
- **`PublishError`** — publishing an artifact failed.

### Flow control

- **`CancelledError`** — an operation was cancelled.  Three
  subclasses with finer meaning: `TaskingCancelled` for
  task-level cancellation, `TaskingGroupCancelled` for
  group-level.
- **`HaltError`** — a child flow-control context asks the
  parent to halt.
- **`SkipError`** — a child flow-control context (typically a
  test case) asks to be skipped.

### Tasking and looping

- **`LooperError`** — generic looper-pool error.
- **`LooperQueueShutdownError`** — work queued on a shut-down
  queue, or a worker waiting on an empty shut-down queue.
- **`TaskingGroupRuntimeError`** — runtime error inside a
  task-group execution.
- **`TaskingGroupAssertionError`** — an assertion failed inside
  a task group.  Inherits from `AssertionError` (deliberate
  exception to the "library code doesn't raise AssertionError"
  rule because tasking groups *are* test-running code).

### Override / abstract method

- **`AbstractMethodError`** — an abstract method was called
  without being overridden.  Used by abstract base classes when
  the base provides a "you must override" stub:

  ```python
  class Task(FlowNode):

      @abstractmethod
      def perform_task(self, environment, parameters, flowstate) -> int:
          raise AbstractMethodError(
              "Task->perform_task must be overloaded by derived classes."
          ) from None
  ```

- **`NotSupportedError`** — a method that exists is not
  supported in the current context (different from "must be
  overridden").
- **`UnSupportedPlatformError`** — the current platform doesn't
  support the operation (e.g., a macOS-specific call invoked on
  Linux).
- **`UnknownParameterError`** — an unknown keyword argument was
  passed.

### Timeout

Three subclasses of Python's built-in `TimeoutError`, each
naming a specific timeout shape:

- **`CompletionTimeout`** — an operation didn't complete in the
  allotted time.
- **`ConnectionTimeout`** — a connection attempt didn't succeed
  in the allotted time.
- **`InActivityTimeout`** — an operation produced no progress
  for too long (different from "didn't complete" — the operation
  may still be making progress, but in a different dimension).

### Command-line errors

A family of `CommandlineError` subclasses for argparse-style
parsing problems: `BadOptionUsageError`, `BadParameterError`,
`MissingArgumentError`, `MissingOptionError`, `NoSuchOptionError`,
`TooManyArgumentsError`.

### HTTP errors

The largest sub-family — roughly 50 classes — mirroring HTTP
status codes.  Three top-level categories
(`HttpResponse3xxError`, `HttpResponse4xxError`,
`HttpResponse5xxError`) each have specific subclasses for the
common status codes (`HttpNotFoundError`,
`HttpUnAuthorizedError`, `HttpInternalServerError`, etc.).

The discipline:

- **HTTP-aware code may catch the specific subclass** when it
  has a recovery path (retry on a 503, return cached result on
  a 404, refresh token on a 401).
- **HTTP-aware code may catch the family base** (e.g.,
  `HttpResponse5xxError`) when it wants to handle all
  server-side errors uniformly.
- **HTTP-unaware code lets them bubble.**  They are
  `HTTPException` subclasses; generic exception handlers see
  them.

The fine-grained vocabulary is what makes per-status-code
recovery practical without a regex over the message text.

## The six exception-handling rules

Chapter 1 previewed the six rules; chapter 2 mentioned them; this
chapter is the canonical statement.

### Rule 1: Don't handle exceptions you can't recover from

```python
# Forbidden
try:
    result = fetch_remote_value()
except Exception:
    pass    # silently absorbed — the result is now undefined
```

The pattern silently produces an undefined value.  When the
caller eventually uses it, the failure is far from the cause,
and the engineer debugging has no breadcrumb.  The discipline:
*don't catch what you can't handle*.  Let the exception bubble;
the caller gets to decide.

### Rule 2: Catch and reclassify to add specific contextual information

```python
# Required, when the catching layer has context the inner layer doesn't
try:
    payload = self._parse_yaml(content)
except yaml.YAMLError as err:
    errmsg = "Failed to parse landscape file %s." % self._source_path
    raise InvalidConfigurationError(errmsg) from err
```

The catching layer knows the *source path*; the inner layer
doesn't.  The catch reclassifies the generic `yaml.YAMLError`
into a specific `InvalidConfigurationError` with the path
embedded.  The original exception is preserved as `__cause__`
through `from err`.

### Rule 3: Catch and reclassify to improve code-context in failure reports

```python
# Required, when the catching layer can name *what it was trying to do*
try:
    cluster = self._build_cluster(name=name, members=members)
except KeyError as err:
    errmsg = "Building cluster %r failed: missing landscape member %s." % (name, err)
    raise ConfigurationError(errmsg) from err
```

A `KeyError` from deep inside the dispatch tree is hard to
attribute.  Reclassifying it at the layer that *named the
operation* turns the failure into a clear "I was building
cluster X and member Y was missing."

### Rule 4: Catch and add extra contextual information

The variant where the original exception type is preserved but
the *message* is enriched:

```python
try:
    self._engine.connect()
except ConnectionTimeout as err:
    err.add_note("Last successful connection: %s" % self._last_success)
    raise
```

(`add_note` is Python 3.11+; for earlier Pythons, the same
pattern uses `from` and a wrapping exception.)  The original
exception type is preserved, so callers' type-based handlers
still work; the message gains operational context.

### Rule 5: SemanticError must NEVER be caught

The discipline applies to library code, application code, and
test code uniformly.  A `try: ... except SemanticError:` block
*anywhere* is a violation.  The rationale (above): SemanticError
signals API misuse, and absorbing it lets the misuse continue
silently.

The same rule applies to `ConfigurationError` in most contexts —
the exception should reach the layer that *can act on* a
configuration problem (typically the application's top-level
handler that emits a clear error message and exits).  Library
layers may *augment* the error and re-raise (rule 2/3) but may
not absorb it.

### Rule 6: In all other cases, let exceptions bubble

The default is *no catching*.  When a function doesn't have a
specific recovery path, it doesn't have a `try`/`except`.  The
exception travels up the stack; some higher layer either has a
recovery or surfaces the failure to the user.

This is the *errors-never-silent* principle in action.  A
codebase whose default is "catch and recover" accumulates
recovery points that don't actually recover; the failures get
absorbed; the bugs ship.  A codebase whose default is "let it
bubble" produces clear failures that the engineer can diagnose.

## Why `BaseException` for SemanticError and ConfigurationError

Python's exception hierarchy:

```
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ArithmeticError, LookupError, OSError, RuntimeError, ValueError, ...
    └── (most user-defined exceptions)
```

The convention is that `Exception` is for application errors
that *can* be caught; `BaseException` is for system-level events
(`SystemExit`, `KeyboardInterrupt`) that *should not* be caught
by generic `except Exception:` handlers.

By inheriting `SemanticError` and `ConfigurationError` from
`BaseException` directly, the package opts them into the same
"don't catch generically" treatment.  A library wrapper with
`except Exception:` for legitimate recovery does *not* swallow a
SemanticError — the caller's API misuse still surfaces.

This is one of the portfolio's most consequential design
choices.  Most third-party libraries put their custom exceptions
in the Exception subtree; the ccai portfolio deliberately
lifts the must-bubble ones out.  It's a small choice in the
class definition, with a large consequence at every catch site
across every package.

## The vocabulary's role across the portfolio

Every other package in the foundation tier uses
`ccai.errors` for its exception vocabulary.  Specific uses:

- **Volume 0 chapter 4 (Interfaces)** uses
  `AbstractMethodError` to signal that an abstract method's stub
  was hit because no subclass overrode it.
- **Volume 0 chapter 5 (Waiting)** uses `CompletionTimeout`,
  `ConnectionTimeout`, `InActivityTimeout` for the three
  timeout shapes.
- **Volume 0 chapter 9 (Configuration loading)** uses
  `ConfigurationError`, `MissingConfigurationError`,
  `InvalidConfigurationError` for the load-time validation.
- **Volume 3 (every chapter)** uses the vocabulary uniformly for
  both library-side and application-side error reporting.

The vocabulary is *what makes the portfolio interoperate*.  When
two packages from different tiers want to talk about errors, they
talk in the shared language.  A landscape error in Volume 6 that
surfaces during a Volume 3 orchestration run is the same
exception type both packages understand.

## Summary

- **`ccai.errors`** is a small subpackage of `codecraftai-foundations` with ~80
  exception classes organised into seven operational categories
  plus three *special* types whose semantics matter universally.
- **`SemanticError(BaseException)`** signals API misuse.  Must
  always bubble.  Library code may not catch.  Inherits from
  `BaseException` so generic `except Exception:` does not
  swallow it.
- **`ConfigurationError(BaseException)`** signals configuration
  problems.  Library code may catch only to *augment* the
  message and re-raise.  Same `BaseException` inheritance for
  the same reason.
- **`AssertionError`** is Python's built-in.  The convention
  treats it as the test-framework's signal; library code does
  not raise it.
- The other 80 exceptions are organised by operational category:
  command/process, resource lifecycle, flow control, tasking,
  override/abstract, timeout, command-line, HTTP.  Each is a
  `RuntimeError` (or specific-built-in) subclass that *can* be
  caught when appropriate.
- The **six exception-handling rules** govern when to catch and
  when to bubble:
  1. Don't catch what you can't handle.
  2. Catch to add contextual information.
  3. Catch to improve code-context in reports.
  4. Catch to add operational context to existing errors.
  5. Never catch `SemanticError`.
  6. Default to letting exceptions bubble.
- The **`BaseException` choice** for `SemanticError` and
  `ConfigurationError` is the portfolio's most consequential
  exception-handling decision.  It's small in the class
  definition; large at every catch site.
- The vocabulary is what makes packages **interoperate**.  Two
  packages from different tiers talk about errors in the shared
  language.

The next chapter introduces interfaces and contracts — the
abstract-base-class machinery that lets packages declare what
implementers must provide without specifying how.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/04-interfaces-and-contracts -->
# Chapter 4 — Interfaces and Contracts

> *An interface is a promise about behaviour without a commitment
> to implementation.  The promise is the contract; the contract
> is what makes packages substitutable.*

The previous chapter named the typed exception vocabulary that
makes packages talk about errors in the same language.  This
chapter names the typed *interface* vocabulary that makes
packages substitute for each other without their callers
caring which implementation is on the other end.

The code is the
[`ccai.interfaces`](https://github.com/code-craft-ai-company/codecraftai-foundations/tree/main/source/packages/ccai/interfaces)
subpackage of `codecraftai-foundations`.
Eight files; eight protocols; one purpose: **shared contract
definitions that downstream packages implement and other
downstream packages consume.**  The package itself contains no
behaviour — only the typed shape of the behaviour.

## Why protocols, not abstract base classes

Python has two main mechanisms for declaring interfaces:

- **Abstract base classes** (`abc.ABC` with `@abstractmethod`) —
  the implementer *inherits* from the ABC.  Type checkers verify
  the inheritance.  Runtime instance checks (`isinstance(x,
  MyABC)`) are accurate.
- **Protocols** (`typing.Protocol`) — structural typing.  The
  implementer matches the protocol if it provides the required
  methods, regardless of inheritance.  Type checkers verify the
  shape; runtime instance checks require `runtime_checkable`.

The portfolio's interfaces use **`Protocol`** uniformly.  Two
reasons:

1. **Decoupling.**  An implementer that already inherits from
   another base (or that lives in a package that doesn't depend
   on `codecraftai-foundations` at the inheritance level) can still
   *be* an `ICommandRunner` if it has the right method shape.
   No forced inheritance; no diamond-inheritance problems.
2. **Substitutability.**  Test mocks and fakes don't need to
   inherit from the interface to type-check as it.  A test
   builds a small class with the right methods and passes it
   wherever `ICommandRunner` is expected.

The cost of protocols: less rich runtime introspection.  When
the application needs *runtime* enforcement of an interface
(rare in the portfolio), abstract base classes are the right
choice.  When the application needs *structural* checks at the
type-checker level, protocols are.  The portfolio uses protocols
because the structural-check use case dominates.

## The eight protocols

The shipped package has eight protocol files.  This section
walks each, anchored on the actual code.

### `ICommandRunner` — running commands across system boundaries

[`icommandrunner.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/icommandrunner.py)
defines the most-used protocol in the portfolio.  Its job: provide
a uniform shape for *running a command*, regardless of whether
the command runs locally, over SSH, in a container, or via some
other transport.

```python
@dataclass
class RunResult:
    exit_code: Optional[int]
    stdout: str
    stderr: str
    timed_out: bool = False


class ICommandRunner(Protocol):
    def run(
        self,
        args: List[str],
        *,
        elevated: bool = False,
        cwd: Optional[str] = None,
        env: Optional[Dict[str, str]] = None,
        stdin: Optional[str] = None,
        timeout: Optional[float] = None,
        on_output: Optional[Callable[[str], None]] = None,
        on_error: Optional[Callable[[str], None]] = None,
        capture_output: bool = True,
    ) -> RunResult: ...
```

Eight keyword-only parameters.  The *positional* parameter is the
command (`args` as a list, not a string — explicit-is-better and
shell-injection-safer).  The keyword-only parameters are
*aspects* of execution: elevated privilege, working directory,
environment, stdin, timeout, output streaming callbacks, capture
flag.

The shape is consistent across implementations:

- A *local* `LocalCommandRunner` calls `subprocess.run` with
  the args.
- A *remote* `SshCommandRunner` packages the args into an SSH
  command and runs them on the remote host.
- A *containerised* `DockerCommandRunner` calls `docker exec`.
- A *test mock* `FakeCommandRunner` returns canned `RunResult`
  values without running anything.

Code that needs to "run a command" takes an `ICommandRunner`
parameter and gets to work without knowing which runner it
received.

### `IFileOperator` — filesystem operations across boundaries

[`ifileoperator.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/ifileoperator.py)
is `ICommandRunner`'s sibling for filesystem work.  Five methods:
`write_text`, `delete`, `read_text`, `list_dir`, `exists`.  Each
takes an `elevated: bool` parameter, mirroring the privilege
discipline from the runner.

```python
class IFileOperator(Protocol):
    def write_text(self, path: str, content: str, elevated: bool = False) -> None: ...
    def delete(self, path: str, elevated: bool = False) -> None: ...
    def read_text(self, path: str, elevated: bool = False) -> str: ...
    def list_dir(self, path: str, elevated: bool = False) -> List[str]: ...
    def exists(self, path: str, elevated: bool = False) -> bool: ...
```

Same substitutability: a `LocalFileOperator` works against the
local filesystem; a `SshFileOperator` works against a remote
filesystem over SSH; a `S3FileOperator` works against S3-compatible
object storage.  The caller doesn't care.

The minimalism is deliberate.  Files have many possible
operations (set permissions, change owner, create symlinks, copy
ranges, append, truncate); the interface picks the **five most
universally needed** and stops there.  Implementers can offer
*more* on their concrete classes; the interface only promises
these five.

### `ISerializable` — dict round-trip

[`iserializable.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/iserializable.py)
is the smallest protocol in the package.  Two methods:

```python
class ISerializable(Protocol):

    @classmethod
    def from_dict(cls, data: Dict[str, Any]): ...

    def as_dict(self) -> Dict[str, Any]: ...
```

`as_dict` produces a dictionary representation; `from_dict`
constructs an instance from one.  The contract is round-trip:
`obj == obj.from_dict(obj.as_dict())` should hold for any
serialisable object.

Code that needs to serialise heterogeneous objects (write to
JSON, write to YAML, send over the wire to another process)
takes `ISerializable` instances and gets to work.  Code that
needs to *consume* a configuration document constructs the
right concrete class through `from_dict`.

The chapter-9 configuration loader uses this pattern: every
typed configuration class implements `ISerializable`; the
loader calls `from_dict` to construct instances from parsed
YAML/JSON.

### `IBasicCredential` — username + password

[`ibasiccredential.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/ibasiccredential.py)
is the smallest credential interface.  Two read-only properties:

```python
class IBasicCredential(Protocol):
    @property
    def username(self) -> str: ...

    @property
    def password(self) -> str: ...
```

Used by code that consumes basic credentials without caring
*which* credential class produced them.  A
[`CouchDbProfile.connection_string(credobj: IBasicCredential)`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/couchdbprofile.py)
takes any object with `.username` and `.password` properties —
a real `BasicCredential` from `codecraftai-credentials`, a test
`FakeBasicCredential`, an inherited `SshCredential` that has
both fields, etc.

The credential package's `BasicCredential` *implements* this
protocol; `CouchDbProfile` *consumes* it.  The implementing
package doesn't depend on the consuming package; the consuming
package doesn't depend on the implementing package; both depend
on the interfaces package.  This is the dependency-inversion
discipline in action.

### `IIncludeFilter` and `IExcludeFilter` — list filtering

[`iincludefilter.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/iincludefilter.py)
and
[`iexcludefilter.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/iexcludefilter.py)
provide a uniform interface for filtering lists of items.  They
are siblings:

```python
class IIncludeFilter(Protocol):
    def should_include(self, check_object: Any) -> bool: ...
    def filter(self, input_list: List[Any]) -> List[Any]: ...

class IExcludeFilter(Protocol):
    def should_exclude(self, check_object: Any) -> bool: ...
    def filter(self, input_list: List[Any]) -> List[Any]: ...
```

The pair separates *include* and *exclude* semantics
deliberately.  An include filter says "keep these"; an exclude
filter says "drop these."  Composing the two — running both
against the same list — gives a uniform "include unless
excluded" pattern that works without conditional flags.

Both protocols ship a `filter(input_list)` *default* implementation
that walks the list and applies `should_include` /
`should_exclude` to each item.  Implementers only need to
override `should_include` (or `should_exclude`); the list walk
is provided.

This is one of the few places where the protocol carries a
default implementation.  It works because Python's `Protocol` is
permissive about whether members are defined; the structural
check passes if the implementer either overrides or inherits the
default.

### `IHint` — friendly identification

[`ihint.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/ihint.py)
is a small protocol for objects that can produce a "hint" string
matching them against another object:

```python
class IHint(Protocol):
    def get_hint(self) -> str: ...
    def matches(self, obj: Any) -> bool: ...
```

Used by `codecraftai-landscaping`'s
[`FriendlyIdentifier`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/friendlyidentifier.py)
class.  A landscape's apod entries carry friendly names ("dev01",
"runner-east-1") that match the actual host name through hint
strings.  The protocol formalises the relationship.

### `ITestJob` — the test-job contract

[`itestjob.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/interfaces/itestjob.py)
is the largest of the eight protocols.  It defines the contract
for a test job — the unit of work `codecraftai-testbase` and its
extensions execute:

```python
class ITestJob(Protocol):
    @property
    def importerrors(self): ...
    def begin(self) -> None: ...
    def execute(self) -> int: ...
    def finalize(self) -> None: ...
    def query(self, query: str, format: int=0) -> List[Any]: ...
```

Five methods plus one property.  The lifecycle is `begin` →
`execute` → `finalize`; the property surfaces import errors
that occurred during the job's load; the `query` method exposes
introspection for tooling.

The protocol is the contract Volume 2's distributed test jobs
implement (`TestJob` → `InteropLibraryTestJob` →
`DistributedTestJob`).  Each layer of the inheritance chain
satisfies the protocol; consumers (test runners, reporters,
schedulers) talk to whichever layer of the chain they have.

## How the interfaces compose

The eight protocols are not independent — they compose into the
larger contracts the higher tiers of the portfolio rely on.

### Composition example: a sysman feature

A Volume 4 sysman feature operates on a per-host basis.  Each
host is represented by a `HostContext` that carries (among
other things):

- An `ICommandRunner` — to run commands on the host.
- An `IFileOperator` — to read and write files on the host.
- An `IBasicCredential` — to authenticate to the host.

The sysman feature's code takes a `HostContext` and uses the
three interfaces.  The same feature works against:

- A *local* host (LocalCommandRunner + LocalFileOperator + a
  null credential).
- A *remote* host over SSH (SshCommandRunner + SshFileOperator +
  SshCredential).
- A *containerised* host (DockerCommandRunner +
  DockerFileOperator + a basic credential).
- A *mocked* host in unit tests (FakeCommandRunner +
  InMemoryFileOperator + FakeCredential).

The feature's source code is identical in every case.  The
substitutability is what makes the cross-OS contract layer in
Volume 4 work — the same feature implementation runs against
every host because the per-host *machinery* is interface-shaped.

### Composition example: a test runner

A test runner consumes:

- An `ITestJob` — the job to run.
- An `IIncludeFilter` and `IExcludeFilter` — to scope which
  tests within the job to execute.
- An `ISerializable` — to serialise results.

The runner's code takes the four interfaces and runs.  The same
runner works against single-host jobs (`TestJob`),
landscape-aware jobs (`InteropLibraryTestJob`), and distributed
jobs (`DistributedTestJob`) — because all three implement
`ITestJob`.

## What the interfaces do *not* do

Three deliberate non-features.

### Not runtime-enforced (typically)

The protocols are *type-checker enforced*, not runtime-enforced.
A Python program can pass a non-implementer where an
`ICommandRunner` is expected; the type checker complains; the
runtime doesn't.  When *runtime* enforcement is needed, the
consuming code does an explicit `isinstance` check (with the
protocol marked `@runtime_checkable`) or — more often — does
duck-typing checks (`hasattr(runner, 'run')`).

This is consistent with Python's structural-typing tradition
and with the philosophy's *explicit-is-better-than-implicit*
principle.  Runtime enforcement of an interface is *additional*
rigour that costs runtime cycles; the type checker catches the
common case at zero runtime cost.

### Not behaviour, just shape

The protocols describe *what methods exist with what
signatures*; they do not describe *what those methods do*.  A
malicious `ICommandRunner` implementation that returns random
data instead of running the command satisfies the type
contract; the runtime check would still pass; the bug is in the
implementer's behaviour.

The portfolio mitigates this through testing — each concrete
implementation has its own test suite that verifies the
*behaviour* matches the documented contract.  The interface
package doesn't do that work; it shouldn't.

### Not capability discovery

The interfaces don't declare what *capabilities* an
implementation has beyond the methods it implements.  A
filesystem implementation that supports symlinks doesn't have a
"supports_symlinks" flag on the interface; a runner that
streams output doesn't have a "supports_streaming" flag.

When a consumer needs capability discovery, the answer is *a
separate capability protocol*, not feature flags on a base
protocol.  This keeps the base protocols small and the
discovery logic explicit.

## How interfaces drive the dependency graph

The `ccai.interfaces` subpackage, shipped by `codecraftai-foundations`,
sits at the bottom of the portfolio's dependency graph.  Specifically:

```
               codecraftai-foundations  (ccai.interfaces)
                            ▲
                            │ imported by everyone
                            │
           ┌────────────────┼─────────────────┐
           │                │                 │
   codecraftai-credentials   codecraftai-       codecraftai-landscaping
           │              dataprofiles            │
           │                │                     │
           └────────────────┼─────────────────────┘
                            │
                            ▼
                codecraftai-interop, sysman, ...
```

Two important properties of this graph:

- **`ccai.interfaces` depends on nothing.**  Not even on
  `ccai.errors`, its sibling inside the same distribution.
  `codecraftai-foundations` is the leaf; it can be installed
  standalone.
- **Every package that wants to *interoperate* depends on
  `codecraftai-foundations`.**  The dependency is small (eight
  small files); the value is large (every package speaks the
  shared contract language).

When a package introduces a new interface, the right place is
*here*, not in the introducing package's own surface.  This
keeps the interfaces *centralised*; consumers only need to
import from one place.

## When to add a new interface

Three signals that a new interface is the right move:

### Signal 1: two implementations, one consumer

When a consuming package has *one shape it wants to consume* and
two or more implementing packages produce that shape, the shape
is an interface.  The interface goes in `ccai.interfaces`;
both implementers depend on the interfaces package; the consumer
depends only on the interfaces package.

### Signal 2: testing requires a substitute

When unit testing a package would benefit from a fake of some
external dependency, the dependency's shape is a candidate
interface.  Testing the sysman feature without spawning real SSH
sessions requires a fake `ICommandRunner`; making the fake easy
to write requires `ICommandRunner` to be a protocol.

### Signal 3: cross-tier substitutability

When a higher-tier package wants to plug a different
implementation in for a lower-tier dependency (chapter-7's
extension and factory machinery is the substantive form of this
pattern), the seam is an interface.

The `ccai.interfaces` subpackage deliberately stays *small*.
Eight files today; growth is deliberate.  Each new interface
goes through the question: *do at least two existing or planned
implementations want to satisfy this shape?*  If yes, add it; if
no, don't add the interface yet.

## Summary

- **`ccai.interfaces`**, inside `codecraftai-foundations`, is the leaf
  of the portfolio's dependency graph.  Eight protocol files; one purpose: shared
  contract definitions for substitutable behaviour.
- The protocols are **`typing.Protocol`** (structural typing),
  not `abc.ABC` (nominal typing).  Implementers don't inherit;
  they match by shape.  The cost is less runtime introspection;
  the benefit is decoupling and easy mocking.
- The eight protocols:
  - **`ICommandRunner`** — running commands across local,
    remote, containerised boundaries.
  - **`IFileOperator`** — filesystem operations with the same
    cross-boundary uniformity.
  - **`ISerializable`** — dict round-trip via `from_dict` and
    `as_dict`.
  - **`IBasicCredential`** — username + password read-only
    properties.
  - **`IIncludeFilter`** / **`IExcludeFilter`** — uniform list
    filtering with default implementations of `filter()`.
  - **`IHint`** — friendly identification.
  - **`ITestJob`** — the test-job lifecycle contract used by
    Volume 1 and Volume 2.
- The interfaces **compose** into larger contracts.  A sysman
  feature consumes `ICommandRunner` + `IFileOperator` +
  `IBasicCredential` to operate on a host — local, remote,
  containerised, or mocked, all with the same code.
- The protocols describe **shape, not behaviour**.  Behaviour
  conformance is verified through each concrete implementation's
  test suite; the interface package itself contains no tests of
  behaviour.
- New interfaces earn their place by satisfying **at least two
  existing or planned implementations**.  The package stays
  small deliberately; growth is gated.

The next chapter introduces the waiting primitives — small
helpers for the recurring problem of "wait for a condition" that
turn the trivial loop into a debugged, observable, timeout-aware
operation.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/05-waiting-with-rich-diagnostics -->
# Chapter 5 — Waiting with Rich Diagnostics

> *Every codebase that interacts with anything outside its
> process has a `wait_for(...)` function in it somewhere.  Most
> of them are buggy.  This chapter is about the small
> investment that makes the function not buggy.*

The previous chapter introduced the interfaces.  This chapter
introduces the smallest substantial subpackage in the foundation
tier:
[`ccai.waiting`](https://github.com/code-craft-ai-company/codecraftai-foundations/tree/main/source/packages/ccai/waiting),
shipped by `codecraftai-foundations`.
Three files, two classes, one function.  The subpackage's job is
to give the rest of the portfolio a *correct* answer for "wait
for a condition with a timeout, and tell me what happened if
it didn't work."

The package's interface is small enough to fit in this paragraph:
the `wait_for_it(looper, *args, timeout=N, interval=I, ...)`
function calls `looper(wait_context)` repeatedly until either
the looper returns `True` (condition met, done) or the timeout
elapses (raise `TimeoutError` with rich context).  The function
is half a page; the *correctness* of the function is the rest
of this chapter.

## Why a package for this

A naive `wait_for` is half a dozen lines:

```python
def wait_for(condition, timeout=60, interval=5):
    deadline = time.time() + timeout
    while time.time() < deadline:
        if condition():
            return True
        time.sleep(interval)
    return False
```

Every Python codebase has some version of this function.  Most
of them ship subtle bugs:

- The `True/False` return loses every bit of *why* it didn't
  succeed.  The caller has to wrap the call with their own
  diagnostic.
- The final iteration is the *last* one before the deadline; the
  caller wants to know whether the deadline cut off a
  *near-miss* or a *not-even-close* — there's no way to tell.
- The first iteration runs *immediately*; sometimes the
  condition needs a brief warm-up, and the first check is wasted.
- The interval is constant; sometimes it should be variable
  (back off on long-running waits).
- The condition is called on every loop; logging that the wait
  is still in progress requires an out-of-band hack.
- The timeout's *what for* — what was the wait actually waiting
  for? — never makes it into the resulting error.

Each bug is small.  Cumulatively, they make every team's
`wait_for` slightly different and slightly worse than it could
be.  The
[`ccai.waiting`](https://github.com/code-craft-ai-company/codecraftai-foundations/tree/main/source/packages/ccai/waiting)
subpackage's job is to fix all of them in one place so that every
caller benefits.

## The four shipped types

The package defines exactly four types in
[`waitmodel.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/waitmodel.py):

- **`TimeoutContext`** — the state object that tracks a single
  wait operation.
- **`WaitCallback`** — a `Protocol` defining the looper-function
  signature.
- **`WaitContext`** — currently a thin alias for `TimeoutContext`,
  with the name reserved for future divergence.
- **`WaitGate`** — a wrapper around `threading.Event` that adds
  message and timeout metadata.

Plus one context manager:

- **`WaitingScope`** — clears a list of gates on entry, waits
  for them on exit.

And one function in
[`waitfunction.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/waitfunction.py):

- **`wait_for_it(looper, *args, ..., wctx=None)`** — the
  public entry point.

This is the entire shipped API.  Five concepts, one function.
The smallness is intentional; chapter 1's *if-it's-hard-to-
explain-try-another-way* principle would have rejected a
larger interface.

## `TimeoutContext` — the state object

The
[`TimeoutContext`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/waitmodel.py)
is the chapter's substantive contribution.  It tracks a single
wait operation and exposes the operation's progress to both the
caller and the looper function.

The constructor takes four parameters:

```python
def __init__(self, timeout: float, interval: float=0, delay: float=0,
             doevery: int=-1, what_for: Optional[str]=None):
    self._timeout = timeout
    self._interval = interval
    self._delay = delay
    self._doevery = doevery
    self._what_for = what_for

    self._loop_counter = 0
    self._now_time = None
    self._start_time = None
    self._end_time = None
    self._final_attempt = False
    self._wait_state = TimeoutState.NotStarted
    return
```

Five timing fields plus internal state.  Walk each one.

### `timeout` — the deadline budget

The maximum time in seconds the operation may run.  After the
context's `mark_begin()` call, the deadline is fixed at
`start_time + timedelta(seconds=timeout)`.

### `interval` — the gap between checks

The seconds to sleep between consecutive looper invocations.
Default zero (back-to-back checks).  Typical value 1–5 seconds
for most operations.

### `delay` — the warm-up

The initial seconds to sleep *before* the first looper invocation.
Default zero (start immediately).  Useful when the system being
waited on needs a moment to settle (a service that just started,
a database that's still doing initial loads).

### `doevery` — the heartbeat interval

The loop-counter interval at which the caller wants a "still
waiting" event.  Default `-1` (no heartbeat).  When set to a
positive integer N, the property `is_do_every_interval` returns
`True` on every N-th loop iteration; the looper can use this to
log a heartbeat without spamming the log on every check:

```python
def looper(wctx: TimeoutContext) -> bool:
    if wctx.is_do_every_interval:
        logger.info("Still waiting for service to be healthy...")

    return service.is_healthy()
```

This addresses the "logging on every loop is too noisy" failure
mode of the naive `wait_for`.

### `what_for` — the diagnostic string

The string description of *what is being waited for*.  Carried
into any timeout error the context produces, so the failure
report names the wait's purpose without the caller having to
add it.

```python
wctx = TimeoutContext(
    timeout=60,
    interval=2,
    what_for="staging-db to accept connections",
)
```

A timeout from this context produces:

```
Timeout waiting for staging-db to accept connections:
    timeout=60 start_time=2026-04-30 14:32:11 end_time=2026-04-30 14:33:11 now_time=2026-04-30 14:33:13 time_diff=0:01:02
```

The error message is *self-documenting*.  No log archaeology
required.

### State tracking

Internal fields track the operation's state through five
states (`TimeoutState` enum from
[`constants.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/constants.py)):

- `NotStarted` — the context exists but `mark_begin()` hasn't been
  called.
- `Tracking` — reserved for future use.
- `Running` — the wait is in progress.
- `Completed` — the looper returned `True`.
- `TimedOut` — the deadline was reached.

The state machine is internal but observable through the
`completed`, `has_timed_out`, and `wait_state` properties.

## `wait_for_it` — the function

The public entry point in
[`waitfunction.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/waitfunction.py)
is *one function*.  Annotated with the chapter's discipline:

```python
def wait_for_it(looper: WaitCallback, *largs, what_for: Optional[str]=None,
                delay: float=DEFAULT_WAIT_DELAY,
                interval: float=DEFAULT_WAIT_INTERVAL,
                timeout: float=DEFAULT_WAIT_TIMEOUT,
                lkwargs: Optional[Dict[Any, Any]]=None,
                wctx: Optional[WaitContext]=None):
    if lkwargs is None:
        lkwargs = {}

    if what_for is None:
        what_for = "'{}'".format(looper.__name__)

    if wctx is None:
        wctx = WaitContext(timeout, interval=interval, delay=delay, what_for=what_for)

    if wctx.delay > 0:
        time.sleep(DEFAULT_WAIT_DELAY)

    wctx.mark_begin()

    condition_met = False

    while True:
        condition_met = looper(wctx, *largs, **lkwargs)
        if condition_met:
            wctx.mark_complete()
            break

        if not wctx.should_continue():
            break

        if wctx.interval > 0:
            time.sleep(wctx.interval)

    if not condition_met:
        # Mark the time we are performing the final attempt
        wctx.mark_time()
        wctx.mark_final_attempt()
        condition_met = looper(wctx, *largs, **lkwargs)

    if not condition_met:
        toerr = wctx.create_timeout(what_for)
        raise toerr

    wctx.mark_complete()

    return
```

Five things worth naming.

### Defaults are package-level constants

`DEFAULT_WAIT_DELAY = 0`, `DEFAULT_WAIT_INTERVAL = 5`, and
`DEFAULT_WAIT_TIMEOUT = 60` are defined in
[`constants.py`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/constants.py).
Keeping the defaults as named constants makes them
greppable across the codebase and changeable in one place.

### `what_for` defaults to the looper's `__name__`

When the caller doesn't provide a `what_for`, the function uses
the looper function's name.  This is the *graceful fallback*
discipline — when context isn't provided, infer something
reasonable rather than leave the failure report empty.

### The looper's first invocation precedes `should_continue`

The function's loop runs the looper *first*, then checks whether
to continue.  This means: the first iteration always runs, even
if `timeout=0` was passed.  A caller who genuinely wants
"check once and timeout immediately" gets the check.

### The "final attempt" pattern

This is the chapter's most subtle correctness contribution.  When
the loop's main pass exhausts the timeout, the function does
*one more invocation* of the looper with `wctx.final_attempt =
True`.  The looper can detect the final attempt and produce a
*final diagnostic snapshot* — log the system state, dump
relevant data, capture a screenshot — even though the wait is
about to fail.

Without this pattern, the timeout error fires at the end of the
last sleep, with no information about *what state the system was
actually in* at the moment of failure.  With the pattern, the
final invocation is a deliberate "last chance to capture the
failure context" call.

```python
def wait_for_service_healthy(wctx: TimeoutContext, service) -> bool:
    is_healthy = service.is_healthy()
    if not is_healthy and wctx.final_attempt:
        logger.error("Final state: %s", service.dump_diagnostic_state())
    return is_healthy
```

The looper logs the diagnostic state *only* on the final
attempt — every intermediate failure is silent (correct for
"still waiting"), and the final failure is loud (correct for
"timed out, here's why").

### `create_timeout` produces a rich `TimeoutError`

When the function gives up, it asks the context to construct
the error:

```python
toerr = wctx.create_timeout(what_for)
raise toerr
```

The `create_timeout` method (in `TimeoutContext`) builds the
diagnostic message from the timing fields plus the `what_for`
description.  The chapter-3 timeout-error vocabulary
(`CompletionTimeout`, `ConnectionTimeout`, `InActivityTimeout`)
applies — callers who want a *specific* timeout type wrap the
call:

```python
try:
    wait_for_it(check_db_ready, timeout=60, what_for="staging-db ready")
except TimeoutError as err:
    raise ConnectionTimeout(str(err)) from err
```

The `TimeoutError` from the package becomes a `ConnectionTimeout`
at the wrapping layer.  The original message is preserved; the
type changes for the caller's discrimination.

## `WaitGate` and `WaitingScope` — the threading primitives

Two additional types address the *cross-thread waiting* case.

### `WaitGate` — a threading.Event with metadata

[`WaitGate`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/waiting/waitmodel.py)
wraps a `threading.Event` and adds three pieces of metadata:

```python
class WaitGate:
    def __init__(self, gate: threading.Event,
                 message: Optional[str]=None,
                 timeout: Optional[float]=None,
                 timeout_args: Optional[list]=None):
        self._gate = gate
        self._message = message
        self._timeout = timeout
        self._timeout_args = timeout_args
```

The `message` is the timeout diagnostic the gate carries; the
`timeout` is the default wait duration; `timeout_args` are
formatting arguments for the message.

The gate exposes the underlying event's `set()`, `clear()`,
`is_set()`, and `wait()` methods, plus the `raise_timeout` flag
that converts a wait failure into a `TimeoutError` directly.

### `WaitingScope` — coordinated multi-gate waits

```python
class WaitingScope:
    def __init__(self, gates: List[WaitGate]):
        self._gates = gates

    def __enter__(self):
        for gate in self._gates:
            gate.clear()

    def __exit__(self, ex_type, ex_inst, ex_tb):
        return

    def wait(self):
        for gate in self._gates:
            gate.wait()
```

The pattern: enter the scope to *clear* all gates, do work
that's expected to set them, then call `wait()` to *wait for
all of them*.  This is the cross-thread version of `wait_for_it`
— the looper polls a single condition; the scope waits on
multiple events that are set by other threads.

A typical usage:

```python
service_ready = WaitGate(threading.Event(), timeout=30, message="service ready")
config_loaded = WaitGate(threading.Event(), timeout=10, message="config loaded")

with WaitingScope([service_ready, config_loaded]):
    start_service()      # this thread sets service_ready
    load_config()        # this thread sets config_loaded

# Outside the scope, both gates are set
```

The `WaitingScope` is a small abstraction.  Most callers don't
need it.  When the orchestration is genuinely multi-threaded
and the synchronisation is *gate-based*, the scope is the
right primitive.

## Three patterns the package enables

Three usage patterns recur across the portfolio.

### Pattern 1: simple wait for a condition

```python
from ccai.waiting.waitfunction import wait_for_it

def is_db_accepting_connections(wctx, host, port):
    try:
        with socket.create_connection((host, port), timeout=2):
            return True
    except (ConnectionRefusedError, socket.timeout):
        return False

wait_for_it(
    is_db_accepting_connections,
    "staging-db.internal", 5432,
    what_for="staging-db accepting connections",
    timeout=60,
    interval=2,
)
```

Six lines.  The looper takes the wait context plus any
positional args and returns True/False.  The function handles
the loop, the timeout, the diagnostic.

### Pattern 2: wait with heartbeat logging

```python
def is_long_job_done(wctx, job):
    if wctx.is_do_every_interval:
        logger.info("Job %s still running (%d/%d sec elapsed)",
                    job.id, wctx.timeout - (wctx.end_time - datetime.now()).seconds,
                    wctx.timeout)
    return job.is_done()

wait_for_it(
    is_long_job_done,
    job=long_running_job,
    timeout=3600,
    interval=10,
)
```

The looper checks `is_do_every_interval` to throttle log output.
With `interval=10`, the heartbeat fires every 60 seconds (every
sixth check); without this pattern, every check would log,
producing noise.

To set up `doevery=6`, the caller passes a custom
`TimeoutContext`:

```python
wctx = TimeoutContext(timeout=3600, interval=10, doevery=6, what_for="long job")
wait_for_it(is_long_job_done, wctx=wctx, lkwargs={"job": long_running_job})
```

### Pattern 3: wait with final-attempt diagnostics

```python
def is_test_environment_healthy(wctx, env):
    healthy = env.health_check()
    if not healthy and wctx.final_attempt:
        # Last chance — capture full diagnostic for the failure
        env.dump_state(path="./failure-diag.txt")
        env.capture_logs(path="./failure-logs/")
    return healthy

wait_for_it(
    is_test_environment_healthy,
    env=staging_environment,
    what_for="staging environment healthy",
    timeout=300,
)
```

The diagnostic dump runs *only* on the final attempt — once,
right at the moment of failure, with the full system state
available.  Without this pattern, the engineer debugging the
timeout has to *re-run* the test to capture the state, which
often produces a successful run because the flake doesn't
reproduce.

## What the package does *not* do

Three deliberate non-features.

### Not async/await

The shipped `wait_for_it` is a synchronous function.  It uses
`time.sleep` between checks and blocks the calling thread.  When
the calling code is `async`, a different primitive is needed —
typically `asyncio.wait_for` or a custom looper — and the
shipped package doesn't provide one.

This is a deliberate scoping choice.  An async equivalent could
be added; the discipline is to wait until two callers actually
need it before adding the parallel implementation.  Adding it
preemptively would double the surface area for hypothetical
benefit.

### Not retries with backoff

The `interval` parameter is constant.  When the caller wants
exponential backoff (interval doubles after each failed check),
the looper itself implements it — typically by tracking attempts
in a closure or in `wctx`'s loop counter.

The shipped package doesn't bake in a backoff curve because no
single curve fits every use case.  Linear, exponential, capped-
exponential, jittered — each is a few lines of looper code.  The
discipline keeps the chapter-2 *no-clever-defaults* rule.

### Not progress estimation

The `wait_for_it` function knows when the deadline is, but it
doesn't know the *progress* of the underlying operation.  When
the caller wants "60% done" rather than "44 of 60 seconds
elapsed," that's a property of the operation, not of the wait.

The package leaves progress estimation to the operation — the
looper can call into the operation and compute progress, then
include it in the heartbeat log.  The wait function stays
neutral about progress; it only knows about time.

## Cross-references

The waiting primitives are used across every higher tier of the
portfolio:

- **Volume 0 chapter 9 (Configuration loading)** uses
  `wait_for_it` for the HTTP source's connection retries.
- **Volume 3** uses the package implicitly through tasks that
  block on subprocess completion; the chapter-19 `RunCommand`
  task wraps `subprocess.communicate()` which is its own wait
  primitive.
- **Volume 5 (Interop)** uses `wait_for_it` extensively for SSH
  session establishment, DNS lookups, and similar
  network-bound waits.
- **Volume 6 (Landscape)** uses `wait_for_it` for "wait for the
  device to come online" patterns during landscape activation.

The `Aspects` pattern from Volume 5 chapter 1 is the affirmative
form of *behaviour parameterisation* the chapter-2 decorator
discipline pointed at.  Aspects' `timeout`, `interval`, and
`retry_count` fields are exactly the fields the wait function
expects — the two layers compose cleanly.

## Summary

- **`ccai.waiting`** is a small subpackage of `codecraftai-foundations`
  with one job: give
  the rest of the portfolio a correct, observable, timeout-
  aware *wait for a condition* primitive.
- The shipped API is **five concepts and one function**:
  `TimeoutContext`, `WaitCallback`, `WaitContext`, `WaitGate`,
  `WaitingScope`, plus `wait_for_it`.
- `TimeoutContext` carries five timing fields (`timeout`,
  `interval`, `delay`, `doevery`, `what_for`) plus internal
  state.  The `what_for` field is what makes timeout errors
  self-documenting.
- The **final-attempt pattern** is the chapter's most subtle
  correctness contribution.  After the timeout exhausts, one
  more looper invocation with `wctx.final_attempt = True` lets
  the looper capture diagnostic state at the moment of failure.
- `wait_for_it` is the public entry point.  Defaults to
  `DEFAULT_WAIT_TIMEOUT=60`, `DEFAULT_WAIT_INTERVAL=5`,
  `DEFAULT_WAIT_DELAY=0`.  Returns nothing on success; raises
  `TimeoutError` with rich context on failure.
- `WaitGate` and `WaitingScope` provide the cross-thread
  variant — gates are `threading.Event` wrappers with timeout
  metadata; the scope clears gates on entry and waits for them
  on exit.
- Three patterns recur: **simple condition wait**, **wait with
  heartbeat logging** (via `doevery` + `is_do_every_interval`),
  **wait with final-attempt diagnostics** (via
  `wctx.final_attempt`).
- The package is *not* async/await aware, *not* a retry-with-
  backoff library, and *not* a progress estimator.  Each
  scoping choice is deliberate.
- Higher tiers (orchestration, interop, landscape, sysman) all
  use the wait function for time-bounded operations.

The next chapter introduces application identity and presence —
the `ThisApplication` machinery that gives every running process
in the portfolio a structured identity, with the persona-variable
descriptors that make configuration overrides traceable.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/06-application-identity-and-presence -->
# Chapter 6 — Application Identity and Presence

> *Every running process in the portfolio answers two questions
> the same way: "Who am I?" and "Where do my files live?"  This
> chapter is about the small machinery that makes the answers
> consistent.*

The previous chapter introduced the waiting primitives.  This
chapter introduces the *identity* primitives —
[`codecraftai-this-app`](https://github.com/code-craft-ai-company/codecraftai-this-app/tree/main/source/packages/ccai/thisapp),
the package whose `ThisApplication` singleton represents the
running process to itself.  Every higher tier of the portfolio
calls into `ThisApplication` to resolve configuration variables,
locate well-known directories, and emit identity metadata into
logs and traces.

The package began small but has grown consequential.  At its core
is one singleton; one function (`establish_app_persona`) that
*every* application calls early in startup; one global
context that carries identity values across the process.  As of the
runtime-foundation consolidation it *also* owns the machinery that
used to live in the former `codecraftai-runtime` package: the
`RuntimeVariables` descriptor set, the well-known-paths helpers, the
profile/job enumerations, and the activation sequence.  The old
`codecraftai-runtime` is being retired — it now holds only thin
re-export shims, and import paths shift from `ccai.runtime.X`
to `ccai.thisapp.X`.

This consolidation is what lets `codecraftai-testbase` sit *above* the
identity-and-presence tier without depending on it: testbase reads
the handful of core environment knobs it needs directly, and the
richer runtime machinery described later in this chapter is what an
*integrated* run (Volume 1) layers back on top via
`codecraftai-integration-runtime`.

## The two-phase pattern

The identity machinery has a deliberate two-phase startup:

1. **`establish_app_persona(...)`** — called once, before any
   other ccai machinery.  This sets the application's
   *persona*: name, logger name, log file basename, presence
   directory (where the app's files live on disk), settings
   file path.  After this call, identity is fixed for the
   process's lifetime.

2. **`ThisApplicationSingleton()`** — called any number of times
   afterward.  Returns the singleton `ThisApplication` instance.
   The first call constructs the instance from the persona set
   in phase 1.

The split is intentional.  Phase 1 is *configuration*; phase 2
is *use*.  The singleton refuses to construct itself if phase 1
hasn't run — it raises `SemanticError` (chapter 3's must-bubble
type) so the failure is loud and the caller is told exactly
what's wrong:

```python
errmsg = "You must call 'establish_app_persona' early in your application startup to define " \
         "your application's presence settings before attempting to use the `ThisApplication` " \
         "object or its corresponding singleton."
raise SemanticError(errmsg)
```

This is the *errors-never-silent* discipline applied to
initialisation order.  An application that forgets to establish
its persona doesn't get a `None` back — it gets a clear
`SemanticError` telling it to fix the startup order.

## `establish_app_persona` — the canonical entry point

The function lives in
[`persona.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/persona.py).
The signature:

```python
def establish_app_persona(*, name: str, logger_name: str, log_basename: str,
                          presence_dir: Optional[str]=None,
                          log_directory: Optional[str]=None,
                          settings_file: Optional[str]=None,
                          extensions: Optional[str]=None,
                          service_name: Optional[str]=None,
                          **other):
```

Three required parameters, four optional.  Every parameter is
keyword-only (the leading `*`); this is the chapter-2 named-
parameter discipline.

Walk each parameter.

### `name` — the application's identity

The application's name in machine-readable form.  Lowercase
with hyphens for spaces.  Examples: `"codecraftai-testbase"`,
`"my-deployment-tool"`, `"nightly-runner"`.  Used to derive
default paths and to identify the process in logs.

### `logger_name` — the root logger name

The Python logger name the application uses as its root.  When
the application emits logs through `logging.getLogger("my-app")`,
`logger_name="my-app"`.  Subsystems use child loggers
(`logging.getLogger("my-app.subsystem")`); the root name lets the
logging configuration apply formatters and handlers
hierarchically.

### `log_basename` — the log file's base name

The base name of the application's log files.  When the app
writes `my-app-2026-04-30.log` and `my-app-2026-04-30-debug.log`,
`log_basename="my-app"`.  The chapter-9 configuration loader
uses this to wire up the log file paths.

### `presence_dir` — where the app's files live

The directory where the application's files live on disk.  When
unspecified, defaults to `os.path.join("~", name.lower())` —
i.e., `~/my-deployment-tool/` for an app named
`"my-deployment-tool"`.

The presence directory is the *home base* for the application's
runtime artifacts: settings files, log files, captured profiles,
output directories, lock files.  Code that needs a writable
location asks `ThisApplication.presence_dir` and gets a
predictable path.

### `log_directory` — where logs go

Defaults to `os.path.join(presence_dir, "logs")` when
unspecified.  Override when logs need to live somewhere other
than the presence directory (a system log path, a separate
mounted volume).

### `settings_file` — the canonical settings file

Defaults to `os.path.join(presence_dir, "settings.config")`.

The function has a small convenience: if `settings_file` is a
bare filename (no path component), it's resolved relative to the
presence directory.  When a full path is given, it's expanded
through `os.path.expandvars` and `os.path.expanduser`.

### `extensions` — extension module list

A semicolon-separated string of extension module names.  Used by
the chapter-7 extension/factory machinery to discover plug-ins.

### `service_name` — the service identity

When the application runs as a system service, the service name.
Used by logging and observability to tag events with the
service identity.

## What `establish_app_persona` does at runtime

The function does three things:

1. **Validates** — confirms the singleton hasn't already been
   constructed.  If it has, raises `SemanticError` (you can't
   re-establish a persona).
2. **Stores the persona** — copies the parameters onto module-
   level `AppPersona` class attributes.
3. **Returns** — the function returns `None` (per chapter-2
   discipline, with explicit `return`).

The actual `ThisApplication` instance isn't constructed yet —
that happens lazily on the first `ThisApplicationSingleton()`
call.  The two-phase pattern keeps startup responsive: an
application that calls `establish_app_persona` early but doesn't
actually need the singleton until later doesn't pay the
construction cost upfront.

## `ThisApplication` — the singleton's surface

The
[`ThisApplication`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/thisapplication.py)
class exposes seven properties (read-only views of the persona
fields) and four methods (the *active* surface for variable
resolution and overrides):

```python
class ThisApplication:
    # Properties (read-only)
    @property
    def name(self) -> str: ...

    @property
    def logger_name(self) -> str: ...

    @property
    def log_basename(self) -> str: ...

    @property
    def log_directory(self) -> str: ...

    @property
    def presence_dir(self) -> str: ...

    @property
    def settings_file(self) -> str: ...

    @property
    def service_name(self) -> str: ...

    # Methods
    def context_insert(self, path: str, obj: Any) -> None: ...
    def context_lookup(self, path: str, default=None, raise_error=False) -> Any: ...

    def get_variable_value(self, variable_desc: AppVarDesc, ...) -> Union[str, Any]: ...
    def override_variable_value(self, variable_desc: AppVarDesc, new_value: Any) -> None: ...

    def resolve_variable_set(self, variable_set: AppVarSet) -> None: ...
```

Each property is a *simple data accessor* — chapter 2's
property-as-pure-data discipline.  Each method does *one thing*
clearly named.

## The variable-descriptor model

The richest subsystem in `codecraftai-this-app` is the
*variable-descriptor* model.  It's the formal mechanism for
declaring which configuration values a subsystem cares about,
where each value can come from, and how it's converted to a
typed value.

Three classes participate:

### `AppVarDesc` — the descriptor

A descriptor declares *one variable*:

```python
AM_LOG_BASENAME = AppVarDesc(
    source=VariableSource.NONE,
)

AM_PRESENCE_DIR = AppVarDesc(
    source=VariableSource.NONE,
    context_path=ContextPaths.PRESENCE_HOME_DIRECTORY,
)

AM_INTERACTIVE_CONSOLE = AppVarDesc(
    default=False,
    converter=parse_bool,
)
```

Each descriptor carries:

- **`source`** — where the variable's value originates.  Common
  values: `VariableSource.ENVIRONMENT` (read from `os.environ`),
  `VariableSource.SETTINGS` (read from the app's settings file),
  `VariableSource.NONE` (resolved programmatically).
- **`default`** — the value to use when the source doesn't
  provide one.
- **`converter`** — a function that takes the raw string and
  returns the typed value.  The chapter-9 loader's
  `parse_bool`, `split_and_normalize_name_list`, etc., are the
  shipped converters.
- **`context_path`** — when the variable is also published to
  the shared application context, the well-known path it
  publishes to.  This is the `ContextPaths` enum we'll meet
  shortly.

The descriptor is *just data* — no behaviour, no resolution
logic.  The behaviour lives on `ThisApplication`.

### `AppVarSet` — a group of descriptors

A set of related descriptors live in a class that subclasses
`AppVarSet`:

```python
class PersonaVariablesDesc(AppVarSet):

    AM_NAME = AppVarDesc(source=VariableSource.NONE,
                          context_path=ContextPaths.PRESENCE_NAME)
    AM_LOGGER_NAME = AppVarDesc(source=VariableSource.NONE)
    AM_LOG_BASENAME = AppVarDesc(source=VariableSource.NONE)
    AM_PRESENCE_DIR = AppVarDesc(source=VariableSource.NONE,
                                  context_path=ContextPaths.PRESENCE_HOME_DIRECTORY)
    AM_LOG_DIRECTORY = AppVarDesc(source=VariableSource.NONE)
    AM_SETTINGS_FILENAME = AppVarDesc(source=VariableSource.NONE)
    AM_EXTENSIONS = AppVarDesc(source=VariableSource.NONE,
                                context_path=ContextPaths.PRESENCE_EXTENSIONS)
    AM_SERVICE_NAME = AppVarDesc(source=VariableSource.NONE)
```

Eight related descriptors, one class.  The class is *iterable* —
`AppVarSet.items()` yields the descriptors in declaration order
— so resolution code can walk the whole set without naming each
descriptor individually.

The volume's other variable sets follow the same pattern.
`ConfigVariablesDesc` (in `codecraftai-configuration`) declares the
configuration-related descriptors.  `RuntimeVariablesDesc` —
formerly in `codecraftai-runtime`, now in
[`ccai.thisapp.runtimevariables`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/runtimevariables.py)
— declares the runtime-related descriptors and itself subclasses
`PersonaVariablesDesc`.  Each subsystem owns its own variable set;
the sets compose at the `ThisApplication` level.

### `AppVarAccessor` — typed read-through

For convenient access at the call site, a parallel class
provides typed read-through accessors:

```python
class PersonaVariables:
    AM_NAME = AppVarAccessor(PersonaVariablesDesc.AM_NAME)
    AM_LOGGER_NAME = AppVarAccessor(PersonaVariablesDesc.AM_LOGGER_NAME)
    AM_LOG_BASENAME = AppVarAccessor(PersonaVariablesDesc.AM_LOG_BASENAME)
    # ...
```

A consumer calls `PersonaVariables.AM_NAME` and gets the
resolved value.  The accessor object internally calls
`ThisApplicationSingleton().get_variable_value(descriptor)`.
The two-class pattern (Desc + Accessor) is the chapter's
ergonomic concession: `Desc.AM_NAME` is the *descriptor* (used
when calling `get_variable_value`); `Accessor.AM_NAME` is the
*resolved value* (used at the call site).

## Variable resolution

The active surface is `ThisApplication.get_variable_value(desc)`.
The resolution order:

1. **Override** — has the variable been overridden via
   `override_variable_value`?  If yes, return the override.
2. **Environment** — does the variable's source include
   environment variables, and is one set?  If yes, return the
   typed-converted environment value.
3. **Settings** — does the variable's source include the
   settings file, and is the variable present in it?  If yes,
   return the typed-converted settings value.
4. **Default** — is there an explicit default on the descriptor?
   If yes, return it.
5. **Else** — return `None` (or the `default` parameter if
   the caller provided one).

The order is deterministic and inspectable — given a descriptor,
you can predict what value it'll resolve to without running the
code.  This is the chapter-1 *explicit-is-better-than-implicit*
discipline applied to configuration resolution.

## Override semantics

`override_variable_value(desc, new_value)` is the runtime
override mechanism.  It writes the new value into an internal
override map; the next `get_variable_value(desc)` returns it
ahead of every other source.

Concretely, the implementation does *more* than just store the
value:

- For descriptors with `VariableSource.ENVIRONMENT` source, the
  override *also* writes to `os.environ` so child processes
  inherit the new value.  This is the chapter-21 (Volume 3)
  bootstrap-name-flow mechanism in action — the orchestration's
  override of `AM_LANDSCAPE_NAMES` reaches subprocess jobs
  through the environment variable.
- For *some* descriptors, the override *also* hot-swaps a
  matching field on `ThisApplication`.  Specifically:
  `AM_SERVICE_NAME` updates `_service_name`; `AM_LOG_BASENAME`
  updates `_log_basename`; `AM_LOG_DIRECTORY` updates
  `_log_directory`; `AM_LOGGER_NAME` updates `_logger_name`.
  This was the in-flight refactor flagged during the portfolio
  hygiene sweep in the prior session.

The pattern: the override is *immediate* in this process and
*propagated* to subprocess processes through `os.environ`.  Two
levels; same value; one entry point.

## The shared context

Beyond variable resolution, `ThisApplication` carries a *context*
— a hierarchical key-value store accessible by *path strings*:

```python
ctx = ThisApplicationSingleton().context

ctx.insert("/environment/landscape/names", ["staging-east"])
landscape_names = ctx.lookup("/environment/landscape/names")
```

The context is shared across the process; it's the *serializable*
identity of the running application.  Subsystems write
configuration data into it; consumers read it out.

The well-known paths live in
[`ContextPaths`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/contextpaths.py)
— a string enum:

```python
class ContextPaths(str, Enum):

    BUILD_RELEASE = "/environment/build/release"
    BUILD_BRANCH = "/environment/build/branch"
    BUILD_NAME = "/environment/build/name"

    CONFIG_LANDSCAPE_NAMES = "/environment/landscape/names"
    CONFIG_LANDSCAPE_URIS = "/environment/landscape/uris"
    CONFIG_LANDSCAPE_FILES = "/environment/landscape/files"

    CONFIG_RUNTIME_NAMES = "/environment/runtime/names"
    CONFIG_RUNTIME_URIS = "/environment/runtime/uris"

    CONFIG_TOPOLOGY_NAMES = "/environment/topology/names"
    # ...

    JOB_ID = "/environment/job/id"
    JOB_NAME = "/environment/job/name"
    JOB_LABEL = "/environment/job/label"
    JOB_SEED = "/environment/job/seed"
    # ...
```

Each enum entry is *both a name and a path string*.  Code that
needs to publish or consume a value uses the enum:

```python
ctx.insert(ContextPaths.JOB_ID, run_uuid)
job_id = ctx.lookup(ContextPaths.JOB_ID)
```

The string-enum dual-citizenship makes the path *greppable* and
*rename-safe*.  When a path moves (rare), the enum value changes
in one place and every consumer that uses the enum is
automatically updated.

## The variable-descriptor + context-path composition

A variable descriptor with a `context_path` field automatically
publishes its resolved value to the context at that path:

```python
AM_PRESENCE_DIR = AppVarDesc(
    source=VariableSource.NONE,
    context_path=ContextPaths.PRESENCE_HOME_DIRECTORY,
)
```

When the variable is resolved (at startup, through
`resolve_variable_set`), its value is *also* written to
`/presence/home-directory` in the context.  The descriptor is
the *source of truth*; the context path is the *publication
endpoint*.

This composition is what enables Volume 3's bootstrap-name flow
to work cleanly.  The orchestration sets `AM_LANDSCAPE_NAMES`;
the variable resolution writes the value into both `os.environ`
and the context at `/environment/landscape/names`; subsystems
that need to *find* the active landscape names read the context
path; subsystems that need to *override* call
`override_variable_value` and trust the propagation.

## The absorbed runtime foundation

Everything above describes the *persona* machinery — the half of
`codecraftai-this-app` that was always here.  The other half arrived
with the runtime-foundation consolidation: the modules that used to
constitute the standalone `codecraftai-runtime` package now live under
`ccai.thisapp`, and the application-identity-and-presence tier is
their home.  They build directly on the descriptor model this
chapter just introduced.

> **As-built note.**  The old `codecraftai-runtime` package still
> exists, but only as a set of thin re-export shims so that
> in-flight callers importing `ccai.runtime.X` keep working
> while they migrate to `ccai.thisapp.X`.  New code imports from
> `ccai.thisapp` directly.

### `RuntimeVariablesDesc` / `RuntimeVariables` — the runtime descriptor set

[`runtimevariables.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/runtimevariables.py)
declares `RuntimeVariablesDesc`, which *subclasses*
`PersonaVariablesDesc` — so a single descriptor set carries both the
persona fields and the runtime fields.  It is a large set: build
metadata (`AM_BUILD_RELEASE`, `AM_BUILD_BRANCH`, `AM_BUILD_NAME`,
`AM_BUILD_URL`), pipeline identity (`AM_PIPELINE_ID`,
`AM_PIPELINE_NAME`, `AM_PIPELINE_INSTANCE`), job identity
(`AM_JOB_ID`, `AM_JOB_NAME`, `AM_JOB_TYPE`, `AM_JOB_SEED`, …),
diagnostics knobs, logging levels, the output directory, the
test-result path templates, and more.  Each descriptor follows the
same `AppVarDesc` contract — `default`, `converter`, `context_path`
— so every runtime value resolves and publishes through the exact
pipeline the persona variables use.

The companion accessor class `RuntimeVariables` (subclass of
`PersonaVariables`) exposes the typed read-through views.  Call-site
code reads `RuntimeVariables.AM_JOB_TYPE` and gets the resolved,
typed value; resolution code passes
`RuntimeVariablesDesc.AM_JOB_TYPE` to `get_variable_value` /
`override_variable_value`.

A small free function, `resolve_runtime_variables()`, performs the
one-time resolution of the whole runtime set: it coerces the
start-time and shared-output-directory values, then calls
`this_app.resolve_variable_set(RuntimeVariablesDesc)` so every
descriptor with a `context_path` publishes into the shared context.
It guards against double resolution with a module-level
`RUNTIME_VARIABLES_RESOLVED` flag.

### Well-known paths

[`paths.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/paths.py)
turns the resolved runtime variables into concrete on-disk
locations.  The shipped helpers include:

- `get_presence_directory()` — the application's home base, derived
  from `RuntimeVariables.AM_PRESENCE_DIR`.
- `get_path_for_output()` — the timestamped directory where results
  and artifacts are deposited.
- `get_path_for_testresults()` — the testresults directory.
- `get_path_for_testcase_by_products(test_id)` — a per-test-case
  by-products folder under the testresults directory.
- `get_path_for_diagnostics(label)` and `get_path_for_artifacts(label)`
  — labelled subfolders of the output directory.
- `get_summary_html_template_source()`,
  `get_summary_static_resource_dest_dir()`,
  `get_summary_static_resource_src_dir()` — the HTML-summary template
  and static-resource locations.

Several of these are *activation-gated*: helpers like
`get_path_for_diagnostics`, `get_path_for_testresults`, and
`get_path_for_testcase_by_products` raise `SemanticError` when
`RuntimeVariables.AM_JOB_TYPE == JobType.Unknown` — i.e. when the
process was *not* activated as a job.  This is the same
errors-never-silent discipline applied to the directory layout: a
plain, non-activated process is told plainly that the presence-folder
results tree does not exist for it.

> **Consequence for testbase.**  Plain `codecraftai-testbase` runs
> *without* activation — it records to stdout, has no presence
> folder, and never touches `RuntimeVariables` or this-app.  The
> presence-folder results layout, JSOS-to-disk streaming, HTML
> summary, and CI/build metadata are all the responsibility of
> `IntegratedTestJob` in `codecraftai-integration-runtime`, which
> activates the process as a job and *then* delegates to these
> path helpers.

### Profile and job enumerations

[`enumerations.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/enumerations.py)
defines two string enums that govern *how* a process is activated:

```python
class ActivationProfile(str, Enum):
    Command = "command"
    Console = "console"
    Job = "job"
    Service = "service"

class JobType(str, Enum):
    Unknown = "Unknown"
    Build = "Build"
    Infrastructure = "Infrastructure"
    Orchestration = "Orchestration"
    TestRun = "TestRun"
```

`ActivationProfile` selects the *shape* of the process — a one-shot
command, an interactive console, a long-running service, or a job
that collects by-products into an output folder.  `JobType`
sub-classifies a job and drives which results subtree the activation
sequence builds (`Build`, `Infrastructure`, `Orchestration`,
`TestRun`).

### The activation sequence

[`activation.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/activation.py)
ties the enumerations, the runtime variables, and the well-known
paths together.  It exposes one function per profile —
`activate_profile_command()`, `activate_profile_console()`,
`activate_profile_service(...)`, and `activate_profile_job(job_type)`
— each of which:

1. **Guards against double activation** — if
   `RuntimeVariables.AM_ACTIVATION_PROFILE` is already set, it
   refuses (a `SemanticError` for most profiles), because a process
   has exactly one persona-shape.
2. **Records the profile** — overrides
   `RuntimeVariablesDesc.AM_ACTIVATION_PROFILE` with the chosen
   `ActivationProfile`.
3. **Runs the common activation** — `_activate_profile_common`
   resolves the start-time-stamped output directory appropriate to
   the `JobType`, assigns a job id if one was not supplied, and
   overrides `AM_OUTPUT_DIRECTORY` accordingly.
4. **Activates logging** — wires up file, console, or rotating
   service logging through the `ccai.xmodules.xlogging` helpers.

`activate_profile_job(JobType.TestRun)` is the entry point an
integrated test run uses.  In `codecraftai-integration-runtime`, the
activation init (`establish_app_persona` →
`resolve_runtime_variables` → `activate_profile_job(JobType.TestRun)`)
is what populates the presence-folder output context and the
`RuntimeVariables`-sourced run metadata that `IntegratedTestJob`
records against.  This chapter's tier supplies the mechanism; that
package supplies the policy of *when* to invoke it.

## Cross-references

The identity machinery is used by every higher tier:

- **Volume 0 chapter 9 (Configuration loading)** uses
  `AppVarDesc` and `AppVarAccessor` as the typed descriptor for
  every configuration variable; `ConfigVariablesDesc` extends
  `PersonaVariablesDesc`.
- **Volume 0 chapter 8 (Runtime variables and well-known
  paths)** goes deeper on the runtime-related variables
  (`AM_RUN_ID`, `AM_INTERACTIVE_CONSOLE`, etc.) and the
  activation sequence — all of which, per the consolidation, now
  live in `ccai.thisapp` alongside the persona machinery rather
  than in a separate `codecraftai-runtime` package.
- **Volume 3 chapter 14 (Configuration profiles as a
  discipline)** proposes the `ContextPaths.ACTIVE_PROFILE`
  addition.  The chapter is using the same machinery this
  chapter introduces.
- **Volume 3 chapter 16 (Reproducing CI failures locally)**
  captures the resolved variables — the snapshot is a JSON
  serialisation of `AppVarSet.items()` results across all
  variable sets.

The pattern is consistent throughout the portfolio: identity is
managed by `ThisApplication`; values are typed through
descriptors; publication is through well-known context paths;
overrides are explicit and traceable.

## What this machinery is *not*

Three deliberate non-features.

### Not a generic dependency-injection container

`ThisApplication` carries identity and configuration variables.
It does *not* carry arbitrary application objects.  When the
application has a database connection, a cache client, an HTTP
session — those live in their own singletons or are passed
explicitly to functions.  The identity machinery is not the
right home for them.

### Not multi-application

The `ThisApplication` singleton is per-process.  When two
applications share a process (test harnesses, plugin
ecosystems), only one persona can be established.  When
isolation between two applications matters, they need separate
processes.

This is consistent with the philosophy's *simple-is-better-than-
complex* principle.  A multi-application machinery would be
heavier; the per-process model is sufficient for almost every
use case.

### Not stateful across restarts

The `ThisApplication` state lives in process memory.  When the
process restarts, the singleton is reconstructed from the
persona — but the override map and the runtime context are
fresh.  When state needs to *persist*, the application writes
it to the settings file or to disk.

The chapter-16 capture-and-replay pattern is the explicit
mechanism for cross-process state recovery.  The discipline does
not pretend that runtime overrides survive process boundaries
without that machinery.

## Summary

- **`codecraftai-this-app`** provides the identity and
  presence machinery every running process in the portfolio
  uses.  Two-phase startup: `establish_app_persona(...)` first
  (sets identity), then `ThisApplicationSingleton()` (gets the
  configured singleton).  Calling the singleton without
  establishing the persona raises `SemanticError`.
- The persona has **seven fields**: `name`, `logger_name`,
  `log_basename`, `presence_dir`, `log_directory`,
  `settings_file`, `extensions`, `service_name`.  Three are
  required; the rest have sensible defaults derived from
  `name`.
- **`ThisApplication`** exposes seven read-only properties (the
  persona fields) plus four methods (`context_insert`,
  `context_lookup`, `get_variable_value`,
  `override_variable_value`, `resolve_variable_set`).
- The **variable-descriptor model** has three classes:
  `AppVarDesc` (the data), `AppVarSet` (a group of related
  descriptors), `AppVarAccessor` (typed read-through at the
  call site).  Each subsystem ships its own descriptor set.
- **Variable resolution** has a deterministic order: override →
  environment → settings → default.  Inspectable; predictable.
- **Override propagation**: `override_variable_value` writes to
  the override map *and* to `os.environ` (for subprocess
  inheritance) *and* hot-swaps matching fields on
  `ThisApplication` for a small set of identity-critical
  variables.
- The **shared context** is a path-keyed key-value store
  accessible through `ThisApplication.context`.  Well-known
  paths live in `ContextPaths`, a string enum that's both a
  name and a path string for grep-and-rename safety.
- **Variable descriptors with `context_path`** automatically
  publish their resolved values to the context, making the
  resolution pipeline observable end-to-end.
- The tier now also **owns the absorbed runtime foundation** (moved
  here from the retired `codecraftai-runtime`):
  `RuntimeVariablesDesc` / `RuntimeVariables` and
  `resolve_runtime_variables` (runtime descriptor set), the
  well-known-paths helpers in `paths.py` (several activation-gated
  via `JobType.Unknown`), the `ActivationProfile` / `JobType`
  enumerations, and the `activate_profile_*` sequence in
  `activation.py`.  Plain testbase runs *without* this layer;
  `codecraftai-integration-runtime` layers it back on for integrated
  runs.
- The machinery is *not* a generic dependency-injection
  container, *not* multi-application, *not* persistent across
  restarts.  Each scoping choice is deliberate.

The next chapter introduces the extension and factory machinery
— the precedence-ordered factory model that lets the portfolio
plug new behaviour into existing extension points without
modifying the consumers of those extensions.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/07-extension-and-factory-machinery -->
# Chapter 7 — Extension and Factory Machinery

> *Some packages need to plug new behaviour into existing extension
> points without modifying the consumers of those extensions.
> The factory machinery in `codecraftai-extension` is the formal
> mechanism that makes this possible — declaratively, with
> precedence ordering, with substitutability.*

This chapter introduces
[`codecraftai-extension`](https://github.com/code-craft-ai-company/codecraftai-extension/tree/main/source/packages/ccai/extension),
the foundation-tier package whose job is to provide the
plug-in machinery the rest of the portfolio uses.  The package
defines three kinds of artifact:

- **`FactoryProtocolBase`** — the base of every factory protocol;
  declares what kind of factory it is.
- **`FactoryExtension`** — the base of every concrete factory;
  registers itself by precedence.
- **`SuperFactory`** — the engine that walks discovered factories
  and produces instances by protocol.

The pattern is *not* the GoF Factory pattern in textbook form.
It's a *registration-based, precedence-ordered, multi-source*
factory model that deals with the realities of an
`codecraftai-*`-style portfolio: many packages, each contributing
factories that override or supplement the base.

## The problem the machinery solves

Consider a sysman feature that needs to *create a network
adapter representation* on a per-OS basis.  The base sysman
package defines a generic `NetworkAdapter` class.  RHEL needs a
`RhelNetworkAdapter` with NetworkManager integration.  Ubuntu
needs an `UbuntuNetworkAdapter` with netplan handling.  macOS
needs a `MacOSNetworkAdapter` with `networksetup` shelling.
Windows needs PowerShell-driven implementations.  UEFI doesn't
need network adapters at all.

The naive solution: a big switch statement keyed on the host
OS.  A `if os == "rhel": return RhelNetworkAdapter()` chain.
This works for one extension point and grows untenable across a
portfolio.  Every new OS adds branches; every new extension
point duplicates the switch.

The `codecraftai-extension` solution: each per-OS package
*registers* a factory that produces its OS-specific
implementation.  The `SuperFactory` discovers all registered
factories at startup, sorts them by precedence, and dispatches
calls.  Adding a new OS is a *new package* with a registered
factory; the consumer code doesn't change.

## The three artifact types

### `FactoryProtocolBase` — the contract for protocols

[`factoryprotocolbase.py`](https://github.com/code-craft-ai-company/codecraftai-extension/blob/main/source/packages/ccai/extension/factory/factoryprotocolbase.py)
defines the marker base for any factory protocol declaration.  In
its entirety:

```python
class FactoryProtocolBase(Protocol):

    factory_protocol_name = None

    def __init_subclass__(cls, *args, **kwargs):
        if cls.factory_protocol_name is None:
            errmsg = (
                "The 'factory_protocol_name' class level field must be set on derived protocol types."
            )
            raise RuntimeError(errmsg)
        return super().__init_subclass__(*args, **kwargs)
```

Two things to note:

- The base is a `typing.Protocol`, *not* an abstract base class.
  Implementers don't inherit from `FactoryProtocolBase`
  directly; they inherit from a *derived protocol* that
  declares the methods of the protocol.
- `__init_subclass__` enforces that every derived protocol sets
  `factory_protocol_name` to a unique string.  A protocol that
  forgets this raises `RuntimeError` at class definition time —
  *before* any instances are created.

A concrete protocol declaration in a higher tier looks like:

```python
class NetworkAdapterFactoryProtocol(FactoryProtocolBase):

    factory_protocol_name = "network_adapter"

    def get_network_adapter_types(self) -> List[Type[NetworkAdapter]]:
        ...
```

The protocol declares *one method*: how to enumerate the
network-adapter types this factory provides.  Different
protocols can have different methods; the only universal
constraint is `factory_protocol_name`.

### `FactoryExtension` — the base for concrete factories

A *concrete factory* is a class that implements one or more
factory protocols.  It inherits from `FactoryExtension`:

```python
class RhelFactoryExtension(FactoryExtension, NetworkAdapterFactoryProtocol):

    PRECEDENCE = 100   # higher = later = wins overrides

    @classmethod
    def get_network_adapter_types(cls) -> List[Type[NetworkAdapter]]:
        return [RhelNetworkAdapter]
```

Two things this declares:

- The class implements `NetworkAdapterFactoryProtocol` (declared
  by the higher tier).
- The class has a `PRECEDENCE` class attribute — a number that
  controls dispatch order.  Higher numbers win when multiple
  factories implement the same protocol.

The `PRECEDENCE` value is the chapter's substantive design
choice.  When two factories implement the same protocol, the
`SuperFactory`'s default behaviour is to walk them by descending
precedence and use the first one that produces a non-None
result.  This means: a project that wants to override the
default RHEL implementation ships its own factory with a higher
`PRECEDENCE`; the override automatically wins.

### `SuperFactory` — the dispatch engine

[`superfactory.py`](https://github.com/code-craft-ai-company/codecraftai-extension/blob/main/source/packages/ccai/extension/factory/superfactory.py)
is the engine.  Its job: discover all registered factory
classes at startup, sort them by precedence, and provide
dispatch methods for protocol-specific lookups.

The discovery happens through a known package namespace:
`ccai.factories`.  Every package that ships factory classes
puts them under that namespace; the `SuperFactory` scans
`sys.path` for the namespace, walks every package found,
imports every module, and collects every `FactoryExtension`
subclass:

```python
def scan_ccai_factories_namespace() -> List[str]:
    search_paths = set([p for p in sys.path])
    rel_amr_factories = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
    search_paths.add(rel_amr_factories)

    modules_found = []

    for dir_path in search_paths:
        check_dir = os.path.join(dir_path, "ccai", "factories")
        if os.path.exists(check_dir) and os.path.isdir(check_dir):
            check_dir_modules = scan_for_descendant_modules(check_dir)
            modules_found.extend(check_dir_modules)

    return modules_found
```

The pattern is *namespace-package-driven*.  Python's namespace
package mechanism lets multiple installed packages contribute
to the same dotted namespace — `codecraftai-sysman-linux-redhat`
contributes `ccai.factories.sysman.linux.redhat`,
`codecraftai-sysman-linux-ubuntu` contributes
`ccai.factories.sysman.linux.ubuntu`, and so on.  The
`SuperFactory` doesn't care which package contributed which
factory; it sees them all in the unified namespace.

After discovery, the `SuperFactory` sorts by precedence (using
`operator.attrgetter('PRECEDENCE')`) and exposes dispatch
methods.

## The five dispatch modes

The `SuperFactory` provides five dispatch methods, each suited
to a different consumption pattern:

### `create_instance_by_order(get_type_method)`

Walks all factories in precedence order, calls
`get_type_method` on each, and returns an instance of the
*last* type returned (the lowest-precedence factory's type).
This is the "fallback to default" mode — the highest-precedence
factory's contribution can be `None` (returning nothing) and
the dispatch falls through to lower-precedence factories.

### `create_instance_for_each(get_type_method)`

Walks all factories and returns a list of instances, one per
factory that returned a non-None type.  Use when the consumer
wants *all* contributed implementations.

### `get_override_types_by_order(get_type_method)`

Like `create_instance_by_order` but returns the *type*, not an
instance.  The caller instantiates the type itself.  Use when
the consumer needs control over construction arguments.

### `iterate_override_types_for_each(get_type_method, iterate_type_lists=True)`

A generator that yields each contributed type one by one.  When
a factory's `get_type_method` returns a *list* of types, the
generator yields each type in the list separately (controlled by
the `iterate_type_lists` parameter — note: per the recent
hygiene-sweep typo fix, the parameter name was corrected from
the previous `interate_type_lists`).

This is the workhorse dispatch — most consumer code uses this to
iterate every contribution.

### `iterate_override_types_for_each` use-case

A typical consumer:

```python
super_factory = ConfiguredSuperFactorySingleton()

for adapter_type in super_factory.iterate_override_types_for_each(
        NetworkAdapterFactoryProtocol.get_network_adapter_types
):
    register_adapter_type(adapter_type)
```

The consumer doesn't know which packages contributed which
adapter types.  It walks the contributions in precedence order
and registers each one.  The same loop works whether the host
is RHEL, Ubuntu, macOS, or has multiple OS-specific factories
installed.

## How factories register

A package that wants to contribute a factory does three things:

### 1. Create the namespace-package directory

The package's source layout includes
`ccai/factories/<scope>/`:

```
codecraftai-sysman-linux-redhat/
└── source/packages/
    └── ccai/
        └── factories/
            └── sysman/
                └── linux/
                    └── redhat/
                        ├── __init__.py
                        └── networkadapterfactory.py
```

The directories are *namespace packages* (no `__init__.py` until
the leaf, or empty `__init__.py`).  This is what lets multiple
installed packages contribute under the same `ccai.factories.*`
prefix without conflicting.

### 2. Define the factory class

`networkadapterfactory.py` contains:

```python
from ccai.extension.factory.factoryextension import FactoryExtension
from ccai.sysman.common.networkadapterfactoryprotocol import NetworkAdapterFactoryProtocol
from ccai.sysman.linux.redhat.networkadapter import RhelNetworkAdapter


class RhelNetworkAdapterFactoryExtension(FactoryExtension, NetworkAdapterFactoryProtocol):

    PRECEDENCE = 100

    @classmethod
    def get_network_adapter_types(cls):
        return [RhelNetworkAdapter]
```

The class is a `FactoryExtension` and implements the protocol.
Its `PRECEDENCE` is set; its method returns the contributed
type(s).

### 3. Install the package

`pip install codecraftai-sysman-linux-redhat`.  Python's namespace
package mechanism merges the package's `ccai.factories.*`
contribution into the `ccai.factories` namespace.  The
`SuperFactory`'s next discovery pass finds the new factory.

That's the full registration.  No central registry, no manual
imports, no init-time configuration.  The package is installed;
the factory is discovered.

## The precedence ordering

`PRECEDENCE` is a class attribute — an integer.  Higher numbers
*win* when the dispatch reaches a "use the first one" mode.

The convention across the portfolio:

- `PRECEDENCE = 100` — the *base* contribution from a foundation
  package.
- `PRECEDENCE = 200` — a *standard override* from a per-OS or
  per-environment package.
- `PRECEDENCE = 300+` — a *project-specific override* from a
  consuming application.

The numbers are conventions, not enforced.  When two factories
have the same `PRECEDENCE`, the dispatch order is *insertion
order* (which is determined by namespace-walking order — not
predictable across reinstalls).  The discipline is: don't share
`PRECEDENCE` values between factories that implement the same
protocol.

## What the factory machinery is *not*

Three deliberate non-features.

### Not a service locator

The factories return *types*, not *instances*.  The consumer
instantiates the type and manages the lifetime of the instance.
This is consistent with the philosophy's
*explicit-is-better-than-implicit* principle — service-locator
patterns hide instance lifetime, which complicates resource
management.

The five dispatch methods include both type-returning forms
(`get_override_types_by_order`,
`iterate_override_types_for_each`) and instance-returning forms
(`create_instance_by_order`, `create_instance_for_each`).
Consumers that want types use the first; consumers that want
instances use the second.  Both are explicit about which mode
they're using.

### Not runtime-mutable

Factories are discovered at startup and sorted once.  After
that, the factory list is fixed.  Dynamically registering a
factory at runtime — say, after a plugin is installed — requires
re-running discovery.  The shipped `SuperFactory` doesn't
expose an API for this; consumers that need it call the
discovery functions directly.

This is consistent with chapter-1's *stability-over-performance*
principle.  A runtime-mutable factory list would buy
hot-reload support at the cost of locking and synchronisation;
the shipped form is simpler and covers the typical use case.

### Not a dependency-injection container

The factories produce types that satisfy a protocol.  They do
*not* declare dependencies between types, do *not* automatically
construct dependency graphs, do *not* manage transitive
lifetimes.  Each factory is independent; each factory call is
independent; composition is the consumer's responsibility.

When the consumer's needs grow into "I want a type *and* its
dependencies wired up," the right answer is either to extend
the factory protocol (each factory now returns a wired
instance) or to bring in an actual DI container (a separate
tool entirely).  The chapter-2 *if-it's-hard-to-explain-try-
another-way* principle gates the decision.

## How `codecraftai-this-app` configures the SuperFactory

The `codecraftai-this-app` package's `wellknown.py` exposes a
helper:

```python
def ConfiguredSuperFactorySingleton():
    global CONFIGURED_SUPER_FACTORY

    if CONFIGURED_SUPER_FACTORY is None:
        # ... discovery + configuration ...
        CONFIGURED_SUPER_FACTORY = build_super_factory()

    return CONFIGURED_SUPER_FACTORY
```

The pattern: *one* `SuperFactory` instance per process, lazily
constructed on first use.  Subsystems that need factory
dispatch call `ConfiguredSuperFactorySingleton()` and use the
returned instance; the discovery happens once.

The `AppPersona.AM_EXTENSIONS` field (chapter 6) carries a
semicolon-separated list of *additional* extension modules the
`SuperFactory` should scan beyond the default
`ccai.factories` namespace.  This is the project-specific
extension hook — a project ships its own factories under a
different namespace and points the persona at it.

## Cross-references

The factory machinery is used by:

- **Volume 4 (Sysman)** for the per-OS implementation dispatch
  catalogued in chapter 2 of that volume.
- **Volume 5 (Interop)** for the protocol implementation
  registry — DNS, SSH, serial, power, etc.  Each protocol
  implementation registers as a factory under
  `ccai.factories.interop.*`.
- **Volume 6 (Landscape)** for the device-detail-mapper
  registry — UPnP devices, networking devices, etc.

The pattern is consistent: each package contributes factories;
the consumer dispatches by protocol; the discovery is automatic.

The chapter-23 of Volume 3 (Extending the Task Model) is the
*opposite* pattern — there, the workflow YAML's `tasktype:
module@Class` reference is *direct*, not factory-mediated.  The
choice is deliberate: workflow tasks are *named explicitly* by
the workflow author, who wants visibility into which task is
selected; cross-OS implementations are *dispatched* by the
runtime, which abstracts the choice from the consumer.  Both
patterns coexist; the choice between them is the chapter-1
*explicit-vs-implicit* question applied to extension dispatch.

## Summary

- **`codecraftai-extension`** ships the registration-based,
  precedence-ordered factory machinery that lets packages plug
  new behaviour into existing extension points without
  modifying the consumers.
- Three artifact types: **`FactoryProtocolBase`** (the marker
  for a factory protocol; enforces unique
  `factory_protocol_name`), **`FactoryExtension`** (the base
  for concrete factories; declares `PRECEDENCE`),
  **`SuperFactory`** (the dispatch engine).
- Discovery is **namespace-package-driven**.  Packages ship
  factories under the `ccai.factories.*` namespace; the
  `SuperFactory` scans `sys.path` for the namespace and
  imports every contributed module.  Adding a new factory is a
  new package install; no central registry.
- The **five dispatch modes** —
  `create_instance_by_order`, `create_instance_for_each`,
  `get_override_types_by_order`,
  `iterate_override_types_for_each`, plus the per-protocol
  custom dispatchers — give consumers a choice between
  type-returning and instance-returning forms.
- **Precedence convention**: 100 (base), 200 (per-OS / per-env
  override), 300+ (project-specific override).  Higher wins.
- The machinery is *not* a service locator (returns types, not
  managed instances), *not* runtime-mutable (discovery happens
  at startup), *not* a DI container (no transitive dependency
  graph).
- `ConfiguredSuperFactorySingleton()` from
  `codecraftai-this-app/wellknown.py` is the per-process singleton.
  Discovery is lazy on first call; the
  `AppPersona.AM_EXTENSIONS` field configures additional
  namespaces beyond `ccai.factories`.
- Used by Volume 4 (sysman per-OS dispatch), Volume 5
  (interop protocol registry), Volume 6 (device-detail
  mappers).  The pattern is universal across the portfolio.

The next chapter introduces the runtime variables and well-known
paths — the `codecraftai-runtime` package that builds on chapter 6's
identity machinery to give every running process a structured
view of its runtime environment.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/08-runtime-variables-and-well-known-paths -->
# Chapter 8 — Runtime Variables and Well-Known Paths

> *Application identity is structural; runtime is operational.
> The runtime layer is what every running process inherits the
> moment its persona is established and its activation profile
> is selected.*

The previous chapter introduced the extension and factory
machinery.  This chapter introduces the *runtime* variable set
and the *well-known path* helpers every higher tier uses.  These
modules now live inside
[`codecraftai-this-app`](https://github.com/code-craft-ai-company/codecraftai-this-app/tree/main/source/packages/ccai/thisapp),
under the `ccai.thisapp` namespace alongside the chapter-6
persona machinery.  (Historically they were a separate
`codecraftai-runtime` package; that package is being retired and now
holds thin re-export shims that forward `ccai.runtime.X` to
`ccai.thisapp.X`.  Update any `ccai.runtime.*` imports to
`ccai.thisapp.*`.)

> **As-built note.**  The runtime variable set, the path helpers,
> the activation profiles, and the enumerations were all folded
> into `codecraftai-this-app` so that the variable-descriptor model
> and the operational variables ship from one tier.  This also let
> `codecraftai-testbase` become near-standalone: testbase does *not*
> import `RuntimeVariables` or these path helpers at all.  See the
> sidebar later in this chapter, "What testbase actually uses".

The runtime-related contents of `ccai.thisapp` are organised
into these modules:

- **`runtimevariables.py`** — `RuntimeVariablesDesc` and
  `RuntimeVariables` extending the chapter-6 persona variable
  set, plus `DefaultValue`, the factory defaults, and
  `resolve_runtime_variables`.
- **`paths.py`** — well-known directory helpers (cache,
  presence, output, results, diagnostics, test-results,
  by-products, temp, summary-template).
- **`activation.py`** — activation-profile commands that
  configure logging and behaviour for the running process.
- **`enumerations.py`** — `JobType` and `ActivationProfile`.

The programmatic override helpers (`optionoverrides.py`) have not
yet been moved into `ccai.thisapp`; they currently still live
in `codecraftai-runtime` (see the section on option overrides
below).

## The runtime variable set

[`runtimevariables.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/runtimevariables.py)
extends `PersonaVariablesDesc` (chapter 6) with the variables
every running process needs for *operational* concerns —
distinct from chapter-6's *identity* concerns.

```python
class RuntimeVariablesDesc(PersonaVariablesDesc):

    AM_ACTIVATION_PROFILE = AppVarDesc(default=None)
    AM_AUTOMATION_POD = AppVarDesc(default=DefaultValue.NotSet)

    AM_BUILD_RELEASE = AppVarDesc(default=DefaultValue.NotSet,
                                   context_path=ContextPaths.BUILD_RELEASE)
    AM_BUILD_BRANCH = AppVarDesc(default=DefaultValue.NotSet,
                                  context_path=ContextPaths.BUILD_BRANCH)
    AM_BUILD_NAME = AppVarDesc(default=DefaultValue.NotSet,
                                context_path=ContextPaths.BUILD_NAME)
    AM_BUILD_URL = AppVarDesc(default=DefaultValue.NotSet,
                               context_path=ContextPaths.BUILD_URL)

    AM_RUN_ID = AppVarDesc(default=DefaultValue.NotSet,
                            context_path=ContextPaths.RUNID)

    AM_PIPELINE_ID = AppVarDesc(default=DefaultValue.NotSet,
                                 context_path=ContextPaths.PIPELINE_ID)
    AM_PIPELINE_NAME = AppVarDesc(default=DefaultValue.NotSet,
                                   context_path=ContextPaths.PIPELINE_NAME)

    AM_JOB_ID = AppVarDesc(default=DefaultValue.NotSet,
                            context_path=ContextPaths.JOB_ID)
    AM_JOB_LABEL = AppVarDesc(default=DefaultValue.NotSet,
                               context_path=ContextPaths.JOB_LABEL)
    AM_JOB_NAME = AppVarDesc(default=DefaultValue.NotSet,
                              context_path=ContextPaths.JOB_NAME)
    AM_JOB_SEED = AppVarDesc(default=default_job_seed,
                              context_path=ContextPaths.JOB_SEED)
    AM_JOB_TYPE = AppVarDesc(default=DefaultValue.NotSet,
                              context_path=ContextPaths.JOB_TYPE)

    AM_INTERACTIVE_CONSOLE = AppVarDesc(default=False)

    AM_DEBUG_BREAKPOINTS = AppVarDesc(default=None,
                                       context_path=ContextPaths.DEBUG_BREAKPOINTS)
    AM_DEBUG_DEBUGGER = AppVarDesc(default=None,
                                    context_path=ContextPaths.DEBUG_DEBUGGER)

    # ... and many more
```

The descriptors fall into five thematic groups:

### Activation and pod identity

- **`AM_ACTIVATION_PROFILE`** — names the activation profile the
  process is running under (Command, Console, Job, Service —
  the `ActivationProfile` enum from `enumerations.py`).
- **`AM_AUTOMATION_POD`** — the apod identifier (Volume 3
  chapter 5's apod from the landscape).

### Build provenance

- `AM_BUILD_RELEASE`, `AM_BUILD_BRANCH`, `AM_BUILD_FLAVOR`,
  `AM_BUILD_NAME`, `AM_BUILD_URL` — the build identity of the
  code currently running.  Carried into traces and result
  reports for retrospective analysis.

### Run, pipeline, job identity

- `AM_RUN_ID` — UUID for the current run.
- `AM_PIPELINE_ID` / `AM_PIPELINE_NAME` /
  `AM_PIPELINE_INSTANCE` — CI pipeline metadata.
- `AM_JOB_ID` / `AM_JOB_LABEL` / `AM_JOB_NAME` /
  `AM_JOB_SEED` / `AM_JOB_TYPE` — the running job's identity.

### Debugging knobs

- `AM_INTERACTIVE_CONSOLE` — boolean indicating whether the
  process has an attached interactive console.
- `AM_DEBUG_BREAKPOINTS` — set of named breakpoints the process
  should honour (the `breakpoints:` field from the chapter-7
  runtime YAML in Volume 3).
- `AM_DEBUG_DEBUGGER` — which debugger to attach
  (`debugpy`, `pydevd`, `none`).

### Diagnostics policy

- `AM_DIAGNOSTICS_POSTRUN` / `AM_DIAGNOSTICS_PRERUN` — whether
  to capture diagnostics around runs.
- `AM_DIAGNOSTICS_TRACEBACK_POLICY_OVERRIDE` — override the
  default traceback policy (the chapter-1 errors-never-silent
  discipline applied to traceback verbosity).

### `AM_JOB_SEED` — the reproducibility seed

The `AM_JOB_SEED` descriptor uses a *factory function* as its
default, not a literal value:

```python
def default_job_seed():
    rtnval = str(uuid4())
    return rtnval

AM_JOB_SEED = AppVarDesc(default=default_job_seed,
                         context_path=ContextPaths.JOB_SEED)
```

The factory returns a fresh UUID on every resolution.  When the
caller doesn't override the seed, every run gets a new UUID;
when the caller *does* override (passing a captured seed for
replay — the chapter-16 of Volume 3 capture-and-replay pattern),
the override pipeline takes precedence.

This is the descriptor model's *graceful fallback* for values
that need to be unique per run but reproducible when
explicitly fixed.

## `RuntimeVariables` — the typed accessor

For consumer convenience, the package ships
`RuntimeVariables` — an `AppVarAccessor`-based class that
parallels `RuntimeVariablesDesc`:

```python
class RuntimeVariables(PersonaVariables):

    AM_ACTIVATION_PROFILE = AppVarAccessor(RuntimeVariablesDesc.AM_ACTIVATION_PROFILE)
    AM_RUN_ID = AppVarAccessor(RuntimeVariablesDesc.AM_RUN_ID)
    AM_JOB_SEED = AppVarAccessor(RuntimeVariablesDesc.AM_JOB_SEED)
    # ... and so on
```

A consumer reads `RuntimeVariables.AM_RUN_ID` and gets the
resolved UUID.  The accessor calls
`ThisApplicationSingleton().get_variable_value(descriptor)` under
the hood; the call site doesn't need to know.

This is the *typed-read-through* pattern from chapter 6
applied at scale.  Forty-some descriptors; forty-some accessors;
the call sites are uniform.

## Activation profiles

[`enumerations.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/enumerations.py)
defines the `ActivationProfile` enum with four values:

- **`Command`** — the process is running as a CLI command.
  Logs go to a standard file plus the console; user-interactive.
- **`Console`** — the process is an interactive console.  Logs
  go to a file (away from the screen) so log output does not
  disrupt user interaction; the output directory is a temp
  directory.
- **`Job`** — the process runs as a job that collects its
  by-products (logs, results) under an output folder.  A job
  carries a `JobType` (see below) — Build, Infrastructure,
  Orchestration, or TestRun.
- **`Service`** — the process is a long-running service.  Logs
  go to a rotating file handler; non-interactive.

The companion `JobType` enum names the kinds of job: `Unknown`
(the default until activation sets it), `Build`,
`Infrastructure`, `Orchestration`, and `TestRun`.  As the path
section below shows, the test-reporting path helpers are
*activation-gated* on `AM_JOB_TYPE` being something other than
`Unknown`.

[`activation.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/activation.py)
provides four `activate_profile_*` functions — one per profile:
`activate_profile_command`, `activate_profile_console`,
`activate_profile_service`, and `activate_profile_job(job_type)`
(the job activation takes the `JobType` to run under).  Each
function:

1. **Guards** against duplicate activation — reads
   `RuntimeVariables.AM_ACTIVATION_PROFILE` and raises if already
   set.  A process may activate exactly one profile.
2. **Sets the active profile** through `override_variable_value`.
3. **Runs common setup** (`_activate_profile_common`) that
   resolves the output directory appropriate to the job type.
4. **Configures logging** appropriate to the profile (file path,
   format, level, handler type).
5. **Returns** with the process ready to run.

```python
def activate_profile_command():

    # Guard against attemps to activate more than one, activation profile.
    if RuntimeVariables.AM_ACTIVATION_PROFILE is not None:
        errmsg = "An attempt was made to activate multiple environment activation profiles. profile={}".format(
            RuntimeVariables.AM_ACTIVATION_PROFILE
        )
        raise SemanticError(errmsg)

    this_app = ThisApplicationSingleton()

    this_app.override_variable_value(RuntimeVariablesDesc.AM_ACTIVATION_PROFILE, ActivationProfile.Command)

    _activate_profile_common(this_app)

    log_directory = RuntimeVariables.AM_LOG_DIRECTORY
    log_basename = RuntimeVariables.AM_LOG_BASENAME

    logging_location = LoggingLocation(log_directory, log_basename)

    console_level = RuntimeVariables.AM_LOG_LEVEL_CONSOLE
    file_level = RuntimeVariables.AM_LOG_LEVEL_FILE

    activate_standard_file_logging(logging_location, console_level, file_level)

    return
```

The pattern is consistent across the four activation functions —
guard against duplicate activation; set the profile; common
setup; profile-specific logging.  (One honest wrinkle:
`activate_profile_job` currently raises a plain `RuntimeError`
rather than `SemanticError` on the duplicate-activation guard,
where the other three raise `SemanticError`.)

The application's startup code calls *one* of the activate
functions:

```python
from ccai.thisapp.persona import establish_app_persona
from ccai.thisapp.activation import activate_profile_command

establish_app_persona(
    name="my-tool",
    logger_name="my-tool",
    log_basename="my-tool",
)
activate_profile_command()

# ... rest of the application ...
```

Two lines of startup; both required; both clearly named.  The
chapter-1 *explicit-is-better-than-implicit* discipline applied
to process initialization.

## Well-known paths

[`paths.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/paths.py)
provides helpers for the directories every running process needs.
Each helper returns an *absolute path*, creating the directory
if it doesn't exist.

Module-level globals cache the resolved paths:

```python
DIR_PRESENCE_DIRECTORY = None
DIR_CACHE_DIRECTORY = None
DIR_DIAGNOSTICS_DIRECTORY = None
DIR_RESULTS_DIRECTORY = None
DIR_SHARED_STORE_DIRECTORY = None
DIR_TESTRESULTS_DIRECTORY = None
DIR_TESTCASE_BYPRODUCTS_DIRECTORY = {}
```

Each helper checks its global, computes the path on first call,
caches it, and returns.  Subsequent calls return the cached
value.  The pattern keeps path resolution fast without exposing
the caching mechanism to callers.

The always-available helpers — these resolve from the persona /
output variables and do not require a job activation:

- **`get_presence_directory()`** — returns the application's
  well-known presence directory (`AM_PRESENCE_DIR`).
- **`get_directory_for_cached_files()`** — returns the
  per-application cache directory (`<presence_dir>/cache`).  Used
  for intermediate artifacts that persist across runs.
- **`get_path_for_output()`** — returns the output directory
  (`AM_OUTPUT_DIRECTORY`) where results and artifacts are
  deposited.
- **`get_path_for_shared_store()`** — returns the configured
  shared-store directory (raises `ConfigurationError` if
  `AM_SHARED_STORE_DIRECTORY` is unset).
- **`get_temporary_directory()` / `get_temporary_file(suffix, prefix)`** —
  a `temp/` directory under the output directory, and a temp
  file within it via `tempfile.mkstemp`.  Used by Volume 3's
  `ShellScript` task (chapter 19) to create the per-task temp
  script.
- **`collect_python_modules(search_dir, max_depth)`** — utility
  for module discovery; used by the chapter-7 factory machinery
  for namespace scanning.

The *activation-gated* helpers — these raise `SemanticError`
unless the process was activated as a job (i.e. `AM_JOB_TYPE` is
something other than `JobType.Unknown`).  They resolve paths that
only make sense inside a results-collecting job:

- **`get_path_for_artifacts(label)`** — `<output>/artifacts/<label>`.
- **`get_path_for_diagnostics(label)`** —
  `<output>/diagnostics/<label>`.  Used by the chapter-5
  final-attempt pattern when a wait fails and dumps state.
- **`get_path_for_testresults()`** — the test-results directory
  (`AM_RESULT_PATH_FOR_TESTS`, falling back to the output
  directory).
- **`get_path_for_testcase_by_products(test_id)`** — per-test-case
  scratch space under `<testresults>/tc-by-products/<test_id>`.
- **`get_summary_html_template_source()`**,
  **`get_summary_static_resource_src_dir()`**,
  **`get_summary_static_resource_dest_dir()`** — locate the HTML
  summary template and its static resources.

> **These activation-gated helpers are consumed by
> integration-runtime, not testbase.**  Their only caller is
> `RuntimeOutputContext` in
> [`codecraftai-integration-runtime`](https://github.com/code-craft-ai-company/codecraftai-integration-runtime/blob/main/source/packages/ccai/integrationruntime/runtimeoutputcontext.py),
> which `IntegratedTestJob` installs by overriding
> `TestJob._create_output_context()`.  Because the helpers raise
> `SemanticError` unless the process was activated as a job, this
> is precisely why *plain* `testbase` must not call them —
> standalone testbase runs without activation.

The directory layout is conventional:

```
<presence_dir>/
├── cache/
├── logs/
│   └── <log_basename>-<timestamp>.log
├── results/
│   └── testresults/
│       └── <starttime>/
│           ├── artifacts/<label>/
│           ├── diagnostics/<label>/
│           └── tc-by-products/<test_id>/
├── settings.config
└── ... (other artifacts)
```

A consumer that needs to write a file asks the helper for the
right directory; the directory is created if needed; the
consumer writes the file.  No path-construction code in
consumers; no per-application directory layout decisions.

## Option overrides

[`optionoverrides.py`](https://github.com/code-craft-ai-company/codecraftai-runtime/blob/main/source/packages/ccai/runtime/optionoverrides.py)
provides programmatic override helpers for runtime variables.
(Unlike the other runtime modules, this one has *not* yet been
folded into `ccai.thisapp` — it still lives in
`codecraftai-runtime`, importing `RuntimeVariablesDesc` from
`ccai.thisapp.runtimevariables`.)  The helpers are bundled as
static methods on an `AM_RUNTIME_OPTION_OVERRIDES` class; each
wraps an `override_variable_value` call for one knob.  The
pattern: a CLI tool's `--debug-debugger` flag sets
`AM_DEBUG_DEBUGGER`; a CLI tool's `--seed` flag sets
`AM_JOB_SEED`:

```python
class AM_RUNTIME_OPTION_OVERRIDES:

    @staticmethod
    def override_debug_debugger(debugger: str):
        this_app = ThisApplicationSingleton()
        this_app.override_variable_value(RuntimeVariablesDesc.AM_DEBUG_DEBUGGER, debugger)
        return

    @staticmethod
    def override_job_seed(job_seed: str):
        this_app = ThisApplicationSingleton()
        this_app.override_variable_value(RuntimeVariablesDesc.AM_JOB_SEED, job_seed)
        return
```

The CLI calls:

```python
@click.option("--debug-debugger", default=None, help="...")
@click.option("--seed", default=None, help="...")
def run_command(debug_debugger, seed):
    if debug_debugger is not None:
        AM_RUNTIME_OPTION_OVERRIDES.override_debug_debugger(debug_debugger)
    if seed is not None:
        AM_RUNTIME_OPTION_OVERRIDES.override_job_seed(seed)
    # ...
```

The CLI doesn't reach into the descriptor model directly — it
calls a *named override method* per knob.  When a knob is
renamed, the override method's signature stays stable; the
CLI doesn't change.  When a new knob is added, a new override
method joins the class; the CLI gets a new option that
calls it.

The pattern is the chapter-2 *named-parameter-passing*
discipline applied at the override layer.

## How the runtime layer is consumed

Three consumption patterns recur.

### Pattern 1: read a runtime variable

```python
from ccai.thisapp.runtimevariables import RuntimeVariables

run_id = RuntimeVariables.AM_RUN_ID
log_dir = RuntimeVariables.AM_LOG_DIRECTORY
job_seed = RuntimeVariables.AM_JOB_SEED
```

The accessor reads through the resolution pipeline (override →
environment → settings → default).  Cached after first read
within the resolution.

### Pattern 2: get a well-known path

```python
from ccai.thisapp.paths import (
    get_directory_for_cached_files,
    get_path_for_diagnostics,
    get_path_for_output,
)

cache_dir = get_directory_for_cached_files()
output_dir = get_path_for_output()
diagnostics_dir = get_path_for_diagnostics("connect-timeout")
```

The helper creates the directory if needed; subsequent calls
return the cached path.  (`get_path_for_diagnostics` is
activation-gated — it requires a job activation.)

### Pattern 3: activate the runtime profile

```python
from ccai.thisapp.activation import activate_profile_command

activate_profile_command()
```

One call.  The activation function does the rest — sets the
profile, configures logging, prepares the runtime environment.

## What the runtime layer is *not*

Three deliberate non-features.

### Not a job runner

`AM_JOB_*` variables identify the job, but the runtime layer
doesn't *run* jobs.  Job execution is Volume 1's territory
(`codecraftai-testbase`'s test job runner) and Volume 2's
(distributed test jobs).  The runtime layer provides identity
for whatever job is running; the running is elsewhere.

> **What testbase actually uses.**  A key consequence of the
> testbase decoupling is that *plain* `testbase` does **not**
> consume this runtime layer at all.  It does not import
> `RuntimeVariables`, the path helpers, or the activation
> functions.  Instead it reads the handful of core env knobs it
> needs (`AM_TESTROOT`, `AM_OUTPUT_DIRECTORY`, `AM_RUN_ID`,
> `AM_DEBUG_*`, `AM_DIAGNOSTICS_*`, `AM_TESTRUN_TIMEOUT`) directly
> from `os.environ` via a tiny, dependency-free module,
> [`ccai.testbase.environment`](https://github.com/code-craft-ai-company/codecraftai-testbase/blob/main/source/packages/ccai/testbase/environment.py).
> Standalone testbase runs *without* activation — no presence
> folder, no `RuntimeVariables`, no `codecraftai-this-app` — and
> records its run summary to stdout.  The full runtime variable
> set, the presence-folder results layout, and the rendered HTML
> summary are the responsibility of `IntegratedTestJob` in
> `codecraftai-integration-runtime`, which activates the process as a
> `JobType.TestRun` job and installs the `RuntimeOutputContext`
> shown above.

### Not a CI vendor adapter

`AM_PIPELINE_*` variables identify the CI pipeline, but the
runtime layer doesn't read GitLab CI's `$CI_PIPELINE_ID` or
GitHub Actions' `${{ github.run_id }}` automatically.  The
*adapter* is in the project — typically a small helper at
startup that reads the vendor's env vars and sets the runtime
variables:

```python
def import_vendor_pipeline_identity():
    if "CI_PIPELINE_ID" in os.environ:
        # GitLab CI
        AM_RUNTIME_OPTION_OVERRIDES.override_pipeline_id(os.environ["CI_PIPELINE_ID"])
        AM_RUNTIME_OPTION_OVERRIDES.override_pipeline_name(os.environ.get("CI_PIPELINE_NAME", "unnamed"))
    elif "GITHUB_RUN_ID" in os.environ:
        # GitHub Actions
        AM_RUNTIME_OPTION_OVERRIDES.override_pipeline_id(os.environ["GITHUB_RUN_ID"])
        AM_RUNTIME_OPTION_OVERRIDES.override_pipeline_name(os.environ.get("GITHUB_WORKFLOW", "unnamed"))
    # ... etc.
```

The adapter lives in the consuming application; the runtime
layer is vendor-neutral.

### Not multi-tenant

`AM_RUN_ID`, `AM_JOB_ID`, etc., are *per-process* identifiers.
A process running multiple jobs concurrently (a worker pool)
has *one* `AM_JOB_ID` at any time — typically updated as the
worker picks up each job.  When the runtime needs to track
multiple concurrent jobs, the consumer manages that explicitly;
the runtime layer's defaults assume sequential per-process
work.

## Cross-references

The runtime layer is consumed by:

- **Volume 0 chapter 9 (Configuration loading)** —
  `ConfigVariablesDesc` extends `RuntimeVariablesDesc`,
  inheriting all runtime variables and adding configuration-
  specific descriptors on top.
- **Volume 1 (Testing)** — the *integration* test runner
  (`IntegratedTestJob` in `codecraftai-integration-runtime`) reads
  `AM_JOB_*` for job identity, `AM_BUILD_*` / `AM_PIPELINE_*` for
  run metadata, and routes results through the presence-folder
  path helpers.  Plain `codecraftai-testbase` runs standalone and
  reads only its few core knobs from `os.environ` (see the
  testbase sidebar above) — it does not consume this layer.
- **Volume 3 (Orchestration)** — the chapter-21
  bootstrap-name flow propagates `AM_*_NAMES` from the runtime
  layer through to subprocess jobs.

The pattern is consistent across the portfolio: every running
process inherits the runtime layer; every higher tier extends
it with subsystem-specific descriptors; consumers read through
the typed accessors.

## Summary

- The runtime variable set, path helpers, activation profiles,
  and enumerations now live in **`ccai.thisapp`** (the old
  `codecraftai-runtime` package is being retired and holds only
  re-export shims).  They provide the *operational* runtime
  variable set plus well-known path helpers plus
  activation-profile commands.
- **`RuntimeVariablesDesc`** extends `PersonaVariablesDesc`
  (chapter 6) with ~40 descriptors organised into themes:
  activation/pod identity, build provenance, run/pipeline/job
  identity, debugging knobs, diagnostics policy.
- **`AM_JOB_SEED`** uses a factory-function default
  (`default_job_seed`) to produce a fresh UUID on every
  unspecified resolution.  Captured-replay (Volume 3 chapter
  16) overrides through the standard pipeline.
- **`RuntimeVariables`** is the typed accessor class — one
  accessor per descriptor, each invoking
  `get_variable_value` through the chapter-6 resolution
  pipeline.
- **Four activation profiles** — Command, Console, Job, Service.
  Each is activated by a single call to `activate_profile_*`
  (`activate_profile_job` takes a `JobType`); duplicate
  activation is guarded.  Each configures logging appropriately.
- **Well-known path helpers** — always-available ones
  (`get_presence_directory`, `get_directory_for_cached_files`,
  `get_path_for_output`, `get_temporary_file`) and
  *activation-gated* ones (`get_path_for_testresults`,
  `get_path_for_diagnostics`, `get_path_for_testcase_by_products`,
  the summary-template helpers) that raise `SemanticError` unless
  the process was activated as a job.  Each creates the directory
  on first call and caches the path.  The gated helpers are
  consumed by integration-runtime's `RuntimeOutputContext`, not
  by plain testbase.
- **Option overrides** (`AM_RUNTIME_OPTION_OVERRIDES`, still in
  `codecraftai-runtime`) — static methods that wrap
  `override_variable_value` calls per runtime variable.
  The CLI doesn't reach into the descriptor model; it calls
  named override methods.
- The runtime layer is *not* a job runner, *not* a CI vendor
  adapter (the adapter lives in the project), *not* multi-tenant;
  and plain `testbase` does *not* consume it at all.

The next chapter introduces `codecraftai-configuration` — the
configuration-loading machinery that builds on the runtime
variable model and provides the multi-source, multi-format,
optionally-encrypted file loader the rest of the portfolio
relies on.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/09-configuration-loading -->
# Chapter 9 — Configuration Loading

> *Where Volume 3 talked about *what* configuration to put in
> files, this chapter talks about *how the loader actually
> works* — the multi-source, multi-format, optionally-encrypted
> file machinery every higher tier relies on.*

The previous chapter introduced the runtime variable layer.
This chapter introduces
[`codecraftai-configuration`](https://github.com/code-craft-ai-company/codecraftai-configuration/tree/main/source/packages/ccai/configuration),
the foundation-tier package whose job is to *load* configuration
files — from disk, from HTTP, from CouchDB, from MongoDB —
parse them, optionally decrypt them, validate them, and produce
typed `MergeMap` objects the rest of the portfolio consumes.

Volume 3 used this package's machinery extensively without
explaining how the machinery works.  This chapter is the
missing piece.  It complements Volume 3 chapters 3 (the six
configuration categories) and 4–8 (the per-category file
formats) by walking the *loader internals* — the
`ConfigurationLoader`, the `ConfigurationSourceBase` hierarchy,
the cryptography primitives, and the resolution pipeline that
drives all of it.

## The loader's job

A consumer wants the data inside a configuration file by
*name*.  They say "give me the landscape named `staging-east`,"
and the loader handles:

1. **Source resolution** — which place(s) to look (a local
   directory, an HTTP endpoint, a CouchDB database)?
2. **Filename derivation** — what file in the source corresponds
   to `staging-east`?  (Tries `staging-east.yaml`,
   `staging-east.yml`, `staging-east.json` in order.)
3. **Format detection** — based on the extension that resolved.
4. **Reading** — fetching the bytes from wherever they are.
5. **Decryption** — when the bytes form an encrypted document.
6. **Parsing** — turning the bytes into a Python dict.
7. **Composition** — overlaying onto a `MergeMap` if the
   category supports composition.

Seven steps.  The consumer sees one call:

```python
config_uri, config_data = loader.load_configuration_by_name("staging-east")
```

The chapter walks the seven steps from bottom to top.

## `ConfigurationLoader` — the public surface

The
[`ConfigurationLoader`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationloader.py)
class is the consumer-facing entry point.  Constructed with a
list of source URIs:

```python
loader = ConfigurationLoader(
    sources=[
        "dir://~/.ccai/configurations/landscapes",
        "http://config.staging-east.internal/landscapes",
        "couchdb://config-couch.internal/landscapes",
    ],
    credentials={...},
    verify_certificate=True,
)
```

Three parameters:

- **`sources`** — list of source URIs.  Each URI's scheme
  selects the source class.  Sources are tried in order.
- **`credentials`** — credential mapping for sources that need
  authentication.
- **`verify_certificate`** — TLS certificate verification flag
  for HTTPS sources.

The class exposes two main methods:

```python
def load_configuration_by_name(self, config_name: str,
                                key: Optional[str] = None,
                                keyphrase: Optional[str] = None) -> Tuple[str, dict]:
    """Returns (source_uri, parsed_config_dict)."""

def load_configuration_from_file(self, config_file: str,
                                  key: Optional[str] = None,
                                  keyphrase: Optional[str] = None) -> dict:
    """Returns parsed_config_dict for a single explicit file path."""
```

The first method walks every source until one returns a
configuration; the second loads a specific file directly.  Both
take optional `key` or `keyphrase` for encrypted documents.

When neither method finds a configuration, the loader raises
`ConfigurationError` (chapter 3's must-bubble type) listing
every source URI it tried.  The error is *findable*; the
engineer reading the failure knows where the loader looked
without having to instrument anything.

## The source hierarchy

Every source extends
[`ConfigurationSourceBase`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/sources/configurationsourcebase.py),
the abstract base that declares two methods:

```python
class ConfigurationSourceBase:

    scheme: str = None    # subclasses override

    @classmethod
    def parse(cls, uri: str) -> Optional["ConfigurationSourceBase"]:
        ...

    def try_load_configuration(self, config_name: str,
                                credentials: Optional[Dict] = None
                                ) -> Tuple[Optional[ConfigurationFormat], Optional[dict]]:
        ...
```

Two methods, one class attribute:

- **`scheme`** — the URI scheme this source handles (`dir`,
  `http`, `couchdb`, `mongodb`).
- **`parse(uri)`** — given a URI, construct an instance of this
  source class if the URI matches the scheme; otherwise return
  `None`.
- **`try_load_configuration(name, credentials)`** — try to load
  the named configuration; return `(format, dict)` on success
  or `(None, None)` if not found.

The shipped source classes:

### `DirectorySource` — filesystem

URIs of the form `dir://path/to/directory`.  Loads files from a
local filesystem.  Implementation detail worth naming: when the
URI doesn't start with `dir://`, the source treats the entire
URI as a directory path — so `~/.ccai/configurations` works
without the prefix.

The directory is expanded through `os.path.expandvars` and
`os.path.expanduser`, then queried with the standard extension
fallback (`.yaml`, `.yml`, `.json` in order).

### `HttpSource` — HTTP / HTTPS endpoints

URIs of the form `http://host/path` or `https://host/path`.
Loads files via HTTP GET requests.  The same extension fallback:
the source tries `<base_url>/<name>.yaml`, then `.yml`, then
`.json`, and returns the first that responds with HTTP 200.

The `verify_certificate` flag from the loader controls TLS
verification; the `credentials` mapping is consulted for HTTP
basic-auth.

### `CouchDbSource` — CouchDB documents

URIs of the form `couchdb://host:port/database`.  Loads
configurations as CouchDB documents — each document's `_id` is
the configuration name.  The `pycouchdb` (or equivalent) library
is required.

The chapter-9 (Volume 3) cross-reference: a `CouchDbProfile`
data profile points at the same kind of CouchDB.  The two
packages share the same protocol — `codecraftai-dataprofiles`'s
profile is the *connection*; `codecraftai-configuration`'s source
is the *configuration storage* using the same connection
mechanics.

### `MongoDbSource` — MongoDB documents

URIs of the form `mongodb://host:port/database`.  Loads
configurations as MongoDB documents.  Same pattern as CouchDB;
the `pymongo` library is required.

## The format detection

Per the configuration loader's logic, file format is detected
by *extension*:

```python
_FORMAT_BY_EXTENSION = {
    ".yaml": ConfigurationFormat.YAML,
    ".yml": ConfigurationFormat.YAML,
    ".json": ConfigurationFormat.JSON,
}
```

`ConfigurationFormat` is the small enum from
[`configurationformat.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationformat.py):

```python
class ConfigurationFormat(str, Enum):
    JSON = "json"
    YAML = "yaml"
```

A string-enum dual-citizenship class.  The same pattern as
chapter 6's `ContextPaths` — both a name and a value, greppable
and rename-safe.

The format dictates the parser:

- YAML → `yaml.safe_load(content)`
- JSON → `json.loads(content)`

The choice of `yaml.safe_load` (not `yaml.load`) is the
chapter-1 *errors-never-silent* discipline applied to YAML
parsing — `yaml.load` accepts arbitrary Python object
constructors, which is a security risk; `safe_load` rejects them.

## Encrypted configurations

When the loader receives a parsed dict and detects an
`encrypted_content` key, it activates the cryptography path.

### The encrypted document format

Encrypted configurations have a known shape:

```yaml
version: "1.0"
format: yaml          # the inner content's format
encrypted_content: "BASE64-CIPHERTEXT-HERE"
```

The
[`codecraftai-configuration/cryptography.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/cryptography.py)
module produces and consumes documents in this shape.  Volume 3
chapter 11 ("Credentials in flight") detailed the cryptography
primitives for credentials; the same primitives apply to *any*
configuration file.

### `generate_fernet_key(keyphrase)` — deterministic key derivation

```python
def generate_fernet_key(keyphrase: str) -> str:
    rgen = random.Random(keyphrase)
    key_bytes = rgen.randbytes(32)
    key = base64.urlsafe_b64encode(key_bytes)
    return key
```

Deterministic — the same keyphrase always produces the same
key.  The trade-off (Volume 3 chapter 11 discussed in detail):
shared-keyphrase distribution is easier; weak keyphrases produce
weak keys; PBKDF2 / scrypt / Argon2 derivation is the project's
responsibility when stronger derivation is needed.

The `Fernet` class from `cryptography.fernet` accepts any
32-byte URL-safe-base64 key, so a project that wants stronger
derivation passes that key directly to `encrypt_content` /
`decrypt_content` rather than going through
`generate_fernet_key`.

### `encrypt_content` / `decrypt_content` — symmetric round-trip

```python
def encrypt_content(key: str, plain_content: str) -> str:
    cryptor = Fernet(key)
    plain_bytes = plain_content.encode("utf-8")
    cypherbytes = cryptor.encrypt(plain_bytes)
    encrypted_content = base64.b64encode(cypherbytes).decode("utf-8")
    return encrypted_content


def decrypt_content(key: str, encrypted_content: str) -> str:
    cryptor = Fernet(key)
    cipherbytes = base64.b64decode(encrypted_content.encode("utf-8"))
    plainbytes = cryptor.decrypt(cipherbytes)
    plaincontent = plainbytes.decode("utf-8")
    return plaincontent
```

Symmetric, base64-wrapped, UTF-8 strings on both sides.  The
encryption is `cryptography.fernet.Fernet`'s standard AES-128-CBC
+ HMAC-SHA256 + base64 — a well-understood primitive, not a
roll-your-own.

### `create_encrypted_configuration(key, plain_configuration, format)`

The wrapping helper:

```python
def create_encrypted_configuration(key: str, plain_configuration: str,
                                    format: str = ConfigurationFormat.JSON):
    encrypted_configuration = encrypt_content(key, plain_configuration)

    docinfo = {
        "version": "1.0",
        "format": format,
        "encrypted_content": encrypted_configuration,
    }
    return docinfo
```

Takes a plaintext string, encrypts it, wraps it in the
known-shape document.  The result can be written to disk or to
any of the configuration sources; the loader recognises the
shape and decrypts on read.

## The composition layer

Loaded configurations don't exist in isolation — they compose.
A *single* category has *many* files (per-environment landscapes,
per-environment runtimes, etc.); each file contributes to a
unified resolved configuration.

The composition primitive is
[`MergeMap`](https://github.com/code-craft-ai-company/codecraftai-xmodules/blob/main/source/packages/ccai/xmodules/xcollections/mergemap.py)
from `codecraftai-xmodules` (chapter 0's foundation tier).  A
`MergeMap` is a `MutableMapping` that holds an ordered list of
component maps and composes them with *overlay* semantics — later
maps shadow earlier maps key-by-key, but lower-priority entries
remain inspectable.

In `codecraftai-configuration`,
[`configurationmaps.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationmaps.py)
defines the five well-known composition targets:

```python
class CONFIGURATION_MAPS:

    CREDENTIAL_CONFIGURATION_MAP = MergeMap()
    DATAPROFILE_CONFIGURATION_MAP = MergeMap()
    LANDSCAPE_CONFIGURATION_MAP = MergeMap()
    RUNTIME_CONFIGURATION_MAP = MergeMap(ConfigurationDefaults.DEFAULT_CONFIGURATION)
    TOPOLOGY_CONFIGURATION_MAP = MergeMap()
```

Five `MergeMap` instances.  Each is the resolved view of *all*
loaded configurations of that category, composed in load order.

### `resolve_*_configuration` — per-category resolution

For each of the five categories, `configurationmaps.py` ships a
resolution function:

```python
def resolve_landscape_configuration(this_app, keyphrase=None, credentials=None):

    landscape_files = this_app.get_variable_value(ConfigVariablesDesc.AM_CONFIG_LANDSCAPE_FILES)
    landscape_names = this_app.get_variable_value(ConfigVariablesDesc.AM_CONFIG_LANDSCAPE_NAMES)
    landscape_sources = this_app.get_variable_value(ConfigVariablesDesc.AM_CONFIG_LANDSCAPE_SOURCES)

    # ... default fallbacks if names/sources unspecified ...

    loader = ConfigurationLoader(sources=landscape_sources, credentials=credentials)

    for config_name in landscape_names:
        config_uri, config_info = loader.load_configuration_by_name(
            config_name, keyphrase=keyphrase
        )
        CONFIGURATION_MAPS.LANDSCAPE_CONFIGURATION_MAP.maps.insert(0, config_info)
```

Three things visible:

- **Variables drive resolution.**  The function reads
  `AM_CONFIG_LANDSCAPE_NAMES`, `_FILES`, `_SOURCES` through the
  chapter-6 descriptor pipeline.  These are the same env-var
  selectors Volume 3 chapter 7 named — `AM_LANDSCAPE_NAMES` is
  shorthand for `AM_CONFIG_LANDSCAPE_NAMES`.
- **Multiple files compose.**  Each loaded configuration is
  *prepended* (`maps.insert(0, ...)`) to the `MergeMap`.  The
  *first* configuration loaded ends up *last* in the chain;
  later loads have higher priority.  This is intentional — it
  means a chain like `[base, staging, staging-east-overlay]`
  resolves with the overlay on top of staging on top of base.
- **The resolved map is shared.**  Every consumer that needs the
  active landscape reads
  `CONFIGURATION_MAPS.LANDSCAPE_CONFIGURATION_MAP`; the same
  instance is updated by every loaded configuration.

### `resolve_configuration_maps` — the umbrella entry

The umbrella function calls each of the five resolution
functions in turn, conditional on the per-category `USE_*`
flags:

```python
def resolve_configuration_maps(this_app=None, use_credentials=None,
                                use_dataprofile=None, use_landscape=None,
                                use_runtime=None, use_topology=None,
                                keyphrase=None, credentials=None):
    if this_app is None:
        this_app = ThisApplicationSingleton()

    # ... resolve use_* flags from variables if not explicitly passed ...

    if use_credentials:
        resolve_credentials_configuration(this_app, keyphrase=keyphrase, credentials=credentials)
    if use_dataprofile:
        resolve_dataprofiles_configuration(this_app, keyphrase=keyphrase, credentials=credentials)
    if use_landscape:
        resolve_landscape_configuration(this_app, keyphrase=keyphrase, credentials=credentials)
    if use_runtime:
        resolve_runtime_configuration(this_app, keyphrase=keyphrase, credentials=credentials)
    if use_topology:
        resolve_topology_configuration(this_app, keyphrase=keyphrase, credentials=credentials)
```

A consumer (typically the application's startup code) calls this
once after `establish_app_persona` and `activate_profile_*`.  By
the time the call returns, all five `CONFIGURATION_MAPS` are
populated, and the rest of the application reads from them.

## The variable model the loader uses

[`configurationvariables.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationvariables.py)
defines `ConfigVariablesDesc`, extending the chapter-8
`RuntimeVariablesDesc`:

```python
class ConfigVariablesDesc(RuntimeVariablesDesc):

    AM_CONFIG_CREDENTIAL_NAMES = AppVarDesc(default=None,
        context_path=ContextPaths.CONFIG_CREDENTIAL_NAMES,
        converter=split_and_normalize_name_list)
    AM_CONFIG_CREDENTIAL_FILES = AppVarDesc(default=None,
        context_path=ContextPaths.CONFIG_CREDENTIAL_FILES,
        converter=split_and_normalize_path_list)
    AM_CONFIG_CREDENTIAL_SOURCES = AppVarDesc(default=None,
        context_path=ContextPaths.CONFIG_CREDENTIAL_SOURCES)

    # ... per-category descriptors for landscape, runtime, topology, dataprofile

    AM_CONFIG_USE_CREDENTIALS = AppVarDesc(default=False, source=VariableSource.NONE)
    AM_CONFIG_REQUIRE_CREDENTIALS = AppVarDesc(default=False, converter=parse_bool)
    # ... USE_* / REQUIRE_* per category
```

Per category, six descriptors:

- `AM_CONFIG_<CAT>_NAMES` — the list of config names to load.
- `AM_CONFIG_<CAT>_FILES` — explicit file paths to load (when
  not loaded by name).
- `AM_CONFIG_<CAT>_SOURCES` — the source URI list.
- `AM_CONFIG_<CAT>_URIS` — the resolved-URI output (read-only;
  populated post-load).
- `AM_CONFIG_USE_<CAT>` — boolean controlling whether to load
  this category at all.
- `AM_CONFIG_REQUIRE_<CAT>` — boolean controlling whether
  failure to load is fatal.

Six descriptors per category × five categories = thirty
descriptors, plus their typed accessors in `ConfigVariables`.
This is the chapter-6 descriptor model at scale.

## The input normalizers

The descriptors above use *converter* functions to turn raw
string values into typed lists:
[`normalize.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/normalize.py)
ships three:

- **`split_and_normalize_name_list(value, sep=";")`** — splits
  on `;`, strips each entry.
- **`split_and_normalize_path_list(value, sep=";")`** — splits,
  strips `file://` prefix, expands `~` and `$VARS`, makes
  absolute.
- **`split_and_normalize_source_list(value, sep=";")`** —
  splits, expands non-URI paths.  When an entry starts with
  `http://`, `https://`, `dir://`, `mongodb://`, or `couchdb://`,
  it's left alone; otherwise it's path-expanded.

The default separator is `;` rather than `:` because filesystem
paths on Windows use `:` as a drive separator.

## The settings layer

[`configurationsettings.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationsettings.py)
provides the *one-time* initialisation function that establishes
the application persona and the default configuration:

```python
def establish_configuration_settings(*, name: str = None,
                                       logger_name: str = None,
                                       log_basename: str = None,
                                       presence_dir: str = None,
                                       log_directory: str = None,
                                       settings_file: str = None,
                                       extensions: str = None,
                                       service_name: str = None,
                                       default_configuration: dict = None,
                                       **other):
```

The function is *idempotent* — guarded by a
`CONFIG_SETTINGS_ESTABLISHED` flag.  It calls
`establish_app_persona` (chapter 6) with the provided
parameters; if `default_configuration` is provided, replaces
`ConfigurationDefaults.DEFAULT_CONFIGURATION`.

This is the typical application's *first* call:

```python
from ccai.configuration.configurationsettings import establish_configuration_settings

establish_configuration_settings(
    name="my-tool",
    logger_name="my-tool",
    log_basename="my-tool",
)
```

One call.  After this, the persona is established (chapter 6),
the runtime layer is available (chapter 8), and the
configuration loader is ready to be invoked through
`resolve_configuration_maps`.

## What the loader is *not*

Three deliberate non-features.

### Not a configuration *editor*

The loader reads configurations.  It does *not* provide a
write-back path for *modifying* them at runtime.  When a
configuration needs to change (a new entry added, an entry
modified, an entry removed), the change happens at the source
(file edit, database update); the loader's next read picks it
up.

For *runtime overrides*, the chapter-6 `override_variable_value`
mechanism applies.  An override doesn't modify the loaded
configuration; it adds a higher-priority resolution that
shadows it.

### Not a generic schema validator

The loader reads YAML/JSON into Python dicts.  Schema
validation — "this dict has these specific fields with these
specific types" — is the consumer's responsibility.  Per
Volume 3 chapter 5 (Landscape files), the loader doesn't
validate the landscape's apod or infrastructure shape; the
landscape *consumer* (typically `codecraftai-landscaping`) does
that.

The `validate` classmethod pattern from Volume 3 (chapter 8 —
data profiles, chapter 9 — credentials) is the per-category
validation hook.  The loader doesn't enforce it; the manager
that builds typed instances from the loaded dict does.

### Not asynchronous

The loader is synchronous.  Loading a configuration blocks the
calling thread until the source returns.  When a configuration
load is *slow* (large files, slow databases, far-away HTTP
servers), the calling code waits.

For most consumers, configuration loading happens *once* at
startup — synchronous loading is the right choice for a
one-time cost.  Consumers that need asynchronous loading
(streaming configuration updates, hot reload) build the
async layer themselves on top of the synchronous loader.

## Cross-references

The configuration loader is consumed by:

- **Volume 3 (Orchestration)** uses the loader extensively.
  Chapter 9 / 10 / 11 / 12 of Volume 3 explain credentials in
  depth; chapters 4–8 explain the per-category content; chapter
  3 lists the categories.  The current chapter is the *loader*
  side of all that material.
- **Volume 1 (Testing)** — `codecraftai-testbase` calls
  `resolve_configuration_maps` at startup to populate the
  active configurations before tests run.
- **Volume 5 (Interop)** — interop's per-protocol adapters
  read connection details from data profiles and credentials
  loaded by this layer.

The chapter-9 (Volume 3) `${VAULT:...}` resolver discipline is
the layer *above* this one — the loader produces dicts whose
secret values may be `${SCHEME:...}` references; the
chapter-10 (Volume 3) resolver turns those references into
materialised secrets.  The loader doesn't know about
references; the resolver doesn't know about file formats.  The
two layers compose cleanly.

## Summary

- **`codecraftai-configuration`** is the configuration-loading
  package every higher tier uses.  The loader fetches
  configuration files from filesystem, HTTP, CouchDB, or
  MongoDB sources; detects format by extension; optionally
  decrypts; parses into Python dicts; composes into
  per-category `MergeMap`s.
- The **`ConfigurationLoader`** is the public surface.  Its
  two methods (`load_configuration_by_name`,
  `load_configuration_from_file`) take care of source iteration,
  filename derivation, format detection, decryption, and
  parsing.
- Four shipped **source classes** under `sources/`:
  `DirectorySource`, `HttpSource`, `CouchDbSource`,
  `MongoDbSource`.  Each extends `ConfigurationSourceBase` with
  a `scheme` attribute and `parse(uri)` / `try_load_configuration`
  methods.
- **Format detection** is by extension: `.yaml` / `.yml` / `.json`
  in that fallback order.  The format determines the parser
  (`yaml.safe_load` for YAML, `json.loads` for JSON).
- **Encrypted configurations** have a known three-key shape
  (`version`, `format`, `encrypted_content`).  The
  `cryptography.py` module ships `generate_fernet_key`,
  `encrypt_content`, `decrypt_content`, and
  `create_encrypted_configuration` — Fernet symmetric
  encryption with deterministic key derivation from a
  keyphrase.
- The **composition layer** uses `MergeMap` to compose multiple
  loaded configurations per category.  Five
  `CONFIGURATION_MAPS` instances (Credentials, DataProfiles,
  Landscape, Runtime, Topology); five `resolve_*_configuration`
  functions; one umbrella `resolve_configuration_maps`.
- **`ConfigVariablesDesc`** extends the chapter-8
  `RuntimeVariablesDesc` with thirty configuration-related
  descriptors — six per category — driving the resolution
  pipeline.  The chapter-7 `AM_*_NAMES` family in Volume 3 is
  shorthand for `AM_CONFIG_*_NAMES`.
- **Input normalizers** (`split_and_normalize_*`) convert
  string env vars into typed lists; the default separator is
  `;` for cross-platform path safety.
- The loader is *not* a configuration editor (read-only),
  *not* a generic schema validator (the consumer validates),
  *not* asynchronous (synchronous loading is the right choice
  for one-time startup cost).

The next chapter — the last in Volume 0 — is the survey
chapter for the remaining foundation-tier packages
(`codecraftai-credentials`, `codecraftai-dataprofiles`, and
`codecraftai-storage-database`).  These three are smaller in
substance than the packages this chapter covered; the survey
treatment fits.


<!-- https://code-craft.ai/references/codecraftai/00-foundations/10-credentials-data-profiles-storage -->
# Chapter 10 — Credentials, Data Profiles, Storage Scaffolding

> *Three small packages close out the foundation tier.  Each
> provides a typed vocabulary for one domain — credentials,
> data-source profiles, database connections — that higher
> tiers consume without re-implementing.*

This is the last chapter of Volume 0.  It surveys the three
remaining foundation-tier packages whose surface area is small
enough that survey treatment fits, but whose contribution to the
portfolio is not small at all.  Each follows the discipline
the previous chapters established:

- A typed vocabulary class (or class hierarchy) per domain.
- A manager class that loads vocabulary instances from
  configuration data.
- A `Singleton` accessor that other packages call.

The chapter walks each package in turn, then closes Volume 0.

## `codecraftai-credentials` — the typed credential vocabulary

[`codecraftai-credentials`](https://github.com/code-craft-ai-company/codecraftai-credentials/tree/main/source/packages/ccai/credentials)
provides the typed credential vocabulary every other package
consumes.  Volume 3 chapter 9 covered the eleven shipped
credential classes in extensive detail; this chapter is the
*foundation-tier view* that names what's in the package without
the orchestration-discipline framing.

### The shipped classes

Eleven concrete credential classes inherit from
[`BaseCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/basecredential.py):

| Class | Category | Used for |
|---|---|---|
| `BasicCredential` | basic / rest-basic | Username + password |
| `SshCredential` | ssh | SSH with password / keyfile / agent |
| `ApiTokenCredential` | api-token | Single opaque token |
| `PersonalApiTokenCredential` | personal-api-token | Token + username |
| `SecretTokenCredential` | secret-token | Token + service endpoint |
| `AwsAccessKeyCredential` | aws-access-key | AWS keyid + secret |
| `AzureClientSecretCredential` | azure-client-secret | Azure tenant + client + secret |
| `PublicKeyCredential` | public-key | Generic public/private keypair |
| `GithubTokenCredential` | github-token | GitHub PAT + username |
| `GithubAppCredential` | github-app | GitHub App keyfiles |
| `WifiChoiceCredential` | wifi-choice | List of SSID + password pairs |

Each is a *typed container* — fields named, accessed through
typed properties, no behaviour beyond storage.

### `BaseCredential` — the three universal fields

```python
class BaseCredential:

    def __init__(self, *, identifier: str, categories: List[str],
                 role: Optional[str]="priv"):

        if len(identifier) == 0:
            raise ConfigurationError("...identifier required.")
        if len(categories) == 0:
            raise ConfigurationError("...categories required.")

        self._identifier = identifier
        self._categories = categories
        self._role = role
```

Three universal fields — `identifier`, `categories`, `role` —
with `BaseException`-class validation at construction time
(per chapter 3's discipline).

### `CredentialManager` — the registry

[`CredentialManager`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/credentialmanager.py)
takes a parsed dict (typically from
`codecraftai-configuration`'s loader) and produces an
identifier-keyed dictionary of typed instances.  Its
dispatch — chapter 19 of Volume 3 walked this in detail — is
the category-as-discriminator pattern:

```python
if category == "basic" or category == "rest-basic":
    credobj = BasicCredential(**credential)
elif category == "ssh":
    credobj = SshCredential(**credential)
elif category == "api-token":
    credobj = ApiTokenCredential(**credential)
# ... eleven branches ...
```

The shipped manager has no resolution layer — secrets are
treated as opaque strings.  Volume 3 chapter 10 develops the
resolver layer the consumer builds on top.

### `CredentialManagerSingleton` — the per-process accessor

[`wellknown.py`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/wellknown.py)
provides the per-process singleton.  Consumers call
`CredentialManagerSingleton().lookup_credential(name)` and get
the typed credential object.

The singleton's first-call signature requires the parsed
credential info and the source URIs:

```python
def CredentialManagerSingleton(credential_info: Optional[dict] = None,
                               credential_uris: Optional[List[str]] = None
                               ) -> CredentialManager:
    global CREDENTIAL_MANAGER_SINGLETON

    if CREDENTIAL_MANAGER_SINGLETON is None:
        if credential_info is None or credential_uris is None:
            errmsg = "The first call ... must include the credential_info and credential_uris parameters."
            raise SemanticError(errmsg)
        # ... construct the singleton ...

    return CREDENTIAL_MANAGER_SINGLETON
```

The same two-phase pattern from chapter 6 — the *first* call
configures; subsequent calls retrieve.  A subsequent call
without configuration raises `SemanticError` so the failure is
loud.

### Cross-references

The credential package is consumed directly by:

- **`codecraftai-dataprofiles`** (next section) — every data
  profile carries a credential identifier.
- **`codecraftai-storage-database`** (next section) — database
  connections take credentials.
- **`codecraftai-configuration`** — the loader's credentials
  category produces credential objects through the manager.

Volume 3 Part III (chapters 9–12) is the orchestration-layer
treatment.  This chapter is the foundation-tier baseline the
orchestration treatment builds on.

## `codecraftai-dataprofiles` — typed data-source profiles

[`codecraftai-dataprofiles`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/tree/main/source/packages/ccai/dataprofiles)
provides the typed data-source-profile vocabulary.  Volume 3
chapter 8 covered the five shipped profile classes; this
chapter is the foundation-tier baseline.

### The shipped classes

Five concrete profile classes inherit from
[`BaseDataProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/basedataprofile.py):

| Class | Category | Used for |
|---|---|---|
| `DatabaseBasicSocketProfile` | basic-socket | Unix-socket databases |
| `DatabaseBasicTcpProfile` | basic-tcp | TCP-connected databases |
| `CouchDbProfile` | couchdb | CouchDB instances |
| `MongoDBAtlasProfile` | mongodb-atlas | MongoDB Atlas (template form) |
| `SnowflakeProfile` | snowflake | Snowflake databases |

### `BaseDataProfile` — the three universal fields

```python
class BaseDataProfile:

    def __init__(self, identifier: str, *, category: str,
                 credential: Optional[str] = None):

        self._identifier = identifier
        self._category = category
        self._credential = credential
```

Identifier, category, optional credential reference.  Like
`BaseCredential`, the universal fields are minimal; the
category-specific fields live on the concrete subclasses.

### `DataProfileManager` — the registry

[`DataProfileManager`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/dataprofilemanager.py)
mirrors `CredentialManager` — same dispatch pattern, different
domain.  Five dispatch branches; lookup-by-identifier; failure-
loudly on missing entries.

### `DataProfileManagerSingleton`

Same singleton pattern as `CredentialManagerSingleton`.  The
first call configures; subsequent calls retrieve; `SemanticError`
on unconfigured access.

### Cross-references

Data profiles compose with credentials.  A `CouchDbProfile`'s
`connection_string(credobj)` method takes an
`IBasicCredential` (chapter 4's interface) and produces the
connection URL with username/password substituted in.  The
profile carries the *where*; the credential carries the *who*;
the combination produces a connection.

Volume 3 chapter 8 develops the orchestration-layer view.

## `codecraftai-storage-database` — database connection scaffolding

[`codecraftai-storage-database`](https://github.com/code-craft-ai-company/codecraftai-storage-database/tree/main/source/packages/ccai/storage/database)
is the smallest and youngest of the foundation-tier packages.
Its job: take a data profile + a credential and produce a
SQLAlchemy `Engine` ready for queries.

### `DatabaseConnectionFactory` — the factory

[`dbconnection.py`](https://github.com/code-craft-ai-company/codecraftai-storage-database/blob/main/source/packages/ccai/storage/database/dbconnection.py)
defines `DatabaseConnectionFactory`:

```python
class DatabaseConnectionFactory:

    def __init__(self, profile_name: str, *,
                 dbprofile: DatabaseBasicSocketProfile,
                 credential: BaseCredential):
        self._profile_name = profile_name
        self._dbprofile = dbprofile
        self._credential = credential

    def create_engine(self, *, dbname=None, echo=True) -> SqlEngine:
        # subclass implements
        ...

    def format_connection_string(self, dbname=None) -> str:
        # subclass implements
        ...
```

A factory takes a profile + credential and exposes:

- `create_engine(dbname=None, echo=True)` — produces a
  configured SQLAlchemy `Engine`.
- `format_connection_string(dbname=None)` — produces the
  connection-string form of the profile + credential, used by
  the chapter-9 cross-reference (CouchDB profiles' connection
  strings).

The base class has *placeholder* implementations; the
real implementations live in per-engine subclasses that come
in higher tiers (or are shipped as separate packages — RHEL,
Ubuntu, Postgres-specific extensions).

### `dbio.py` — typed CRUD helpers

[`dbio.py`](https://github.com/code-craft-ai-company/codecraftai-storage-database/blob/main/source/packages/ccai/storage/database/dbio.py)
provides the operational helpers built on top of the factory:

- `database_exists(conn_factory, dbname=None)` — check whether
  a database exists.  Wraps `sqlalchemy_utils.database_exists`.
- `database_create(conn_factory, dbname=None, dbmetadata=None)`
  — create a database.  Wraps
  `sqlalchemy_utils.create_database`.
- `database_drop(conn_factory, dbname=None)` — drop a
  database.

The pattern: a small set of typed helpers around SQLAlchemy
primitives.  No reinvention; just the typed envelope that
turns the SQLAlchemy primitives into codecraftai-conventional
calls.

### `mixins.py` — `SerializableMixIn`

[`mixins.py`](https://github.com/code-craft-ai-company/codecraftai-storage-database/blob/main/source/packages/ccai/storage/database/mixins.py)
ships one mixin: `SerializableMixIn`, which adds `as_dict()`
and `to_json()` to SQLAlchemy model classes:

```python
class SerializableMixIn:

    def as_dict(self):
        dval = {}
        model = type(self)
        mapper = inspect(model)
        for col in mapper.attrs:
            col_key = col.key
            dval[col_key] = str(getattr(self, col_key))
        return dval

    def to_json(self, indent=4):
        # ... uses as_dict(), wraps with json.dumps ...
```

A model that mixes in `SerializableMixIn` becomes
`ISerializable`-compatible (chapter 4's interface) — the
chapter-4 protocol describes `as_dict` and `from_dict`; the
mixin provides one half (the write side); SQLAlchemy's row
construction provides the other.

### What the package is *not*

`codecraftai-storage-database` is not a complete storage layer.
It does not provide:

- An ORM beyond SQLAlchemy's defaults.
- Migrations (use Alembic).
- Connection pooling beyond SQLAlchemy's defaults.
- Schema generation.
- Multi-database transactions.

The package is *scaffolding*.  It connects the
data-profile vocabulary to SQLAlchemy in a typed,
discipline-conformant way.  When a project needs more storage
machinery, it builds on top of this package; the package
itself stays small.

### Cross-references

Used by tiers that store structured data:

- **Volume 1 (Testing)** — testbase's result-storage layer
  uses the connection factory for the JSOS results database.
- **Volume 6 (Topology / Provisioning)** — landscape state
  storage uses the connection factory.
- **Volume 8 (Time-Travel)** — server-side ingestion uses the
  connection factory for trace event storage.

The pattern is consistent across consumers: the project ships
its SQLAlchemy models with `SerializableMixIn`; the project
configures a `DataProfile` for the database; the project
constructs a `DatabaseConnectionFactory`; the project does its
work.

## What the three packages share

The three packages this chapter covers — credentials,
dataprofiles, storage-database — share four structural
features:

### A typed vocabulary class hierarchy

Each package has a `BaseX` abstract class plus concrete
subclasses keyed by *category*.  `BaseCredential` /
`BaseDataProfile` carry the universal fields; subclasses
extend with category-specific fields.

### A manager class that dispatches by category

`CredentialManager`, `DataProfileManager` (storage-database
delegates dispatch to SQLAlchemy through the factory).  Each
takes a parsed dict and produces typed instances.

### A `Singleton` accessor

`CredentialManagerSingleton`, `DataProfileManagerSingleton`.
Per-process; first-call configures; subsequent retrieves.

### A `category` discriminator

The dict format always carries a `category` field naming the
concrete subclass to instantiate.  This is the
chapter-3 (Volume 3) category-as-discriminator pattern applied
at the foundation tier.

The pattern is the chapter-6 *typed-descriptor* model applied
to vocabulary objects rather than runtime variables.  The same
discipline; different domain.

## The complete foundation-tier picture

Volume 0 has now walked the full foundation tier:

| Package | Chapter | Role |
|---|---|---|
| (philosophy) | 1 | The ten principles |
| (coding standards) | 2 | The rules |
| `codecraftai-foundations` (`ccai.errors`) | 3 | The exception vocabulary |
| `codecraftai-foundations` (`ccai.interfaces`) | 4 | The contract layer |
| `codecraftai-foundations` (`ccai.waiting`) | 5 | The wait-with-diagnostics primitive |
| `codecraftai-this-app` | 6 | Identity + persona variables |
| `codecraftai-extension` | 7 | Factory and plug-in machinery |
| `codecraftai-runtime` | 8 | Runtime variables + paths + activation |
| `codecraftai-configuration` | 9 | The configuration loader |
| `codecraftai-credentials`, `-dataprofiles`, `-storage-database` | 10 | Typed vocabularies |

Plus the cross-cutting `codecraftai-xmodules` package (chapter 9
mentioned its `MergeMap`; chapter 7 touched its
`ximport.import_by_name`).  `xmodules` is the catch-all utility
package — `xformatting.indent_lines`, `xpython.expand_path`,
`xcollections.MergeMap`, etc.  It's consumed everywhere; it's
not itself a chapter target because its contents are utilities,
not architecture.

A reader who finishes Volume 0 understands:

- The **philosophy** every package is built around.
- The **standards** that turn the philosophy into rules.
- The **error vocabulary** that makes errors-never-silent
  operational.
- The **interfaces** that let packages substitute for each
  other.
- The **waiting primitive** that turns `wait_for_it` from
  bug-prone to bug-free.
- The **identity machinery** that gives every running process
  a structured persona.
- The **factory machinery** that lets packages plug in
  without modifying consumers.
- The **runtime layer** that gives every running process its
  operational identity.
- The **configuration loader** that produces typed
  configurations from multi-source files.
- The **typed vocabulary classes** for credentials, data
  profiles, and database connections.

Every higher volume in the series builds on these foundations.
Volume 1 is testing; it uses the runtime layer for run identity
and the configuration loader for runtime profiles.  Volume 2
is distributed automation; it uses the interfaces for
substitutability and the factory machinery for cross-host
dispatch.  Volume 3 is integration orchestration; it uses
*every* foundation chapter at some point.  Volume 4 is sysman;
it uses the factory machinery for cross-OS dispatch.  Volume 5
is interop; it builds on the Aspects pattern previewed in
chapter 1 and chapter 2.  Volume 6 is landscape; it uses the
typed-vocabulary discipline for device entries and the
configuration loader for landscape files.  Volume 7 is the AI
agent SDK; it uses the testbase substrate from Volume 1.
Volume 8 is time-travel debugging; it uses the storage-database
scaffolding for trace event ingestion.

Every volume uses *some* of Volume 0; Volume 0 is the *common
language* the rest of the series speaks.

## What Volume 0 deliberately doesn't cover

Three areas of the portfolio that aren't in the foundation tier:

### Networking primitives

`codecraftai-networking` ships TCP/IP utility code.  It's a
*support* package rather than a foundation package — it
doesn't define a typed vocabulary or an architecture; it's a
set of helpers.  Volume 5 (Interop) treats it where
appropriate.

### Logging configuration

The detailed logging configuration — how the runtime layer
configures Python's logging system, what handlers and
formatters are wired up, how the log files are named — lives
in `codecraftai-xmodules/xlogging`.  It's referenced through this
volume but not treated as its own chapter; the depth doesn't
warrant it.

### Test framework

`codecraftai-testbase` is foundation-tier *adjacent* — most other
volumes depend on it — but it's substantial enough to deserve
its own volume (Volume 1).  Treating it in Volume 0 would
shortchange both the testing material and the foundation
material.

## Summary — and end of Volume 0

- **`codecraftai-credentials`** ships eleven typed credential
  classes inheriting from `BaseCredential`, plus a
  `CredentialManager` registry and a `CredentialManagerSingleton`
  accessor.  Volume 3 chapter 9 develops the orchestration-
  layer view.
- **`codecraftai-dataprofiles`** ships five typed data-source-
  profile classes inheriting from `BaseDataProfile`, plus a
  `DataProfileManager` registry and a singleton accessor.
  Volume 3 chapter 8 develops the orchestration-layer view.
- **`codecraftai-storage-database`** ships the
  `DatabaseConnectionFactory` that takes a profile + credential
  and produces a SQLAlchemy `Engine`, plus a small set of
  typed CRUD helpers (`database_exists`, `database_create`,
  `database_drop`) and the `SerializableMixIn` for SQLAlchemy
  model classes.
- The three packages share **four structural features**: typed
  vocabulary class hierarchies, manager classes that dispatch
  by category, per-process singleton accessors, and the
  category-as-discriminator pattern.
- The volume's complete picture: ten chapters, ten foundation-
  tier packages walked.  Every higher volume in the series
  builds on these chapters.
- Volume 0 deliberately doesn't cover networking primitives
  (support package, not architecture), logging configuration
  (utility code in `codecraftai-xmodules`), or the test framework
  (Volume 1's territory).

A reader who finishes Volume 0 has the **discipline** of the
portfolio.  Every later volume turns that discipline into a
specific working system: a test framework, a distributed
test infrastructure, an orchestration layer, a cross-OS sysman
tier, an interop layer, an environment-modelling layer, an
agent SDK, a time-travel debugger.  The discipline is the
common language; the volumes are dialects.

The series's reading order is `0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 →
8 → Appendix`.  Volume 0 is the prerequisite for everything
that follows.  Whatever volume the reader picks up next, the
foundation is now in place.

---

This concludes Volume 0 — *Foundations of Automation Engineering
with Python*.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework -->
# Volume 1 — Building a Test Framework

*testbase, arcli, and Single-Host Test Infrastructure*

## About this volume

Volume 1 stands up the *single-host* test framework on top of the
foundation tier from Volume 0.  Everything Volume 2 (distributed)
adds is an extension of contracts established here: the
`TestJob` lifecycle, the resource factory model, the streaming
results pipeline, and the landscape boundary.  Volume 3 reaches
into Volume 1 for orchestration-level invocation — taskplus
calling a testbase test run from a workflow.

## Who it is for

SDETs and test-infrastructure engineers.  This is the volume to
hand a senior SDET who has been writing pytest for a decade and
needs to learn why their tools stop scaling once the system under
test is no longer a single Python process.

## Source material

- `codecraftai-testbase` — the test framework itself
- `codecraftai-arcli` — the declarative CLI toolkit underneath every
  `codecraftai-*` CLI
- `codecraftai-agent-kit` — case study source (a 3,700+ test
  single-host suite)

## Contents

- [Chapter 1 — Why `unittest` and `pytest` Fall Short for Automation](chapters/01-why-unittest-pytest-fall-short.md)
- [Chapter 2 — `testbase` Architecture](chapters/02-testbase-architecture.md)
- [Chapter 3 — Resource Factories and Dependency Injection](chapters/03-resource-factories-and-di.md)
- [Chapter 4 — Property-Based Testing with `@given`](chapters/04-property-based-given.md)
- [Chapter 5 — Streaming JSOS Results](chapters/05-streaming-jsos-results.md)
- [Chapter 6 — The `testbase agents` Machine-Readable CLI](chapters/06-testbase-agents-cli.md)
- [Chapter 7 — Mocking and Stubbing Without the Framework Getting in the Way](chapters/07-mocking-and-stubbing.md)
- [Chapter 8 — `arcli`: The Declarative CLI Toolkit](chapters/08-arcli-the-declarative-cli-toolkit.md)
- [Chapter 9 — Building Tests Around Landscape Concepts (Single-Host Edition)](chapters/09-landscape-concepts-single-host.md)
- [Chapter 10 — Case Study: A 3,700-Test Suite That Runs on One Host](chapters/10-case-study-agent-kit-suite.md)

## How it connects to the other volumes

- Volume 2 (Distributed Automation) extends `TestJob` →
  `InteropLibraryTestJob` → `DistributedTestJob`.  Chapter 9
  carries the single-host half of the contract; chapter 10's case
  study demonstrates the framework before the chain widens.
- Volume 3 (Integration Automation) reaches into Volume 1 for
  test-run invocation from a taskplus workflow.  The arcli
  decorators in chapter 8 are the same ones taskplus uses.
- Volume 7 (AI Agent SDK) reaches into Volume 1 for the `testbase
  agents` machine-readable subcommand surface in chapter 6.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/01-why-unittest-pytest-fall-short -->
# Chapter 1 — Why `unittest` and `pytest` Fall Short for Automation

> *Application unit tests verify a function in isolation.  Integration
> tests verify two collaborators in isolation.  Automation tests
> verify a system in motion — devices waking, credentials being
> fetched, configurations being merged, jobs being scheduled across
> hosts.  The tools that ship with Python were never designed for
> the third thing.  This chapter is about what's missing, and why
> patching `pytest` to fill the gap eventually fights the framework
> rather than helping it.*

`unittest` and `pytest` are excellent tools.  Across millions of
Python codebases they verify the correctness of functions, classes,
modules, and small graphs of objects.  The `codecraftai-*` portfolio
uses both — `pytest` runs on the framework's *own* unit tests,
`unittest` is the foundation of `unittest.mock`'s spec-aware
mocking, and the discipline of writing testable functions started
in those communities long before automation engineering picked it
up.

This volume is not a polemic against pytest.  It is an explanation
of why a particular kind of test — the kind that runs against a
*system* rather than a *function* — needs a different framework.
That framework is `codecraftai-testbase`, and the rest of Volume 1
walks through it.  This chapter establishes *why testbase exists*
by listing the constraints automation testing places on a runner,
then asking which of those constraints `pytest` was designed to
satisfy.

The answer turns out to be: not many.  That's not a criticism of
pytest.  It's a statement about scope.

## The shape of an automation test

An automation test, in this volume's sense, is a test where:

1. The system under test is **not in the test process**.  The
   system under test is a device, a fleet of devices, a service
   running on another machine, or a constellation of services
   coordinated by an orchestration layer.
2. The test must **acquire and release real resources** — SSH
   sessions, credentials, license tokens, devices that other
   tests in the suite may also need, network namespaces.
3. The test must **survive failures it can't reproduce locally**.
   A flaky network on host 7 of 12 is part of the production
   environment; the framework has to tell you what happened on
   host 7 with enough detail that you don't need to reproduce.
4. The test must **stream results** while it runs, not write
   them all at the end.  A four-hour suite that crashes in hour
   three should still tell you what passed in hours one and two.
5. The test must **be machine-readable** — by CI dashboards,
   replay tools, AI agents extracting failure patterns.  Stdout
   and a green-or-red exit code don't carry enough information.
6. The test must **describe its environment** — which devices it
   needs, which credentials, which configuration profile — so that
   a runner on another machine can reproduce or refuse the run.

The first three of these are what most engineers mean when they
say "automation testing is hard."  The last three are what most
engineers don't notice until they try to scale a test suite past
about five hundred tests across more than one host.  Automation
test frameworks live or die on the last three.

## What `pytest` does well

It is worth being precise about pytest's strengths before
explaining why testbase exists, because dismissing pytest is a
mistake too — many of testbase's design choices are pytest's
choices made differently for a different problem.

Pytest does the following extremely well:

- **Test discovery is implicit.**  Functions whose names start
  with `test_` in files whose names match `test_*.py` are tests.
  No registration ceremony.
- **Fixtures compose cleanly.**  A fixture can depend on other
  fixtures, and pytest works out the dependency order.
- **Parametrisation is first-class.**  `@pytest.mark.parametrize`
  generates test variants by inspection of the test function's
  parameters.
- **Plugins are first-class.**  Reporting, parallelism,
  randomisation, coverage, hypothesis, xdist — the plugin
  ecosystem is the framework's strongest feature.
- **Failure reports are diagnostic.**  Pytest's assertion
  rewriting gives you the values of the operands of a failed
  assertion without having to write `assertEqual(a, b, "...")`.

`codecraftai-testbase` keeps the first three properties.  Tests are
plain functions whose names start with `test_`, found by
inspecting modules under a test root (see Chapter 2).  Fixtures
compose; they are just expressed differently.  Parametrisation
exists in two forms — explicit data-driven (akin to pytest's
`parametrize`) and property-based (akin to Hypothesis).

What testbase *doesn't* keep is the assumption that the framework
runs in the same process as the system under test.

## What automation tests need that pytest doesn't ship

There are seven gaps.  The remainder of Volume 1 is, in effect, an
extended explanation of how testbase fills them.

### 1. A lifecycle wider than the test function

A pytest test has fixtures with `function`, `class`, `module`,
`session` scope.  That's enough when the system under test lives
inside the Python process.  When the system under test is *outside*
the Python process, you need lifecycle hooks for things that don't
map onto fixtures:

- **Attaching to the framework**: registering integration couplings,
  binding the test runner to a results pipeline.
- **Attaching to the environment**: connecting to devices, opening
  SSH sessions, fetching credentials.
- **Activating configuration**: telling the landscape that this
  particular run wants this particular profile.
- **Activating integration**: bringing online the cross-host
  collaboration the suite depends on.
- **Activating operations**: starting the side processes (log
  shippers, metric agents) that other tests will rely on.

In `codecraftai-testbase`, these are five `fire_*` hooks on `TestJob`:
`fire_attach_to_framework`, `fire_attach_to_environment`,
`fire_activate_configuration`, `fire_activate_integration`,
`fire_activate_operations` (file:
`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:379–408`).
On a single-host run all five are no-op stubs — they exist
because Volume 2's `InteropLibraryTestJob` (in `codecraftai-interop`,
`source/packages/ccai/interop/testing/testjob.py:22`) overrides
them to drive landscape activation.  The hooks are *the* place
where testbase's single-host runner and Volume 2's distributed
runner meet.

A pytest fixture cannot model "I need the entire test session to
have woken up the lab's twelve devices and pre-fetched their
credentials before the first test starts, and put them to sleep
after the last test ends" without contortions.  A `fire_*` hook
on a job-spanning singleton can.

### 2. Tests that declare what they need, not what they import

A pytest test imports its fixtures.  An automation test cannot
always do that, because the resource it needs may not be present
on the host the test author is editing on.  In testbase the
resource declaration is a separate decoration:

```python
@testbase.attach.parameter(some_device, identifier="dev")
def test_device_responds(dev):
    """
        Verify that the lab device responds to a probe.
    """
    response = dev.probe()
    rtnval = response.is_ok()
    assert rtnval
    return
```

The `@testbase.attach.parameter(source, identifier=...)` form
(`codecraftai-testbase/source/packages/ccai/testbase/declaration/attachments.py`)
uses an originator to produce the parameter, with constraints
on lifespan and assigned scope.  The originator can come from a
*different package* than the test, registered via the namespace
package extension model from Volume 0 chapter 7.  A test author
on a laptop without the device declares the dependency without
having to import a module that would fail at import time.

### 3. Results that stream, not aggregate

Pytest writes its summary at the end.  Plugins exist to write
JUnit XML or other formats, but the framework's mental model is
"run all the tests, then report."  An automation suite that takes
hours to run cannot afford to lose its history when the runner
crashes.

Testbase produces three result artefacts during a run:

- `testsummary.json` — the high-level summary of pass/fail counts.
- `testresults_stream.jsos` — a stream file written *as tests
  finish*, one record per event, prefixed by an ASCII record
  separator character (`codecraftai-testbase/source/packages/ccai/testbase/results/recorders/jsonresultrecorder.py:90–183`).
- `testresults.json` — the consolidated final document, written
  at the end.

The stream file is the one that matters for hours-long runs.  A
CI dashboard, a local tail process, or an AI agent reading
`failures` (Chapter 6) can begin extracting failures *while the
suite is running*.  A crashed run still leaves a partial stream
that tells you what passed and what hadn't started.  Chapter 5
walks the JSOS format in detail.

### 4. Property-based testing with a CI fast path

Hypothesis is excellent at finding edge cases and shrinking them.
But Hypothesis defaults — generating examples until either the
test passes a budget or finds a failure — are calibrated for
running locally with attention.  A 3,700-test suite that triggers
Hypothesis's default `max_examples=100` on every property test
will run for hours without finding anything new.

Testbase ships an environment-variable cap:
`TESTBASE_MAX_EXAMPLES_CAP`
(`codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:47–79`)
caps the per-test `max_examples` regardless of what the test
declared.  CI runs at `TESTBASE_MAX_EXAMPLES_CAP=10` to keep the
fast path under an hour; full-sweep runs leave the cap unset.
The cap is a runtime knob, not a test-author knob — exactly the
right shape for a build-engineer tuning the suite without editing
hundreds of test files.

Chapter 4 is the full treatment.

### 5. A machine-readable surface

Modern CI dashboards extract test summaries by parsing stdout.
That works until the dashboard wants to do something more
ambitious — diff the failing tests against last week's, group
failures by which device they touched, drive an AI agent that
decides whether a flake is real.  Stdout doesn't survive that.

Testbase ships an `agents` CLI subcommand
(`codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/__init__.py:18–28`)
with three groups: `summary`, `testresult`, `config`.  Each
returns structured data — extracted from the JSOS stream by a
parser shared across the subcommands
(`codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/jsosparser.py`)
— that an agent or dashboard can consume directly.  The
agent-readable surface is *the same* surface a human reads;
Chapter 6 walks the contract.

### 6. A landscape boundary

The most subtle gap.  An automation test runs in an environment
— a "landscape" in the portfolio's vocabulary.  The landscape
includes devices, services, credentials, network reachability.
The test framework needs to be aware that the landscape *exists*,
without dragging the entire landscape implementation into the
unit-test process.

Testbase solves this with a *contract*: `TestJob` exposes the
five `fire_*` hooks as no-op stubs.  `InteropLibraryTestJob`
(Volume 2) overrides them to drive landscape activation.  A
single-host test using `TestJob` directly never needs the
landscape; a distributed test using `DistributedTestJob` (also
Volume 2) does.  The contract sits exactly at the boundary, so
that single-host test authors don't pay landscape's
import-time cost and distributed test authors get landscape
hooks where the lifecycle actually fires.

Chapter 9 carries the single-host half of this contract; Volume 2
carries the distributed half.

### 7. Single actor, multiple observers — tandem testing, not parallel

The least-obvious gap, and the one that most cleanly distinguishes
automation testing from application testing.  In a distributed
environment where the machines under test interact with each other,
a test is only deterministic when *one actor* causes effects in the
environment at a time.  When the controller (the test) interacts
with a machine, the test must wait for the effect to propagate to
all the other machines in the environment before checking that
the effect took hold.  Often there are multiple checks — a poll
loop watching for a state, or an event subscription waiting for
a state change.  If more than one actor is allowed to cause
effects concurrently, the ordering of events stops being
deterministic, and the test stops being deterministic with it.

This is *not* a parallelism problem the framework can solve by
adding locks.  It is a determinism property of the system under
test.  Application testing avoids it by running the system under
test inside the test process, where the test is the only
controller; automation testing has to enforce it explicitly,
because the system under test is *outside* the process and
nothing prevents another actor from reaching it.

Pytest's plugin ecosystem includes `pytest-xdist` for parallel
test execution.  That plugin is exactly the wrong tool for an
automation suite where the tests share an environment.  Two
parallel tests against the same fleet *will* race on shared
state and the suite *will* turn flaky.

Testbase enforces single-actor execution by treating the test
itself as the actor and providing a separate mechanism for
*observers*:

- The **test** is the single actor.
- **Observers** are `Validator` instances (subclassing
  `ValidatorCoupling`) that the framework attaches to the test
  scope via `attach_to_test(testscope, suffix)`.  They watch
  for effects, run *in tandem* with the test, and produce their
  own per-observer outcomes recorded alongside the test's result.

This is **tandem testing**: one actor + N attached observers.
The mental model is a *tandem bicycle*: one driver pedals and
steers; one or more observers ride along watching the world go
by.  The driver is the only one steering — that's the
single-actor commitment — but the observers are full
participants in the ride, with their own perspective and their
own report at the end.

File: `codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py`.

Where parallelism *does* live: at the **job level**, across
independent **Pods**.  A Pod is a set of devices configured to
interoperate with each other (Volume 6 covers Pod formation in
detail).  Within one Pod, jobs run sequentially because a Pod
is a shared environment.  Across Pods, jobs run in parallel
because Pods are independent — effects in one Pod cannot
propagate to another.  A CI orchestration that wants more
horizontal scale provisions more Pods, not more parallel tests
within a Pod.  Volume 3 covers the orchestration side; Volume
2 covers the distributed test side; the two volumes meet at
the Pod boundary.

## Where `unittest` still lives in the portfolio

There is a second, quieter reason this chapter is not a polemic
against the standard library: testbase does not try to replace
`unittest` everywhere.  It replaces it *almost* everywhere — and
the handful of packages it deliberately leaves on `unittest` are
the ones that make this honesty possible.

Earlier in the portfolio's life, the set of packages that had to
stay on plain `unittest` was large, because testbase dragged in a
chain of dependencies — configuration, the runtime foundation,
this-app — that many low-level packages could not import without a
cycle.  That chain is being severed.  testbase is becoming
**near-standalone**: its intended dependencies are exactly
`codecraftai-arcli`, `codecraftai-foundations` (which consolidated
`codecraftai-errors`, `codecraftai-interfaces` and `codecraftai-waiting`), and
`codecraftai-xmodules` — no configuration tier,
no runtime foundation, no this-app.  The *core* runner already lives
on that floor: `TestJob` reads its handful of environment knobs
directly via the dependency-free `ccai.testbase.environment`
module, and it resolves output and run metadata through injected
seams (`IOutputContext` / `IRunMetadata`) rather than importing the
runtime.  The presence-folder results layout, the
JSOS-to-disk-with-HTML summary, and the CI/build metadata that used
to be testbase's business are now the job of `IntegratedTestJob` in
`codecraftai-integration-runtime`; plain testbase records to stdout and
runs without activation.  Chapter 2 and Volume 2 walk that seam.

This is partly in-flight.  The runner core is off the old chain, but
testbase's *CLI and bootstrap* (`ccai.testbase.initialize`, the
`cli/` command tree, the result server) still import
`ccai.runtime.*` and `ccai.thisapp.*`, and `pyproject.toml`
still lists configuration, runtime, and this-app among its
dependencies.  Finishing that decoupling — and trimming the
dependency list to the five-package floor — is the remaining step;
the architecture below describes the floor the package is converging
on.

The practical consequence is that testbase can now be used to test
**almost every other package in the portfolio** — the device
abstractions, the interop layer, the orchestration tiers, the REST
clients.  A package no longer has to choose `unittest` just because
it sits below the framework's old dependency chain.

What remains on `unittest` is a small, **permanent** exclusion set:

- `codecraftai-foundations` (formerly the separate errors, interfaces
  and waiting packages)
- `codecraftai-xmodules`
- `codecraftai-arcli`
- `codecraftai-extension`

The rationale is a single rule with one product decision attached.
The first five are testbase's *own* dependencies — a framework
cannot test the packages it is built on without a bootstrap cycle,
so those stay on `unittest` by necessity.  (testbase depends on
`arcli` for its own CLI, which is why `arcli` is on this list even
though it is otherwise a general-purpose toolkit.)  The sixth,
`codecraftai-extension`, is the product decision: it underpins the
namespace-package extensibility model that `IntegratedTestJob`
registers through (the same model Volume 0 chapter 7 introduced),
and keeping it on `unittest` avoids a circularity between the
extension machinery and the framework that consumes it.

Everything outside that set has shifted — or can shift — to
testbase.  The split is not "framework versus standard library as a
matter of taste"; it is a precise dependency-floor boundary, and the
list above is the floor.

That shift is no longer hypothetical.  Across the portfolio's
unittest-bearing packages — the `configuration` and `this-app`
foundation tiers, the `credentials` and `networking` device layers,
the `integration-runtime` reporting package, the `timetravel-pod`
tracer, the `storage-database` and `dataprofiles` libraries, and the
`sysman-common` tier — the tests have been converted to testbase at
the source level.  Each package's tests now live as plain `def test_*`
functions under `source/testroots/ccai/tests/…` (discovered by the
runner via `--includes`) where they had been `unittest.TestCase`
methods under `source/tests/`.  The conversion is one-to-one: every
original test method becomes exactly one test function; `setUp` /
`tearDown` state is replicated per test through module-level helper
functions (testbase tests have no class lifecycle); `unittest.mock` is
retained as-is; duplicate method names across sibling `TestCase`
classes are disambiguated rather than collided; and
`IsolatedAsyncioTestCase` tests are wrapped with `asyncio.run`.  The
migration keeps both suites in parallel for safety — the legacy
`unittest` files remain authoritative, and the merge gate keeps
running them, until each converted suite is verified green under the
testbase runner and the package's CI is cut over to `testbase testing
run`.  (Two of the converted tiers, `this-app` and `configuration`,
are a special case: testbase still declares them among its own
dependencies, so they cannot take a testbase test-dependency — and
thus cannot run under the testbase runner — until testbase finishes
shedding that chain.  Their test *sources* are converted; their
cutover waits on the decoupling described above.)

## What this volume builds

Volume 1's ten chapters walk testbase from the outside in:

- Chapter 2 covers the architecture — the pieces that make a run
  go.
- Chapter 3 covers resource factories — how tests acquire and
  release the things they need.
- Chapter 4 covers `@given` and the property-based testing model
  with the CI cap.
- Chapter 5 covers JSOS streaming results.
- Chapter 6 covers the `agents` machine-readable CLI.
- Chapter 7 covers testbase's own mocking primitives.
- Chapter 8 covers `arcli`, the declarative CLI toolkit underneath
  every `codecraftai-*` CLI.
- Chapter 9 carries the single-host half of the landscape
  contract — the public API Volume 2 will extend.
- Chapter 10 is a case study: the 3,700-test single-host suite
  in `codecraftai-agent-kit`, with `TESTBASE_MAX_EXAMPLES_CAP=10`,
  finishing in a CI window short enough that engineers run it
  voluntarily.

By the end, a senior SDET should be able to read a testbase test
file and know exactly which framework concept each line is using.
That fluency is the prerequisite for Volume 2 (the distributed
extension) and Volume 3 (orchestration-level invocation) to make
sense.

## A note on continuity

Every chapter in Volume 1 assumes the reader has read Volume 0
— or knows the foundation tier well enough to skip it.  When a
chapter says "the explicit-`return` discipline" or "the
`SemanticError` rule" or "the Aspects pattern," it is referring
to material from Volume 0 chapters 1–4.  Volume 0 anchored those
ideas; Volume 1 uses them.

When a chapter forward-references the distributed extension, it
will say so explicitly: "Volume 2 chapter N covers the
distributed-half of this contract."  The single-host text never
relies on the distributed text — but it carefully marks the
seams, because Volume 2 is the volume that walks across them.

## What we have not written

This volume *is not* a tutorial on Python testing.  It assumes
you can write a passing pytest test, you understand
parametrisation, you have used a fixture.  The volume's job is to
show you how an automation-grade test framework is different —
and why those differences are *necessary*, not preferences.

This volume *is not* a comparison shop.  We do not survey
`Robot Framework`, `behave`, `pytest-bdd`, or any other Python
testing framework.  Those tools have their reasons; testbase has
ours.  The volume's job is to show why testbase is the shape it
is, so that a reader who needs to *extend* it knows where the
seams are.

This volume *does not* cover distributed test execution.  That
is Volume 2.  Where the contract that distributed extends sits
inside testbase, this volume marks the contract.  Where the
*implementation* lives, Volume 2 picks up.

## What we have committed to

By the end of Volume 1 you will:

- Know the five `fire_*` lifecycle hooks of `TestJob` and what
  each does on a single-host run.
- Know the resource-factory model and how originators register
  with the injection registry from Volume 0 chapter 7.
- Know the JSOS streaming format and how the `agents` CLI
  consumes it.
- Know `arcli`'s `Command` / `ArgField` / `OptionField` model
  and the validator-and-parser hardening pattern from PR #13.
- Be able to read a testbase test file and identify, line by
  line, which framework concept each construct represents.

That fluency is what Volume 2 builds on.  And Volume 3.  And
parts of Volumes 6, 7, and 8.  This is the substrate.

The next chapter walks the architecture itself.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/02-testbase-architecture -->
# Chapter 2 — `testbase` Architecture

> *A test framework's architecture is the answer to a single
> question: what happens, in what order, when you run the suite?
> The interesting part of the answer is never the test functions
> themselves — those are just the leaves.  The interesting part
> is the path the runner takes from "here is a test root" to
> "here are the results."  This chapter walks that path.*

`codecraftai-testbase` is not a small framework.  Counting only
its own source tree it is around 40,000 lines of Python; the
test suite that exercises it is 146 test files
(`codecraftai-testbase/source/packages/ccai/testbase/`,
`codecraftai-testbase/source/tests/`).  But the architecture is
small in the sense that matters: the number of *concepts* a
reader has to hold in mind to follow a run is fewer than ten.

This chapter introduces those concepts and walks a single
non-distributed run from CLI invocation to result file.  Later
chapters revisit the same path looking at a single concept in
depth — the resource factory model in Chapter 3, the property
runner in Chapter 4, the JSOS recorder in Chapter 5.

It is worth stating up front what testbase is *not* coupled to.
The framework's core run path is near-standalone: it depends only
on `codecraftai-arcli`, `codecraftai-foundations` (which consolidated
the former errors, interfaces and waiting packages), and
`codecraftai-xmodules`.  It does *not* depend
on `codecraftai-configuration`, `codecraftai-runtime`, or
`codecraftai-this-app`.  The handful of core environment knobs it
needs (`AM_TESTROOT`, `AM_OUTPUT_DIRECTORY`, `AM_RUN_ID`,
`AM_TESTRUN_TIMEOUT`, the `AM_DEBUG_*`/`AM_DIAGNOSTICS_*` flags)
are read directly from `os.environ` through a tiny dependency-free
module, `ccai.testbase.environment`.  Everything richer — the
`RuntimeVariables` descriptor set, the well-known presence-folder
paths, CI/build metadata — is the integration layer's job, reached
through injected seams rather than imports.  (The *source* core is
fully decoupled; the testbase CLI/bootstrap still imports the
runtime/this-app machinery for activation, a clean-up that is
in-flight at the time of writing.)

## The five concepts

A reader who finishes this chapter should be able to define
each of the following in their own words.  Everything else in
the framework is built from them.

1. **TestRef** — a lazy, metadata-rich reference to a single
   test function.
2. **TestCollector** — the discovery component that builds a
   tree of `TestRef` objects from a test root.
3. **TestSequencer** — the component that walks the discovered
   tree through five phases, applying graph operations.
4. **TestJob** — the job-spanning runner that owns the
   lifecycle: enter → fire hooks → run → finalize → exit.
5. **ResultRecorder** — the recorder that captures the run.  Plain
   testbase defaults to `StdoutResultRecorder`, which echoes the
   summary to stdout and writes the machine-readable JSOS/JSON
   artefacts; the HTML-rendering `JsonResultRecorder` is the
   integration runtime's choice (covered below).

Around these five there are couplings (integration,
configuration, validator), the property runner, the agents CLI,
and the mocking module — but those are extensions of the core,
not the core.

## A test as code

Before the architecture, a single example, so that the rest of
the chapter can refer to a concrete shape.  A testbase test is a
plain Python function:

```python
# tests/example/test_arithmetic.py
import testbase

def test_addition_is_commutative():
    """
        Verify that integer addition is commutative.
    """
    a = 2
    b = 3
    rtnval = (a + b) == (b + a)
    assert rtnval
    return
```

No subclass.  No `@pytest.mark.unit`.  No fixtures imported by
default.  The function name starts with `test_`, the file lives
under a test root, and the framework finds it.  The
explicit-`return` discipline from Volume 0 chapter 1 is in play:
the function ends with `return` even though the function returns
nothing, because future-you may add a return value, and the
breakpoint anchor is worth the line.

That is the leaf.  Now the path that gets us to the leaf.

## Discovery: TestRef and TestCollector

The discovery process answers a single question: given a test
root path, what test functions exist under it?

`TestRef`
(`codecraftai-testbase/source/packages/ccai/testbase/testref.py:25–65`)
is the data class.  Each `TestRef` wraps a `FunctionType` and
stores:

- The function reference itself (`testfunc: FunctionType`).
- A list of *monikers* — string identifiers used to filter
  tests by name pattern at discovery time.
- A list of *pivots* — keyed parametrisation values that vary
  across instances of the same test function.
- An async flag — whether the function is `async def`.

`TestCollector`
(`codecraftai-testbase/source/packages/ccai/testbase/testcollector.py:60–231`)
is the orchestrator.  Given a test root, includes/excludes, and
metafilters, the collector:

1. Walks the file system under the test root.
2. Imports each test module.
3. Calls `collect_test_references()`
   (`codecraftai-testbase/source/packages/ccai/testbase/queries.py:29–94`)
   on each imported module.  That function uses
   `inspect.getmembers(mod, inspect.isfunction)` to find
   functions whose names match the test prefix and wraps each
   in a `TestRef`.
4. Applies the includes/excludes/metafilters to prune the
   references that don't match.
5. Builds a `TestGroup` tree — a hierarchical structure mirroring
   the package layout, with `TestRef` objects at the leaves.

The output of discovery is a `TestGroup` tree.  Nothing has run
yet; nothing has been imported except the test modules themselves.

## Sequencing: TestSequencer's five phases

Once discovery has produced a tree, the sequencer drives it
through five phases.  The phases are an enum:

```python
# codecraftai-testbase/source/packages/ccai/testbase/sequencing/testsequencer.py:106–150

class TEST_SEQUENCER_PHASES(IntEnum):
    Initial = 0
    Discovery = 1
    Collection = 2
    Graph = 3
    Traversal = 4
```

Each phase has a job:

- **Initial** — sequencer constructed; no work done.
- **Discovery** — the `TestCollector` walks the test root.  At
  the end of this phase the tree of `TestRef` objects exists
  but no resources have been resolved.
- **Collection** — the sequencer collects *originators* (the
  resource factories from Chapter 3) and *integrations* needed
  to satisfy the tests' declared dependencies.
- **Graph** — the sequencer builds the resource dependency
  graph and computes the order in which resources must be
  acquired and released.  This is where cyclic dependencies
  surface.
- **Traversal** — the sequencer walks the graph, executing
  tests in dependency-respecting order.

The sequencer's phases are *not* the same as the `TestJob`'s
`fire_*` hooks.  The phases describe how the sequencer thinks
about the suite; the hooks describe what events the job
broadcasts to the integrations.  A given `fire_*` hook can fire
at a particular phase boundary (the docstrings call out which
ones — `fire_diagnostic_capture_pre_testrun`'s docstring at
`testjob.py:487–492` documents its position).

## TestJob: the lifecycle owner — and the customisation entry point

`TestJob`
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:45`)
is the singleton-like object that owns the run.  It has a
constructor (line 68) that stores the logger, the test root, the
includes/excludes, and — crucially — obtains its run-level
metadata and output directories through *injected seams* rather
than reaching into a runtime/configuration layer.  The
constructor calls two provider hooks: `_create_run_metadata()`
returns an `IRunMetadata` (line 95) and `_create_output_context()`
returns an `IOutputContext` (line 96).  It is used as a
context manager:

```python
with TestJob(logger=logger, testroot="tests/", ...) as job:
    job.execute()
```

`__enter__`
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:109`)
calls `begin()`.  `__exit__`
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:113`)
calls `finalize()`.  The run itself happens in `execute()`
(line 157), invoked between enter and exit.  Across the three
methods the job:

1. **`begin()`** (line 135): resolves the results directory from
   the output context (`self._output_context.get_test_results_dir()`)
   and computes the artefact filenames under it.  Note: `begin()`
   does *not* construct the recorder or the sequencer — those are
   created lazily inside `execute()`.
2. **`fire_attach_to_framework(tseq)`** (line 389): asks the
   sequencer to attach integrations to the framework — register
   originators, wire up couplings.
3. **`fire_attach_to_environment(tseq)`** (line 379): asks the
   sequencer to attach integrations to the environment.  On a
   single-host run the integrations may have nothing to attach
   to; on a distributed run this hook drives device wake-up.
4. **`fire_diagnostic_capture_pre_testrun(tseq, level)`** (line
   463): captures pre-test diagnostic state — host details, env
   vars, package versions.
5. **The recorder is constructed** (inside `execute()`, line 256)
   via `_create_recorder(...)` and entered as a context manager.
6. **The run itself**: the sequencer's traversal phase walks
   the tree, executing tests and streaming results.
7. **`fire_diagnostic_capture_post_testrun(tseq, level)`** (line
   411): captures post-test diagnostic state for failure
   triage.
8. **`recorder.finalize()`**: in the `finally` block of
   `execute()` (line 326), the recorder writes its artefacts and
   the run summary is rendered to the log.
9. **`finalize()`** (line 521): the `TestJob`-level finalize is a
   no-op on the base class — there are no integrations to tear
   down on a single-host standalone run.  Subclasses override it
   when they own teardown.

The `fire_*` hooks are the broadcast points.  Two of them — the
attach hooks — do real work even on plain testbase (they delegate
to the sequencer).  The other three landscape hooks —
`fire_activate_configuration()` (line 399),
`fire_activate_integration()` (line 403), and
`fire_activate_operations()` (line 407) — are stubs on the base
class: each logs a debug line and returns.  In fact `execute()`
does not even call the activation hooks; they exist purely as
override points for the landscape-activating subclass.

> **Where landscape activation lives.**  The five landscape
> `fire_*` hooks (the two `fire_attach_*` plus the three
> `fire_activate_*`) have *bodies* in Volume 2's
> `InteropLibraryTestJob` (in `codecraftai-interop`), not here.  On
> plain testbase there is no landscape to activate, so the
> activation hooks stay stubs.  Importantly, `IntegratedTestJob`
> in the integration runtime does *not* override these five
> hooks either — its job is result recording, not landscape
> activation.  The order is the contract; the interop subclass
> fills the stubs in.  A reader who understands this lifecycle
> understands the distributed runner's lifecycle too — the order
> doesn't change, just the hooks' bodies.

### TestJob is the customisation entry point

`TestJob` is not just the lifecycle owner — it is *the* place
where the user customises the framework.  Per the meta-thesis
from Volume 0 chapter 1 ("framework supplies the skeleton and
patterns; the organisation customises"), `TestJob` is the
specific, concrete plug-in point.

The customisation works by **subclassing**.  The user picks a
starting layer and overrides the template methods they need.
There is a four-deep inheritance chain, each layer adding one
concern:

- **`TestJob`** (single-host base, this chapter; in
  `codecraftai-testbase`).  Standalone: stdout recorder, injected
  `IRunMetadata`/`IOutputContext` seams, the five landscape
  `fire_*` hooks as stubs.  Override `_create_sequencer()`,
  `_create_recorder()`, `_create_output_context()`,
  `_create_run_metadata()`, or any hook for single-host
  customisation.
- **`IntegratedTestJob`** (in `codecraftai-integration-runtime`;
  `ccai.integrationruntime.integratedtestjob`).  Adds
  *integration result recording*: it overrides exactly three
  provider hooks — `_create_output_context()` to a
  presence-folder-backed `RuntimeOutputContext`,
  `_create_run_metadata()` to a `RuntimeVariables`-backed
  `RuntimeRunMetadata`, and `_create_recorder()` to the
  HTML-rendering `JsonResultRecorder`.  It does *not* touch the
  five landscape `fire_*` hooks.
- **`InteropLibraryTestJob`** (Volume 2; in `codecraftai-interop`).
  Adds *landscape activation*: it fills in the five `fire_*`
  hooks and declares the abstract `_get_landscape()`.  Override
  `_get_landscape()` to return your project's `Landscape`
  subclass; the framework picks up the custom Features and
  topology shapes through that landscape.
- **`DistributedTestJob`** (Volume 2; in `codecraftai-testplus`).
  Adds the distributed sequencer and a concrete `_get_landscape()`
  for distributed multi-host scenarios.

> **In-flight note.**  The chain above is the as-built design,
> but the re-parenting is not fully landed: `IntegratedTestJob`
> is built and in place, yet `InteropLibraryTestJob` still
> extends `TestJob` directly in the interop source today
> (`codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22`),
> not `IntegratedTestJob`.  Slotting interop onto `IntegratedTestJob`
> is the remaining step.  When it lands, an interop run will pick
> up the presence-folder output context and HTML recorder for
> free; until then interop runs still get the testbase defaults
> for those.

The framework's entry points accept the user's subclass:

```python
# Single-host
generic_test_entrypoint(job_type=YourCustomJob)

# Distributed
distributed_test_entrypoint(job_type=YourCustomDistributedJob)
```

Real scaffolding examples live in
`codecraftai-testbase/source/tests/ccai_tests/testbase/integration/test_custom_job_type_integration.py`
(`CustomTestJob`, `FailingCustomTestJob`).  An SDET writing a
custom job type follows that pattern: subclass at the right
layer, override the template methods, plug into the entry
point.

The construction cascade that follows from a custom `TestJob`:

```
User-defined TestJob subclass             ← CUSTOMISATION ENTRY POINT
    │
    ├─ creates → TestSequencer (custom or default)
    │              │
    │              ├─ creates → Landscape (user's subclass — Vol 6)
    │              ├─ creates → Topology  (user's subclass — Vol 6)
    │              └─ orchestrates the lifecycle
    │
    └─ owns the lifecycle (the five fire_* hooks above)
```

Everything Vols 2 and 6 cover about Landscape and Topology
*subclassing* hangs off this cascade.  The user's `TestJob`
subclass is the integration point; the rest is what the
template methods return.

## The recorder pipeline

The base `TestJob` records to **stdout** by default.
`_create_recorder()`
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:564`)
returns a `StdoutResultRecorder`
(`codecraftai-testbase/source/packages/ccai/testbase/results/recorders/stdoutresultrecorder.py:36`),
not a `JsonResultRecorder`.  The recorder is constructed inside
`execute()` (not `begin()`) and entered as a context manager;
every test event the sequencer fires (test started, test
finished, error, skip) is forwarded to it.

`StdoutResultRecorder` is a thin subclass of `JsonResultRecorder`
that reuses the JSOS/JSON serialisation but **skips the HTML
rendering**: its `initialize_report()` is a no-op (no template to
publish) and its `finalize()` echoes the run summary to stdout
once.  So a standalone testbase run prints its summary and — when
an output directory is in play — still writes the machine-readable
artefacts that the `testbase agents` CLI consumes:

- **`testrun_summary.json`** — written at finalize.  High-level
  pass/fail counts, duration, environment fingerprint.  This is
  what a green/red dashboard reads.
- **`testrun_results_stream.jsos`** — written *as tests finish*.
  Each test event becomes a JSON record; records are separated
  by an ASCII record-separator character.  A consumer can tail
  this file and parse incrementally.  Chapter 5 covers the
  format in depth.
- **`testrun_results.json`** — written at finalize.  The
  consolidated final document containing the same information
  as the stream file but reorganised for batch consumption.

What plain testbase does *not* produce is the rendered HTML
summary.  That, along with the presence-folder results layout and
the CI/build/pipeline metadata, is the responsibility of
`IntegratedTestJob` in `codecraftai-integration-runtime`, which
overrides `_create_recorder()` to return the full
`JsonResultRecorder`
(`codecraftai-testbase/source/packages/ccai/testbase/results/recorders/jsonresultrecorder.py:90`).

> **Standalone vs. integrated.**  The split is deliberate.  Plain
> testbase runs *without activation* — no presence folder, no
> `RuntimeVariables`, no this-app — and records to stdout plus the
> JSOS/JSON files under whatever output directory the
> `SimpleOutputContext` resolves (`AM_OUTPUT_DIRECTORY` or
> `<cwd>/.output`).  The well-known presence-folder results
> directory, the HTML summary, and the build/pipeline/job metadata
> are layered on by `IntegratedTestJob` via its three overridden
> provider hooks (`_create_output_context`, `_create_run_metadata`,
> `_create_recorder`).

## Couplings: where integrations live

A *coupling* is a piece of code that lives between testbase and
some other system.  Three families exist:

- **IntegrationCoupling** — the most common.  An integration
  coupling represents "testbase needs to know about *that*
  external thing."  Examples: a coupling to a credentials
  store, a coupling to a device pool, a coupling to a
  dataprofile registry.  The base class is at
  `codecraftai-testbase/source/packages/ccai/testbase/coupling/integrationcoupling.py`,
  and the public method `establish_connectivity(allow_missing_devices=False)`
  (line 512) is the place where the coupling decides whether
  it can run with whatever it has.
- **ConfigurationCoupling** — represents "testbase needs to
  consume a configuration source" — runtime files, landscape
  files, etc.
- **ValidatorCoupling** — represents "testbase needs to validate
  something against a schema, a policy, or an evolving system
  state."  Validators are the framework's *observer* mechanism:
  one or more validators attach to a test scope via
  `attach_to_test(testscope, suffix)` and run *in tandem* with
  the test, producing their own outcomes recorded alongside the
  test's result.  The base class lives in
  `codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py`.
  This is the substrate for **tandem testing** — the framework's
  answer to "how do you run a test deterministically against a
  distributed system without forbidding observation."  Volume 2
  covers the tandem model in depth; the single-host text in
  Chapter 9 of this volume carries the bridge.

Couplings are registered as integrations during the `Collection`
phase, attached to the framework during `fire_attach_to_framework`,
and connected during `fire_attach_to_environment`.  Chapter 3
covers the coupling lifecycle in detail when discussing how
resource factories are scoped.

Volume 2 extends the coupling model with landscape-aware
couplings.  Volume 3 extends it further with credential-store
couplings that fetch from external secret managers.  The model
is the same in all three volumes — only the kinds of things
being coupled change.

## Decorators: what's in the public API

A reader skimming a testbase test file will see a small set of
decorators.  They divide cleanly:

- **Discovery / classification** — none.  Tests are plain
  functions; no `@testbase.test` decorator exists.  The name
  prefix is the contract.
- **Resource attachment** — `@testbase.attach.parameter(...)`
  attaches a parameter (a fixture, in pytest vocabulary) to a
  specific test
  (`codecraftai-testbase/source/packages/ccai/testbase/declaration/attachments.py`).
- **Resource origination** — `@integration_factory()`,
  `@configuration_factory()`, and `@resource_factory()` register
  the *source* of a resource
  (`codecraftai-testbase/source/packages/ccai/testbase/declaration/registrations.py:45–93`).
  These are usually written in framework or extension modules,
  not test files.
- **Property-based testing** — `@given(...)` is the
  Hypothesis-style decorator declaring strategies for a test's
  parameters
  (`codecraftai-testbase/source/packages/ccai/testbase/properties/decorators.py:23–97`).
  Chapter 4 covers it.
- **Skip / conditional** — `@skip_if(...)`, `@only_if(...)`,
  and the metafilter family.  These are evaluated at discovery
  time and may prune the `TestRef` from the tree before the
  Graph phase.

The set is intentionally small.  Per the Volume 0 chapter 2
discipline on decorators, testbase decorators are either
metadata-attaching (`@given`, `@skip_if`) or registration
(`@integration_factory`).  None of them modify behaviour
silently.  None of them inject a retry or timeout — those are
parameters on the test or on the fixture, per the Aspects pattern
from Volume 0 chapter 4.

## Async support

Tests can be `async def`.  The `TestRef` carries an async flag,
and the property runner / sequencer call into asyncio for those
tests.  The handling is uniform — async tests live in the same
tree, get the same lifecycle, produce the same JSOS records.
The framework does not present "async tests" as a separate
category to the test author.

## Where the test root lives

Throughout testbase the *test root* is the directory that
contains test packages.  The convention across the portfolio is:

```
<repo>/source/testroots/ccai/tests/
                                 ├── singlehost/
                                 ├── distributed/
                                 └── ...
```

`codecraftai-agent-kit`'s test root is the basis for Chapter 10's
case study.  `codecraftai-testbase`'s own test root is at
`codecraftai-testbase/source/tests/` and demonstrates testbase
testing testbase — a useful self-application that catches
framework regressions.

## A run, end to end

Bringing the pieces together: what happens when an SDET runs

```bash
testbase run --testroot tests/ --include "test_arithmetic*"
```

(rough form — the actual CLI uses arcli; Chapter 8 walks the
syntax):

1. The arcli entry point parses the command and constructs a
   `TestJob` (its constructor resolves the `IRunMetadata` and
   `IOutputContext` seams).
2. The job's `__enter__` runs `begin()`, which resolves the
   results directory from the output context and computes the
   artefact filenames.  `execute()` then drives the rest.
3. `execute()` creates the sequencer and enters the *Discovery*
   phase: walks `tests/`, imports modules, calls
   `collect_test_references()`, applies filters, builds the
   `TestGroup` tree.
4. The sequencer enters *Collection*: collects originators and
   integrations.
5. The job fires `fire_attach_to_framework` and
   `fire_attach_to_environment`.  Couplings register and
   establish connectivity.
6. The sequencer enters *Graph*: builds the resource
   dependency graph, plans acquisition order.
7. The job fires `fire_diagnostic_capture_pre_testrun`.  (On the
   base `TestJob` the three `fire_activate_*` hooks are stubs and
   are not called from `execute()` at all; a landscape-activating
   subclass is where they do work.)
8. The recorder is created via `_create_recorder()` —
   `StdoutResultRecorder` on plain testbase — and entered.
9. The sequencer enters *Traversal*: for each test in
   dependency-respecting order, the recorder receives a
   "started" event, the test runs, the recorder receives a
   "finished" event, and a record is written to the JSOS
   stream.
10. The job fires `fire_diagnostic_capture_post_testrun`.
11. In the `finally` block, `recorder.finalize()` writes the
    summary and consolidated documents and echoes the summary to
    stdout; the run summary is rendered to the log.  `__exit__`
    then calls `TestJob.finalize()`, a no-op on the standalone
    base.

Each numbered step has a chapter reference somewhere in this
volume.  The next chapter goes deep on step 4 and 6 — the
resource dependency graph, the way originators register, and the
way `@testbase.attach.parameter` lines up with it all.

## What's next

- Chapter 3 — Resource factories and dependency injection.
  How originators register, how `@testbase.attach.parameter`
  and `@integration_factory` differ, how the dependency graph
  is built.
- Chapter 4 — Property-based testing with `@given`.  The
  `TESTBASE_MAX_EXAMPLES_CAP` ceiling, the
  SHA-256(job_seed + ":" + test_name) per-test seed, the
  distinction between a strategy's `max_examples` and the
  cap.
- Chapter 5 — Streaming JSOS results.  The format, the three
  output files, the consumer patterns.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/03-resource-factories-and-di -->
# Chapter 3 — Resource Factories and Dependency Injection

> *A test that imports its dependencies cannot run on a machine
> that doesn't have them.  A test that declares its dependencies
> can.  This chapter is about the difference, and about the small
> set of decorators testbase uses to express it.*

The previous chapter walked the architecture from CLI invocation
to result file.  This chapter zooms in on one phase — the
*Collection* and *Graph* phases — where resource factories are
discovered, dependency graphs are built, and the test
parameters are bound to the resources they need.

The vocabulary here matters.  Pytest calls them *fixtures*.
Spring (in another world) calls them *beans*.  Dagger / Guice
call them *bindings*.  Testbase calls them *originators*, and
the way they get attached to a test parameter is the
`@testbase.attach.parameter(...)` decorator.  The vocabulary is
not arbitrary: an *originator* is the function that *originates*
the resource; the *attachment* binds the originator's output to
a parameter on a test.

By the end of this chapter you will know how to register an
originator, how to declare a parameter on a test, and how the
sequencer's Graph phase lines them up.

## The principle: declare, don't import

A pytest test that needs a database session imports a fixture:

```python
# pytest
from myproject.testfixtures import db_session

def test_some_query(db_session):
    rtnval = db_session.query(...).all()
    assert len(rtnval) > 0
```

The import works only if `myproject.testfixtures` is on the
Python path *at the time the test module is imported*.  On a
laptop without the dev database, the import fails — and pytest
reports a collection error rather than a clean "this test isn't
runnable here."

A testbase test attaches a parameter via decoration:

```python
# testbase
import testbase

@testbase.attach.parameter(db_session, identifier="db")
def test_some_query(db):
    """
        Verify that the canonical query returns at least one row.
    """
    rtnval = db.query(...).all()
    assert len(rtnval) > 0
    return
```

The `db_session` reference here is the *originator function* —
a function registered with the framework via
`@configuration_factory()` or `@integration_factory()` (more on
the difference below).  The originator is *not imported* at the
test file's top level.  It is referenced; the framework
resolves the reference during the Collection phase, and only
then does it know whether the resource is available on this
machine.

The consequence is that a test author can *declare* a dependency
that won't resolve on their laptop, and the framework's response
is "this test is unrunnable here" — not an import error.

## Originators: where resources come from

An originator is a Python function that yields a resource.  It
lives in a *factory module* — typically
`<repo>/source/packages/<vendor>/<package>/factories/` — and is
decorated with one of three registrations:

```python
# codecraftai-testbase/source/packages/ccai/testbase/declaration/registrations.py:45–93

def integration_factory(scope: ResourceLifespan = ResourceLifespan.Session):
    """
        Decorator that registers a generator function as an integration
        factory yielding IntegrationCoupling subclasses.
    """
    def _decorator(func: Callable) -> Callable:
        InjectionRegistry.register_integration_factory(func, scope=scope)
        rtnval = func
        return rtnval
    return _decorator
```

The three families:

- **`@integration_factory()`** — yields integration couplings.
  Used to register, for example, a coupling to a credentials
  store, a coupling to a device pool.  These are typically
  *Session*-scoped: one instance for the whole run.
- **`@configuration_factory()`** — yields configuration
  couplings.  Used to register a runtime file source, a
  landscape source.  Typically Session-scoped.
- **`@resource_factory()`** — yields plain resources (database
  sessions, HTTP clients, mock objects).  Lifespan varies —
  Session, Package, Test.

The decorator does not *modify* the function; it registers the
function with `InjectionRegistry`
(`codecraftai-testbase/source/packages/ccai/testbase/injection/injectionregistry.py`).
This is the Volume 0 chapter 2 discipline: decorators that
register, not decorators that wrap.  When `func` is called,
nothing about its body has been altered.  Per Volume 0 the
decorator family is metadata + registration only — never
behaviour modification.

## Lifespan: when the resource is created and destroyed

`ResourceLifespan` is an enum
(`codecraftai-testbase/source/packages/ccai/testbase/resourcelifespan.py`)
with three values:

- **Session** — created once at the start of the run, destroyed
  once at the end.  Use for expensive resources that are
  read-only or thread-safe across tests: a credentials store, a
  device pool registry, a configuration object.
- **Package** — created once per test package, destroyed when
  the package's tests finish.  Use for moderately expensive
  resources that you don't want to share across packages:
  per-package mock servers, per-package temporary directories
  with cross-test continuity.
- **Test** — created and destroyed for each test invocation.
  Use for resources that need a clean slate per test: a fresh
  database transaction, a per-test subdirectory.

The choice of lifespan is the *test author's* choice but the
*framework's* mechanism: the sequencer's Graph phase resolves
each parameter's lifespan and inserts the right number of
acquisition / release boundaries into the traversal plan.

## The originator as a generator

The originator is a generator, not a plain function.  The
generator pattern lets the originator clean up after itself:

```python
@testbase.factories.integration_factory(scope=ResourceLifespan.Session)
def db_session():
    """
        Yields a session-scoped database connection.
    """
    conn = connect_to_db(...)
    try:
        yield conn
    finally:
        conn.close()
    return
```

Everything before the `yield` is acquisition; the value yielded
is what the test receives; everything after the `yield` is
cleanup.  The framework drives `next()` to acquire and the
generator's `__exit__` (via a `try`/`finally`) to release.

The trailing `return` at function end is the explicit-return
discipline from Volume 0 chapter 1.  In a generator the `return`
is also the way to signal `StopIteration` cleanly.

## `@testbase.attach.parameter`: binding the originator to a test

A test asks for a resource by attaching a parameter:

```python
# codecraftai-testbase/source/packages/ccai/testbase/declaration/attachments.py

@testbase.attach.parameter(db_session, identifier="db")
def test_some_query(db):
    """
        Verify the canonical query returns at least one row.
    """
    rtnval = db.query(...).all()
    assert len(rtnval) > 0
    return
```

The decorator takes:

- A reference to the originator (`db_session`).
- An `identifier` — the parameter name on the test function.
- Optionally `constraints` — see below.

When the sequencer builds the dependency graph, it sees the
attachment, resolves the originator from the registry, computes
the lifespan-aware acquisition plan, and inserts a binding into
the traversal plan that ensures the originator's yielded value
is bound to the parameter at the moment the test runs.

The originator function is *not called at import time*.  The
attachment decoration only stores metadata on the test
function; resolution happens during Collection.  This is the
property that lets a test author declare a dependency on a
resource their machine cannot provide.

## `parameter()` vs `@testbase.attach.parameter`

There are two surfaces for declaring a parameter, and the
distinction trips people up at first:

- **`parameter()`** — module-level call (not a decorator)
  that creates a *global* parameter from an originator.  Lives
  in
  `codecraftai-testbase/source/packages/ccai/testbase/declaration/originations.py:31–92`.
  Useful when many tests in a package will need the same
  parameter, and you don't want to repeat the `@attach`
  decoration on each.
- **`@testbase.attach.parameter(...)`** — decorator on a single
  test (or on a `TestGroup` to apply to all tests under it).
  Lives in
  `codecraftai-testbase/source/packages/ccai/testbase/declaration/attachments.py`.
  Useful for per-test attachments.

The trap: `parameter()` is *not* a decorator.  Calling
`@parameter(...)` on a test function will fail.  The originations
module includes an explicit error trap (lines 101–103) that
catches the misuse and points the user at
`@testbase.attach.parameter()`.  The error message is the kind
of guard from Volume 0 chapter 3 — the framework noticing it's
about to behave wrongly, and refusing with a `SemanticError`-
shaped message rather than failing silently.

## Constraints on attachments

The `constraints` argument on
`@testbase.attach.parameter(..., constraints=...)` is where
fixture-level Aspects live.  The constraint dict can specify:

- `life_span` — override the originator's default lifespan for
  this attachment.
- `assigned_scope` — pin the resource to a specific package or
  test scope.
- Originator-specific keys.

This is the Aspects pattern from Volume 0 chapter 4: rather
than have a `@retry_on_failure` decorator wrap the originator,
the originator accepts an `Aspects`-shaped parameter that the
attachment can fill in.  The behaviour is *parameterised*, not
*decorated*.  A test that needs the resource with retries
declares retries; a test that wants the unwrapped resource
declares no retries.  Same originator; different aspects.

## The dependency graph

During the *Graph* phase
(`codecraftai-testbase/source/packages/ccai/testbase/sequencing/testsequencer.py:106–150`),
the sequencer walks the discovered tests and their attached
parameters, looks up each originator in the injection registry,
and constructs a directed acyclic graph where:

- Each originator is a node.
- Each test is a node.
- Each `@testbase.attach.parameter` reference is an edge.
- Each cross-originator dependency (one originator that needs
  another resource as input) is an edge.

The sequencer topologically sorts the graph to derive an
acquisition order.  Cycles are detected here — a cycle in the
resource graph is a `SemanticError` and bubbles immediately,
because by Volume 0 chapter 3 a circular dependency is an API
misuse the framework cannot reasonably recover from.

The graph also drives the *release* order: resources are
released in reverse acquisition order, so a session-scoped
credentials store is closed *after* the last test that depended
on it has finished.

## Composition: an originator that depends on another originator

Originators can depend on other originators by declaring their
own attachments:

```python
@testbase.factories.resource_factory(scope=ResourceLifespan.Session)
@testbase.attach.parameter(credentials_store, identifier="creds")
def db_session(creds):
    """
        Yields a database session authenticated against the credentials
        store.
    """
    user, password = creds.lookup("db_main")
    conn = connect_to_db(user=user, password=password)
    try:
        yield conn
    finally:
        conn.close()
    return
```

Now `db_session` depends on `credentials_store`.  The Graph
phase sees the dependency and ensures the credentials store is
acquired *before* the session, and released *after*.

This composition is how testbase handles the realistic case
where a "fixture" is not one resource but a chain of them: a
session that needs credentials that need a profile that needs
a runtime configuration.  Volume 3 chapters 18–23 walk a
similar chain at orchestration scale.

## Cleanup ordering

The framework guarantees that cleanup runs in *reverse*
acquisition order.  If `db_session` was acquired after
`credentials_store` (because `db_session` depends on
`credentials_store`), then `db_session` will be released
*before* `credentials_store` is.  This is critical for
resources that must be released while their underlying
dependencies are still alive — a database session must be
closed while the credentials are still valid, not after the
credentials store has been torn down and the auth tokens
revoked.

The framework runs each generator's post-yield cleanup as a
separate try/finally block, so a failure in one cleanup does
not abort the others.  The sequencer logs failed cleanups as
errors but does not raise.  This is one of the few places where
testbase deliberately *does not* let an exception bubble — per
Volume 0 chapter 3, "errors should never pass silently unless
purposefully handled and silenced," and a partial-cleanup error
is the canonical "we're already on the way out, log and
continue" case.

## A worked example

Walking the path concretely.  Suppose a test author writes:

```python
# tests/billing/test_invoice.py
import testbase
from ccai.billingkit.factories import db_session, billing_runtime

@testbase.attach.parameter(db_session, identifier="db")
@testbase.attach.parameter(billing_runtime, identifier="runtime")
def test_invoice_total_includes_tax(db, runtime):
    """
        Verify that an invoice total includes tax for runtime profile X.
    """
    invoice = create_test_invoice(db, runtime)
    expected = invoice.subtotal * Decimal("1.0875")
    actual = invoice.total
    rtnval = (actual == expected)
    assert rtnval
    return
```

What happens:

1. **Discovery**: the file is imported.  The two `@attach`
   decorators stash `(originator_ref, identifier)` pairs on
   `test_invoice_total_includes_tax`.  No originator is called
   yet.
2. **Collection**: the sequencer scans the registered
   originators.  `db_session` is a session-scoped resource_factory
   that depends on `credentials_store`.  `billing_runtime` is a
   session-scoped configuration_factory that depends on
   `runtime_profile`.
3. **Graph**: the sequencer builds a DAG with five nodes
   (`credentials_store`, `db_session`, `runtime_profile`,
   `billing_runtime`, `test_invoice_total_includes_tax`) and
   four edges.  Topological sort gives acquisition order:
   `credentials_store`, `db_session`, `runtime_profile`,
   `billing_runtime`, then test execution.  Release runs
   reverse.
4. **Traversal**: the sequencer drives the generators in order.
   `credentials_store` yields a credentials object.
   `db_session` consumes it, yields a session.  Same for the
   runtime path.  Then the test runs with `db` bound to the
   yielded session and `runtime` bound to the yielded runtime
   object.  The recorder gets a "started" event before the
   test body runs and a "finished" event after.
5. **Cleanup**: in reverse order — `billing_runtime`'s
   post-yield, `runtime_profile`'s, `db_session`'s,
   `credentials_store`'s.

This is the worked example you can carry into Chapter 5 (where
the recorder's events become JSOS records) and into Chapter 9
(where the same dependency graph extends to landscape-aware
resources).

## What this enables

The originator + attach + lifespan model is the foundation for
three things later in the volume and the series:

- **Property-based tests with shared resources** (Chapter 4).
  A `@given` test still uses `@attach.parameter` for non-strategy
  parameters; the property runner cycles through generated
  values for the strategy parameters while reusing the
  attached resources across iterations.
- **The `agents` CLI's config extraction** (Chapter 6).  The
  config extract reads the same originator registry to tell an
  agent what resources the suite needs.
- **The distributed runner's landscape activation** (Volume 2).
  `InteropLibraryTestJob`'s `fire_activate_*` hooks fire at
  *exactly* the same phase boundaries described here; the
  hooks just have non-trivial bodies.

## What's next

- Chapter 4 — Property-based testing.  How `@given` strategies
  compose with attached parameters; the per-test seed; the CI
  cap.
- Chapter 5 — Streaming JSOS.  Each "started" / "finished"
  event becomes a JSOS record; the format and the three output
  files.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/04-property-based-given -->
# Chapter 4 — Property-Based Testing with `@given`

> *Example-based tests verify the cases the test author thought
> of.  Property-based tests verify the property the test author
> stated, against an unbounded space of inputs.  The hard part
> is not writing the property — it is making the framework's
> generation budget *match* the time the suite has to run.  This
> chapter is about that.*

Property-based testing in `codecraftai-testbase` is conceptually a
sibling of Hypothesis — the testing library most Python engineers
know — but with two specific adaptations for automation suites:

1. **A runtime-overridable cap on `max_examples`.**  The
   environment variable `TESTBASE_MAX_EXAMPLES_CAP` constrains
   how many examples each property test may generate, regardless
   of what the test declared.  This lets a build engineer tune
   the *whole suite* without editing the *individual tests*.
2. **A deterministic per-test seed derived from the job seed.**
   The framework computes
   `SHA-256(job_seed + ":" + test_name)` and feeds it to the
   per-test generator.  Re-running a job with the same job seed
   reproduces the same per-test seeds — the basis for replayable
   property-based runs.

The rest of the chapter walks `@given`, the strategies it
accepts, the cap, and the per-test seed.  Where Hypothesis
behaviour differs, the chapter calls it out explicitly.

## The `@given` decorator

```python
# codecraftai-testbase/source/packages/ccai/testbase/properties/decorators.py:23–97

def given(*, constraints: Optional[Dict[str, Any]] = None, **strategies: Strategy) -> Callable:
    """
        Decorate a test function with strategies for property-based
        execution.

        :param constraints: optional dict with `max_examples` and/or
            `max_shrink_attempts` keys.
        :param strategies: keyword arguments mapping the test
            function's parameter names to Strategy instances.
    """
    def _decorator(func: Callable) -> Callable:
        func._property_ = {
            "strategies": strategies,
            "constraints": constraints if constraints is not None else {},
        }
        rtnval = func
        return rtnval
    return _decorator
```

The decorator stores metadata on the function under
`_property_`.  Per Volume 0 chapter 2's decorator discipline,
nothing about the function body changes.  The property runner
(below) is the thing that *uses* the metadata.

A typical use:

```python
import testbase
from testbase.strategies import integers, text

@testbase.given(
    a=integers(min_value=-1000, max_value=1000),
    b=integers(min_value=-1000, max_value=1000),
    constraints={"max_examples": 50},
)
def test_addition_is_commutative(a, b):
    """
        Verify that integer addition is commutative across the
        given range.
    """
    rtnval = (a + b) == (b + a)
    assert rtnval
    return
```

The strategy keys map onto the test function's parameter names.
`constraints={"max_examples": 50}` says "generate at most 50
examples per run."  The `max_shrink_attempts` key (also in the
constraints dict) caps how many shrinks the runner attempts after
finding a failure.

## Constraint vocabulary

The constraint dict supports two keys
(`codecraftai-testbase/source/packages/ccai/testbase/properties/decorators.py:42–44`):

- **`max_examples`** — integer, default 100.  How many distinct
  inputs to generate before declaring the property holds.
- **`max_shrink_attempts`** — integer, default 100.  After the
  runner finds an input that violates the property, how many
  attempts to make at finding a smaller input that still
  violates.

Per Volume 0 chapter 4 (the Aspects pattern), behaviour
parameters live on the test, not on a wrapping decorator.  There
is no `@retry`, no `@timeout`, no `@shrink_more` decorator.  The
test author writes `constraints={...}` and the runner respects
it — except where the runtime cap overrides.

## `TESTBASE_MAX_EXAMPLES_CAP`: the build engineer's knob

Here is where testbase parts company with Hypothesis.

```python
# codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:47–79

ENV_MAX_EXAMPLES_CAP = "TESTBASE_MAX_EXAMPLES_CAP"

def _resolve_max_examples_cap() -> Optional[int]:
    """
        Read the TESTBASE_MAX_EXAMPLES_CAP environment variable and
        return the integer value, or None if unset.

        :returns: integer cap or None.
        :raises: ValueError when the env var is set but unparseable.
    """
    raw = os.environ.get(ENV_MAX_EXAMPLES_CAP, None)
    if raw is None:
        rtnval = None
        return rtnval

    try:
        rtnval = int(raw)
    except ValueError as cause:
        err_msg_lines = [
            "TESTBASE_MAX_EXAMPLES_CAP must be a parseable integer.",
            "    VALUE: {!r}".format(raw),
        ]
        err_msg = os.linesep.join(err_msg_lines)
        raise ConfigurationError(err_msg) from cause

    return rtnval
```

The function reads the environment variable at runtime.  When the
property runner constructs itself, it stores the cap.  When it
runs a test, it picks the *smaller* of (the test's declared
`max_examples`, the cap).  A test that says `max_examples=200`
running under `TESTBASE_MAX_EXAMPLES_CAP=10` runs ten examples.
A test that says `max_examples=5` running under the same cap
runs five.

The cap is *only an upper bound*.  It cannot make a test
generate more examples than its declaration; it can only generate
fewer.  This is intentional: a test author who deliberately
budgets 5 examples (because the strategy is expensive) does not
have their budget *raised* by an environment variable.

The cap is the build engineer's knob.  In CI, set it to 10 — the
suite finishes in a CI window short enough to run on every PR.
On a nightly full-sweep run, leave it unset — the suite runs at
its full declared budget.  In local development, set it
to 1 or 5 for a fast smoke pass.

This is the Aspects pattern from Volume 0 chapter 4 lifted to
*the suite level*.  The behaviour parameter (how many examples)
is set by the *caller* of the runner, not edited into hundreds
of test files.  Chapter 10 returns to this knob in detail —
the case study suite uses `TESTBASE_MAX_EXAMPLES_CAP=10` to
finish in 49 minutes wall time.

## Determinism: the per-test seed

Property-based tests are only useful in CI if they are
reproducible.  A test that fails non-deterministically because
each run generates different inputs is a flake.  Testbase
addresses this by deriving the per-test seed deterministically
from the job seed:

```python
# codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:82–97

def derive_test_seed(job_seed: bytes, test_name: str) -> int:
    """
        Derive a deterministic per-test seed from the job seed and the
        test name.

        :param job_seed: the run-level seed (bytes).
        :param test_name: the fully-qualified test function name.

        :returns: integer per-test seed suitable for feeding to a PRNG.
    """
    payload = job_seed + b":" + test_name.encode("utf-8")
    digest = hashlib.sha256(payload).digest()
    rtnval = int.from_bytes(digest[:8], byteorder="big")
    return rtnval
```

The job seed is a fixed-length byte string set once at job start
(typically by `TestJob`'s constructor).  The test name is the
fully-qualified module path plus function name.  SHA-256 over
`job_seed + b":" + test_name` produces a 32-byte digest; the
first eight bytes become a 64-bit integer the runner feeds to
the strategy.

The properties this gives:

- **Re-running the same job seed reproduces every per-test
  seed.**  A failing run can be replayed end-to-end.
- **Adding a test does not change the seeds of existing tests.**
  The seed is a function of *that test's* name and the job seed,
  so the inputs other tests see are unchanged.
- **Renaming a test changes its seed.**  This is unavoidable —
  but the build engineer who renames a test should expect to
  re-baseline that test's recent runs anyway.

A specific question this raises: can the test author override
the per-test seed?  No.  Per the property runner declaration
(`codecraftai-testbase/source/packages/ccai/testbase/properties/decorators.py:49`)
there is no `seed` constraint key.  The `job_seed` is the only
seed input; the framework is the only seed deriver.  This is
deliberate: a test that gets to override its own seed cannot be
re-baselined to a known-good run by an external operator.

## The PropertyRunner

The runner that ties the strategies, constraints, cap, and seed
together is `PropertyRunner`
(`codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:100–120`):

```python
class PropertyRunner:
    def __init__(self,
                 constraints: Dict[str, Any],
                 job_seed: bytes,
                 test_name: str,
                 recorder: ResultRecorder,
                 parent_inst: TestInstance) -> None:
        self._constraints = constraints
        self._job_seed = job_seed
        self._test_name = test_name
        self._recorder = recorder
        self._parent_inst = parent_inst
        self._cap = _resolve_max_examples_cap()
        return
```

Six instance variables, all set in `__init__` per the Volume 0
chapter 2 discipline (no late-introduced instance variables).
The runner exposes a `run()` method that:

1. Resolves the effective `max_examples` (`min(declared, cap)`).
2. Resolves the `max_shrink_attempts` (declared only — the cap
   does not apply).
3. Derives the per-test seed.
4. Drives the strategy generators for `max_examples` iterations.
5. For each iteration: emits a *property-iteration* event to
   the recorder, runs the test body with the generated inputs,
   emits a result event.
6. On failure, runs up to `max_shrink_attempts` shrinks and
   reports the smallest failing input.

The result events get recorded to the JSOS stream same as
example-based tests; Chapter 5 covers the format.

## Composition with `@testbase.attach.parameter`

A `@given` test can also have attached parameters from Chapter
3.  The decorators compose:

```python
@testbase.attach.parameter(db_session, identifier="db")
@testbase.given(
    invoice_number=integers(min_value=1, max_value=10**9),
    constraints={"max_examples": 25},
)
def test_invoice_lookup_round_trips(db, invoice_number):
    """
        Verify that an invoice round-trips through the lookup API.
    """
    inserted = db.insert_invoice(invoice_number)
    looked_up = db.lookup_invoice(invoice_number)
    rtnval = (inserted == looked_up)
    assert rtnval
    return
```

The strategy keys (`invoice_number`) and the attachment keys
(`db`) are disjoint — they bind to different parameters.  The
attached resources are acquired *once* at the resource lifespan
boundary (Session, Package, or Test); the strategy values are
generated *per iteration* of the property test.

A subtle point: when `db` is a Test-scoped resource, the runner
re-acquires `db` on *every iteration*, not just every test
invocation.  If `db` is expensive, prefer Package or Session
scope — or generate the per-iteration data inside the test body
rather than asking the originator to do it.

## Strategy library

Testbase ships its own strategy library (under
`codecraftai-testbase/source/packages/ccai/testbase/strategies/`).
The vocabulary is similar to Hypothesis:

- `integers(min_value=..., max_value=...)`
- `floats(min_value=..., max_value=..., allow_nan=...)`
- `text(min_size=..., max_size=..., alphabet=...)`
- `booleans()`
- `lists(elements=..., min_size=..., max_size=...)`
- `tuples(*element_strategies)`
- `dictionaries(keys=..., values=..., min_size=..., max_size=...)`
- `one_of(*strategies)`
- `none()` and `just(value)`

Composition is via `lists(elements=integers())` and `one_of(...)`.
A user-defined strategy is a class that subclasses
`Strategy` and implements `draw(self, rng)` — the per-iteration
hook the runner calls.

The strategy implementations are testbase's own, not Hypothesis's.
This is a deliberate choice: it keeps the framework's dependency
graph free of Hypothesis (a sizable external dependency) and
gives the runner control over the generation budget at a
per-test level.

## Shrinking

When the runner finds an input that violates the property, it
attempts to *shrink* it — find a smaller, structurally-simpler
input that also violates the property.  The shrink algorithm is
strategy-specific: integers shrink toward zero, lists shrink by
removing elements, strings shrink toward empty.

The `max_shrink_attempts` constraint caps the number of shrink
attempts.  When the cap is reached the runner reports the
smallest-found-so-far input.

The reported failure carries:

- The input that failed.
- The seed used.
- The number of shrinks attempted.
- The exception (or the failed assertion's left/right operands).

A test author re-running locally with the reported input can
reproduce the failure deterministically — without re-running
the entire generation budget.

## What property-based testing is good for

A working list, with examples from the portfolio:

- **Round-trip invariants**: encode/decode, serialise/parse,
  insert/lookup.  Property: `decode(encode(x)) == x`.
- **Algebraic identities**: commutativity, associativity,
  identity, idempotency.
- **Boundary discovery**: for any `i in [min, max]`, function
  `f(i)` does not raise.
- **Ordering invariants**: a sort produces a non-decreasing
  sequence; a deduplicator never emits the same key twice.
- **Configuration loaders**: for any valid config dict shaped
  like `S`, the loader produces an object whose properties match
  `S`'s field semantics.

What property-based testing is *not* good for: end-to-end
integration tests where each iteration is expensive (each one
takes a database round-trip).  Those should remain
example-based, with a small handful of carefully chosen inputs.

## What's next

- Chapter 5 — Streaming JSOS results.  Each iteration of a
  property-based test becomes a JSOS record; the format and
  the consumers.
- Chapter 6 — The `agents` CLI.  How `agents testresult list`
  and `agents testresult failures` consume the JSOS stream.

Chapter 10's case study uses `TESTBASE_MAX_EXAMPLES_CAP=10` on
a 3,700-test single-host suite.  By the time the reader reaches
that chapter the cap will be familiar — the case study just
shows what the suite-level wall time is when the cap is set.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/05-streaming-jsos-results -->
# Chapter 5 — Streaming JSOS Results

> *A test framework's results pipeline is the part that survives
> when everything else doesn't.  A run that crashes after three
> hours is useless if the framework was buffering its output for
> the final report.  This chapter is about why testbase streams,
> what it streams, and how a consumer reads it.*

`codecraftai-testbase` writes test results in a streaming format
called **JSOS** — *JSON Separated Object Stream*.  The format
is straightforward: each event is a self-contained JSON object,
prefixed by an ASCII record-separator character (Unicode code
point U+001E) and a newline.  A consumer reads the file by
scanning for the separator, capturing the JSON between
separators, and parsing.

The format exists because the alternative — buffering events
until the run ends and writing one consolidated JSON document —
loses everything when a run crashes.  Streaming means a partial
run is still a useful run.

This chapter walks the format, the recorders that produce it,
the result files testbase emits, and the consumer patterns
that read the stream.  A note up front on division of labour:
plain `codecraftai-testbase` defaults to a *stdout* recorder and
runs standalone — it writes the JSOS/JSON artefacts only when a
run is given an output directory, and it never renders the HTML
summary.  The full HTML-rendering recorder and the
presence-folder results-directory layout are the responsibility
of `IntegratedTestJob` in `codecraftai-integration-runtime`.  The
*format* is identical in both cases; this chapter is about the
format and the standalone recorder, and flags where the
integration tier takes over.

## What "streaming" means in this context

A streaming results file is one where:

1. Each record is appended to the file *as soon as the event
   happens*.
2. The file is parseable at any moment, including immediately
   after a crash.
3. A consumer can `tail -f` the file and process records in
   near-real-time.

JSOS satisfies all three.  Each record is a complete JSON
object; appending one record does not invalidate any previously
written records; a `tail` consumer can see a record as soon as
the recorder writes it (the recorder flushes after each event —
see the recorder lifecycle below).

Compared to alternatives:

- **JSON Lines** (newline-delimited JSON) is the closest
  cousin.  JSOS uses the ASCII record-separator character
  (`\x1e`) instead of `\n` because some result fields legitimately
  contain newlines (multi-line stack traces, multi-line log
  excerpts).  A newline-delimited format requires escaping
  every newline in the payload; the record-separator character
  doesn't appear in normal text, so no escaping is needed.
- **JUnit XML** is the format CI dashboards historically read.
  It is not streamable: the document is closed at the end of
  the run.  Testbase produces a JSON-shaped consolidated file
  for batch consumers, but JSOS is the live stream.
- **Protobuf streams** would be more compact but harder to
  inspect by hand.  The portfolio's debuggability principle from
  Volume 0 chapter 1 prefers human-readable text.

## The record separator

The single line that defines the format is in the recorder
import block:

```python
# codecraftai-testbase/source/packages/ccai/testbase/results/recorders/jsonresultrecorder.py:24

from ccai.xmodules.jsos import CHAR_RECORD_SEPERATOR
```

`CHAR_RECORD_SEPERATOR` is the two-byte sequence `"\x1E\n"` —
the ASCII record-separator character followed by a newline.
(The newline is a readability convenience: it keeps the raw file
greppable line-by-line in a pager.  The structural delimiter is
the `\x1E`; the newline is not load-bearing.)  Every JSOS file
is a sequence of `(separator, json_object)` pairs.  The first
bytes of the file are a separator; from there each separator
marks the start of the next record.

A reader does not need testbase to parse the file.  Any program
that can split on `\x1e` and parse JSON can consume the stream:

```python
# A minimal reader.

def read_jsos(path: Path) -> Iterator[dict]:
    """
        Read a JSOS file and yield each record as a dict.
    """
    with path.open("rb") as handle:
        raw = handle.read()
    parts = raw.split(b"\x1e")
    for part in parts:
        text = part.strip()
        if len(text) == 0:
            continue
        record = json.loads(text)
        yield record
    return
```

The chapter does *not* claim this is the consumer the framework
ships — Chapter 6 walks the agents CLI's parser
(`jsosparser.py`).  But the format is open: any reader that
can find `\x1e` and parse JSON between occurrences is a
conforming consumer.

## The three result files

When a run is given an output directory, the recorder produces
three files in the run's results directory.  The filenames are
chosen by `TestJob.begin()`
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:148–150`)
and the serialisation is performed by `JsonResultRecorder`
(`codecraftai-testbase/source/packages/ccai/testbase/results/recorders/jsonresultrecorder.py:90`),
the JSOS/JSON serialisation core both the standalone and
integration recorders inherit from:

### `testrun_summary.json`

Written at finalize.  The high-level summary of the run:

```json
{
  "run_id": "20260430-153012-abc123",
  "started_at": "2026-04-30T15:30:12.123Z",
  "finished_at": "2026-04-30T16:19:43.456Z",
  "duration_seconds": 2971,
  "total": 3712,
  "passed": 3680,
  "failed": 18,
  "errored": 4,
  "skipped": 10,
  "host": {
    "hostname": "ci-runner-04",
    "os": "Linux 6.5.0",
    "python": "3.12.5"
  }
}
```

This is the artefact a CI dashboard polls for status.  It does
not include per-test detail; for that, the consumer reads the
stream or the consolidated file.

### `testrun_results_stream.jsos`

Written *as tests finish*.  Each record is one of a small set
of event kinds:

- `test_started`
- `test_finished`  (with `outcome` ∈ `{passed, failed, errored, skipped}`)
- `property_iteration`  (one per iteration of a `@given` test)
- `phase_started`  (one per sequencer phase)
- `phase_finished`
- `diagnostic_capture`  (host details, package versions, etc.)
- `coupling_event`  (integration coupling lifecycle)

A representative `test_finished` record:

```json
{
  "kind": "test_finished",
  "test_name": "ccai.agentkit.tests.test_costtracker.test_addition_is_commutative",
  "started_at": "2026-04-30T15:30:14.001Z",
  "finished_at": "2026-04-30T15:30:14.018Z",
  "duration_ms": 17,
  "outcome": "passed",
  "monikers": ["unit"],
  "pivots": null
}
```

A representative `test_finished` for a failure:

```json
{
  "kind": "test_finished",
  "test_name": "ccai.agentkit.tests.test_invoice.test_invoice_total_includes_tax",
  "started_at": "2026-04-30T15:35:21.501Z",
  "finished_at": "2026-04-30T15:35:21.612Z",
  "duration_ms": 111,
  "outcome": "failed",
  "monikers": ["billing"],
  "exception": {
    "type": "AssertionError",
    "message": "expected Decimal('108.75'), got Decimal('100.00')",
    "traceback": [
      "  File \".../test_invoice.py\", line 14, in test_invoice_total_includes_tax",
      "    assert rtnval"
    ]
  }
}
```

The stream is the rich source of truth.  Anything you can ask
about a test run can be answered by parsing the stream.

### `testrun_results.json`

Written at finalize.  The consolidated final document.  Same
information as the stream, reorganised into a tree by package
→ module → test for batch consumers (post-run analysis tools,
human readers).  Producing both is cheap because the recorder
already has every record in memory by finalize time.

The consolidated file is the one a build engineer commits to a
results-archive bucket; the stream file is what they keep
locally for live monitoring.

## The recorder lifecycle

`TestJob.begin()` (Chapter 2) resolves the results directory and
the artefact filenames
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:148–150`);
the recorder itself is constructed later, in `TestJob.execute()`,
through the `_create_recorder(...)` provider hook
(`.../testbase/testjob.py:564`) and entered as a context manager
(`.../testbase/testjob.py:256–257`):

```python
# .../testbase/testjob.py — recorder construction in execute()

with self._create_recorder(runid=runid, start=start, render_info=render_info,
                           build_info=build_info, pipeline_info=pipeline_info,
                           job_info=job_info) as recorder:
    ...
    tseq.execute_tests(runid, recorder)
```

> **The default recorder is `StdoutResultRecorder`, not
> `JsonResultRecorder`.**  Plain `testbase` runs standalone — no
> `codecraftai-this-app`, no `RuntimeVariables`, no HTML summary
> template — so `TestJob._create_recorder()` returns a
> `StdoutResultRecorder`
> (`.../testbase/results/recorders/stdoutresultrecorder.py:36`).
> That recorder *subclasses* `JsonResultRecorder` to reuse the
> JSOS-stream and JSON-summary serialisation, but it makes
> `initialize_report()` a no-op (skipping the HTML template and
> static-resource publication) and, on `finalize()`, prints the
> human-readable run summary to stdout.  The integration tier
> overrides this hook to use the full `JsonResultRecorder`
> instead — see "Where the integration tier takes over" below.

Recorder setup, on context entry, does the following:

1. Initialises the output environment (for the stdout recorder,
   report initialisation is a no-op; the JSOS/JSON files are still
   written when an output directory is configured).
2. Opens `testrun_results_stream.jsos` for writing.
3. Writes the run-level preview / initial summary.

Every test result the sequencer produces is then forwarded to
the recorder, which (`.../jsonresultrecorder.py:268–341`):

1. Serialises the `ResultNode` to JSON via `to_json(...)`.
2. Writes the record-separator sequence (`\x1E\n`).
3. Writes the JSON.
4. Calls `flush()` on the underlying stream handle.
5. Updates the pass/fail/error/skip counters under a lock.

The flush is what makes the stream tail-able.  Without it
Python's I/O buffering would batch writes and a tail consumer
would see records in lumps.  The flush has a measurable cost on
fast suites — but on automation suites the per-test cost is
dwarfed by the test itself, and the flush is worth its weight.

On context exit / `finalize()`:

1. The recorder finalises counts and result determination.
2. It builds the consolidated JSON tree from its in-memory
   result log and writes `testrun_results.json`.
3. It builds the summary aggregates and writes
   `testrun_summary.json`.
4. The `StdoutResultRecorder` additionally prints the summary to
   stdout (exactly once — `finalize` guards against the
   double-invocation that the context manager's `__exit__` would
   otherwise cause).

If the run crashes between setup and `finalize()`, the stream
file is partial but valid: every record between the opening
separator and the last full record is parseable.  A consumer
reading the partial stream can compute its own summary from
what's there.

## Where the integration tier takes over

Everything above is the *standalone* recorder.  Three things
that this chapter used to attribute to `testbase` are now the
responsibility of `IntegratedTestJob` in
`codecraftai-integration-runtime`
(`codecraftai-integration-runtime/source/packages/ccai/integrationruntime/integratedtestjob.py:51`).
`IntegratedTestJob` extends `TestJob` and overrides exactly
three provider hooks:

1. **The full HTML-rendering recorder.**
   `IntegratedTestJob._create_recorder()`
   (`.../integratedtestjob.py:79`) returns a `JsonResultRecorder`
   in place of the `StdoutResultRecorder` — the JSOS stream plus
   the consolidated/summary JSON *and* the rendered HTML summary
   (the template copy and static-resource publication that
   `StdoutResultRecorder.initialize_report()` no-ops out).

2. **The presence-folder results-directory layout.**
   `IntegratedTestJob._create_output_context()` returns a
   `RuntimeOutputContext`
   (`.../integrationruntime/runtimeoutputcontext.py:40`), which
   delegates to `ccai.thisapp.paths` so that results,
   diagnostics, and per-testcase by-products land under the
   activated application's well-known *presence folder*.  Those
   path helpers are activation-gated — they raise `SemanticError`
   unless the process was activated as a job — which is exactly
   why plain `testbase` (which never activates) cannot use them
   and roots its artefacts under `AM_OUTPUT_DIRECTORY` or
   `<cwd>/.output` via the standalone `SimpleOutputContext`
   instead.

3. **Real CI/build metadata.**
   `IntegratedTestJob._create_run_metadata()` returns a
   `RuntimeRunMetadata`
   (`.../integrationruntime/runtimerunmetadata.py`) sourced from
   the resolved `RuntimeVariables`, where the standalone
   `StandaloneRunMetadata` supplies an auto-generated run id and
   `(unknown)` placeholders for build/pipeline/job.  This is the
   metadata that populates the `build`, `pipeline`, and `job`
   blocks of `testrun_summary.json`.

> The intended inheritance chain is `TestJob` ->
> `IntegratedTestJob` -> `InteropLibraryTestJob` ->
> `DistributedTestJob`.  As of this writing `IntegratedTestJob`
> is built and wired, but the interop tier has not yet been
> re-parented onto it — in the interop source,
> `InteropLibraryTestJob` still extends `TestJob` directly.  The
> JSOS format is unaffected either way; the only thing in flight
> is *which class supplies the recorder/context/metadata*.

The point for this chapter: the **format** is identical across
both tiers.  Whether your records come from a standalone stdout
run or an activated integration run, a `\x1E\n`-delimited stream
of JSON objects is what lands on disk, and the same `testbase
agents` consumers read it.

## Streaming to external services

For runs that need to report to an external dashboard while
running, testbase ships a *streaming encoder* and *result
streamer* alongside the recorder
(`codecraftai-testbase/source/packages/ccai/testbase/results/streaming/resultstreamer.py:44–78`).

The classes:

- **`StreamingJsonEncoder`** (line 44) — a `json.JSONEncoder`
  subclass that handles `datetime`, `Decimal`, and other types
  the standard encoder doesn't.  Used by the recorder to
  serialise events.
- **`ResultStreamer`** (line 64) — a worker that runs in a
  background thread, reads events from a queue, and POSTs them
  to an external URL.  Used when the test job is configured
  with a `streaming_url`.

The streamer is opt-in: when the job's constructor receives a
`streaming_url`, the recorder also pushes events into the
streamer's queue.  The streamer does best-effort delivery —
failures are logged but don't fail the run — because the local
JSOS stream is the source of truth and the remote stream is a
convenience.

This is the Aspects pattern from Volume 0 chapter 4 once more:
the recorder's behaviour (write locally vs. write locally and
push remotely) is parameterised, not decorated.  A test author
writes the same test; the build engineer picks the recorder's
behaviour.

## Consumer patterns

Three consumer shapes show up across the portfolio:

### Live monitoring

A developer running a long suite watches the stream:

```bash
tail -F results/testrun_results_stream.jsos | testbase agents testresult list --stream
```

The agents CLI's `testresult list --stream` command (Chapter 6)
parses the JSOS as it arrives and prints a one-line summary per
test.  A failing test is colour-coded.  When the run ends the
stream tail closes cleanly.

### Failure extraction

After a run, an engineer wants the failures only:

```bash
testbase agents testresult failures --results results/
```

The `failures` subcommand reads the stream file, filters to
`outcome != passed`, and emits a structured JSON document an
engineer can grep through or feed to an agent.  The
`codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/testresult/failures.py`
file is the implementation.

### Dashboard ingestion

A CI dashboard polls the summary file every minute, then on
completion ingests the consolidated JSON for per-test detail.
The dashboard does *not* normally consume the JSOS stream — it
is the live tool, not the historical record.

For dashboards that *do* want live updates, the
`ResultStreamer`'s remote-streaming mode pushes events to a URL
the dashboard owns.  The dashboard receives JSON-encoded events
in real time without having to poll a file.

## Replayability

A point worth making explicit: the stream is *the* source of
truth.  The summary and consolidated files are both derivable
from the stream.  If a build engineer keeps only the stream,
they can recompute the other two at any time.

This matters for compliance and audit cases where every test
event must be archived but storage is at a premium.  The stream
is the smallest representation that loses nothing.  The
consolidated file is a convenience.

It also matters for replay: a run's stream can be fed into a
*replayer* that reproduces the run's timing on a different
host, for human or agent viewing.  Volume 8 (*Time-Travel
Debugging in Production*) builds on the same pattern at a
different layer of the stack — the principles are the same.

## What stays out of the stream

Some things deliberately do not become JSOS records:

- **Per-test stdout / stderr.**  These are written to a
  per-testcase *by-products* directory resolved through the
  output context (`get_testcase_byproducts_dir(test_id)` —
  rooted under `.output` for standalone runs, or under the
  presence folder for integration runs).  The stream record
  carries a *reference* to the directory but not the contents —
  log volume can be large, and dragging it into the stream would
  balloon record sizes.
- **Per-test screenshots / artefacts.**  Same — a reference,
  not the bytes.
- **Coverage data.**  Coverage is written as a separate file by
  the coverage plugin, not as a JSOS record.

The principle: the stream carries the *structured* data of the
run; the directory carries the *blob* data.  A consumer that
needs the blobs walks the directory; a consumer that needs the
structure parses the stream.

## The agents-readable contract

A note of forward reference: the *exact same JSOS records* the
recorder produces are what the `agents` CLI subcommands
(Chapter 6) parse.  There is no separate "agent format" — the
contract between the framework and any consumer (human or AI)
is the JSOS stream.  This is why Chapter 6 treats the agents
CLI as a thin parser-and-formatter on top of the stream rather
than a separate subsystem.

## What's next

- Chapter 6 — The `testbase agents` CLI.  The thin layer that
  parses the JSOS stream and presents it to humans and AI
  agents alike.
- Chapter 7 — Mocking.  Tests that don't touch real systems
  still produce JSOS records — the recorder treats mocks
  identically to real resources.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/06-testbase-agents-cli -->
# Chapter 6 — The `testbase agents` Machine-Readable CLI

> *Most CI dashboards extract test data by parsing stdout.  Most
> AI agents extract test data by asking another agent to scrape
> the dashboard.  Both approaches lose information at every
> hop.  This chapter is about a CLI that is structured at the
> source — so any consumer, human or machine, reads the same
> shape.*

The `testbase agents` subcommand exists because at some point a
test framework's output stops being for humans only.  CI
dashboards want machine-readable data.  AI agents reading test
results want machine-readable data.  Build engineers writing
post-run scripts want machine-readable data.

The cheap answer is "parse the JSOS stream."  Chapter 5 showed
that the stream is the source of truth.  But making every
consumer write its own JSOS parser is a tax — and a tax that
gets paid wrongly.  Some consumers will mishandle the
record-separator byte; others will assume newline-delimited
records; an AI agent will hallucinate the schema.

The `agents` CLI is the *blessed* parser.  Its three subcommand
groups (`summary`, `testresult`, `config`) read the JSOS
stream, parse it correctly, and emit a structured shape — JSON
to stdout — that any consumer can rely on.  Same shape every
time.

This chapter walks the three subcommand groups, the rationale
for each, and the way they compose with the framework's
streaming model.

## The shape of an "agent" subcommand

The vocabulary around the `agents` subcommand was chosen with
AI agents in mind, but the audience is broader.  In the
portfolio's vocabulary, an *agent* is anything that wants
machine-readable output: a CI dashboard, a script, a human
running `--json` for grep-friendliness, an AI agent in a
post-run review loop.

The structural commitments of every `agents` subcommand are:

1. **JSON to stdout.**  No tables, no colour codes, no
   human-formatted text.  A consumer calls the subcommand and
   parses the JSON.
2. **A stable schema.**  The output shape is documented in the
   subcommand's `--help` and does not silently change between
   minor versions.
3. **Failures to stderr.**  Stdout carries only the structured
   answer; stderr carries human-readable context for when the
   subcommand cannot answer.
4. **Exit codes are meaningful.**  0 = success, 1 = subcommand
   error, 2 = usage error, with conventional CLI semantics.

These commitments mean a consumer can pipe `agents` output to
`jq`, to a Python script, to an LLM context window, to anything
that handles JSON — without writing a parser.

## The group declaration

Top of the agents subcommand tree:

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/__init__.py:18–28

@arcli.group("agents")
def group_testbase_agents():
    """
        Commands for AI agents to extract and analyze test result data.
    """
    return

group_testbase_agents.add_command(group_testbase_agents_summary)
group_testbase_agents.add_command(group_testbase_agents_testresult)
group_testbase_agents.add_command(group_testbase_agents_config)
```

The pattern is the arcli group pattern from Chapter 8: a
`@arcli.group("name")` decorator that turns a function into a
`GroupingCommand`, then `.add_command(...)` to attach
subcommand groups.  Each of the three sub-groups has its own
file under `cli/cmdtree/agents/`:

- `agents/summary/` — extract summary information
- `agents/testresult/` — list, filter, extract individual test
  results
- `agents/config/` — extract and inspect configuration

The triple maps onto the three result-file pairs from Chapter
5: `summary` reads `testsummary.json`, `testresult` reads the
JSOS stream and the consolidated file, `config` reads the
run's startup-configuration file.

## `agents summary`

The `summary` group answers high-level questions about a run:

- Total counts (passed, failed, errored, skipped).
- Wall-clock duration.
- Host fingerprint.
- Whether the run completed cleanly.

Sample subcommands (representative — the actual surface may
expand):

```bash
testbase agents summary extract --results <results-dir>
testbase agents summary compare --baseline <dir-a> --candidate <dir-b>
```

The `extract` form prints the summary file as JSON, with
nothing else.  The `compare` form is more interesting: it
diffs two summary files and reports the deltas — counts moved,
new failures, regressions.  A CI dashboard runs `compare` on
every PR run against the trunk's last green run; the output is
a structured "what changed" record an agent can consume.

## `agents testresult`

The `testresult` group answers per-test questions:

- List every test in the run with its outcome.
- Filter to the failures.
- Extract one specific test's full record.

Two subcommands matter most:

### `agents testresult list`

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/testresult/list.py
```

Emits one JSON object per test, in the order the tests
finished:

```json
{"test_name": "...", "outcome": "passed", "duration_ms": 17}
{"test_name": "...", "outcome": "failed", "duration_ms": 111}
```

This is JSON Lines, not JSOS — because `list` is the
*human-readable* of the agent commands.  A consumer pipes the
output to `jq`, or `grep`, or `awk`.  An AI agent reads the
whole thing and reasons about it.

The subcommand accepts `--include` / `--exclude` flags that
filter by test-name pattern, mirroring the `TestCollector`'s
filter vocabulary (Chapter 2).

### `agents testresult failures`

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/testresult/failures.py
```

The shortcut for the most common consumer query: "what failed."
Emits a JSON document with one entry per failed test, including
the exception type, message, and a representative chunk of the
traceback.  A representative output:

```json
{
  "run_id": "20260430-153012-abc123",
  "failures": [
    {
      "test_name": "ccai.agentkit.tests.test_invoice.test_invoice_total_includes_tax",
      "outcome": "failed",
      "exception_type": "AssertionError",
      "exception_message": "expected Decimal('108.75'), got Decimal('100.00')",
      "traceback_excerpt": [
        "  File \".../test_invoice.py\", line 14, in test_invoice_total_includes_tax",
        "    assert rtnval"
      ],
      "duration_ms": 111,
      "monikers": ["billing"]
    }
  ],
  "failed_count": 1,
  "errored_count": 0
}
```

This is the subcommand an AI agent's review loop calls first.
The output is small enough to fit in a context window even for
runs with thousands of tests, because each failure carries only
a *traceback excerpt* — the top frame plus the assertion line —
not the full traceback.  A consumer that needs the full
traceback walks to the per-test directory under the results
directory; the JSOS reference is in the record.

## `agents config`

The `config` group is the smallest of the three but the most
interesting in scope.  Where `summary` and `testresult` answer
"what happened in the run," `config` answers "what *would*
happen, given this configuration."

### `agents config extract`

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/config/extract.py:32–50

@arcli.option("--config-file", required=True, help=HELP_CONFIG_FILE)
@arcli.command("extract")
def command_agents_config_extract(config_file: str):
    """
        Extracts key configuration information from a test run
        startup-configuration.json file.
    """
    try:
        resolved = resolve_file_path(config_file)
    except FileNotFoundError as err:
        format_error(str(err))
        sys.exit(1)
    # ...
```

The subcommand reads the run's `startup-configuration.json` —
the file the framework writes at the start of a run capturing
*everything* that influenced how the run was constituted — and
emits a flattened, structured projection.  The projection
includes:

- The test root.
- The includes / excludes that selected the tests.
- The runtime configuration profile in effect.
- The `TESTBASE_MAX_EXAMPLES_CAP` value (Chapter 4).
- The job seed.
- The host fingerprint.

A consumer running this against a known-good run gets enough
information to reproduce the run.  An AI agent reasoning about
"why did this run have only 200 tests when the suite has 3,700"
can read the includes/excludes from the extract and answer in
one query — without having to grok the run's invocation
arguments from a CI log.

## Composability

The three subcommands compose:

```bash
# What changed in failures between the baseline and the candidate?
diff <(testbase agents testresult failures --results baseline/) \
     <(testbase agents testresult failures --results candidate/)
```

```bash
# Every new failure (jq pipeline).
testbase agents testresult failures --results candidate/ \
  | jq '.failures[] | select(.test_name as $n |
       any($baseline_failures; .test_name == $n) | not)' \
       --argjson baseline_failures "$(testbase agents testresult failures --results baseline/ | jq '.failures')"
```

The agents CLI is not the orchestrator.  The agents CLI is the
*adapter* between the JSOS stream (and friends) and a
shell / script / agent that does the orchestrating.  The
composition is the consumer's responsibility.

## What this contract gives an AI agent

A specific scenario the agents CLI was designed for: an AI
agent reviewing a CI run.  The agent's flow:

1. Call `agents summary extract` to learn whether the run
   completed and how many tests failed.
2. If failures exist, call `agents testresult failures` to get
   a structured list.
3. For each failure, decide whether the failure pattern looks
   like a flake (compare to last week's `failures` output),
   a regression (compare to the trunk's `failures` output), or
   a real bug (no precedent).
4. For real bugs, call `agents config extract` to capture the
   exact invocation that triggered the bug.
5. Open an issue with the structured data and the engineer's
   suggested next step.

The agent does not need to parse JSOS.  The agent does not
need to know about the record-separator byte.  The agent's
input is JSON; its output is JSON; its reasoning runs over
structured data the framework guarantees the shape of.

## What this contract gives a human

The same agent-readable surface gives the human a consistent
script library:

```bash
# In every CI run that fails, pipe the failures to my dashboard.
testbase agents testresult failures --results "$RESULTS" \
  | curl -X POST -H 'Content-Type: application/json' --data @- \
    https://dashboard.internal/api/runs

# In every nightly, diff against the trunk.
diff <(testbase agents summary extract --results candidate/) \
     <(testbase agents summary extract --results trunk/)
```

The same data, the same shape.  No human-only formatter, no
agent-only formatter.  The framework commits to one structured
output and lets every consumer build on it.

## Implementation notes

A few patterns worth calling out for an SDET extending the
agents tree:

### The shared JSOS parser

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/jsosparser.py
```

Every `agents` subcommand that reads the JSOS stream uses this
parser.  Centralising the parser means the framework's
record-separator handling, JSON deserialisation, and error
reporting are written once.  An extension subcommand that
needs to read the stream should reuse the parser; an
extension that writes its own parser will get the
record-separator byte wrong.

### Failure mode discipline

Each subcommand handles four failure modes explicitly:

- `--results` directory does not exist → exit 1, error to
  stderr.
- Results directory exists but has no JSOS stream →  exit 1,
  error to stderr.
- JSOS stream is partial (run crashed) → exit 0, structured
  output with a `truncated: true` flag.
- Any other unexpected error → uncaught, bubbles per Volume 0
  chapter 3's discipline.

The third mode is the interesting one: a partial run is *still
useful*.  The agents CLI treats partial streams as legitimate
input, flags them clearly, and lets the consumer decide what
to do.  This is the streaming-format's payoff from Chapter 5
realised at the consumer layer.

### Exit codes

```
0 — Success.  Subcommand produced its structured output.
1 — Subcommand error: input invalid, results directory unreadable, etc.
2 — Usage error: unknown flag, missing required argument.
```

Conventional CLI semantics, deliberately so.  A consumer that
uses `set -e` or `if cmd; then` works without surprise.

## Where the agents CLI plugs into Volume 7

A forward reference: Volume 7 (*Building an AI Agent SDK*)
covers the lifecycle of a production AI agent — tools, memory,
observability.  One of the canonical *tools* a testing-focused
agent owns is "run the testbase agents subcommand and parse the
output."

Because the agents CLI's contract is structured JSON, the tool
is a thin wrapper: shell out to `testbase agents ...`, parse
the JSON, hand the result to the agent.  No screen-scraping;
no format-drift risk.  The framework's commitment to structured
output is what makes the tool reliable.

Volume 7 covers the agent SDK side of the bridge.  This
chapter covers the framework side.  The two together are the
contract.

## What's next

- Chapter 7 — Mocking.  Tests that don't touch real systems
  produce JSOS records the agents CLI reads identically to
  real-system tests.
- Chapter 8 — `arcli`.  The decorator system that makes the
  three-line `@arcli.group("agents")` declaration work — and
  the validator-and-parser hardening from PR #13.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/07-mocking-and-stubbing -->
# Chapter 7 — Mocking and Stubbing Without the Framework Getting in the Way

> *Mocks exist for one reason: the system you'd have to summon
> to test the unit under test is too expensive, too slow, or
> too unavailable.  Once you've made the choice to mock, the
> framework should help — not impose its own opinions about
> what mocks should look like.  This chapter is about how
> testbase's mocking helpers stay out of the way.*

`codecraftai-testbase` ships its own mocking module rather than
wrapping `unittest.mock`.  The choice is deliberate: the
portfolio's mocking needs don't fit neatly under the
unittest.mock shape, and rather than fight it, testbase ships
primitives shaped for the actual cases.

This chapter walks the four primitives that make up the
mocking surface — `MockType`, `SpecViolationError`,
`SideEffect`, and the `Mock` class itself — and discusses when
to mock at all.  The chapter intentionally does *not* try to
re-derive the entire `unittest.mock` API; it walks the parts
testbase added because unittest.mock alone wasn't enough, and
the parts testbase made simpler because unittest.mock did
more.

## When to mock at all

Before talking about the API, the choice.  The portfolio has
three rules of thumb that drive whether a test mocks:

1. **Mock at the boundary, not in the middle.**  If the test
   verifies behaviour A → B → C, and only C touches the
   external system, mock only C.  Mocking B is a sign the test
   is testing too much.
2. **Don't mock what you don't own.**  Mocking the standard
   library, the `requests` HTTP client, or `datetime.now` is
   usually a code smell.  Wrap the dependency in a class you
   own and mock the class.
3. **An integration test that mocks the integration is not
   doing its job.**  If the test is for the integration with
   service X, the test must talk to a service-X-shaped
   thing — at minimum a fake-X-server, ideally service X
   itself.  A mock of X tests nothing X-specific.

These rules come from Volume 0 chapter 1's debuggability
principle: a mock that lies hides bugs from the test.  The
framework's job is to make the *legitimate* mocks easy and the
*illegitimate* mocks at least uncomfortable.  Spec violation
errors (below) are how testbase makes the second part work.

## `MockType`: knowing what kind of mock you have

```python
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:46–56

class MockType(IntEnum):
    Generic = 0
    Class = 1
    Instance = 2
    Property = 3
    Function = 4
    Method = 5
```

Every testbase `Mock` carries a `MockType`.  The type is what
the mock *represents*:

- `Generic` — a free-floating mock with no spec.  Use sparingly.
- `Class` — a mock that stands in for a class (calls to it
  return mocks shaped like instances).
- `Instance` — a mock that stands in for an instance of a
  class.
- `Property` — a mock that stands in for a property
  (`@property` / descriptor).
- `Function` — a mock that stands in for a free function.
- `Method` — a mock that stands in for a method (the first
  argument is `self`).

The type matters because testbase's spec validation behaves
differently for each kind.  A `Class` mock allows attribute
access for any class member; an `Instance` mock allows
attribute access only for instance members; a `Method` mock
expects to be called with `self` as the first argument.

A consumer who picks the wrong type gets a
`SpecViolationError` (below) at the first wrong access — not
a mysterious `AttributeError` later when the mock returns
something it shouldn't have.

## `SpecViolationError`: refusing to play along

```python
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:25–43

class SpecViolationError(BaseException):
    """
        Raised when a mock is used in a way that violates its spec.

        A spec violation is a programming error in the test, not a
        runtime condition the test can recover from.  The error
        inherits from BaseException so that a `try / except Exception`
        in the code under test cannot accidentally swallow it.
    """
    pass
```

`SpecViolationError` inherits from `BaseException` — the same
discipline as `SemanticError` and `ConfigurationError` from
Volume 0 chapter 3.  An ordinary `try / except Exception` does
not catch it.  This matters because a mock is, by definition,
inserted into code that is being tested; the code may have
broad exception handlers that would swallow a normal exception
and let the test pass spuriously.  By inheriting from
`BaseException`, a spec violation forces the test to fail
loudly.

A spec violation happens when:

- A `MockType.Instance` mock is accessed for a member the
  spec class does not declare.
- A `MockType.Method` mock is called without `self`.
- A `MockType.Function` mock is called with the wrong number
  of arguments and signature checking is enabled.
- A `MockType.Class` mock is used as if it were an instance
  (or vice versa).

The principle: if the test is wrong about *what shape of
thing* it is mocking, the framework refuses to play along.

## `SideEffect`: the simplest mock behaviour

For mocks whose only job is to return a value or run a
callable, testbase ships `SideEffect`:

```python
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:59–120

class SideEffect:
    def __init__(self,
                 callable: Callable,
                 default_args: Optional[Union[List[Any], Tuple[Any, ...]]] = None,
                 default_kwargs: Optional[Dict[str, Any]] = None) -> None:
        self._callable = callable
        self._default_args = default_args if default_args is not None else ()
        self._default_kwargs = default_kwargs if default_kwargs is not None else {}
        return

    def __call__(self, *args, **kwargs) -> Any:
        merged_args = self._default_args + tuple(args)
        merged_kwargs = {**self._default_kwargs, **kwargs}
        rtnval = self._callable(*merged_args, **merged_kwargs)
        return rtnval
```

Three things to notice:

1. The default args and kwargs are stored as instance
   variables in `__init__` — Volume 0 chapter 2's discipline
   on instance variables.
2. The mutable defaults trap (Volume 0 chapter 2) is avoided
   by using `None` as the default and assigning the empty
   tuple / dict inside.
3. The merge order matters: caller arguments *override* the
   defaults.  A test sets up a `SideEffect(lambda x: x*2,
   default_args=(5,))` and the call `effect()` returns 10;
   `effect(7)` returns 14.

`SideEffect` is the building block for "this mock returns X"
or "this mock runs Y when called."  Larger mock behaviours
compose `SideEffect` instances.

## `Mock` and `MockContext`

The two top-level mocking constructs:

- **`Mock`** (`codecraftai-testbase/source/packages/ccai/testbase/mocking.py:1603`)
  — the spec-aware mock class itself.  Constructed with a
  spec (a class, an instance, or a function), it produces
  attribute access and call behaviour shaped by the spec.
- **`MockContext`** (line 304) — a context manager that
  sets up a group of mocks, optionally patches names in a
  module, and tears the patches down on exit.

A representative usage:

```python
import testbase
from testbase.mocking import Mock, MockContext, MockType
from ccai.billingkit.invoice import InvoiceService

@testbase.attach.parameter(some_db_session, identifier="db")
def test_invoice_service_uses_billing_runtime(db):
    """
        Verify InvoiceService consults the billing runtime.
    """
    runtime = Mock(spec=BillingRuntime, mock_type=MockType.Instance)
    runtime.tax_rate = SideEffect(lambda: Decimal("0.0875"))

    with MockContext() as ctx:
        ctx.patch("ccai.billingkit.invoice.get_runtime",
                  Mock(spec_class=BillingRuntime,
                       mock_type=MockType.Function,
                       side_effect=SideEffect(lambda: runtime)))

        service = InvoiceService(db=db)
        invoice = service.create_invoice(...)
        rtnval = invoice.tax_amount > Decimal("0")

    assert rtnval
    return
```

The `Mock(spec=BillingRuntime, ...)` constructor produces a
mock whose attribute access is restricted to members of
`BillingRuntime`.  Setting `runtime.tax_rate = SideEffect(...)`
binds a callable behaviour to the attribute.  The
`MockContext` patches `get_runtime` in the module under test
for the duration of the `with` block; on exit, the patch is
unwound automatically.

The pattern reads similarly to `unittest.mock.patch` but with
testbase-specific specifics: spec validation is on by default,
spec-violation errors bubble unstoppably, and the `MockType`
disambiguates what kind of thing each mock is.

## What testbase mocking does *not* do

A short list of unittest.mock conveniences testbase deliberately
omits:

- **`MagicMock`'s automatic dunder support.**  Testbase's
  `Mock` does *not* auto-magic `__len__`, `__iter__`,
  `__getitem__`, etc.  If you need a mock that supports
  iteration, you set up the dunder explicitly with a
  `SideEffect`.  The reasoning: an auto-magic iter that
  silently returns an empty iterator hides bugs more often
  than it helps.
- **String-based attribute lookup at construction time.**
  unittest.mock lets you write `mock.foo.bar.baz()` and
  silently invents the chain.  Testbase's spec-aware `Mock`
  refuses unknown attributes; the chain has to be set up
  with explicit attributes that have explicit specs.
- **Implicit return values.**  A testbase `Mock` of
  `MockType.Function` raises `SpecViolationError` if called
  before a `SideEffect` is bound.  The framework refuses to
  invent a return value the test author did not declare.

These are not small differences.  A test author migrating from
unittest.mock will hit each of them.  The framework's
philosophy from Volume 0 chapter 1 — that explicit beats
implicit, even at the cost of more typing — drives every one.

## Mocking and the resource factory model

Mocks fit into the resource factory model from Chapter 3 in
two ways:

### Mock-yielding originators

A factory can yield a mock instead of a real resource:

```python
@testbase.factories.resource_factory(scope=ResourceLifespan.Test)
def billing_runtime_mock():
    """
        Yields a Test-scoped mock of BillingRuntime for unit tests
        that don't need the real runtime.
    """
    runtime = Mock(spec=BillingRuntime, mock_type=MockType.Instance)
    runtime.tax_rate = SideEffect(lambda: Decimal("0.0875"))
    yield runtime
    return
```

A test attaches the mock-yielding factory exactly as it would
attach a real one:

```python
@testbase.attach.parameter(billing_runtime_mock, identifier="runtime")
def test_invoice_total(runtime):
    ...
```

The advantage: the test does not know it has a mock.  Swapping
to a real `BillingRuntime` later means changing the factory,
not the test.

### `MockContext` for short-lived patches

For patches that should not outlive a single test,
`MockContext` is the right shape.  The factory model is for
resources whose lifespan matches one of the standard
(Session / Package / Test) scopes; the `MockContext` is for
patches that need to be torn down at a *finer* scope —
mid-test, around a specific code path.

The two compose: a Test-scoped factory provides the standing
mock; a `MockContext` inside the test patches one specific
name for one specific code path.

## Mocks in JSOS records

Mocks do not produce special JSOS records.  A test that runs
against a mock produces the same JSOS shape (Chapter 5) as a
test that runs against a real resource.  This is intentional:
the agents CLI (Chapter 6) treats mock-running tests
identically to real-resource tests.  The fact that a test was
mocked is information for the test author and the test's
maintainer; the framework's record of the run does not need
to know.

If a build engineer wants to track which tests use mocks, the
convention is to attach a moniker — `@testbase.moniker("unit")`
on tests that mock everything, `@testbase.moniker("integration")`
on tests that talk to real resources.  The JSOS record
carries the monikers (Chapter 5), and the agents CLI can
filter on them.

## A worked anti-example

A mock that hides a bug:

```python
# Bad: this test passes because the mock pretends success.

def test_invoice_creation_emits_audit_log():
    audit_log = Mock(spec=AuditLog, mock_type=MockType.Instance)
    audit_log.emit = SideEffect(lambda *args, **kwargs: None)

    service = InvoiceService(audit_log=audit_log)
    service.create_invoice(...)

    rtnval = True  # The test asserts nothing useful.
    assert rtnval
    return
```

The test "passes" but it does not verify the property the
test name claims.  The audit log was mocked; the call to
`emit` was made; the test does not check that the call
happened.  A reviewer should reject this test.

The fix:

```python
def test_invoice_creation_emits_audit_log():
    """
        Verify that creating an invoice records an entry on the
        audit log.
    """
    audit_log = Mock(spec=AuditLog, mock_type=MockType.Instance)
    audit_log.emit = SideEffect(lambda *args, **kwargs: None)

    service = InvoiceService(audit_log=audit_log)
    service.create_invoice(...)

    rtnval = audit_log.emit.call_count > 0
    assert rtnval
    return
```

Now the test verifies the property the name claims.  The
`call_count` attribute is one of the spec-aware behaviours
testbase mocks expose for verification — the framework's
counterpart to unittest.mock's call-tracking facilities.

## What's next

- Chapter 8 — `arcli`.  The CLI toolkit underneath every
  testbase subcommand, including the `agents` group.
- Chapter 9 — Single-host landscape concepts.  How to write a
  test that uses a *real* resource selectively, with the
  right hooks for Volume 2's distributed runner to extend.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/08-arcli-the-declarative-cli-toolkit -->
# Chapter 8 — `arcli`: The Declarative CLI Toolkit

> *Every package in the portfolio that ships a CLI ships
> the same CLI: same decorator surface, same flag parsing,
> same `--help` rendering, same error reporting.  The package
> that makes that uniformity possible is `codecraftai-arcli`.
> This chapter is about how it works and why each design
> choice was the right one.*

`codecraftai-arcli` is the declarative CLI toolkit underneath
every `codecraftai-*` command-line surface: `testbase`,
`taskplus`, `configuration`, `arcli` itself, and a dozen
others.  Reading `arcli` is the prerequisite for reading any
of those CLIs; once you can read `arcli`, every CLI in the
portfolio reads the same way.

This chapter walks the four declarative pieces — `Command`,
`GroupingCommand`, `ArgField`, `OptionField` — plus the
parameter-type system, the help-text generator, and the
validator-and-parser hardening from PR #13.

## What `arcli` is for

A CLI in the modern Python world has plenty of options:
`argparse`, `click`, `typer`, `cleo`, hand-rolled parsers
on top of `sys.argv`.  Each has a position.  `arcli`
exists because the portfolio needed three properties from
its CLI toolkit that no off-the-shelf option offered all of:

1. **Decorator-driven declaration.**  A command is a
   function with decorators that declare its arguments,
   options, and group membership.  No subclassing.
2. **First-class command trees.**  A CLI is a tree of
   groups and commands, not a flat list.  The toolkit must
   make tree assembly idiomatic — not a bolt-on after the
   fact.
3. **Validator composition that works for list-mode
   options.**  An option that can be repeated (`--filter
   foo --filter bar --filter baz`) must validate each
   element.  Click and typer have nuances here that the
   portfolio's validator-and-parser hardening (PR #13)
   addressed at the framework level.

The chapter assumes the reader has used `argparse` or
`click` enough to recognise the patterns; everything that
follows is `arcli`'s take.

## A minimal `arcli` command

```python
import arcli

@arcli.option("--name", required=True, help="Greeting target.")
@arcli.command("greet")
def cmd_greet(name: str):
    """
        Print a greeting to the named target.
    """
    print(f"Hello, {name}!")
    return
```

The decorators apply bottom-up:

1. `@arcli.command("greet")` turns `cmd_greet` into a
   `Command` object whose name is `"greet"` and whose
   callable is the original function.
2. `@arcli.option("--name", ...)` attaches an `OptionField`
   to the command.

The command can be registered into a tree:

```python
my_root.add_command(cmd_greet)
```

And invoked from the shell:

```bash
my_root greet --name Alice
# → Hello, Alice!
```

That is the whole shape.  Everything else in this chapter
is *what those decorators construct* and *how the runtime
parser walks them*.

## The `Command` class

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/command.py:22–61

class Command:
    def __init__(self, name: str, callable: Callable) -> None:
        self._name = name
        self._callable = callable
        self._flags_short = {}
        self._flags_long = {}
        self._traps = []
        self._options = collections.OrderedDict()
        self._arguments = []
        return
```

Seven instance variables, all initialised in `__init__` per
Volume 0 chapter 2.  Notable choices:

- `_options` is an `OrderedDict`.  Argument order in the
  declaration is preserved — important for `--help` output
  and for the generated usage line.
- `_flags_short` and `_flags_long` are separate dicts.  An
  option can have a short flag (`-v`), a long flag
  (`--verbose`), or both; the parser looks up the
  corresponding `OptionField` in O(1).
- `_traps` is a list of error-trap callables.  When an
  argument or option fails to parse / validate, the trap
  callables run before the framework reports.  Custom
  traps let a command attach diagnostic context.

The class has `add_argument(argfield)`, `add_option(optfield)`,
`parse(argv)`, and `execute()` methods.  The decorator
machinery calls `add_argument` / `add_option` at decoration
time; the runtime parser calls `parse` / `execute`.

## `GroupingCommand`: the tree node

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/groupingcommand.py:17–52

class GroupingCommand(Command):
    def __init__(self, name: str, callable: Callable) -> None:
        super().__init__(name, callable)
        self._command_nodes = {}
        return

    @property
    def has_children(self) -> bool:
        rtnval = len(self._command_nodes) > 0
        return rtnval

    def add_command(self, command: Command) -> None:
        self._command_nodes[command.name] = command
        return
```

`GroupingCommand` is the tree node: a `Command` that owns
children.  The decorator is `@arcli.group("name")`; the
function it wraps becomes the group's "entry function" —
typically a no-op that just describes the group, since the
real work lives in the child commands.

The `_command_nodes` dict keys by name; the parser dispatches
to a child by looking up `argv[0]` in `_command_nodes`.  An
unknown subcommand raises a usage error.

There is *no* separate `CommandSet` or `CommandRegistry`
class.  The tree *is* the model — a `GroupingCommand` with
nested `GroupingCommand`s and leaf `Command`s, all the way
down.

## `ArgField`: positional arguments

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/fields/argfield.py:16–62

class ArgField:
    def __init__(self,
                 name: str,
                 alias: Optional[str] = None,
                 ptype: Type[ParameterBase] = None,
                 required: bool = True,
                 default: Any = None,
                 help: Optional[str] = None) -> None:
        self._name = name
        self._alias = alias if alias is not None else name
        self._ptype = ptype if ptype is not None else StringParameter
        self._required = required
        self._default = default
        self._help = help
        return
```

Six instance variables, again initialised in `__init__`.  The
declaration shape:

```python
@arcli.argument("path", ptype=Path(exists=True), help="Input file path.")
@arcli.command("ingest")
def cmd_ingest(path: str):
    ...
```

The argument's `name` is the kwarg name on the function; the
parser binds the parsed value to that kwarg when invoking.
`Path(exists=True)` here is a *parameter type* — see below.

## `OptionField`: flags

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/fields/optionfield.py:40–122

class OptionField:
    def __init__(self,
                 *flags: str,
                 alias: Optional[str] = None,
                 ptype: Type[ParameterBase] = None,
                 default: Optional[Any] = None,
                 required: bool = False,
                 mode: OptionMode = OptionMode.Simple,
                 help: Optional[str] = None) -> None:
        self._flags = flags
        # Sort flags into short / long for parser dispatch.
        self._flag_short = None
        self._flag_long = None
        for flag in flags:
            if flag.startswith("--"):
                self._flag_long = flag
            elif flag.startswith("-"):
                self._flag_short = flag
        self._ptype = ptype if ptype is not None else StringParameter
        self._mode = mode
        self._default = default
        self._required = required
        self._help = help
        return
```

`OptionMode` is the interesting addition over `ArgField`:

- `OptionMode.Simple` — the option appears at most once on
  the command line.  `--name foo` sets the value to `"foo"`.
- `OptionMode.Multiple` — the option may appear multiple
  times.  `--filter foo --filter bar` produces a list
  `["foo", "bar"]`.
- `OptionMode.Counter` — the option counts occurrences.
  `-vvv` produces 3.

The mode drives both parsing and validation.  PR #13's
hardening (below) was specifically about getting validation
right for `Multiple` mode.

## The parameter type system

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/parameters/parameterbase.py:13–34

class ParameterBase(Generic[PT]):
    def __init__(self, alias: str, tvalue: Union[str, List[str]]) -> None:
        self._alias = alias
        self._tvalue = tvalue
        return

    def get_value(self) -> Union[PT, None]:
        err_msg_lines = [
            "ParameterBase.get_value must be overridden by subclasses.",
            "    ALIAS: {}".format(self._alias),
        ]
        err_msg = os.linesep.join(err_msg_lines)
        raise AbstractMethodError(err_msg)
```

Each parameter type is a subclass of `ParameterBase` whose
`get_value()` does the parsing.  The built-ins:

- `StringParameter` — strips quotes; returns `str`.
- `IntegerParameter` — `int(tvalue)`; returns `int`.
- `BooleanParameter` — recognises true/false/yes/no/0/1.
- `ListOfStringParameter` — for `OptionMode.Multiple`;
  returns `List[str]`.
- `ListOfIntegerParameter` — `Multiple`-mode integer list.

The base class is `Generic[PT]`, where `PT` is the produced
type.  Type checkers that introspect arcli get the right
inference.

### Validators as type factories

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/parameters/validators.py:14–227
```

The validator system is *factory functions* that produce
`ParameterBase` subclasses configured with the validator's
constraints:

- `Choice(choices, case_sensitive=True, base_type=StringParameter)`
  — restricts the value to a fixed set.
- `Range(min_val, max_val)` — numeric-range validation.
- `Path(exists=True, file_okay=True, dir_okay=True, writable=False)`
  — filesystem path validation.
- `Regex(pattern, flags=0)` — regex-match validation.
- `Custom(converter)` — apply an arbitrary callable.

Each factory returns a class.  Using a validator looks like:

```python
@arcli.option("--mode",
              ptype=Choice(("dev", "stage", "prod")),
              required=True)
```

The `ptype` *is the produced class*, not an instance — the
parser instantiates it with the raw `tvalue` per parsed
occurrence.

## `OptionMode.Multiple` and the PR #13 hardening

A subtle bug existed in the validator chain before PR #13.
The validators (above) were written assuming `tvalue` was a
single string.  In `OptionMode.Multiple` the parser passes a
*list* of strings — and the original validators applied
their logic to the list-as-a-whole, which made `Choice`
either reject all list values (if the list wasn't itself a
choice) or pass none (if it accidentally was).

The fix in PR #13 (commit `64ced05`, "Harden validator and
parser behavior with regression tests") refactored each
validator's `get_value()` to branch on
`isinstance(self._tvalue, list)`:

```python
# codecraftai-arcli/source/packages/ccai/arcli/model/parameters/validators.py:22–46

def get_value(self) -> Any:
    if isinstance(self._tvalue, list):
        validated_values = []
        for titem in self._tvalue:
            citem = self._base_type(self._alias, titem).get_value()
            vitem = self._validate_choice_value(citem)
            validated_values.append(vitem)
        rtnval = validated_values
    else:
        val = self._base_type(self._alias, self._tvalue).get_value()
        val = self._validate_choice_value(val)
        rtnval = val
    return rtnval
```

Two paths: one for list-mode, one for scalar-mode.  Each
path applies the same `_validate_*_value()` helper to the
canonical-typed value.  The base type runs first (parses
the string), then the validator runs over the parsed value.

A 162-line regression suite at
`codecraftai-arcli/source/tests/test_validators.py` exercises
each validator in both modes.  The suite is the pattern an
SDET should mirror when adding a new validator: write the
single-value tests, write the multi-value tests, run them
both.

The hardening is a Volume 0 chapter 1 success story —
errors that were passing silently (a `--filter` value that
didn't match any choice was silently dropped instead of
rejected) now surface loudly.

## Decorators: how the trees assemble

The four decorators that assemble a tree:

- **`@arcli.command("name")`** — wraps a function in a
  `Command`.
- **`@arcli.group("name")`** — wraps a function in a
  `GroupingCommand`.
- **`@arcli.entry("name")`** — wraps a function in the
  *root* `GroupingCommand` of a CLI binary (the entry
  point).
- **`@arcli.argument(...)`**, **`@arcli.option(...)`** —
  attach `ArgField` / `OptionField` to the wrapped command.

A composite example from `codecraftai-testbase`:

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/testbase_command.py:32–64

@arcli.option("-v", "--verbose",
              default=0,
              mode=OptionMode.Counter,
              help="Set the verbosity level.")
@arcli.entry("testbase")
def testbase_root_command(verbose: int):
    initialize_testbase_runtime()
    return

testbase_root_command.add_command(group_testbase_agents)
testbase_root_command.add_command(group_testbase_services)
```

The `@arcli.entry("testbase")` wraps the function in the
root.  The `-v / --verbose` option is in `Counter` mode —
`-vv` becomes `2`.  Two child groups are added: the
`agents` group from Chapter 6 and a `services` group.

## Help-text generation

```python
# codecraftai-arcli/source/packages/ccai/arcli/helpgenerator.py:15–72

class HelpGenerator:
    @staticmethod
    def generate_help(command: Command) -> str:
        parts = []
        parts.append(HelpGenerator._generate_usage(command))
        if command.callable.__doc__:
            doc = textwrap.dedent(command.callable.__doc__).strip()
            parts.append(f"  {doc}")
        options_output = HelpGenerator._generate_options(command)
        if options_output:
            parts.append("Options:")
            parts.append(options_output)
        if isinstance(command, GroupingCommand):
            commands_output = HelpGenerator._generate_commands(command)
            if commands_output:
                parts.append("Commands:")
                parts.append(commands_output)
        rtnval = os.linesep.join(parts)
        return rtnval
```

Help is auto-generated from:

- The command's name and arguments → the usage line.
- The function's docstring → the description.
- The command's options → the Options section.
- The grouping command's children → the Commands section.

A test author writes a docstring on the function (per the
Volume 0 chapter 2 docstring style) and arcli's `--help`
output is correct.  No second source of truth.

## Examples in the wild

The agents group from Chapter 6:

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/__init__.py:18–26

@arcli.group("agents")
def group_testbase_agents():
    """
        Commands for AI agents to extract and analyze test result data.
    """
    return

group_testbase_agents.add_command(group_testbase_agents_summary)
group_testbase_agents.add_command(group_testbase_agents_testresult)
group_testbase_agents.add_command(group_testbase_agents_config)
```

A leaf command in the agents tree:

```python
# codecraftai-testbase/source/packages/ccai/testbase/cli/cmdtree/agents/config/extract.py:32–34

@arcli.option("--config-file", required=True, help=HELP_CONFIG_FILE)
@arcli.command("extract")
def command_agents_config_extract(config_file: str):
    """
        Extracts key configuration information from a test run
        startup-configuration.json file.
    """
    ...
```

The pattern is identical at every level: decorate the
function, attach the result to the parent.  The tree
self-documents through `--help`.

## What `arcli` doesn't do

A short list, in the spirit of Chapter 7's "what testbase
mocking doesn't do":

- **No automatic generation of completions.**  Bash / zsh
  completions are out of scope.  The portfolio's CLIs that
  ship completions ship them as separate files.
- **No async commands.**  A command's callable is sync.
  An async command should call `asyncio.run(...)` itself.
- **No automatic prompting for missing required values.**
  A required option that's missing is a usage error, not a
  prompt.  This is a deliberate choice: the portfolio's
  CLIs run unattended in CI, where prompting is wrong.
- **No subprocess shelling.**  `arcli` parses; the
  command's callable does whatever it does.  The toolkit
  has no opinion about subprocess execution (which lives in
  Volume 0's `codecraftai-this-app` and Volume 5's protocol
  layer).

## What's next

- Chapter 9 — Single-host landscape concepts.  How tests
  describe their environment without dragging in the
  distributed landscape.
- Chapter 10 — Case study: a 3,700-test suite that runs on
  one host with `TESTBASE_MAX_EXAMPLES_CAP=10`.

The `arcli` decorators in this chapter are the *same*
decorators every CLI in the portfolio uses.  Volume 3's
taskplus CLI uses them; Volume 5's protocol-ssh uses them;
Volume 7's agent SDK uses them; Volume 8's time-travel
debugging tools use them.  Once you can read this chapter,
you can read every CLI.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/09-landscape-concepts-single-host -->
# Chapter 9 — Building Tests Around Landscape Concepts (Single-Host Edition)

> *A test framework that knows about the landscape becomes a
> distributed test framework.  A test framework that does not
> know about the landscape stays a unit test framework.  The
> hard part is the third option: a framework that is aware
> the landscape exists, ships a contract for the landscape to
> implement, but stays usable on a single host without a
> landscape at all.  This chapter is about how testbase walks
> that line.*

The previous eight chapters of Volume 1 walked testbase as a
single-host test framework.  Tests are functions, fixtures are
originators, results stream to a JSOS file, mocks stand in
where real systems would be too expensive.  Everything in those
chapters works on an SDET's laptop with no special
infrastructure.

This chapter is the bridge.  It carries the *contract* that a
distributed test framework must implement to extend testbase —
the one Volume 2 walks in detail.  But it carries it from
inside the single-host frame: what does a single-host test
look like that *would*, if the landscape were available,
exercise multi-host behaviour?  And how does testbase let a
single-host author write that test without the landscape
machinery being a hard dependency?

## Vocabulary: Landscape, Topology, Runtime, Pod

Before going further, four words need precise meanings.  Readers
who have used pytest fixtures or `unittest.mock` will recognise
none of these as testing-framework vocabulary — they belong to
the *automation* vocabulary, and conflating them with each other
is the most common mistake the rest of this chapter exists to
prevent.

**Landscape** answers the question *what resources exist and how
are they physically connected?*  A Landscape file enumerates the
devices, services, and other resources the suite has access to
— their addresses, their protocols, their credentials, the
network they live on.  It is the **physical inventory + wiring**.

**Topology** answers the question *how are those resources
logically organised, and what roles do they play together?*  A
Topology file says things like "Players A, B, C form a Home
Sound System" or "Nodes A, B, C are cluster members and Node D
is a spare."  It is the **functional grouping + role assignment**.

The two are distinct.  The same Landscape can underpin many
Topologies (one fleet of devices, organised differently for
different test campaigns).  The same Topology pattern can apply
to many Landscapes (the cluster-with-spare shape, applied to
different fleets).  In `codecraftai-configuration`'s loader, they
live in *separate* MergeMaps — `LANDSCAPE_CONFIGURATION_MAP` and
`TOPOLOGY_CONFIGURATION_MAP` — exactly because they are
independent inputs.

**Runtime** answers the question *how should the suite execute?*
A Runtime is a behavioural / diagnostic profile — `Normal`,
`Debug`, `Diagnostics`, `UPNP Diagnostics`, `Bluetooth
Diagnostics`.  Runtimes influence how loud the logging is, what
extra state to capture, what protocol traces to enable.  They do
*not* influence what resources exist (that is the Landscape) or
how those resources are organised (that is the Topology).

**Pod** is the runtime instance produced by combining a
Landscape with a Topology.  A Pod is what a test job actually
runs *against* — a concrete set of resources organised concretely.
Pods are the unit of horizontal scaling at the CI layer (more on
that below).

There is **no `codecraftai-topology` package**.  Topology is a
*configuration category*, not a package.  The shape of a topology
file beyond a small canonical convention (a `clusters` section
with `name` / `group` / `nodes` / `spares` keys consumed by
`LandscapeOperationalLayer._create_clusters` in
`codecraftai-landscaping`) is **left to the user to implement**.
The framework loads the file; the user decides what's in it.

A *single-host* landscape is the special case where the
"landscape" is one machine — possibly the SDET's laptop, possibly
a dedicated CI runner — and the structured description
collapses to "this host."  The interesting question is:
*how does a test express that it needs landscape concepts
without requiring the landscape package?*

## The contract: testbase's `fire_*` hooks

The contract sits on `TestJob`'s five `fire_*` lifecycle
hooks (Chapter 2):

- `fire_attach_to_framework(tseq)` — register integration
  couplings with the test framework.
- `fire_attach_to_environment(tseq)` — establish connectivity
  to the environment.
- `fire_activate_configuration()` — activate the run's
  configuration profile.
- `fire_activate_integration()` — bring up cross-host
  integration.
- `fire_activate_operations()` — start side processes that
  other tests will rely on.

On a single-host run using `TestJob` directly, the last three
are *no-op stubs*
(`codecraftai-testbase/source/packages/ccai/testbase/testjob.py:399–409`)
— they only log a `debug` line and `return`.  Volume 2's
`InteropLibraryTestJob`
(`codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22`)
overrides each of them to drive landscape activation:

- `fire_activate_configuration()` calls
  `landscape.activate_configuration()` (line 114).
- `fire_activate_integration()` calls
  `landscape.activate_integration()` (line 124).
- `fire_activate_operations()` calls
  `landscape.activate_operations()` (line 134).

The hooks are the *contract*.  A single-host test never
notices they exist; a distributed test relies on them; both
use the same `TestJob` lifecycle.

> **A new class sits in the chain — but not on these hooks.**
> Between `TestJob` and `InteropLibraryTestJob` there is now an
> intermediate base class, `IntegratedTestJob`
> (`codecraftai-integration-runtime/source/packages/ccai/integrationruntime/integratedtestjob.py`),
> introduced by the integration-runtime package.  It is worth
> naming here so the chain is not surprising in Volume 2, but it
> is important to be precise about *what it adds*:
> `IntegratedTestJob` is about **result recording**, not
> landscape behaviour.  It overrides exactly three *provider*
> hooks — `_create_output_context()`, `_create_run_metadata()`,
> and `_create_recorder()` — to route results into the activated
> app's presence folder, stamp them with CI/build metadata from
> `RuntimeVariables`, and render the HTML summary.  It does **not**
> touch the five `fire_*` landscape hooks; those stay as no-op
> stubs all the way down to `InteropLibraryTestJob`, which is
> still where landscape activation is wired.  (See Chapter 5 for
> the recorder/output-context seams and Volume 2 for the full
> inheritance cascade.)  At the time of writing, the interop
> tier's re-parent onto `IntegratedTestJob` is still in flight:
> `InteropLibraryTestJob` currently extends `TestJob` directly
> (`codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22`),
> so a distributed run does not yet inherit the integration
> result-recording behaviour through this chain.

## What lives at the boundary

Three concepts straddle the single-host / distributed
boundary.  A single-host author writes tests against them in
shapes that work on either side.

### 1. Couplings with `establish_connectivity`

The `IntegrationCoupling` base class
(`codecraftai-testbase/source/packages/ccai/testbase/coupling/integrationcoupling.py:512`)
exposes a method:

```python
def establish_connectivity(self,
                           allow_missing_devices: bool = False) -> None:
    """
        Bring the coupling online.

        :param allow_missing_devices: When True, the coupling reports
            but does not raise when one or more declared devices are
            unreachable.  Used in single-host smoke runs where the
            full device set is not present.
    """
    ...
```

The `allow_missing_devices` parameter is the boundary
mechanism.  On a distributed run with all devices present, the
default `False` is correct: a missing device is a hard failure.
On a single-host run where the test author wants to verify the
coupling's *graceful-degradation* path, `allow_missing_devices=True`
lets the coupling come online with a partial device set.

This is the Aspects pattern from Volume 0 chapter 4 once more:
the behaviour parameter is a parameter, not a decorator.  The
single-host test author and the distributed test author write
*the same coupling code* — they pass different values for the
parameter.

### 2. Mock-yielding originators

A test that needs landscape-shaped resources but runs on a
single host can attach a *mock* originator (Chapter 7):

```python
@testbase.factories.resource_factory(scope=ResourceLifespan.Test)
def device_pool_mock():
    """
        Yields a Test-scoped mock of the device pool for single-host
        tests.
    """
    pool = Mock(spec=DevicePool, mock_type=MockType.Instance)
    pool.list_devices = SideEffect(lambda: [])
    pool.acquire = SideEffect(lambda name: Mock(spec=Device,
                                                 mock_type=MockType.Instance))
    yield pool
    return
```

A test attaches `device_pool_mock` exactly as it would attach
the real device pool.  On a single-host run, the mock is what
the test sees; on a distributed run, swap the originator for
the real one (`device_pool_real`) and the test runs unchanged.

The decision of *which* originator to attach is best made at
the package level — a `single_host_factories.py` module that
exports mock originators for unit tests, a `distributed_factories.py`
module that exports real originators for the integration suite.

### 3. Landscape-conditional test markers

A test that *only* makes sense with a real landscape can be
marked:

```python
@testbase.skip_if(lambda: not is_distributed_landscape())
def test_replication_lag_is_under_threshold():
    """
        Verify replication lag across the cluster.
    """
    ...
```

The `is_distributed_landscape` callable runs at discovery time
(per Chapter 2's discovery / collection split).  On a single
host the callable returns False; the test is skipped before
the landscape package is even imported.  On a distributed
landscape it returns True and the test runs.

The trick: the callable cannot import the landscape package at
module top-level.  A safe shape is:

```python
def is_distributed_landscape() -> bool:
    """
        Return True if the running framework has a distributed
        landscape attached.  Imports landscape lazily to avoid
        single-host import failures.
    """
    try:
        from ccai.interop.testing.testjob import InteropLibraryTestJob
    except ImportError:
        rtnval = False
        return rtnval

    rtnval = isinstance(testbase.current_job, InteropLibraryTestJob)
    return rtnval
```

The `try / except ImportError` here is *not* swallowing an
exception in the Volume 0 chapter 3 sense — it is a sentinel
import-time check.  The function returns False if the
distributed framework is not installed; the function returns
False if the running job is not a distributed job; in both
cases the test is skipped.  No landscape package is required
to evaluate the predicate.

## The single-host test that would also run distributed

A test author who wants their test to run on either side
writes it to depend on the *coupling* not the *landscape*:

```python
@testbase.attach.parameter(some_billing_service, identifier="svc")
def test_invoice_round_trips(svc):
    """
        Verify an invoice round-trips through the billing service.

        Runs identically on single-host (with svc as a coupling-mock)
        and distributed (with svc as a coupling to the real billing
        service).
    """
    invoice = svc.create_invoice(amount=Decimal("100.00"))
    looked_up = svc.get_invoice(invoice.id)
    rtnval = (looked_up.amount == invoice.amount)
    assert rtnval
    return
```

The originator `some_billing_service` decides what `svc`
actually is.  The test does not know.  The test depends on the
coupling's *interface* — Volume 0 chapter 4's contracts.

This is the shape that maximises reuse.  The same test runs as
a unit test (mock coupling), as a single-host integration test
(real billing service on the same host), and as a distributed
test (real billing service on another host) — by choosing the
originator appropriately.

## Landscape-aware fire_* on the distributed side

Just so the contract is concrete: here is what
`InteropLibraryTestJob`'s overrides look like (forward
reference to Volume 2):

```python
# codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:88

def fire_attach_to_environment(self, tseq) -> None:
    """
        Attach the sequencer to the environment, providing the
        landscape as the connection vehicle.
    """
    landscape = self._get_landscape()
    tseq.attach_to_environment(landscape=landscape)
    return
```

The landscape gets *passed in* as a kwarg to the sequencer's
`attach_to_environment`.  The sequencer accepts the landscape
via `**kwargs`; on a single-host run no landscape is passed
and the sequencer's environment attachment runs without one.
On a distributed run the landscape is the vehicle through
which couplings discover devices, fetch credentials, and
schedule cross-host activity.

The single-host author does not write the override.  The
single-host author writes tests; testbase's `fire_*` stubs
fire as no-ops; the run completes.  The distributed author
inherits `InteropLibraryTestJob` and gets the overrides for
free.

## A pragmatic split: `singlehost/` and `distributed/`

In the portfolio, large test suites split their test roots:

```
<repo>/source/testroots/<vendor>/tests/
                           ├── singlehost/      # mock coupling, fast
                           ├── distributed/     # real coupling, slow
                           └── ...
```

A run picks one root or the other.  The `singlehost/` root
has its own factory module that yields mocks; the
`distributed/` root has its own factory module that yields
real coupling instances.  The tests *themselves* may share
between the two roots — but it is more common, and clearer,
to keep them separate so a reader knows immediately which
side they are on.

The case study in Chapter 10 is the `singlehost/` root of
`codecraftai-agent-kit`.

## Determinism: single actor, multiple observers

The biggest single thing distributed automation testing requires
that single-host application testing does not is *enforced
single-actor-per-environment*.  This is not a framework feature
in the implementation sense — there is no lock somewhere
preventing two tests from running.  It is a *design property*
the framework commits to and the test author honours.

The argument:

When a test interacts with a machine in a distributed
environment, the effect of the interaction takes time to
propagate to the other machines.  The test must *wait* — usually
by polling or by subscribing to an event — until the effect is
observable everywhere it needs to be observed.  If a second
actor can cause effects in the same environment during that
wait window, the order in which effects appear is not
controlled by the test.  Two effects that arrive in different
orders on different runs produce different states; the test is
no longer deterministic.

The conclusion: in any given environment, *one actor at a time*.

The framework's resolution to "but I want concurrent observation
of the system under test" is **tandem testing**: the test is the
single actor, and one or more *validators* attach to the test
scope as observers.  The validator base class lives in
`codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py`
and inherits `ValidatorCoupling`.  The framework calls
`validator.attach_to_test(testscope, suffix)`, the validator
registers its own `TestResult` alongside the test's, and when
the test runs the validator runs in tandem — watching, polling,
producing its own pass/fail outcomes recorded in the JSOS
stream (Chapter 5) the same way the test's are.

A test with three validators attached is *one test* with *four
recorded outcomes* — the test plus three observers.  No test
runs concurrently with another in the same environment.

### Where parallelism lives

Parallelism in distributed automation is at the **job level**,
not the test level.  The unit of parallelism is the **Pod**.

| Level | Parallel? | Why |
|---|---|---|
| Within a test (multiple actors) | **No** | Concurrent actors break determinism |
| Across tests in one job (one Pod) | **No** | All tests share the Pod's environment |
| Across jobs on different Pods | **Yes** | Pods are independent environments |

A CI orchestration that wants more horizontal scale provisions
*more Pods*, not *more parallel tests within a Pod*.  Pytest's
`pytest-xdist` (parallel test execution within a process) is
exactly the wrong model for a distributed automation suite.

## The CI hand-off — verified contracts

The Landscape and Topology files are not just configuration.
They are **contracts** between the CI system and the test job,
and the test job verifies both before it runs the first test.

- The **Landscape hand-off** says: "Here are the resources
  allotted to you."  The test job verifies each resource's
  *contact information is correct* — every resource declared in
  the Landscape can be reached at its declared address with its
  declared protocol using its declared credentials.  Failure
  means the CI system is wrong about what it gave you.
- The **Topology hand-off** says: "Here is the initial
  configuration of those resources."  The test job verifies the
  resources are *configured as the topology indicates* — if the
  topology says "cluster of A, B, C; spare D," verification
  confirms the cluster membership and spare assignment.  Failure
  means the CI system is wrong about how the resources were
  configured.

The framework supports this through the
`LandscapeConfigurationLayer.validate_topology()` override hook
and the `validate_topology` flag on `LandscapeActivationParams`
(default `True`).  The framework ships a stub validator; users
override it with the verification logic appropriate to their
topology schema.

Verification runs **before** the first test fires.  A test
against an unverified environment cannot be deterministic —
verification is a precondition for the single-actor model
above.  Volume 6 walks the verification mechanics in detail;
this chapter establishes the *principle*.

## Inside a Landscape file: services, devices, Features

The Landscape is *not* just a flat list of devices.  It has
two top-level sections that together enable the verification
contract above:

- **`services`** — declares the *infrastructure services* the
  devices use.  A serial-concentrator service offering serial
  connections on an IP and port; a power controller offering
  per-port power control; a credential vault.  Each service has
  a name, an address, and a type.
- **`devices`** — declares each device with its *primary access*
  (the `deviceType` and the fields that configure it: `host`,
  `credentials`, `platform`) and its *Features* (additional
  capabilities provided by the services above).

A real device declaration shape:

```yaml
- deviceType: network/ssh         # primary access type
  host: 192.168.1.30              # primary access address
  platform: linux
  credentials:
      - casey-node                # composable list of named credentials
  features:
      isolation: true                          # boolean capability flag
      serial: serial-concentrator: 1           # service-reference Feature
      power: LPC934: 1                         # service-reference Feature
  skip: false
```

Three layers in one declaration:

1. **Primary access** — `deviceType: network/ssh` plus `host`
   and `credentials`.  The `deviceType` selects the Coordinator
   that attaches the primary ProtocolExtension; SSH access here
   is *not* a Feature, it's the deviceType.
2. **Features** — additional capabilities beyond the primary
   access.  Two shapes:
   - *Boolean capability flags* — `isolation: true`.  Pure
     data; no Agent, no service.  Test code or framework can
     query "does this device have isolation?"
   - *Service-reference Features* — `serial: serial-concentrator:1`,
     `power: LPC934:1`.  Names a service from the `services`
     section + a port.  At runtime the framework resolves the
     service, instantiates the matching Agent against the
     service's address, and exposes a ProtocolExtension on the
     device (`device.serial`, `device.power`).
3. **Declarative metadata** — `platform`, `skip`, `credentials`.

### Coordinators, Agents, ProtocolExtensions, Features

Four words that need precise definitions, because casual usage
of "extension" is ambiguous in the portfolio:

- **Agents** *speak protocols.*  An SSH Agent speaks SSH; a
  UPnP Agent speaks UPnP; a Serial Agent speaks the serial
  wire protocol; a Power Agent speaks the power-control
  protocol.  The Agent is the protocol-speaking object.
- **Coordinators** *manage devices and attach Agents to
  devices.*  A Coordinator owns a class of devices (e.g., the
  SSH Coordinator owns devices reachable over SSH) and
  attaches the right Agent to each Device it manages.
- **ProtocolExtensions** *connect a specific protocol Agent to
  a Device.*  A ProtocolExtension is the binding object that
  wraps the Agent and exposes its protocol surface on the
  Device.  This is the user-facing extension point — a user
  adding a new protocol writes a ProtocolExtension that wraps
  their Agent.
- **Features** *are declared on devices in the Landscape* and
  reference services declared in the same Landscape.  A Feature
  declaration is configuration data on the Device's `features`
  tag that says "I have capability X, here's how to access it."

The full layering for a service-reference Feature:

```
Landscape file
├── services section
│     └── serial-concentrator: type=serial-concentrator, address=10.0.0.50
└── devices section
      └── device-1
            └── features tag: { serial: serial-concentrator: 1 }
                                ↓ at runtime
              device-1 instance
                ├── managed by Serial Coordinator
                └── .serial → ProtocolExtension
                                └── wraps → Serial Agent
                                              └── connects to serial-concentrator port 1
```

This also makes Landscape verification (the CI hand-off check
above) concrete.  The verification walks every Device's
Features, resolves each Feature's named service in the
`services` section, instantiates the Agent against the
service's address, and confirms it can connect.  A broken
Feature → service reference, an unreachable service, a
deviceType whose Coordinator can't reach `host` — these are
the failures verification catches before the first test fires.

### The customisation mechanism: subclass `Landscape` and `Topology`

Per the meta-thesis from Volume 0 chapter 1, the framework
supplies the skeleton and patterns; the organisation
customises.  For the environment model, the concrete
customisation mechanism is **subclassing `Landscape` and
`Topology`**.

The framework supplies:

- The base `Landscape` class — knows how to parse the
  `services` / `devices` / `features` shape and dispatch the
  built-in Features it ships support for.
- The base `Topology` class — knows how to parse the canonical
  `clusters` shape (`name` / `group` / `nodes` / `spares`)
  consumed by `LandscapeOperationalLayer._create_clusters`.
- Verification stub hooks (`validate_topology()` and friends)
  and the activation hooks driven by `TestJob.fire_*`.

The user supplies — by subclassing:

- Recognition and handling of project-specific Feature names
  (anything beyond serial / power / isolation) — by
  subclassing `Landscape`.
- Recognition and handling of project-specific Topology
  sections (anything beyond `clusters`) — by subclassing
  `Topology`.
- The verification logic for what those custom shapes mean —
  by overriding `validate_topology()` (and friends) in the
  subclasses.

The subclasses get plugged into a run by being returned from
the user's `TestJob` subclass's template methods (chapter 2's
"TestJob as customisation entry point" cascade).

This is the *concrete* mechanism behind every "left to the
user" note in the books: open schemas + subclassed handlers,
plumbed in via the user's `TestJob` subclass.  Schema openness
is the *what*; subclassing is the *how*; `TestJob` is the
*where*.

## Composability across categories

A property of `codecraftai-configuration` worth establishing here
because the rest of Volume 1's tests depend on understanding it:
**every configuration category is composable**.

The six categories Volume 3 walks — Specifications, Landscapes,
Topologies, Runtimes, Credentials, Data Profiles — all use the
same `MergeMap` overlay model.  A category-level configuration
the test job sees is the merged result of one or more named
source files at that category.  Each category's resolver
(`resolve_landscape_configuration`, `resolve_topology_configuration`,
etc.) takes a *list* of names and a *list* of files, loads each
in order, and overlays them.  Later names override earlier ones
where keys collide.

Examples:

- **Landscape:** `Checkout Resources + Environment Resources` —
  the resources that came from a pool checkout, merged with the
  fixed environment resources (servers, databases, persistent
  infrastructure).
- **Topology:** combining a cluster-membership topology with a
  gateway-routing topology to form the effective topology for a
  particular test campaign.
- **Runtime:** `UPNP Diagnostics + Bluetooth Diagnostics` —
  diagnostic profiles stack to form one effective runtime.
- **Credentials:** lab-wide credentials merged with
  experiment-specific overrides.

This composability is the property that makes the same suite
runnable on a developer laptop (light Runtime, mock Landscape)
and on a nightly full-sweep run (full Runtime, real Landscape +
Topology) without rewriting the tests.  Volume 3 chapter 14
walks the discipline at orchestration scale; this chapter just
needs the principle named, because Chapter 10's case study is
itself a worked example: the same agent-kit suite runs at
`TESTBASE_MAX_EXAMPLES_CAP=10` (a Runtime knob from Chapter 4)
without changing a single test file.

## The landscape as a stable contract

The reason this design works is that *landscape is a contract,
not an implementation*.  The contract is:

- A `TestJob` subclass overrides the five `fire_*` hooks.
- The overrides accept the landscape via a template method
  (`_get_landscape()`) the subclass implements.
- The sequencer's environment / framework attachment accepts
  the landscape via `**kwargs`.

`InteropLibraryTestJob` (Volume 2) is *one* implementation of
the contract.  A different implementation — a research-grade
landscape that lives in a future package — would override the
same hooks differently and the same tests would run.  The
`fire_*` hooks are the *interface*; the landscape is the
*implementation strategy*.

This is Volume 0 chapter 4 again: protocols at the top, code
beneath.  The single-host author depends on the protocol; the
distributed author implements the protocol; neither writes
code that depends on the *other side*.

## What this chapter set up

By the end of this chapter the reader should be able to:

- Identify which `fire_*` hooks fire as no-ops on single-host
  and which become non-trivial on distributed.
- Decide whether a given test should mock the coupling, use a
  real local coupling, or skip on single-host.
- Write a test whose factory choice (mock vs. real) decides
  whether the test runs in single-host or distributed mode.
- Read a `_get_landscape()` template method (in Volume 2) and
  understand how it bridges to the single-host runner.

That fluency is the single-host half of the contract.  Volume
2 carries the distributed half.  The two volumes share the
*same* `TestJob` lifecycle, the *same* coupling base class,
the *same* JSOS streaming format, and the *same* `agents` CLI
output shape.  Only the hooks' bodies differ.

## What's next

- Chapter 10 — Case study.  The `codecraftai-agent-kit`
  single-host suite at `TESTBASE_MAX_EXAMPLES_CAP=10`,
  finishing in 49 minutes wall time.  A working demonstration
  of every chapter that came before.


<!-- https://code-craft.ai/references/codecraftai/01-test-framework/10-case-study-agent-kit-suite -->
# Chapter 10 — Case Study: A 3,700-Test Suite That Runs on One Host

> *Chapters 1 through 9 walked the framework one concept at a
> time.  This chapter watches the framework run.  The subject
> is the `codecraftai-agent-kit` single-host suite — 292 test
> files, more than 3,700 test invocations, finishing under an
> hour with `TESTBASE_MAX_EXAMPLES_CAP=10`.  Every concept from
> the previous chapters lands in the same run.*

A book chapter on test framework architecture is only as
honest as the test suite it can point at.  This chapter
points at one: `codecraftai-agent-kit`, the test root at
`source/testroots/ccai/tests/singlehost/`, used as the
reference single-host suite for the portfolio.

The suite predates this book.  It was written by the same
SDETs who wrote testbase, against the framework testbase
itself.  It is the place in the portfolio where the
framework's design assumptions get exercised at scale —
where "thousands of tests" stops being an aspirational claim
and becomes a measurement.

## The shape of the suite

```
codecraftai-agent-kit/source/testroots/ccai/tests/singlehost/
├── agentkit/
│   ├── test_costtracker_properties.py
│   ├── test_costtracker_observability.py
│   ├── test_invocation_log.py
│   ├── test_memory_management.py
│   └── ... (dozens more)
├── tooling/
│   ├── test_tool_registry.py
│   ├── test_tool_dispatch.py
│   └── ...
├── observability/
│   └── ...
└── ...
```

By the numbers (counted at the time of writing this volume):

- **Test files:** 292 (across the `singlehost/` root).
- **`def test_` declarations:** 308.
- **Test invocations:** more than 3,700 (a single property-
  based test with `max_examples=10` becomes 10
  invocations; with `max_examples=50` it becomes 50; the
  total across the suite is the sum).
- **Wall time at `TESTBASE_MAX_EXAMPLES_CAP=10`:** ~49
  minutes on a single CI runner.
- **Wall time at full sweep (cap unset):** several hours.

The 3,700-invocation count is the one that matters for the
case study: the suite is *worth* running because it covers
that many distinct executions; the cap makes it *runnable*
in a CI window.

## Why this suite is the right case study

`codecraftai-agent-kit` is the agent SDK from Volume 7 — the
Python toolkit for building production AI agents on top of
the foundation tier.  The single-host test root exercises:

- **Cost tracking** (token counting, budgeting).
- **Tool dispatch** (mapping tool calls to handlers).
- **Memory management** (long-conversation truncation).
- **Observability hooks** (events, traces).
- **Configuration loading** (Volume 0 chapter 9 in
  practice).
- **Error vocabulary** (Volume 0 chapter 3 — the agent
  raises typed errors the host application can catch).

Every chapter of Volume 1 lands somewhere in this suite.
Property-based testing (Chapter 4) is heavily used in the
cost-tracker tests.  The JSOS stream (Chapter 5) is the
suite's output.  The agents CLI (Chapter 6) is the way the
suite's results are read.  Mocks (Chapter 7) stand in for
the model-call boundary.  arcli (Chapter 8) drives the
test runner.  The landscape boundary (Chapter 9) is
explicitly *not* invoked — the suite runs on one host with
mock couplings.

## Run anatomy: the wall-clock breakdown

A representative full run, instrumented with phase markers
in the JSOS stream, breaks down approximately as:

| Phase | Duration | Share |
|---|---|---|
| Discovery | ~12 s | <1% |
| Collection | ~3 s | <1% |
| Graph | ~1 s | <1% |
| Traversal — example-based tests | ~9 min | ~18% |
| Traversal — property-based tests (cap=10) | ~38 min | ~78% |
| Diagnostic capture (pre / post) | ~2 min | ~4% |
| Finalize | ~5 s | <1% |
| **Total** | **~49 min** | **100%** |

The numbers are not surprising — the property-based tests
dominate.  At cap=10, each property test runs 10 iterations;
at the full Hypothesis-default of 100 the same tests would
run 10× longer, pushing the suite north of 7 hours.

This is the budget Chapter 4 promised.  The cap is the knob.

## Property-based tests in the wild

A representative cost-tracker property test:

```python
# codecraftai-agent-kit/source/testroots/ccai/tests/singlehost/
# agentkit/test_costtracker_properties.py

import testbase
from testbase.strategies import integers
from ccai.agentkit.costtracker import CostTracker

@testbase.given(
    input_tokens=integers(min_value=0, max_value=10**6),
    output_tokens=integers(min_value=0, max_value=10**6),
    constraints={"max_examples": 50},
)
def test_total_cost_is_input_plus_output_for_any_token_counts(
    input_tokens: int,
    output_tokens: int
) -> None:
    """
        Verify that total cost equals input cost plus output cost
        across the full range of token counts.
    """
    tracker = CostTracker(model="claude-opus-4-7")
    tracker.record_input(input_tokens)
    tracker.record_output(output_tokens)

    expected = (
        tracker.input_unit_cost * input_tokens
        + tracker.output_unit_cost * output_tokens
    )
    actual = tracker.total_cost()
    rtnval = (actual == expected)
    assert rtnval
    return
```

Three observations:

1. The strategy is `integers(0, 10**6)` for both tokens —
   covers realistic and adversarial inputs.
2. `max_examples=50` is what the test author wrote.  At
   `TESTBASE_MAX_EXAMPLES_CAP=10` the runner runs 10.  At
   `TESTBASE_MAX_EXAMPLES_CAP=200` the runner runs 50 (the
   cap is an upper bound, not a multiplier).
3. The test follows Volume 0 chapter 2's discipline:
   typed parameters, explicit `return`, `rtnval` named
   intermediate, no compound `return` statements.

A run with cap=10 generates 10 (input_tokens, output_tokens)
pairs from the SHA-256-derived per-test seed (Chapter 4).
The same job seed reproduces the same 10 pairs.  A failure
is reproducible end-to-end without re-running the suite.

## Mocking the model-call boundary

The agent SDK's central concern is talking to language
models.  None of the single-host tests actually call a
model.  Instead, the test root's factory module yields a
mock:

```python
# codecraftai-agent-kit/source/packages/ccai/agentkit/factories.py

@testbase.factories.resource_factory(scope=ResourceLifespan.Test)
def mock_anthropic_client():
    """
        Yields a Test-scoped mock of the Anthropic client for
        single-host tests.
    """
    client = Mock(spec=AnthropicClient, mock_type=MockType.Instance)
    client.messages.create = SideEffect(
        lambda **kwargs: build_canned_response(**kwargs)
    )
    yield client
    return
```

The `build_canned_response` function returns a deterministic
response shape for any prompt.  The test does not depend on
the language model's actual output — it depends on the
*shape* of the response, which the SDK then parses.

Tests of *the parser* attach `mock_anthropic_client`; tests
of *the cost tracker* attach a different mock that yields
specific token counts; tests of *the tool dispatcher* mock
the tool callable.  Each mock is local to its test domain;
none of them pretends to be the full model.

## What the suite proves about the framework

A test suite of this size, finishing in under an hour, is a
specific claim about the framework: that the framework's
overhead per test is *tiny*.  Working back from the wall
times:

- Property-based: 38 min / ~3,000 invocations ≈ 760 ms per
  invocation — most of which is the test body itself, not
  the runner.
- Example-based: 9 min / ~700 tests ≈ 770 ms per test —
  similar.

The runner overhead per invocation is on the order of
single-digit milliseconds.  This is the cost of one JSOS
record write, one resource-factory generator step, one
sequencer dispatch.  The test framework's *plumbing* is
cheap; the test bodies themselves are what take the time.

This is by design.  Chapter 5's commitment to flushing
after every JSOS record was a per-record cost the framework
deliberately accepts; the case study shows that even at
3,700 invocations the cost is in the noise.

## Reading the run with the agents CLI

After the suite finishes, the standard inspection pattern:

```bash
# Did anything fail?
testbase agents summary extract --results results/20260430-153012/
# Output: {"passed": 3680, "failed": 18, "errored": 4, "skipped": 10, ...}

# What failed?
testbase agents testresult failures --results results/20260430-153012/
# Output: {"failures": [...18 entries...]}

# How was the run configured?
testbase agents config extract --config-file results/20260430-153012/startup-configuration.json
# Output: {"testroot": "...", "max_examples_cap": 10, "job_seed": "...", ...}
```

The three commands are the three audiences from Chapter 6.
A CI dashboard reads the summary; an engineer (or AI agent)
reads the failures; a build engineer auditing a flaky run
reads the config extract to learn the seed and the cap that
were in effect.

A specific use of the config-extract output: the build
engineer can re-run the suite *with the same job seed* —
making property-based runs reproducible across machines.
The seed is in the JSON; pasting it into the next run's
invocation reproduces every per-test seed via the
SHA-256 derivation (Chapter 4).

## What the cap does to coverage

The cap reduces the number of generated examples per
property test.  Does it reduce coverage?

In the strict sense, yes — fewer inputs are tested.  In the
practical sense, less than expected, for two reasons:

1. **The strategies are designed to find edge cases
   quickly.**  `integers(0, 10**6)` doesn't generate uniform
   random integers; it biases toward boundary values (0,
   1, the maximum) and around the midpoint.  Most failures
   surface in the first few examples regardless of cap.
2. **The job seed varies across runs.**  A property test
   with cap=10 sees different inputs in tomorrow's CI run
   than in today's, because the job seed is fresh.  Over
   many runs the suite covers a much wider input space than
   any single run.

The trade-off is "depth in any single run" vs. "breadth
across many runs."  The portfolio chooses breadth: cap=10
in CI on every PR; full-sweep cap-unset in nightly.  An
adversarial input that surfaces in nightly's run gets
captured (with its job seed) and replayed in regression
tests; the next day's CI runs include it as a deterministic
example.

This pattern — "find adversarial inputs at full sweep, lock
them as regression tests" — is the loop that makes
property-based testing economical.  The cap makes the loop
fast.

## What the case study does *not* show

A few things this case study deliberately doesn't
demonstrate, because they belong to other volumes:

- **Real model calls.**  Volume 7 (the agent SDK volume)
  walks tests that actually call a language model.  Those
  tests run as a *separate* (smaller, slower) integration
  suite.  Volume 1's case study is the unit-and-property
  suite that runs on every PR.
- **Distributed execution.**  Volume 2's distributed-test
  case study runs on a multi-host landscape.  The single-
  host suite here is the substrate; the distributed suite
  extends it.
- **CI integration.**  Volume 3 walks the orchestration
  layer that invokes a testbase suite from a taskplus
  workflow.  The case study here treats testbase as
  invoked directly; Volume 3 wraps it.

This volume's job is to make the framework legible.  The
case study is a demonstration of legibility at scale.

## The shape of a successful run

What the engineer sees on a green run, abbreviated:

```text
$ testbase run --testroot source/testroots/ccai/tests/singlehost/ \
              --max-examples-cap 10

[2026-04-30 15:30:12] Phase: Discovery (started)
[2026-04-30 15:30:24] Phase: Discovery (finished, 292 modules, 308 tests)
[2026-04-30 15:30:25] Phase: Collection (started)
[2026-04-30 15:30:28] Phase: Collection (finished, 47 originators)
[2026-04-30 15:30:28] Phase: Graph (started)
[2026-04-30 15:30:29] Phase: Graph (finished, 354 nodes, 802 edges)
[2026-04-30 15:30:29] fire_attach_to_environment
[2026-04-30 15:30:31] fire_attach_to_framework
[2026-04-30 15:30:32] Phase: Traversal (started)
[2026-04-30 15:30:33] PASS test_addition_is_commutative (17 ms)
[2026-04-30 15:30:33] PASS test_subtraction_is_anti_commutative (12 ms)
[2026-04-30 15:30:34] PASS test_total_cost_is_input_plus_output... (10 examples, 84 ms)
... (3,700+ more lines) ...
[2026-04-30 16:19:38] Phase: Traversal (finished)
[2026-04-30 16:19:39] fire_diagnostic_capture_post_testrun
[2026-04-30 16:19:42] Run complete.

Summary: 3680 passed, 18 failed, 4 errored, 10 skipped (49m 27s)
Stream:  results/20260430-153012/testresults_stream.jsos
```

Every line traces back to a chapter.  Discovery: Chapter 2.
Collection / Graph: Chapter 3.  `fire_attach_*`: Chapter 9.
PASS / FAIL counts: Chapter 5.  The wall-time figure: this
chapter.

## What this volume gives the reader

By the end of the case study a reader who started Volume 1
cold should be able to:

- Read a testbase test file and identify each construct's
  framework concept.
- Choose a sensible `TESTBASE_MAX_EXAMPLES_CAP` for their
  CI and full-sweep runs.
- Use the `agents` CLI to extract failures, summaries, and
  configuration from a results directory.
- Tell when a test should mock its dependency vs. depend on
  a real coupling.
- Tell which tests in a suite cross the single-host /
  distributed boundary.
- Read an `arcli`-decorated CLI's source and predict its
  behaviour from the decorators alone.

That fluency is the substrate Volume 2 builds on.  Volume 2
extends `TestJob` to `InteropLibraryTestJob` to
`DistributedTestJob`.  The lifecycle does not change; the
hooks do.  Volume 2 picks up exactly where this case study
ends — with the same suite running across multiple hosts,
the same JSOS streaming format, the same agents CLI
output, but with the `fire_*` hooks doing landscape
activation instead of nothing.

## End of Volume 1

The volume started by asking what's missing from `unittest`
and `pytest` for automation testing (Chapter 1) and ended
by watching a 3,700-test suite finish in under an hour
because the framework filled the gaps (this chapter).

The chapter that comes next is in another volume.  Volume 2
opens with "Why distributed automation is genuinely hard"
and then walks the inheritance chain that extends what this
volume built.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation -->
# Volume 2 — Distributed Automation

*The TestJob Inheritance Chain and Multi-Host Test Execution*

## About this volume

Volume 2 is the *implementation* of the contracts Volume 1 set up.
Volume 1 introduced `TestJob`, the five `fire_*` lifecycle hooks
as no-op stubs on single-host, the tandem-testing model in
abstract, and the landscape boundary at the contract level.
Volume 2 walks the *bodies* of those hooks — what
`InteropLibraryTestJob` and `DistributedTestJob` do at each
activation phase, how the `DistributedTestSequencer` walks a
distributed suite, how tandem validators attach in practice, how
the SSH wire propagates results, how a partial run is handled
honestly.

Volume 3 then *invokes* what Volume 2 builds.  Volume 3's
orchestration layer hands off names; Volume 2's job layer
realises them, verifies them, and runs the suite.

## Who it is for

Test engineers running multi-host integration suites.  Platform
engineers building automation that crosses machine boundaries.
SDETs scaling beyond one host.  This is the volume to hand a
senior SDET who finished Volume 1, ran their first 12-host
integration suite, and needs to understand what just happened
when host 7 of 12 went down mid-run.

## Source material

- `codecraftai-testplus` — `DistributedTestJob`,
  `DistributedTestSequencer`, `distributed_test_entrypoint`.
- `codecraftai-interop` — `InteropLibraryTestJob`, the abstract
  `_get_landscape()` template method.
- `codecraftai-landscaping` — `Landscape`, the three activation
  layers (Configuration / Integration / Operational), the
  verification methods.
- `codecraftai-testbase` (tandem half) — `Validator`,
  `ValidatorCoupling`, `attach_to_test`.
- `codecraftai-protocol-ssh` — the remote-execution wire.
- `codecraftai-credentials`, `codecraftai-dataprofiles` — the
  cross-machine bits Volume 3 owns at orchestration scale and
  Volume 2 owns at job scale.

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — The Discipline | 1, 2, 3 | Why distributed automation is hard; single-actor determinism; the Pod model |
| II — The Customisation Cascade | 4, 5, 6, 7 | TestJob inheritance chain; tandem testing; landscape activation; verified CI hand-off |
| III — The Runtime Machinery | 8, 9, 10, 11, 12, 13 | Sequencer; job_seed; the wire; credentials; data profiles; result aggregation |
| IV — Honesty and Diagnosis | 14, 15, 16 | Partial-run semantics; debugging a distributed failure; 5-host case study |

- [Chapter 1 — Why Distributed Automation Is Genuinely Hard](chapters/01-why-distributed-automation-is-hard.md)
- [Chapter 2 — Single Actor, Multiple Observers — The Determinism Model](chapters/02-single-actor-determinism.md)
- [Chapter 3 — The Pod Model — Where Parallelism Actually Lives](chapters/03-the-pod-model.md)
- [Chapter 4 — The `TestJob` Inheritance Chain — The Customisation Cascade](chapters/04-the-testjob-inheritance-chain.md)
- [Chapter 5 — Tandem Testing in Depth](chapters/05-tandem-testing-in-depth.md)
- [Chapter 6 — Landscape Activation](chapters/06-landscape-activation.md)
- [Chapter 7 — The CI → Job Hand-off as a Verified Contract](chapters/07-ci-handoff-as-verified-contract.md)
- [Chapter 8 — The `DistributedTestSequencer`](chapters/08-distributed-test-sequencer.md)
- [Chapter 9 — `job_seed` and Reproducibility](chapters/09-job-seed-and-reproducibility.md)
- [Chapter 10 — The Remote-Execution Wire](chapters/10-the-remote-execution-wire.md)
- [Chapter 11 — Credentials at Scale](chapters/11-credentials-at-scale.md)
- [Chapter 12 — Data Profiles in Remote Workers](chapters/12-data-profiles-in-remote-workers.md)
- [Chapter 13 — Result Aggregation](chapters/13-result-aggregation.md)
- [Chapter 14 — Partial-Run Semantics](chapters/14-partial-run-semantics.md)
- [Chapter 15 — Debugging a Distributed Failure](chapters/15-debugging-a-distributed-failure.md)
- [Chapter 16 — Case Study: A 5-Host Integration Test for a Sysman Feature](chapters/16-case-study.md)

## How it connects to the other volumes

- **Volume 0** — error vocabulary, waiting primitives, application
  identity, configuration loading, the CodeCraftX philosophy
  (especially ch.1's meta-thesis: skeleton + patterns).
- **Volume 1** — the single-host substrate; the contract Volume 2
  extends.  Vol 1 ch.1's seventh gap (tandem-bicycle) introduced
  the model; Vol 1 ch.2's customisation cascade introduced
  `TestJob` as the customisation entry; Vol 1 ch.9 established
  the landscape boundary.
- **Volume 3** — orchestration invokes Volume 2's distributed
  jobs; Vol 3 ch.21 names the verification contract Volume 2 ch.7
  walks in detail; Vol 3 ch.25 walks the bridge tasks.
- **Volume 5** — SSH transport in detail; Volume 2 ch.10 sketches
  the wire and forwards to Volume 5.
- **Volume 6** — landscape and topology files in detail (Volume 2
  treats them as inputs; Volume 6 walks their schemas).
- **Volume 8** — time-travel debugging in production; Volume 2
  ch.15 sketches the post-mortem flow and forwards.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/01-why-distributed-automation-is-hard -->
# Chapter 1 — Why Distributed Automation Is Genuinely Hard

> *Single-host testing has one machine.  Distributed testing has
> twelve.  The difference is not "twelve times the work" — it is a
> different kind of work.  Failure modes that don't exist on one
> host appear on twelve, and the framework's job is to make them
> *named, reproducible, and recoverable*.  This chapter is about
> what's different.*

Volume 1 walked the single-host test framework.  The hooks, the
sequencer, the property-based runner, the JSOS stream, the agents
CLI — every piece of that volume runs in one Python process
against one machine.  Volume 1 chapter 1 named the seven gaps
between application unit testing and automation testing; the
framework's answer to those gaps is what the rest of Volume 1
walked.

Volume 2 is what happens when the system under test is no longer
one machine.  When an automation test reaches across machine
boundaries — a test runner on one host, a database on another, a
device under test on a third, a power controller on a fourth —
the framework's job changes shape.  The lifecycle hooks that
were no-ops on single-host become non-trivial.  The resource
factories that yielded local mocks now yield landscape-aware
couplings.  The JSOS records that streamed cleanly out of one
process now arrive from N processes on N machines, with all the
ordering and failure-mode questions that implies.

This chapter is the volume's thesis.  The remaining fifteen
chapters are the implementation.

## The premise: the system under test is no longer in your process

The single sentence that distinguishes distributed automation
from any other kind of testing:

> *The system under test is not in the test process, may not be
> on the same machine, and may not be reachable when the test
> starts.*

Each clause adds a problem.

**"Not in the test process"** is the boundary Volume 1 already
addressed.  A pytest test calls a function in the same Python
interpreter; an automation test calls into a fleet of services
through wire protocols.  Volume 1's couplings, mocks, and
landscape boundary are about staying sane across that
process-to-process boundary on a single host.

**"May not be on the same machine"** is the boundary Volume 2
addresses.  The wire is no longer a function call into another
Python module; it is SSH, REST, gRPC, serial-over-TCP, or
something stranger.  The test process talks to the system under
test through code that crosses the kernel — and that code can
fail in ways the function call cannot: network partitions,
machine crashes, NAT failures, clock skew.

**"May not be reachable when the test starts"** is the boundary
Volume 3 addresses at the orchestration layer and Volume 2
addresses at the activation layer.  A real environment goes
down between when CI provisions it and when the test runs;
verification (chapter 7 of this volume) is the framework's
defence.

## Six failure modes single-host frameworks miss

Six concrete classes of failure that automation engineers
running multi-host suites discover, in roughly the order they
discover them.  The remainder of this volume gives each one a
named handling pattern.

### 1. One host of N down

Twelve hosts in the fleet.  Eleven respond to ping.  One
doesn't.  What does the framework do?

A pytest fixture would fail at acquisition; the entire suite
would fail with a single import error.  An automation framework
must do better, because *eleven out of twelve hosts running* is
a real and useful state.  The chapter on landscape activation
(chapter 6) walks the `allow_missing_devices` flag in
`LandscapeActivationParams`.  The chapter on partial-run
semantics (chapter 14) walks how a partial run reports.

### 2. Partial credential failure

The credentials store has eight entries; six resolve, two fail.
The two that fail are for hosts the suite needs.  Single-host
testing assumes credentials either exist or don't; distributed
testing has to handle "exist for some hosts, not others."

The chapter on credentials at scale (chapter 11) walks the
resolution model: per-host scoping, multi-source fallback, the
audit trail of what got fetched and what didn't.  The
verification contract (chapter 7) treats unresolved credentials
as a hand-off failure that the orchestrator owns, not something
the test should silently work around.

### 3. Race conditions in setup or teardown

Two tests in the same suite each acquire the device pool.  Both
ask for "host A."  In a distributed run this surfaces as a race;
in single-host, the resource factory's lifespan resolution
(Volume 1 chapter 3) prevented it from being possible.

The chapter on landscape activation (chapter 6) walks the
device-checkout-and-checkin discipline in
`LandscapeOperationalLayer`.  The chapter on partial-run
semantics (chapter 14) walks what happens when a teardown
crashes mid-run and leaves resources held.

### 4. Machine clock drift

Host A reports the test started at 10:32:01; host B reports the
fixture finished at 10:32:00.  Did the fixture finish before the
test started?  On a single host the answer is "obviously yes";
across machines, the answer is "your clocks disagree by 1.7
seconds, and you don't know it."

The chapter on result aggregation (chapter 13) walks the JSOS
stream's clock-disambiguation: every record carries a host
fingerprint, and the aggregator preserves per-host timelines
rather than forcing them into a single global timeline.

### 5. Network partitions during tests

Mid-test, host C loses connectivity to the controller.  The
test was waiting for an event from C.  Now it will wait
forever.  Or until a timeout.

The chapter on the remote-execution wire (chapter 10) walks the
Aspects pattern as it applies to distributed wire calls: every
remote call has an `Aspects` parameter (Volume 5 ch.1) carrying
its timeout, retry policy, and connection-failure handling, so
that "the network broke" becomes a *bounded* failure mode rather
than a hang.  The chapter on debugging distributed failures
(chapter 15) walks how to triage the result.

### 6. Log aggregation gaps

The test failed.  The error message says "host F responded
unexpectedly."  The relevant context is in host F's syslog,
host C's audit trail, and host A's framework log.  Three logs
on three machines.  The single-host chapter 5 of Volume 1
introduced JSOS streaming for a reason; chapter 13 of this
volume walks the cross-host extension.

## The landscape becomes non-trivial

Volume 1 chapter 9 introduced the landscape boundary at the
*contract* level: `TestJob`'s five `fire_*` hooks are no-ops on
single-host; `InteropLibraryTestJob` overrides them to drive
landscape activation; `DistributedTestJob` plugs in the concrete
landscape.  That contract is the bridge.

Volume 2 walks the bodies of the hooks.  When
`fire_attach_to_environment` actually attaches a sequencer to a
landscape (chapter 6 of this volume), it walks
`LandscapeConfigurationLayer.load_landscape`,
`LandscapeIntegrationLayer.initialize_landscape`, and
`LandscapeOperationalLayer.activate_coordinators` —
each of which has substantial machinery that didn't appear at
all in Volume 1.

A chapter-3 (this volume) reader will be told about *Pods*: the
runtime instance produced by combining a Landscape (the physical
inventory) with a Topology (the logical configuration).  A
distributed test runs in *one Pod*; the Pod is the unit of
horizontal scaling at the CI layer.  Within a Pod, tests run
sequentially because the Pod is a shared environment.  Across
Pods, tests run in parallel because Pods are independent.

The Pod model is what makes "twelve-host integration suite"
possible without it being "twelve times the flake of a one-host
suite."

## The determinism problem

The hardest of the six failure modes — and the one that gives
this volume its second chapter — is the question of *whether
distributed tests can be deterministic at all*.

If the controller (the test) and one of the hosts under test
each cause effects in the environment, the order in which their
effects are visible to other hosts is not deterministic.  A
test that says "do X, wait for Y to be observable, do Z" can
produce different outcomes on different runs because Y and the
test's own action race.

The framework's answer — covered in detail in chapter 2 — is
**enforced single-actor execution per environment**.  The test
is the only actor; observation is delegated to validators that
run *in tandem* with the test.  Volume 1 chapter 1 introduced
the metaphor: a tandem bicycle has one driver and many
observers.  Volume 2 chapter 5 walks the
`codecraftai-testbase/.../tandem/validators.py` machinery.

Without this commitment, distributed automation is flaky
forever.  With it, distributed automation is *deterministic
within a single Pod*; flakes that remain are environmental, not
framework, and the verification contract (chapter 7) rules out
most of them before the first test fires.

## The volume's commitments

By the end of Volume 2 a reader should be able to:

- **Read the TestJob inheritance chain** (chapter 4) and predict
  what each layer adds.
- **Build a custom `DistributedTestJob` subclass** for a
  project's specific environment.
- **Add tandem validators** to a test (chapter 5) for parallel
  observation.
- **Walk the three landscape activation phases** (chapter 6)
  and know which failure surfaces in which phase.
- **Verify a hand-off contract** (chapter 7) before a suite
  runs and refuse to proceed when the verification fails.
- **Understand why the `DistributedTestSequencer` does not
  parallelise tests** (chapter 8) and how it differs from the
  base `TestSequencer` in landscape awareness.
- **Use `job_seed`** (chapter 9) to make a property-based
  distributed run reproducible across hosts and runs.
- **Handle the SSH wire** (chapter 10) with the Aspects pattern
  for behaviour parameterisation.
- **Resolve credentials and data profiles** (chapters 11 and
  12) in cross-machine contexts.
- **Aggregate JSOS streams** (chapter 13) from N workers to one
  coordinator.
- **Scope partial-run outcomes** (chapter 14) honestly — saying
  what passed, what failed, and what couldn't run.
- **Debug a distributed failure** (chapter 15) using the
  framework's audit-diary tools and the six-pass landscaping
  audit pattern as a reference.
- **Read a real worked example** (chapter 16) of a 5-host
  integration test.

## Where this volume sits in the series

A reader's reading order:

- **Volume 0** establishes the foundation (errors, waiting,
  application identity, configurations, philosophy).  Volume 2
  assumes the foundation tier is fluent.
- **Volume 1** establishes the single-host substrate.  Volume 2
  *extends* Volume 1's contracts; the contracts themselves were
  set up at the seam in Volume 1 chapter 9.
- **Volume 3** invokes Volume 2 at the orchestration layer.
  Volume 3 chapter 21 defines the verification contract that
  Volume 2 chapter 7 walks in detail; Volume 3 chapter 25 walks
  the bridge tasks that hand off to a `DistributedTestJob`.
- **Volume 5** covers the wire (SSH, networking, REST) in
  depth.  Volume 2 chapter 10 sketches the wire as it appears
  to distributed tests; Volume 5 walks the wire's own machinery.
- **Volume 6** covers landscapes, topologies, and provisioning
  schemas in depth.  Volume 2 treats them as inputs the framework
  has already loaded; Volume 6 walks what's *in* the files.
- **Volume 8** covers time-travel debugging.  Volume 2 chapter
  15 forwards to it for the production-grade post-mortem flow.

## A note on the meta-thesis

Per Volume 0 chapter 1's meta-thesis, the framework supplies the
*skeleton and patterns*; the organisation customises and refines
to meet the needs of the automation scenarios.  Volume 2 is
where the meta-thesis lands hardest.  The `TestJob` →
`InteropLibraryTestJob` → `DistributedTestJob` chain is the
*entry point* for customisation; the abstract `_get_landscape()`
template method is the *plug-in seam*; the user's `Landscape` and
`Topology` subclasses are the project-specific bodies.

When Volume 2 says "the user implements X" — and it will say so
many times — the framing is always Volume 0 ch.1's: that's
exactly where the framework expects the project to specialise.
The skeleton is opinionated; the bodies are the organisation's
to write.

The next chapter walks the load-bearing design choice that makes
distributed automation possible at all: single-actor determinism
and the tandem-bicycle model of observation.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/02-single-actor-determinism -->
# Chapter 2 — Single Actor, Multiple Observers — The Determinism Model

> *Distributed tests are deterministic when one actor causes
> effects in the environment at a time and observation is
> delegated to validators that ride along with the actor.  This
> chapter is the design rationale; the next chapter is what it
> means for parallelism; chapter 5 walks the framework's
> implementation.*

The most important load-bearing design choice in distributed
automation testing is *enforced single-actor-per-environment*.
This is not a feature of the implementation in the sense of "a
class enforces it" or "a lock prevents violations."  It is a
*design property* the framework commits to — and the framework's
shape (the `TestJob` lifecycle, the sequencer's serial
execution, the tandem-validator mechanism) is what makes the
property hold.

This chapter is the rationale.  Chapter 5 is the implementation.
Both are short; the rationale is short because the argument is
sharp, and the implementation is short because the framework's
correctness rests on a small set of code.

## The argument

When a controller (the test) interacts with a machine in a
distributed environment, the effect of the interaction takes
time to propagate to the other machines.  The test must wait —
usually by polling or by subscribing to an event — until the
effect is observable everywhere it needs to be observable.  If
a *second actor* can cause effects in the same environment
during that wait window, the order in which effects appear is
not controlled by the test.  Two effects that arrive in
different orders on different runs produce different states; the
test is no longer deterministic.

The conclusion: **in any given environment, one actor at a
time.**

Stated differently: distributed automation testing has a
property that single-host application testing does not have to
worry about — the *environment under test is a shared resource
across actors*, and the framework's only defence against
non-determinism is to ensure exactly one actor is acting in
the environment at any given moment.

The single-host case avoids this entirely because the
"environment" is the test process; nothing else can act on it.
Application testing avoids it because the system under test is
inside the test process, where the test is the only controller.
Automation testing has to enforce it explicitly.

## What "actor" means

An *actor* is anything that *causes* effects in the
environment.  Examples:

- The test code itself (calls into the system under test).
- A scheduler or cron job running on one of the hosts.
- A second test running concurrently against the same hosts.
- A human running commands by hand.
- An external monitoring system that reaches in and modifies
  state to test recovery.

The framework's commitment is that *the test* is the actor.
Everything else either:

- Is suspended for the duration of the test (cron, schedulers).
- Operates in a different environment (a second test running
  in a different Pod — chapter 3).
- Is forbidden by operational discipline (no humans in the lab,
  no concurrent tests).

In practice, the third item is the hardest.  Operational
discipline is not a framework property; it is a *team* property.
But the framework's design must be compatible with the team
discipline — and it is, because the framework provides exactly
one mechanism for getting work done in the environment (the
test) and exactly one mechanism for *watching* work happen
(tandem validators).

## What "observer" means

An *observer* is anything that *watches* effects in the
environment without causing them.  Examples:

- A validator polling for a state change.
- A log tail streaming syslog from a host.
- A metric collector reading SNMP / Prometheus.
- A snapshot of a host's running processes taken every five
  seconds.

Observers do not violate the single-actor rule because they do
not cause effects.  Many observers can run concurrently with
each other and with the actor, because none of them can change
the environment's state.  The framework's tandem-validator
mechanism is built specifically around this distinction.

A point of pedantry worth landing: an observer that *could*
change state (e.g., a "monitor" that helpfully restarts a
failing service) is *not an observer*.  It's a second actor.
The framework's Validator base class
(`codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py:33`)
makes this distinction structural — validators inherit from
`ValidatorCoupling`, not from any class that has the framework's
write surface.  A validator that needed to act would need to be
a different class; the discipline is in the type system as much
as the design.

## The tandem-bicycle metaphor

A tandem bicycle has one driver and one or more observers
(passengers).  The driver pedals and steers; the observers ride
along watching the world go by.  The bike's direction and speed
are determined entirely by the driver; the observers contribute
their attention but not their muscle.

The metaphor maps onto the framework cleanly:

- The **test** is the driver.  It pedals (causes effects) and
  steers (decides what to do next).
- The **validators** attached to the test scope are the
  observers.  They ride along, watch for the effects the driver
  is causing, and produce their own report when the ride is
  over.
- A test with three validators is *one ride* with *four
  recorded outcomes* — the test plus three observers, each with
  its own perspective and its own report.

The crucial property the metaphor captures: the observers don't
steer.  An observer that started causing effects would be
trying to ride two bikes at once — the framework refuses to
allow it.

## What this rules out

A short list of patterns the determinism model rules out, with
the right answer for each:

### Concurrent tests in the same Pod

```python
# Wrong — two tests racing on shared state.
import asyncio

async def main():
    await asyncio.gather(
        run_test("test_a"),
        run_test("test_b"),
    )
```

If `test_a` and `test_b` run against the same Pod, they can
produce non-deterministic outcomes by racing.  The framework's
`DistributedTestSequencer`
(`codecraftai-testplus/source/packages/ccai/testplus/distributedtestsequencer.py:33`)
preserves serial test execution for exactly this reason.  Tests
within a Pod are sequential, period.

The right answer: if you want concurrent test execution, run
them in *different Pods*.  Chapter 3 walks Pod-level parallelism
in detail.

### A "monitor" that fixes things

```python
# Wrong — the monitor is a second actor.
def health_monitor():
    while True:
        if service_is_down():
            restart_service()  # second actor!
        time.sleep(5)
```

If a monitor reaches in and restarts a service while a test is
verifying recovery behaviour, the test's outcome depends on
whether the monitor or the test acted first.  Non-deterministic.

The right answer: a monitor that watches and reports without
acting is an *observer*; a monitor that acts is a second actor
and must run in a different Pod or be suspended for the
duration of the test.

### A scheduler that fires during the test

```cron
# Wrong — backup runs during the test window.
0 * * * * /usr/local/bin/backup
```

If a scheduled backup runs against a host the test is using,
the backup's effect on the host is concurrent with the test's
effect.  Non-deterministic.

The right answer: schedulers are a *deployment* concern, not a
framework concern.  The team running the lab disables
schedulers during test runs.  The framework's role is to make
the *test's actions* the only framework-driven actor; the team
ensures no other framework is acting on the same Pod
concurrently.

## What this enables

The single-actor commitment is what makes everything else in
the framework possible:

- **Reproducibility.**  The same test, the same job_seed, the
  same Pod state → the same outcome.  Chapter 9 walks the
  reproducibility machinery.
- **Tandem testing.**  Many validators can attach to one test
  *because* they don't compete with each other for actor
  privileges.  Chapter 5 walks the implementation.
- **Pod-level parallelism.**  Different Pods can run different
  tests concurrently *because* each Pod has exactly one actor
  (its own test).  Chapter 3 walks the Pod model.
- **Honest partial-run semantics.**  When a test fails, the
  framework can say *what* failed, *which validator caught it*,
  and *what hosts were affected* — because the actor and the
  observers are explicitly distinguished.  Chapter 14 walks
  partial-run reporting.

Without single-actor determinism, none of these work cleanly.
Reproducibility becomes "reproducible most of the time."
Tandem testing becomes "parallel testing with a different
name."  Pod parallelism becomes "concurrent tests with a
different name."  Partial-run semantics become "we don't
really know what happened."

The single-actor commitment buys all four properties at once,
at the cost of refusing pytest-xdist-style test parallelism
within a Pod.  The trade-off, the framework makes loudly: more
horizontal scale comes from more Pods, not more tests-within-
a-Pod.  Chapter 3 walks the trade-off.

## The framework's commitments to the model

Three structural commitments hold the model together:

### 1. Sequential test execution within a Pod

`DistributedTestSequencer`
(`codecraftai-testplus/source/packages/ccai/testplus/distributedtestsequencer.py:33`)
extends `TestSequencer` from `codecraftai-testbase` but does *not*
override the test-execution loop.  The base sequencer's
`execute_tests()` method (`testsequencer.py:412`) walks tests in
declaration order, one at a time.  The distributed sequencer
adds landscape-aware coupling attachment (chapter 8 of this
volume walks the differences); it does not add parallelism.
This is by design — the framework refuses to expose the API
that would let an SDET parallelise tests within a Pod, because
exposing that API would invite the determinism violation the
model rules out.

### 2. The actor / observer type distinction

The `Validator` class
(`codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py:33`)
inherits from `ValidatorCoupling` (in
`codecraftai-testbase/.../coupling/validatorcoupling.py:44`), which
inherits from `BaseCoupling`.  Validators have access to the
test scope and the recorder — they can *observe and report* —
but they do not have access to the test's *write surface*
(the methods that act on the system under test).  The type
system encodes the distinction.

A reader writing a custom validator is structurally prevented
from reaching the actor's write surface from inside an
observer.  The discipline is enforced not by code review but by
the class hierarchy.

### 3. The single attach point per test

A test is one function decorated with `@testbase.testcase` (or
the relevant decorator).  There is no "concurrent test" decorator,
no "parallel test group" construct, no "actor pool" mechanism.
A test is a function; a function runs once per invocation; the
framework's run loop processes one test at a time.

The simplicity is the safety.  When the framework only offers
one shape, the shape is hard to use wrongly.

## What the next chapter does

Chapter 3 walks the corollary: if tests within a Pod must run
sequentially, where does parallelism live?  The answer is the
*Pod model*.  Pods are independent environments; jobs in
different Pods run concurrently because they cannot affect each
other; horizontal CI scaling means *more Pods* (provisioned
through more Specifications), not more parallel tests within
one Pod.

The Pod model is the corollary that makes "more horizontal
scale" possible without violating the single-actor commitment
this chapter just established.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/03-the-pod-model -->
# Chapter 3 — The Pod Model — Where Parallelism Actually Lives

> *Tests within a Pod run sequentially because the Pod is a
> shared environment.  Jobs across independent Pods run in
> parallel because Pods are independent environments.  The unit
> of horizontal scaling in distributed automation is the Pod —
> not the test, not the host, not the runner.*

The previous chapter established the single-actor determinism
commitment: within any environment, one actor at a time.  An
honest reader's next question is: *if I can't parallelise tests,
how does my CI run faster?*

The answer is the **Pod model**.

## What a Pod is

A **Pod** is the runtime instance produced by combining a
**Landscape** (the physical inventory and wiring — Volume 6
chapter 1) with a **Topology** (the logical organisation and
roles — Volume 6 chapters 5 and 6).  A Pod is a concrete,
interoperating set of resources that a job runs against.

Volume 1 chapter 9 introduced the vocabulary; Volume 6 walks the
schema in detail.  This chapter establishes what a Pod is *for*:
**a Pod is the unit of horizontal CI parallelism.**

A Pod has the following properties:

1. **Internal interoperability.**  Within a Pod, the
   resources can talk to each other — the Landscape and Topology
   describe their relationships and the framework activates them.
2. **External isolation.**  Pods do not share resources.  A
   device in Pod A is not in Pod B.  A service in Pod A is not
   in Pod B.  An effect caused in Pod A cannot propagate to Pod
   B.
3. **Single-actor execution.**  A Pod has one test job
   running against it at a time (chapter 2's commitment); inside
   that job, tests run sequentially.

The third property is what makes the second property load-bearing.
Pods don't share resources *and* each Pod is single-actor →
**different Pods cannot affect each other** → jobs across Pods
can run in parallel without violating determinism.

## The parallelism table

| Level | Parallel? | Why |
|---|---|---|
| Within a test (multiple actors against the same environment) | **No** | Concurrent actors break determinism (chapter 2) |
| Across tests in one job (one Pod) | **No** | All tests in a job share the Pod's environment |
| Across jobs on different Pods | **Yes** | Pods are independent environments; effects do not cross |

The table is the framework's complete answer to the parallelism
question.  Three rows, two no's, one yes.  The yes is at the
Pod level; the no's are at every finer granularity.

## How Pods get provisioned

A Pod is realised at the orchestration layer.  Volume 3 chapter
4 walks the Specification configuration category — the
declarative manifest that tells CI tooling "I need this set of
resources to form an automation Pod."  CI tooling reads the
Specification, performs the checkout-or-create, and emits the
Landscape and Topology files for the next stage.  Those files
are the Pod's static description; the runtime instantiation
into a *running* Pod happens when a `DistributedTestJob`
activates the Landscape and Topology (chapter 6 of this
volume).

The orchestration-side flow:

```
Specification file (declares "I need a Pod of 5 hosts")
        ↓ CI tooling
   checkout / create resources
        ↓
   emits Landscape file + Topology file
        ↓ orchestration triggers DistributedTestJob
        ↓
   InteropLibraryTestJob.fire_activate_configuration()
   InteropLibraryTestJob.fire_activate_integration()
   InteropLibraryTestJob.fire_activate_operations()
        ↓
   Pod is now realised — devices online, services reachable,
   topology verified
        ↓
   tests run sequentially against this Pod
        ↓
   Pod is torn down (or returned to the pool)
```

A "10-fold parallel CI" in this model means **ten Specifications,
ten Pods, ten DistributedTestJobs running concurrently**.  Each
Specification produces one Pod; each Pod runs one job; the ten
jobs do not affect each other because the ten Pods are
independent.

## A worked example — three Pods running in parallel

Suppose a CI run wants to verify a sysman feature against three
OS variants: RHEL, Ubuntu, macOS.  Each variant needs a 5-host
Pod (one controller + four DUTs).

Three Specifications declare three Pods:

```yaml
# Specification A — RHEL Pod
pod:
    name: rhel-feature-test-pod
    resources:
        - controller-rhel-1
        - dut-rhel-1
        - dut-rhel-2
        - dut-rhel-3
        - dut-rhel-4
```

```yaml
# Specification B — Ubuntu Pod
pod:
    name: ubuntu-feature-test-pod
    resources:
        - controller-ubuntu-1
        - dut-ubuntu-1
        # ... etc.
```

```yaml
# Specification C — macOS Pod
pod:
    name: macos-feature-test-pod
    resources:
        - controller-macos-1
        - dut-macos-1
        # ... etc.
```

CI runs all three Specifications in parallel.  Each emits its
own Landscape and Topology.  Three `DistributedTestJob`
processes run concurrently — one per Pod.  Inside each job,
the sysman feature tests run sequentially.

Three Pods.  Three jobs in parallel.  Within each job, tests
run one at a time.  No tests-in-parallel anywhere — but
three-jobs-in-parallel.

If the sysman feature suite for one OS takes thirty minutes
sequentially, the cross-OS run finishes in *thirty minutes
total* (parallel across Pods), not ninety (serial across Pods).
Three-fold horizontal scaling at the Pod level; zero
test-level parallelism within a Pod.

## What this means for CI scaling

The framework's stance on horizontal scaling is uniform:

> *To go faster, provision more Pods.  Do not parallelise
> tests within a Pod.*

This is a stance that surprises engineers coming from
application-test traditions where pytest-xdist's
`--workers=N` option is the default scaling answer.  The
framework's answer is different because the problem is
different — application tests don't share state across processes,
so parallel pytest workers don't race; distributed automation
tests *do* share state (the Pod's resources), and parallel
workers within a Pod *would* race.

The stance has consequences:

- **Specifications become first-class infrastructure.**
  Volume 3 chapter 4 walks the Specification category as the
  *unit of horizontal scale*.  A team that wants their CI to
  finish twice as fast doesn't double their pytest workers;
  they double their Specifications.
- **Pod isolation must be real.**  A Pod that secretly shares a
  service with another Pod (a database, a license server, a
  shared queue) is not really a Pod.  The framework's
  Specification-to-Landscape pipeline in Volume 3 chapter 4 is
  pessimistic about shared services for this reason.
- **Pod size becomes a tuning parameter.**  A 5-host Pod
  finishes a sysman suite in 30 minutes; a 10-host Pod finishes
  it in 18 minutes (more parallelism within the suite at the
  *test design* level, not at the framework level).  The
  framework's job is to be honest about the trade-off; a
  larger Pod amortises Pod-setup cost over more tests but is
  more expensive to provision.

## What this rules out

The Pod model rules out a few patterns that look attractive
but break the determinism commitment:

### "Parallel tests with shared fixtures"

```python
# Wrong — two tests racing on shared fixture state.
@pytest.mark.parametrize("scenario", scenarios)
@pytest.mark.parallel(workers=4)  # never use this
def test_scenario(scenario):
    ...
```

If `scenarios` exercises shared fixture state in a real Pod,
the four workers race.  The right answer in distributed
automation is to provision one Pod per scenario — four Pods,
four jobs, parallel jobs, sequential tests within each job.

### "Background actors" running between tests

```python
# Wrong — the background actor is a second actor in the Pod.
def setup_module():
    threading.Thread(target=monitor_drift, daemon=True).start()
```

A daemon thread inside the test process can be an *observer*
(if it only watches and records).  But if the daemon ever
*acts* — sends a heartbeat, restarts a service — it becomes a
second actor in the Pod and breaks determinism.  The right
shape for ongoing observation is a tandem validator (chapter
5).

### "Cross-Pod dependencies" in a single job

```python
# Wrong — one test reaches into a different Pod's service.
def test_cross_pod():
    pod_a_service = LandscapeSingleton().get_pod("pod-a").service("X")
    pod_b_service = LandscapeSingleton().get_pod("pod-b").service("X")
    # ... acts on both
```

A test acting on two Pods is acting on what should be a single
larger Pod.  Either combine them into one Pod (and accept the
serial execution) or split the test (and accept the
cross-Pod sequencing concern, which is an *orchestration*-level
problem — Volume 3 chapter 22's flow control covers it).

## What the next chapter does

Chapter 4 walks the `TestJob` inheritance chain in detail.  The
chain is the *customisation cascade* (Volume 0 chapter 1's
meta-thesis applied here): `TestJob` (testbase) →
`InteropLibraryTestJob` (interop) → `DistributedTestJob`
(testplus).  Each layer adds one responsibility.  The chain is
how a project plugs in its custom `Landscape` and `Topology`
subclasses.

Chapter 5 then walks the tandem-validator implementation in
depth — the actual `codecraftai-testbase/.../tandem/validators.py`
machinery, with the real `attach_to_test` signature and the
JSOS recording flow.

By the end of those two chapters, a reader will have the full
picture of *how* the determinism model from chapter 2 is held
together by the chain of customisation.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/04-the-testjob-inheritance-chain -->
# Chapter 4 — The `TestJob` Inheritance Chain — The Customisation Cascade

> *Four classes, each adding one responsibility, plus three
> naming-only fallbacks.  The chain is the framework's
> customisation entry point — the meta-thesis from Volume 0
> chapter 1 (skeleton + patterns) made concrete.  This chapter
> walks the chain from base to distributed and shows how a
> project plugs in its custom Landscape and Topology.*

The `TestJob` inheritance chain is the single most important
piece of code in distributed automation.  It is *the* place
where:

- The single-host substrate from Volume 1 meets the distributed
  extension Volume 2 walks.
- The framework's lifecycle hooks become non-trivial.
- The user's customisations (custom `Landscape`, custom
  `Topology`, custom `Validator`s) plug in.

The chain is small.  Seven classes, three of them naming-only.
The non-trivial work happens at four layers, and each layer
adds exactly one responsibility.

## The seven classes

*The diagram below is the **intended** chain. One edge —
`IntegratedTestJob → InteropLibraryTestJob` — has **not yet landed**
(see "In-flight migration" immediately after): in today's source
`InteropLibraryTestJob` extends `TestJob` directly, so as built it
and `IntegratedTestJob` are **siblings**, both extending `TestJob`.*

```
TestJob (testbase)                       ← single-host base + stdout recorder + standalone seams
   │
   ├── DefaultTestJob (testbase)         ← naming fallback
   │
   └── IntegratedTestJob (integration-runtime)   ← integration result recording
          │
          ├── DefaultIntegratedTestJob (integration-runtime)  ← naming fallback
          │
          └── InteropLibraryTestJob (interop)    ← landscape activation
                 │
                 └── DistributedTestJob (testplus)   ← distributed sequencer
                        │
                        └── DefaultDistributedTestJob (testplus)  ← naming fallback
```

The non-trivial-work layers are `TestJob`, `IntegratedTestJob`,
`InteropLibraryTestJob`, and `DistributedTestJob`.  The other
three are naming-only fallbacks for entry points that need a
default `job_type=`.

> **In-flight migration.** `IntegratedTestJob` is newly built
> and `TestJob` already exposes the three provider hooks it
> overrides.  But the *re-parenting* of interop's
> `InteropLibraryTestJob` onto `IntegratedTestJob` has not yet
> landed: in the interop source today,
> `InteropLibraryTestJob` still extends `TestJob` directly
> (`codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22`
> reads `class InteropLibraryTestJob(TestJob)`).  The diagram
> above is the *intended* chain; the section on
> `InteropLibraryTestJob` notes where the source currently
> diverges.  This is the same kind of partially-landed refactor
> the c785e9b narrative below describes — reported honestly
> rather than overclaimed.

## Layer 1 — `TestJob` (the near-standalone single-host base)

**File:** `codecraftai-testbase/source/packages/ccai/testbase/testjob.py:45`

`TestJob` is the foundation.  Volume 1 chapter 2 walked it in
detail; this chapter recaps what's relevant for distributed
extension.

`testbase` is now *near-standalone* — it no longer depends on
`codecraftai-configuration`, `codecraftai-runtime`, or
`codecraftai-this-app`.  A plain `TestJob` runs **without
activation**: no presence folder, no `RuntimeVariables`, no
this-app.  It reads the handful of core env knobs it needs
(AM_DEBUG_*, AM_DIAGNOSTICS_*, AM_TESTROOT, AM_OUTPUT_DIRECTORY,
AM_RUN_ID, AM_TESTRUN_TIMEOUT) directly from `os.environ` via the
tiny, dependency-free `ccai.testbase.environment` module, and
records its run summary to **stdout**.  Everything heavier —
the presence-folder results layout, JSOS-to-disk, the HTML
summary, CI/build metadata — is the responsibility of
`IntegratedTestJob` in the next layer.

The class establishes:

- The constructor with logger, testroot, includes/excludes,
  metafilters, job_seed, and the test module.
- `__enter__` / `__exit__` driving `begin()` / `finalize()`.
- The five `fire_*` lifecycle hooks — *all stubs on single-host*:
  - `fire_attach_to_environment(tseq)` — line 379.
  - `fire_attach_to_framework(tseq)` — line 389.
  - `fire_activate_configuration()` — line 399.
  - `fire_activate_integration()` — line 403.
  - `fire_activate_operations()` — line 407.
- The provider/template methods that customisation plugs into:
  - `_create_sequencer()` (line 575) — returns a `TestSequencer`
    (the base) by default; subclasses override to return a
    `DistributedTestSequencer` or other variant.
  - `_create_recorder(...)` (line 564) — returns a
    `StdoutResultRecorder` by default.  It prints the run
    summary to stdout and reuses the JSOS/JSON serialisation (so
    `testrun_results_stream.jsos` / `testrun_summary.json` are
    still written when an output dir is used, and still consumed
    by the `testbase agents` CLI), but it **skips the HTML
    rendering**.  Subclasses that want disk-backed JSOS plus a
    rendered HTML summary override this to return a
    `JsonResultRecorder`.
  - `_create_output_context()` (line 583) — returns a
    `SimpleOutputContext` (an `IOutputContext`) by default,
    rooting artefacts under AM_OUTPUT_DIRECTORY or `<cwd>/.output`.
  - `_create_run_metadata()` (line 595) — returns a
    `StandaloneRunMetadata` (an `IRunMetadata`) by default:
    auto-generated UUID `run_id`, `"(unknown)"` build / pipeline
    / job, title from the job's name.

The five `fire_*` hooks are **the contract**.  They run at
specific phase boundaries during a test run (Volume 1 chapter 2
walks the order).  On a single-host run they are no-ops; on
distributed, the subclasses below give them bodies.

The hooks are *lifecycle hooks*, not template methods.  They
run *during* the lifecycle; they do not return values that the
framework consumes.  The provider methods (`_create_sequencer`,
`_create_recorder`, `_create_output_context`,
`_create_run_metadata`, and the abstract `_get_landscape`
introduced two layers down) are the *value-returning*
customisation points.  `_create_run_metadata()` /
`_create_output_context()` are invoked once in the constructor
`__init__` (lines 95–96) so the run's identity and artefact roots
are fixed before `begin()` or any test executes.

### What `TestJob` does NOT have

A point worth being explicit about because the next layer adds
it: `TestJob` has *no concept of a landscape*.  There is no
`_get_landscape()` method, no landscape parameter, no
import of `codecraftai-landscaping` from anywhere in `testbase`.
This is intentional — `codecraftai-testbase` is the foundation
tier (Volume 0); `codecraftai-landscaping` is upstream of it.  The
dependency arrow runs `landscaping → interop → testbase`, not
the other way.  Volume 0 chapter 4 walks the protocol-based
discipline that makes this possible.

The landscape concept enters two layers down, in
`InteropLibraryTestJob` — but does so as an abstract hook the
user (or a downstream package) implements, not as a direct
import.  The result: `testbase` stays landscape-free and the
chain remains importable on a host that doesn't have landscaping
installed.

## Layer 2 — `IntegratedTestJob` (integration result recording)

**File:** `codecraftai-integration-runtime/source/packages/ccai/integrationruntime/integratedtestjob.py:51`

`IntegratedTestJob` is the layer that *re-supplies the
heavyweight reporting* the near-standalone `TestJob`
deliberately leaves out.  It lives in the **new**
`codecraftai-integration-runtime` package (deps: `testbase` +
`this-app` + `extension`) and adds exactly one responsibility:
**integration result recording**.  It does so by overriding the
*three* provider hooks `TestJob` exposes — and nothing else.

> **Single responsibility.** `IntegratedTestJob` does **not**
> override the five `fire_*` landscape hooks.  Those were *not*
> moved here; they stay in `InteropLibraryTestJob` (Layer 3).
> `IntegratedTestJob`'s only job is to swap the standalone
> stdout/`.output` result plumbing for activation-backed
> presence-folder plumbing.

### The three provider overrides

```python
def _create_output_context(self) -> IOutputContext:
    inst = RuntimeOutputContext()
    return inst

def _create_run_metadata(self) -> IRunMetadata:
    run_title = "Test Run"
    if self.name is not None and len(self.name) > 0:
        run_title = self.name
    inst = RuntimeRunMetadata(title=run_title)
    return inst

def _create_recorder(self, runid, start, render_info, build_info,
                     pipeline_info, job_info) -> ResultRecorder:
    inst = JsonResultRecorder(runid=runid, start=start, render_info=render_info,
                              apod=self._apod, build_info=build_info,
                              pipeline_info=pipeline_info, job_info=job_info)
    return inst
```

Each override replaces a standalone default with its
integration counterpart:

- `_create_output_context()` → `RuntimeOutputContext`
  (`codecraftai-integration-runtime/source/packages/ccai/integrationruntime/runtimeoutputcontext.py`)
  — delegates to `ccai.thisapp.paths` so results, diagnostics,
  and per-testcase by-products land under the activated app's
  **well-known presence folder**.  It is **activation-gated**:
  it raises `SemanticError` unless the process was activated as a
  job (this-app activation sets `AM_JOB_TYPE` / `AM_PRESENCE_DIR` /
  `AM_RESULT_PATH_FOR_TESTS`).  This replaces `TestJob`'s
  `SimpleOutputContext`, which roots under `<cwd>/.output`.
- `_create_run_metadata()` → `RuntimeRunMetadata`
  (`codecraftai-integration-runtime/source/packages/ccai/integrationruntime/runtimerunmetadata.py`)
  — sources build / pipeline / job metadata from the resolved
  `RuntimeVariables` (the real CI/runtime context) instead of the
  `"(unknown)"` placeholders `StandaloneRunMetadata` supplies.
- `_create_recorder()` → `JsonResultRecorder` — JSOS streaming to
  disk **plus** the rendered HTML summary, in place of
  `TestJob`'s `StdoutResultRecorder`.

### Activation is a precondition, not a hook

`IntegratedTestJob` *assumes* the process was already activated
as a test-run job before `begin()` resolves the presence-folder
paths.  The activation itself — `establish_app_persona` +
`resolve_runtime_variables` + `activate_profile_job(JobType.TestRun)`
— lives in `ccai.integrationruntime.initialize`, and
integration runs reach the job through
`ccai.integrationruntime.entrypoints.integrated_test_entrypoint`,
which activates and then delegates to testbase's standalone
`generic_test_entrypoint`.  This keeps activation out of the
class hierarchy: `IntegratedTestJob` consumes the activated
context (via `RuntimeOutputContext` / `RuntimeRunMetadata`); it
does not perform activation.

`DefaultIntegratedTestJob` (line 91) is the naming-only fallback
for this layer — same role as `DefaultTestJob` and
`DefaultDistributedTestJob`.

## Layer 3 — `InteropLibraryTestJob` (the landscape-aware hinge)

**File:** `codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22`

`InteropLibraryTestJob` is the *hinge* of the chain.  It adds
exactly one responsibility: **landscape activation on the five
`fire_*` hooks**.  It does so without taking a hard dependency
on `codecraftai-landscaping` — by introducing an abstract
template method `_get_landscape()` that subclasses must
override.

> **In-flight: the parent is moving.** In the intended chain,
> `InteropLibraryTestJob` extends `IntegratedTestJob` (Layer 2)
> so a distributed run inherits both integration result
> recording *and* landscape activation.  That re-parenting has
> **not yet landed**.  In the interop source today the class
> still extends `TestJob` directly — see the declaration below.
> Until the migration completes, an interop/testplus run does
> **not** automatically pick up `IntegratedTestJob`'s
> presence-folder recording from the class hierarchy; it gets
> the standalone stdout plumbing from `TestJob` unless the
> entry point arranges otherwise.  This is reported as-built,
> not as-intended.

### The class declaration

```python
# codecraftai-interop/source/packages/ccai/interop/testing/testjob.py:22
# As built today — still extends TestJob.  Intended parent: IntegratedTestJob.

class InteropLibraryTestJob(TestJob):
    """
        Interop-tier test-job base class.

        Extends :class:`ccai.testbase.testjob.TestJob` (single-host) with
        the **landscape-activation lifecycle** required by any test job that
        runs against a real environment described through the landscape model
        — devices, services, coordinators, control points, integration
        couplings.
    """
```

### The abstract `_get_landscape()` template method

```python
# Lines 60–86

def _get_landscape(self) -> Any:
    """
        Returns the landscape object used by the activation /
        attachment hooks.

        Concrete subclasses must override this method to return the
        landscape instance their environment is built from
        (typically ``LandscapeSingleton()`` from
        ``ccai.landscaping.wellknown``).

        :raises NotImplementedError: When called on a class that does
                                     not override the hook.
    """
    err_msg_lines = [
        "InteropLibraryTestJob subclasses must override _get_landscape().",
        "    SUBCLASS: {}".format(type(self).__name__),
    ]
    err_msg = "\n".join(err_msg_lines)
    raise NotImplementedError(err_msg)
```

The body raises `NotImplementedError` with a descriptive
message naming the subclass that failed to override.  This is
the Volume 0 chapter 3 discipline: errors should never pass
silently.  An `InteropLibraryTestJob` subclass that forgot
`_get_landscape()` fails loudly on first attempt to activate.

### The five `fire_*` overrides

```python
# Line 88 — fire_attach_to_environment

def fire_attach_to_environment(self, tseq: TestSequencer) -> None:
    """
        Attach the sequencer to the environment using the landscape.
    """
    landscape = self._get_landscape()
    tseq.attach_to_environment(landscape=landscape)
    return
```

```python
# Line 101 — fire_attach_to_framework

def fire_attach_to_framework(self, tseq: TestSequencer) -> None:
    """
        Attach the sequencer to the framework using the landscape.
    """
    landscape = self._get_landscape()
    tseq.attach_to_framework(landscape=landscape)
    return
```

```python
# Line 114 — fire_activate_configuration

def fire_activate_configuration(self) -> None:
    """
        Activate the landscape's configuration layer.
    """
    landscape = self._get_landscape()
    landscape.activate_configuration()
    return
```

```python
# Line 124 — fire_activate_integration

def fire_activate_integration(self) -> None:
    """
        Activate the landscape's integration layer.
    """
    landscape = self._get_landscape()
    landscape.activate_integration()
    return
```

```python
# Line 134 — fire_activate_operations

def fire_activate_operations(self) -> None:
    """
        Activate the landscape's operational layer.
    """
    landscape = self._get_landscape()
    landscape.activate_operations()
    return
```

The pattern is identical for every hook: fetch the landscape via
`_get_landscape()`, delegate to the right method on the
landscape.  The landscape is fetched fresh each time because
`LandscapeSingleton()` (the typical implementation) is a
singleton — the cost is one dict lookup, not a re-instantiation.

### The c785e9b commit — what this layer was extracted from

The introduction of `InteropLibraryTestJob` happened in commit
`c785e9b` ("Add InteropLibraryTestJob — interop-tier base class
for landscape-aware test jobs").  Before that commit,
`DistributedTestJob` was a standalone class that *duplicated
~600 lines of `TestJob` verbatim* with the five `fire_*`
overrides bolted on.  The duplication had three problems:

1. **The duplication itself.**  Two copies of the lifecycle
   logic meant any change to `TestJob` had to be made twice.
2. **The dependency direction.**  `DistributedTestJob` was in
   `codecraftai-testplus`, which is downstream of
   `codecraftai-testbase`.  The duplicated code in
   `DistributedTestJob` re-implemented `TestJob` semantics in a
   downstream package — a clear inheritance smell.
3. **The hook implementation.**  The five `fire_*` overrides in
   the original `DistributedTestJob` had hard imports of
   `LandscapeSingleton`.  This made the class un-importable on
   a host without `codecraftai-landscaping` installed — an
   ergonomic failure at the package boundary.

The c785e9b refactor introduced `InteropLibraryTestJob` as the
intermediate layer that:

- Inherits cleanly from `TestJob` (no duplication).
- Lives in `codecraftai-interop` (a tier above `testbase` but below
  `landscaping`).
- Defines `_get_landscape()` as abstract, deferring the landscape
  import to the concrete subclass.

Result: `DistributedTestJob` collapsed from ~615 lines to ~90.

The `IntegratedTestJob` layer (Layer 2) is the *next* step of
the same factoring discipline: rather than baking
presence-folder recording, `RuntimeVariables` metadata, and HTML
rendering into `TestJob` (which would re-couple the foundation
tier to `this-app` and `RuntimeVariables`), that behaviour was
lifted into a separate `codecraftai-integration-runtime` package
and slotted in *between* `TestJob` and `InteropLibraryTestJob`.
As noted above, the final wiring — re-parenting
`InteropLibraryTestJob` onto `IntegratedTestJob` — is the
in-flight step; until it lands, interop's class still extends
`TestJob`, the same way `DistributedTestJob` once extended
`TestJob` before c785e9b.

## Layer 4 — `DistributedTestJob` (the distributed plug-in)

**File:** `codecraftai-testplus/source/packages/ccai/testplus/distributedtestjob.py:24`

`DistributedTestJob` is the layer that *concretes* the abstract
`_get_landscape()` and adds *the distributed sequencer*.  It is
~90 lines, almost all of which is the two override methods.

### `_get_landscape()` — concrete

```python
# Lines 49–57

def _get_landscape(self) -> Any:
    """
        Returns the singleton :class:`Landscape` instance for the
        distributed test environment.

        :returns: The active :class:`Landscape`.
    """
    landscape = LandscapeSingleton()
    return landscape
```

The concrete `_get_landscape` returns
`LandscapeSingleton()` from `ccai.landscaping.wellknown`.
The singleton is factory-overridable (a custom `Landscape`
subclass registered through the factory protocol replaces the
default — Volume 0 chapter 7 walks the factory model), so a
project's custom landscape plugs in *here* without modifying
`DistributedTestJob` itself.

The customisation pattern: the project subclasses `Landscape`,
registers the subclass through the factory protocol,
`LandscapeSingleton()` returns the subclass, the rest of the
chain is unchanged.

### `_create_sequencer()` — distributed

```python
# Lines 59–81

def _create_sequencer(self) -> DistributedTestSequencer:
    """
        Hook used by :meth:`TestJob.execute` to construct the test
        sequencer for this job.  Returns a
        :class:`DistributedTestSequencer` so the sequencer's
        ``attach_to_environment`` / ``attach_to_framework`` methods
        accept the landscape supplied by
        :class:`InteropLibraryTestJob`.
    """
    inst = DistributedTestSequencer(
        self.title,
        self._testroot,
        includes=self.includes,
        excludes=self.excludes,
        metafilters=self.metafilters,
        job_seed=self.job_seed,
    )
    return inst
```

`_create_sequencer` returns a `DistributedTestSequencer`
(chapter 8 of this volume walks it in detail).  The sequencer
is the test-execution loop; the distributed variant differs
from the base in that it accepts a landscape parameter on
`attach_to_environment()` / `attach_to_framework()` — which is
where `InteropLibraryTestJob`'s overrides land the landscape.

### A note on the `self.title` bug

Line 74 references `self.title`, but the `TestJob` base class
defines the attribute as `self._title`.  There is no `title`
property defined anywhere in the chain.  At runtime, this
raises `AttributeError`.

This is a real bug in the source.  The book reports it
faithfully because pretending the chain is bug-free would
mislead the reader.  The fix is straightforward — change
`self.title` to `self._title` (or expose a `@property title`
on `TestJob`).  An SDET reading this chapter and running into
the same issue should know they're not imagining it.

The bug also illustrates a benefit of the layered chain: the
fix is a one-line change in one file.  Before the c785e9b
refactor, the same bug — if it existed — could have been in
the duplicated copy and gone uncaught.

## Layers 5, 6, and 7 — the naming-only fallbacks

`DefaultTestJob` (in testbase, line 613),
`DefaultIntegratedTestJob` (in integration-runtime, line 91),
and `DefaultDistributedTestJob` (in testplus, lines 84–90) are
trivial:

```python
class DefaultDistributedTestJob(DistributedTestJob):
    """
        The DefaultDistributedTestJob is utilized as a job container when
        a job was not specified.
    """
    name = "Test Job"
    description = "Unspecified test job."
```

They exist so that the entry points
(`generic_test_entrypoint`, `integrated_test_entrypoint`,
`distributed_test_entrypoint`) have a reasonable default
`job_type=` value.  A user with no custom job-type subclass uses
the default; a user with a custom subclass passes
`job_type=YourCustomJob`.

## The customisation cascade

Volume 0 chapter 1's meta-thesis: framework supplies the
skeleton and patterns; the organisation customises and refines.
The chain above is the *concrete realisation* for distributed
testing.

A project building a custom distributed test scenario:

1. **Pick the right starting layer.**
   - Single-host, no-activation customisation (stdout recorder,
     `.output` artefacts) → subclass `TestJob`.
   - Single-host customisation that needs activation-backed
     presence-folder recording, `RuntimeVariables` metadata, and
     an HTML summary → subclass `IntegratedTestJob` (and run via
     `integrated_test_entrypoint` so activation happens first).
   - Landscape-aware single-pod customisation that doesn't need
     a custom sequencer → subclass `InteropLibraryTestJob` and
     implement `_get_landscape()`.
   - Distributed multi-host customisation → subclass
     `DistributedTestJob` (and override `_create_sequencer()`
     if you need a custom sequencer).

2. **Override the provider methods you need.**
   - `_get_landscape()` to return your `Landscape` subclass
     (typically via `LandscapeSingleton()` after registering
     your subclass through the factory).
   - `_create_sequencer()` for a custom sequencer.
   - `_create_recorder()` for a custom recorder (stdout vs. JSOS
     + HTML is itself decided here — `TestJob` defaults to
     `StdoutResultRecorder`, `IntegratedTestJob` to
     `JsonResultRecorder`).
   - `_create_output_context()` / `_create_run_metadata()` for a
     custom artefact root or run-identity source.
   - The five `fire_*` hooks if you need extra activation
     behaviour beyond what landscape activation provides
     (rare — usually the layered base classes suffice).

3. **Hand the subclass to the entry point.**
   ```python
   distributed_test_entrypoint(job_type=YourCustomJob)
   ```

The entry point is `codecraftai-testplus/source/packages/ccai/testplus/entrypoints.py:38`
for distributed jobs,
`codecraftai-integration-runtime/source/packages/ccai/integrationruntime/entrypoints.py`
(`integrated_test_entrypoint`) for activated integration jobs,
and `codecraftai-testbase/source/packages/ccai/testbase/entrypoints.py:135`
(`generic_test_entrypoint`) for standalone single-host.  Real
scaffolding examples live in
`codecraftai-testbase/source/tests/ccai_tests/testbase/integration/test_custom_job_type_integration.py`
(`CustomTestJob`, `FailingCustomTestJob`).

## Not in the chain — `OrchestrationJob`

A point of vocabulary worth re-emphasising (Volume 3 chapter 1
also makes it): `OrchestrationJob` (in
`codecraftai-landscaping/source/packages/ccai/landscaping/orchestrationjob.py:23`)
is **not a `TestJob` subclass**.  It inherits from
`ContextUser`.

`OrchestrationJob` is for automation tasks that need a landscape
but are not tests — environment provisioning, inventory
discovery, compliance audits.  It carries job metadata
(`name`, `title`, `description`, `owner`,
`requires_landscape`, `requires_topology`, `requires_runtime`,
`requires_credentials` as class attributes) and wraps the
lifecycle in a `begin()` → `execute()` → `end()` contract
that is *not* the `fire_*` lifecycle from `TestJob`.

The two hierarchies meet at the bridge tasks of Volume 3
chapter 25 — taskplus's `DistributedTestJobTask` invokes a
`DistributedTestJob`; `OrchestrationJob` is invoked by
taskplus directly without passing through the test machinery.

When this volume says "the test job," it means
`DistributedTestJob` or its subclasses.  When Volume 3 says
"the orchestration," it means a taskplus workflow that may or
may not invoke a test job.  The two meet at the bridge; they
don't share a hierarchy.

## What the next chapter does

Chapter 5 walks the tandem-validator implementation in detail:
the `Validator` base class, `attach_to_test`, the JSOS
recording flow, the `wait_for_it` integration from Volume 0
chapter 5.  It is the implementation half of chapter 2's
single-actor + multi-observer rationale.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/05-tandem-testing-in-depth -->
# Chapter 5 — Tandem Testing in Depth

> *Chapter 2 carried the rationale for single-actor + multi-
> observer execution.  This chapter walks the framework's
> `Validator` machinery — how a validator attaches to a test
> scope, how its outcome lands in the JSOS stream alongside the
> test's, and how to write one for a real distributed
> verification.*

The tandem-bicycle metaphor from chapter 2: one driver, many
observers riding along.  This chapter walks the *bicycle* —
the actual `codecraftai-testbase/source/packages/ccai/testbase/tandem/`
machinery that holds it together.

## The class hierarchy

```
BaseCoupling
    └── ValidatorCoupling (testbase/coupling/validatorcoupling.py:44)
            └── Validator (testbase/tandem/validators.py:33)
                    └── (user-defined subclasses)
```

The Volume 1 chapter 2 coupling families (Integration,
Configuration, Validator) are the framework's three coupling
shapes.  ValidatorCoupling is the type that explicitly *does
not* expose a write surface to the system under test — a
validator inheriting from it is structurally constrained to be
an observer.

The hierarchy matters because chapter 2's commitment to
"observers don't act" is partly enforced through types.  A
validator subclass *cannot* accidentally inherit a
`run_command` or `set_state` method from the wrong base class
because no such method exists in the `ValidatorCoupling` /
`BaseCoupling` ancestry.

## The `Validator` class

**File:** `codecraftai-testbase/source/packages/ccai/testbase/tandem/validators.py:33`

```python
class Validator(ValidatorCoupling):
    """
        Base class for validator implementations used with tandem tests.
    """

    def __init__(self):
        super().__init__()

        self._inst_id = str(uuid.uuid4())

        self._suffix = None
        self._testscope = None
        self._parent_scope_id = None
        self._validator_name = None
        self._recorder = None

        self._logger = getEnhancedLogger()
        return
```

The instance variables (lines 38–49) carry:

- `_inst_id` — UUID for this validator instance.  Each
  attachment is a fresh validator; the inst_id distinguishes
  multiple validators of the same class on different tests.
- `_suffix` — string identifier appended to the validator's
  name to make it unique within a test scope.  A test
  attaching three `LatencyValidator`s passes `suffix="db"`,
  `suffix="cache"`, `suffix="queue"`.
- `_testscope` — the `SequencerTestScope` the validator is
  attached to.  Set by `attach_to_test`.
- `_parent_scope_id` — the test's scope ID, used to anchor the
  validator's `TestResult` in the result tree.
- `_validator_name` — `testscope.test_name + "_" + suffix`.
  The composite name used in JSOS records.
- `_recorder` — handle to the result recorder.  Validators
  call `_recorder.preview()` and `_recorder.record()` to land
  outcomes in the JSOS stream.
- `_logger` — enhanced logger from `codecraftai-xmodules`.

Per the Volume 0 chapter 2 discipline, every instance variable
is initialised in `__init__`.  No late introduction.

## The lifecycle: `attach_to_test` → `initialize` → run-in-tandem → `finalize`

A validator's life has four phases.  The framework drives them
in order.

### Phase 1 — `attach_to_test(testscope, suffix)`

```python
# Lines 64–76

def attach_to_test(self, testscope: SequencerTestScope, suffix: str):
    """
        The 'attach_to_test' method is called by the sequencer in order to attach the validator to its partner
        test scope.
    """
    self._testscope = testscope
    self._suffix = suffix
    self._parent_scope_id = self._testscope._parent_scope_id
    self._validator_name = testscope.test_name + "_" + self._suffix
    self._recorder = testscope.recorder
    self._result = TestResult(self._inst_id, self._validator_name, self._parent_scope_id, self._testscope._monikers, self._testscope._pivots)
    self._recorder.preview(self._result)
    return
```

`attach_to_test` is the *binding* moment.  The framework calls
it once for each validator attached to a test, before the test
runs.  The validator:

- Stores the test scope, suffix, parent scope ID.
- Computes its composite name.
- Captures the recorder.
- Constructs a `TestResult` for itself with its own UUID, name,
  and the test's monikers + pivots.
- Calls `recorder.preview(result)` — telling the recorder
  "expect a result with this ID" so the JSOS stream's
  result-tree shape is known up front.

After `attach_to_test`, the validator is bound but not yet
initialised.  Its `TestResult` is in the recorder's preview
state but no outcome has been written.

### Phase 2 — `initialize(*args, **kwargs)`

```python
# Lines 78–91

def initialize(self, *args, **kwargs):
    """
        The 'initialize' method should attempt to initialize the validator but it should not allow an exception
        to bubble up...
    """
    self._logger.warning(
        "Default implementation of `Validator.initialize` invoked.  "
        "Subclasses should override.  args=%r kwargs=%r",
        args, kwargs,
    )
    return
```

`initialize` is the validator's setup hook.  The base
implementation logs a no-op warning; subclasses override to
set up state — open a log tail, subscribe to an event stream,
start a polling thread.

The base body's warning is deliberate: a validator with a
no-op `initialize` is almost always a mistake.  The warning
catches the omission early; a project that legitimately wants
a no-op initialize overrides explicitly with a `pass`.

### Phase 3 — Run in tandem with the test

This is where the metaphor lands.  The test runs.  The
validator runs *concurrently* with the test, watching for
effects.  Different validators do different things during this
phase:

- A polling validator runs a `wait_for_it` loop watching for a
  state change.
- A subscribing validator listens on an event channel.
- A snapshot validator takes periodic snapshots.

Crucially, the validator's run-in-tandem behaviour is
*subclass-specific*.  The base `Validator` class doesn't
prescribe a shape — it provides the lifecycle hooks
(`initialize`, `validate`, `finalize`) and lets the subclass
decide what watching means.

The `LoopingValidator` subclass (line 232 of the same file)
demonstrates one pattern — a worker thread that the validator
manages with `wait_for_it` for shutdown:

```python
# Excerpt — LoopingValidator.wait()

wait_for_it(
    self._wait_for_shutdown,
    what_for="LoopingValidator Shutdown",
    timeout=self._shutdown_timeout,
    interval=self._shutdown_interval,
)
```

The `wait_for_it` primitive comes from Volume 0 chapter 5.  It
polls a callable until either the callable returns a truthy
value or the timeout elapses.  `LoopingValidator` uses it to
wait for its background thread to finish on shutdown.

Other patterns: a `ThresholdValidator` that records the maximum
observed value during the test and asserts a bound at the end;
a `LogScanningValidator` that tails a log and flags errors.
Each is a subclass of `Validator` (or one of `Validator`'s own
subclasses) that overrides `initialize`, an instance method
that runs in tandem, and `validate` (called by `finalize`
below).

### Phase 4 — `finalize()`

```python
# Lines 94–131

def finalize(self):
    """
        The 'finalize' method is called by the sequencer after the test has completed in order to allow
        the validator to perform its checks and record the result.
    """
    try:
        self.validate()
        self._result.mark_passed()
    except AssertionError as cause:
        # ... format traceback, mark failed
        self._result.mark_failed(...)
    except Exception as cause:
        # ... format traceback, mark errored
        self._result.mark_errored(...)

    self._recorder.record(self._result)
    return
```

`finalize` is called by the sequencer *after the test has
completed*.  It:

- Calls `validate()` — the subclass-implemented method that
  asserts the validator's check.
- Catches `AssertionError` (test-style failure) and marks the
  validator's result as failed.
- Catches generic `Exception` (validator broke) and marks the
  result as errored.
- Otherwise marks the result as passed.
- Calls `recorder.record(self._result)` — committing the
  validator's outcome to the JSOS stream.

After `finalize`, the validator's `TestResult` is in the JSOS
stream as a sibling of the test's own result.  The result
tree now contains *N+1 results* for a test with *N
validators*: one for the test, one for each validator.

## How the outcome lands in the JSOS stream

Volume 1 chapter 5 walked the JSOS streaming format.  A
validator's outcome lands in the same stream as the test's,
with the same record shape.  The differences:

- The validator's `TestResult` carries the validator's
  `_inst_id` (UUID), not the test's.
- The validator's name is `<test_name>_<suffix>` — composite.
- The result-tree structure links the validator under the
  test's parent scope, so the consumer can navigate from the
  test to its validators.

A consumer reading the JSOS stream sees, for one test with
three validators:

```
{kind: test_started, name: test_replication_lag, ...}
{kind: validator_started, name: test_replication_lag_metric, ...}
{kind: validator_started, name: test_replication_lag_log, ...}
{kind: validator_started, name: test_replication_lag_health, ...}
{kind: test_finished, name: test_replication_lag, outcome: passed, ...}
{kind: validator_finished, name: test_replication_lag_metric, outcome: passed, ...}
{kind: validator_finished, name: test_replication_lag_log, outcome: failed, ...}
{kind: validator_finished, name: test_replication_lag_health, outcome: passed, ...}
```

The test passed.  Two validators passed.  One validator
(`test_replication_lag_log`) failed — meaning the test's
*action* succeeded but one of the *observers* caught something
the test didn't check for.

This is the tandem model's payoff: a test can *pass* its own
assertions while a validator catches a steady-state violation
the test wasn't even looking at.  The aggregate run report
(chapter 13) treats the validator failure as a real failure;
the test's passing assertions don't override it.

## Writing a custom validator

A worked example.  Suppose a distributed test reboots a host
and verifies the cluster recovers.  The test's *action* is the
reboot; the test's *check* is "cluster reports healthy after
reboot."  But during the reboot, the cluster might *briefly
report degraded* — and a strict observer should catch that and
mark it.

```python
import time
from ccai.testbase.tandem.validators import Validator
from ccai.errors.exceptions import SemanticError

class DegradedDurationValidator(Validator):
    """
        Validates that the cluster's degraded period during a
        reboot test does not exceed a threshold.
    """

    def __init__(self, max_degraded_seconds: float = 30.0) -> None:
        super().__init__()
        self._max_degraded_seconds = max_degraded_seconds
        self._degraded_started_at: Optional[float] = None
        self._degraded_total: float = 0.0
        self._observation_thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()
        return

    def initialize(self, cluster_endpoint: str) -> None:
        """
            Start the observation thread polling cluster health.
        """
        self._cluster_endpoint = cluster_endpoint

        def _observe():
            while not self._stop_event.is_set():
                health = check_cluster_health(self._cluster_endpoint)
                now = time.monotonic()
                if health == "degraded" and self._degraded_started_at is None:
                    self._degraded_started_at = now
                elif health != "degraded" and self._degraded_started_at is not None:
                    self._degraded_total += now - self._degraded_started_at
                    self._degraded_started_at = None
                time.sleep(0.5)
            return

        self._observation_thread = threading.Thread(
            target=_observe, daemon=True
        )
        self._observation_thread.start()
        return

    def validate(self) -> None:
        """
            Stop observation and assert degraded duration is within threshold.
        """
        self._stop_event.set()
        if self._observation_thread is not None:
            self._observation_thread.join(timeout=5.0)

        # Close out any in-flight degraded period.
        if self._degraded_started_at is not None:
            self._degraded_total += time.monotonic() - self._degraded_started_at

        rtnval = self._degraded_total <= self._max_degraded_seconds
        assert rtnval, (
            "Cluster was degraded for {:.2f}s, exceeds threshold of {:.2f}s".format(
                self._degraded_total, self._max_degraded_seconds
            )
        )
        return
```

The validator:

- Initialises a daemon thread polling cluster health every 500ms.
- Tracks the cumulative degraded duration during the test.
- In `validate`, stops the thread, computes the total degraded
  time, asserts it's under the threshold.
- The framework's `finalize` catches the `AssertionError`,
  marks the result failed with a clear message, records it to
  the JSOS stream.

A test using this validator:

```python
@testbase.attach.parameter(cluster_endpoint, identifier="endpoint")
@testbase.attach.validator(DegradedDurationValidator(max_degraded_seconds=30.0),
                            suffix="degraded_duration")
def test_cluster_recovers_from_reboot(endpoint):
    """
        Verify the cluster recovers within the SLA window.
    """
    target_host = pick_a_node(endpoint)
    reboot_host(target_host)
    wait_for_cluster_healthy(endpoint, timeout=120)
    rtnval = cluster_is_healthy(endpoint)
    assert rtnval
    return
```

The test asserts "cluster is healthy after the reboot" — a
single check at the end.  The validator asserts "cluster was
degraded for less than 30 seconds during the reboot" — a
cumulative observation across the test's duration.  The test
might pass its assertion (the cluster *is* healthy at the end)
while the validator fails (the cluster was degraded for too
long during the test).  The aggregate run reports both.

## When to use a validator vs put the check in the test body

A natural question: why use a tandem validator at all?  Why
not just put the check in the test?

Three reasons.

### 1. Continuous observation that the test cannot do while acting

The test is the actor.  While the test is calling
`reboot_host(target_host)`, the test cannot also be polling
cluster health — the call is synchronous, the test code is
blocked.  A validator runs *in parallel with the test* (in a
separate thread / coroutine that the validator manages),
collecting observations the test cannot collect.

### 2. Steady-state checks distinct from the action's checks

A reboot test's action-check is "after the reboot, the cluster
is healthy."  A reboot test's *steady-state* check is "during
the reboot, the cluster's degraded period was bounded."  These
are *different properties* and they belong in *different
result records*.  The validator gives the steady-state check
its own pass/fail outcome that doesn't depend on the test's
outcome.

### 3. Reusability across tests

A `DegradedDurationValidator` written once attaches to *every*
test that does anything to the cluster — reboot, kill, network
partition, drive failure.  The test bodies don't repeat the
validator's logic.  A change to "what counts as degraded" or
"what the threshold is" happens in one class, not N tests.

## What the next chapter does

Chapter 6 walks landscape activation in detail: what happens
when `InteropLibraryTestJob.fire_activate_configuration()` is
called, what `Landscape.activate_configuration()` does, what
the three layers (Configuration / Integration / Operational)
each contribute.  It is the implementation half of Volume 1
chapter 9's landscape boundary.

Chapter 7 follows with the verification contract — the
load-bearing precondition that makes the determinism model
from chapter 2 actually deterministic.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/06-landscape-activation -->
# Chapter 6 — Landscape Activation

> *Volume 1 chapter 9 carried the landscape boundary at the
> contract level: `TestJob`'s five `fire_*` hooks are no-ops on
> single-host; subclasses give them bodies.  This chapter walks
> the bodies.  When the framework "activates" a landscape, what
> actually happens?  Three layers, each with a job, each with
> failure modes the next layer can't recover.*

`Landscape.activate_configuration()`,
`Landscape.activate_integration()`, and
`Landscape.activate_operations()` are the three method calls
`InteropLibraryTestJob` makes during a distributed test run.
Each delegates to a corresponding *layer* on the `Landscape`
class:

- **`activate_configuration`** delegates to
  `LandscapeConfigurationLayer`.
- **`activate_integration`** delegates to
  `LandscapeIntegrationLayer`.
- **`activate_operations`** delegates to
  `LandscapeOperationalLayer`.

The three layers are the framework's separation of concerns
for landscape activation: load files → instantiate objects →
bring online and verify.  Each layer has a clear responsibility
and a clear failure mode.

## The three layers at a glance

| Layer | Responsibility | Output | Failure mode |
|---|---|---|---|
| Configuration | Load Landscape and Topology files; initialise credentials; let integration couplings inspect config | `landscape_info`, `topology_info`, `CredentialManager` populated | Missing files; schema errors; unresolved credentials |
| Integration | Instantiate device, service, power, serial objects from the loaded config | `_integrated_devices`, `_integrated_services`, etc. populated | Unknown deviceTypes; misconfigured Features; coordinator-init errors |
| Operational | Activate coordinators; attach extensions; verify connectivity | Devices online and reachable | Unreachable devices; failed authentication; service-side errors |

The order matters.  Each layer requires the previous layer's
output.  An attempted operational activation against a
landscape whose integration layer didn't produce devices
fails immediately.  An integration activation against a
landscape whose configuration layer didn't load fails
immediately.  The framework enforces the order through the
`Landscape` class's three methods, each of which checks its
predecessor's state.

## Layer 1 — `LandscapeConfigurationLayer`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/layers/landscapeconfigurationlayer.py:51`

The configuration layer is the "load and validate" phase.
It owns the on-disk representation of the landscape and
topology, plus the credentials and runtime references they
contain.

### Key methods

- **`load_landscape(activation_params)`** — reads the
  `LANDSCAPE_CONFIGURATION_MAP` (populated earlier by
  `codecraftai-configuration`'s loader) and stores it as
  `self._landscape_info`.
- **`load_topology(activation_params)`** — same for
  `TOPOLOGY_CONFIGURATION_MAP`, stored as
  `self._topology_info`.  Line 267.
- **`initialize_credentials()`** — instantiates
  `CredentialManager` for the loaded credentials.  Anything
  the test job will need to authenticate against must resolve
  here.
- **`attach_to_environment()`** (lines 201–219) — gives every
  installed integration coupling a chance to *inspect* the
  configuration *before* device creation.  Quoted:

  ```python
  def attach_to_environment(self):
      lscape = self.landscape

      # During the landscape initialization and before we start creating devices, give
      # all of the installed coordinator couplings and opportunity to peek at the configuration
      # and perform any special validation or internal initialization, this also provides the
      # integrations with a first glance at the test landscape after the configuration files
      # have been loaded.
      integ_coupling_table = lscape.installed_integration_couplings
      for integ_key in integ_coupling_table:
          integ_coupling = integ_coupling_table[integ_key]
          integ_coupling.attach_to_environment(lscape)

      return
  ```

- **`validate_landscape()`** and **`validate_topology()`** —
  structural validation hooks.  The shipped
  `validate_topology()` (line 536) is a stub returning empty
  errors / warnings; the user overrides it in a `Landscape`
  subclass to enforce project-specific schema.

### What can go wrong here

- **Missing files.**  If
  `AM_CONFIG_LANDSCAPE_NAMES` references a name that
  `ConfigurationLoader` cannot find, load fails before any
  device is created.  This is the cleanest failure to
  diagnose — the path is explicit, the resolver explicitly
  reports what it tried.
- **Schema errors.**  YAML or JSON parse errors land here.
- **Credential resolution failures.**  A credential name in the
  Landscape that doesn't resolve through any configured backend
  fails here.  The test job sees a `ConfigurationError` and
  refuses to proceed.

The configuration layer is *fast*.  Loading is filesystem +
parse + dict-walk.  No network calls.  No device probing.
Failure here means the CI system handed the job
configuration that doesn't even parse — a clear "your
inputs are wrong" signal.

## Layer 2 — `LandscapeIntegrationLayer`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/layers/landscapeintegrationlayer.py:43`

The integration layer is the "instantiate objects" phase.
It walks the loaded `landscape_info` and constructs in-memory
representations of every device, service, power controller,
and serial coordinator the landscape declares.

### Key method

`initialize_landscape(activation_params)` at lines 295–341 is
the layer's entry point.  Its body:

```python
def initialize_landscape(self, activation_params: LandscapeActivationParams):
    lscape = self.landscape

    devices = None
    with lscape.begin_locked_landscape_scope() as locked:

        layer_config = lscape.layer_configuration

        if layer_config.landscape_info is not None:

            if not activation_params.disable_device_activation:
                # Initialize the devices so we know what they are...
                devices = self._initialize_landscape_devices(layer_config)

                if self._power_request_count > 0:
                    self._initialize_landscape_power(layer_config)

                if self._serial_request_count > 0:
                    self._initialize_landscape_serial(layer_config)
            else:
                self.logger.info("LandscapeIntegrationLayer: 'Device Activation' was disabled.")
                devices = {}

            if not activation_params.disable_service_activation:
                services = self._initialize_landscape_services(layer_config)
            else:
                self.logger.info("LandscapeIntegrationLayer: 'Service Activation' was disabled.")
                services = {}

            self._integrated_devices = devices.copy()
            self._integrated_services = services.copy()
```

Three things to notice:

1. **The lock.**  `begin_locked_landscape_scope()` is held
   during initialisation to prevent concurrent activation from
   multiple threads (a real concern with the
   `LandscapeSingleton` pattern).
2. **The activation flags.**  `disable_device_activation` and
   `disable_service_activation` are
   `LandscapeActivationParams` fields letting the test job
   skip device/service initialisation when only the
   configuration layer is needed (e.g., for inspection or
   audit).  The default is to activate both.
3. **The on-demand power and serial init.**  The integration
   layer only initialises power and serial subsystems if at
   least one device requested them (tracked via
   `_power_request_count` and `_serial_request_count`).
   Pods without power-control devices skip the power init
   entirely.

### What gets created

After `initialize_landscape` returns:

- **`integrated_devices`** — a thread-safe dict of `device_name
  → LandscapeDevice` instances.  Each device knows its
  deviceType, host, credentials, Features, and the
  ProtocolExtensions it should expose.
- **`integrated_services`** — same for services.
- **`integrated_power`** — power-controller objects.
- **`integrated_serial`** — serial-coordinator objects.

The objects exist but are not yet *online*.  No SSH session has
been opened.  No serial port has been probed.  The integration
layer is a graph-construction phase; the operational layer is
the first-contact phase.

### What can go wrong here

- **Unknown deviceTypes.**  A Landscape declaring
  `deviceType: network/specialdevice` against a framework that
  has no Coordinator registered for `network/specialdevice`
  fails here.  The Volume 0 chapter 7 factory model is what
  determines whether a deviceType is known; a project adding a
  new deviceType registers a Coordinator subclass through the
  factory protocol.
- **Misconfigured Features.**  A Feature that references a
  service-name that doesn't exist in the `services` section
  fails here.  The integration layer's
  `_initialize_landscape_devices` walks every device's
  Features and looks up service references; a missing service
  is a `ConfigurationError`.
- **Coordinator-init errors.**  A Coordinator subclass's own
  `__init__` failing surfaces here.

## Layer 3 — `LandscapeOperationalLayer`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/layers/landscapeoperationallayer.py:41`

The operational layer is where landscape activation *contacts
the real world*.  It activates each Coordinator (which brings
its devices online), attaches ProtocolExtensions to devices,
and verifies connectivity.

### Key method

`establish_connectivity(activation_params)` at lines 391–480 is
the layer's verification entry point.  Excerpted:

```python
def establish_connectivity(self, activation_params: LandscapeActivationParams):
    lscape = self.landscape
    layer_integ = lscape.layer_integration

    failures = []

    coordinators_for_services = layer_integ.coordinators_for_services
    for coord in coordinators_for_services.values():
        results = coord.establish_connectivity(activation_params)
        for item in results:
            host, ipaddr, status, stdout, stderr, exc = item
            if exc is not None:
                failures.append(item)
            elif status is not None:
                if isinstance(status, bool):
                    if not status:
                        failures.append(item)
                elif status != 0:
                    failures.append(item)

    # ... similar loops for power, serial, devices ...

    if len(failures) > 0:
        errmsg_lines = [
            "Errors encountered while establishing connectivity with the landscape.",
            "FAILURES:"
        ]

        for item in failures:
            host, ipaddr, status, stdout, stderr, exc = item
            if exc is not None:
                errmsg_lines.append(f"    Host: {host} IP: {ipaddr} Exception: {exc}")
            else:
                errmsg_lines.append(f"    Host: {host} IP: {ipaddr} Status: {status} Stderr: {stderr}")

        errmsg = os.linesep.join(errmsg_lines)

        if not activation_params.allow_missing_devices:
            raise RuntimeError(errmsg)
        else:
            self.logger.error(errmsg)

    return
```

Three properties of the verification:

1. **The result tuple shape.**  Each coordinator's
   `establish_connectivity` returns a list of
   `(host, ipaddr, status, stdout, stderr, exc)` tuples.  The
   tuple captures: which host, what IP it resolved to, what
   status code the probe returned, stdout / stderr from the
   probe, and any exception caught.  This is the primary
   forensic record for "why is host X unreachable."
2. **The `allow_missing_devices` flag.**  When False (the
   default), any failure raises `RuntimeError` and the test
   job refuses to proceed.  When True (typical for smoke
   runs), failures are logged and the run continues with
   missing devices.
3. **The error message construction.**  The framework follows
   the Volume 0 chapter 2 multi-line-error-message discipline:
   build a list of lines, format with `os.linesep.join`,
   raise.  An SDET reading the error gets a structured failure
   report with the host, IP, status, and stderr — enough to
   triage without re-running.

### What gets activated

`activate_coordinators(activation_params)` runs *before*
`establish_connectivity`.  For each Coordinator:

- The Coordinator's `activate()` method runs.  This typically:
  - Walks each device the Coordinator manages.
  - Probes the device for additional metadata (model number,
    OS version, hardware info).
  - Attaches the appropriate ProtocolExtensions to the device
    (`device.ssh`, `device.serial`, `device.power`).
- Per-device features that need the Coordinator's bridging
  (e.g., a serial console reachable through a serial concentrator)
  get their ProtocolExtensions wired through the Coordinator's
  registry.

After `activate_coordinators` plus `establish_connectivity`
succeed, the Pod is *operationally live*.  Devices respond to
calls; ProtocolExtensions are attached; service references
resolve; credentials work.

### What can go wrong here

- **Unreachable devices.**  Network down, host crashed,
  firewall blocking, wrong port — all surface as failures in
  the result tuples.
- **Failed authentication.**  Credentials resolved (layer 1),
  ProtocolExtensions attached (above), but the actual SSH
  authentication failed.  Surface as `SshAuthError` in the
  exc field.
- **Timeout exceeded.**  The Coordinator's probe took too long
  — either a long-running connection attempt hung, or the
  device is up but the probe is slow.  Surface as `TimeoutError`.

## The order: why each phase requires the previous

Volume 0 chapter 4's protocol discipline shows up here.  The
three layers are *strictly ordered*:

1. **Configuration** must succeed before Integration (the
   integration layer reads `landscape_info` from the
   configuration layer).
2. **Integration** must succeed before Operational (the
   operational layer iterates the integrated coordinators
   from the integration layer).
3. **Operational** must succeed before tests run (a test
   against a Pod whose devices aren't online and reachable is
   not deterministic — chapter 2's commitment).

The framework enforces the order by checking state at each
layer's entry.  An out-of-order activation surfaces a
`SemanticError` (Volume 0 chapter 3) — the framework refuses
to run the operational layer if integration didn't produce
devices, refuses to run integration if configuration didn't
load, etc.

## Activation parameters

`LandscapeActivationParams`
(`codecraftai-landscaping/source/packages/ccai/landscaping/landscapeparameters.py:19`)
is the dataclass that controls activation:

```python
class LandscapeActivationParams(SimpleNamespace):
    disable_device_activation: bool=False
    disable_service_activation: bool=False
    allow_missing_devices: bool=False
    allow_unknown_devices: bool=False
    allow_missing_services: bool=False
    allow_unknown_services: bool=False
    validate_features: bool=True
    validate_topology: bool=True
```

Each flag answers a real question:

- `disable_device_activation` — skip device activation
  entirely.  Useful for test jobs that only inspect
  configuration.
- `allow_missing_devices` — proceed even when some devices
  fail to come online.  Useful for smoke runs.
- `validate_topology` — run the topology validator before
  proceeding.  Default `True`; chapter 7 walks the
  verification contract that hangs on this flag.

The default values are *strict*: activation requires every
device and service to come up, every Feature to validate,
every Topology assertion to verify.  A project running a
deliberate smoke / partial run flips the flags explicitly;
the strict defaults ensure no production run accidentally
runs against a half-broken Pod.

## What this means for the verification contract

Chapter 7 walks the verification contract as it appears at
the orchestration / job seam — what the CI system has
promised the job, and how the job verifies the promise.  The
*mechanism* of verification is what this chapter just walked.
Specifically:

- **Landscape verification = `establish_connectivity` returning
  no failures.**  Every device declared in the Landscape can
  be reached at its declared address with its declared
  protocol using its declared credentials.
- **Topology verification = the user's `validate_topology()`
  override returning no errors.**  The shipped stub returns
  empty; a project's `Topology` subclass overrides it to check
  the project-specific Topology shape.

If either fails (and the activation params are strict), the
test job refuses to run.  The Pod is not realised.  Tests do
not start.

## What the next chapter does

Chapter 7 walks the verification contract at the
orchestration-to-job seam.  The CI system says "here are your
resources" (the Landscape) and "here is the configured state"
(the Topology); the test job verifies both before the first
test runs.  This chapter walked the *layers* that do the work;
chapter 7 walks the *contract* the layers honour.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/07-ci-handoff-as-verified-contract -->
# Chapter 7 — The CI → Job Hand-off as a Verified Contract

> *The CI system promises the test job two things and the test
> job verifies both before the first test runs.  Landscape
> says "here are your resources"; the job verifies reachability.
> Topology says "here is the configured state"; the job verifies
> the configured state matches.  Verification is a precondition,
> not a feature.*

The previous chapter walked the three landscape activation
layers: load files (Configuration), instantiate objects
(Integration), bring online and verify (Operational).  This
chapter zooms in on the *verification* part and frames it for
what it really is: the **load-bearing precondition** that
makes the determinism model from chapter 2 actually
deterministic.

The framing matters because verification is easy to under-value.
A reader skimming the activation layers might treat
`establish_connectivity` as an implementation detail — "of
course you check the devices are reachable."  But verification
is more than a check: it is a *contract* between the CI system
and the test job.  Treating it as a contract changes how
failures are diagnosed and how the orchestrator behaves when
verification fails.

## The contract has two halves

The CI system provisions a Pod by reading a Specification
(Volume 3 chapter 4), checking out or creating resources, and
emitting a Landscape file plus a Topology file.  The two files
are then handed off to a `DistributedTestJob`, along with a
chosen Runtime (Volume 3 chapter 7).  The hand-off carries two
*promises*:

- **Landscape promise.**  "Here are the resources allotted to
  you.  Each one is at the declared address, reachable with
  the declared protocol, using the declared credentials."
- **Topology promise.**  "Here is the initial configuration of
  those resources.  The clusters are formed as the Topology
  declares.  The roles are assigned as the Topology declares.
  The relationships are wired as the Topology declares."

The test job's job, before running any tests, is to *verify
both promises*.  If either fails, the test job refuses to
proceed; the Pod did not match what was handed off, and
running tests against it would produce results that depend on
the mismatch — non-deterministic by chapter 2's argument.

## Landscape verification — the reachability check

Landscape verification confirms that *every resource declared
in the Landscape is actually reachable at its declared
address, with its declared protocol, using its declared
credentials*.

The mechanism is `LandscapeOperationalLayer.establish_connectivity`
(chapter 6 walked it in detail).  Specifically, the layer
walks every Coordinator (services, power, serial, devices)
and asks each Coordinator's
`establish_connectivity(activation_params)` method to probe
its set.  Each probe returns a tuple
`(host, ipaddr, status, stdout, stderr, exc)`.  Probes that
return a non-zero status, a `False` boolean, or an exception
are collected as failures.

When all coordinators have reported, the operational layer
inspects the failure list.  If `allow_missing_devices` is
False (the default), a non-empty failure list raises
`RuntimeError` and the test job refuses to proceed.  The
exception's message names every failed host with its IP,
status, stderr, and exception.

### What this catches

- **Wrong addresses.**  The Landscape says
  `host: 192.168.1.30`, but the device actually lives at
  `192.168.1.31`.  Probe fails; verification reports the
  mismatch.
- **Down hosts.**  The Landscape says the host exists, but
  the host is currently down.  Probe times out; verification
  reports the timeout.
- **Bad credentials.**  The credentials in the Landscape
  resolve, but the resolved credentials aren't valid for
  authentication.  Probe surfaces `SshAuthError`; verification
  reports the authentication failure.
- **Wrong protocol.**  The Landscape says
  `deviceType: network/ssh`, but the device only speaks Telnet.
  The Coordinator's probe fails; verification reports the
  protocol mismatch.
- **Service unavailability.**  An infrastructure service
  declared in the Landscape's `services` section is unreachable.
  The Coordinator-for-services probe fails; verification
  reports it.

### What this does NOT catch

Landscape verification only checks *reachability*.  It does
not check:

- Whether the device's *configured state* matches what the
  test expects.  That's Topology verification.
- Whether the device's software versions are right.  That's
  application-specific test logic, not framework verification.
- Whether the device's data is in a known state.  That's
  test-fixture territory.

The separation is deliberate.  The framework owns reachability
and configured-state (Topology); the test owns everything
else.

## Topology verification — the configured-state check

Topology verification confirms that *the resources are
configured as the Topology declares*.

The framework's stance from Volume 6 chapter 5: Topology is
*overloaded* as both a description and an executable
assertion.  The same file that says "Nodes A, B, C are
cluster members and Node D is a spare" is also asserting
that the cluster *is* configured that way.  Topology
verification is what closes the loop.

The mechanism is `LandscapeConfigurationLayer.validate_topology(topology_info)`
(line 536).  The shipped implementation is a stub:

```python
def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
    """
        Validates the topology configuration.
    """
    errors = []
    warnings = []
    return errors, warnings
```

Empty errors, empty warnings.  *The stub passes everything.*
This is intentional, per Volume 0 chapter 1's meta-thesis:
the framework supplies the skeleton and patterns; the
organisation customises and refines.  Topology verification is
*one of the explicit places* where the framework expects the
project to specialise.

A project's `Landscape` (or `Topology`) subclass overrides
`validate_topology` with the project-specific verification
logic.  The override walks the loaded `topology_info` dict,
inspects each section the project cares about, and returns
errors / warnings for any mismatch with the actual Pod state.

### A worked override

Suppose a project's Topology declares clusters using the
canonical `clusters` shape (Volume 6 chapter 5):

```yaml
clusters:
    - name: db-cluster
      group: replication
      nodes: [host-a, host-b, host-c]
      spares: [host-d]
```

The project's `validate_topology` override:

```python
class MyProjectLandscape(Landscape):

    def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
        errors = []
        warnings = []

        clusters = topology_info.get("clusters", [])
        for cluster_decl in clusters:
            cluster_name = cluster_decl["name"]
            declared_nodes = cluster_decl["nodes"]
            declared_spares = cluster_decl["spares"]

            # Probe each node and ask the cluster manager for membership.
            for node_name in declared_nodes:
                node_dev = self.devices.get(node_name)
                if node_dev is None:
                    errors.append(
                        "Cluster '{}' declares node '{}' but no such device "
                        "is in the integrated landscape.".format(cluster_name, node_name)
                    )
                    continue

                actual_membership = node_dev.cluster_membership_query()
                if actual_membership.cluster_name != cluster_name:
                    errors.append(
                        "Node '{}' should be member of cluster '{}' per topology, "
                        "but reports membership in '{}'.".format(
                            node_name, cluster_name, actual_membership.cluster_name
                        )
                    )

                if actual_membership.role == "spare":
                    errors.append(
                        "Node '{}' should be active member of cluster '{}' per topology, "
                        "but reports spare role.".format(node_name, cluster_name)
                    )

            # Verify spares.
            for spare_name in declared_spares:
                spare_dev = self.devices.get(spare_name)
                if spare_dev is None:
                    errors.append(
                        "Cluster '{}' declares spare '{}' but no such device "
                        "is in the integrated landscape.".format(cluster_name, spare_name)
                    )
                    continue

                actual_membership = spare_dev.cluster_membership_query()
                if actual_membership.role != "spare":
                    errors.append(
                        "Node '{}' should be spare of cluster '{}' per topology, "
                        "but reports role '{}'.".format(
                            spare_name, cluster_name, actual_membership.role
                        )
                    )

        return errors, warnings
```

The override:

- Walks each cluster declared in the topology.
- For each declared node, queries the live device for its
  cluster membership.
- Reports errors when the live state doesn't match the
  declared state.
- For each declared spare, verifies the role is actually spare.

When the framework calls `validate_topology(topology_info)`,
this override runs.  If the override returns a non-empty
errors list, the test job's activation fails.  The Pod is not
realised.  Tests do not run.

### What this catches

- **Cluster membership mismatch.**  Node A is supposed to be
  in cluster X but is actually in cluster Y.
- **Role mismatch.**  Node D is supposed to be a spare but is
  actually an active member (or vice versa).
- **Missing devices.**  A node referenced by the topology is
  not in the integrated landscape (couldn't be resolved
  during integration layer activation).
- **Project-specific state assertions.**  Anything the project
  cares about — load-balancer health, replication lag,
  active configuration version — checked against the
  Topology's claim.

## Why this matters for determinism

A test against an unverified Pod cannot be deterministic.

Suppose the Topology says "Node A is the cluster leader; Node
B is a follower."  The test says "kill the leader, verify the
cluster elects a new one."  If the Pod was actually
configured with Node B as the leader (the Topology was wrong
or the CI system mis-provisioned), the test kills Node B
instead — and the cluster behaviour is different.  The test
might pass (a leader was elected) but the *property the test
was checking* (election behaviour after killing the configured
leader) was never actually verified.

Verification eliminates this class of failure.  When
verification passes, the test knows the Pod's state matches
its expectations; the test's actor-driven effects produce
deterministic outcomes against a known-state environment.

When verification fails, the framework refuses to run.  The
test job exits with a structured error report (Volume 0
chapter 3's bubble-the-failure discipline applied at the
hand-off boundary).  The orchestrator sees the failure and
can decide:

- **Re-provision the Pod.**  Often the right answer for
  transient infrastructure issues.
- **Mark the run as infrastructure-failed.**  Distinct from
  test-failed; the report should reflect "we couldn't run the
  tests," not "the tests failed."
- **Halt the campaign.**  If multiple Pods are failing
  verification, something systemic is wrong and continuing
  wastes resources.

Volume 3 chapter 21 walks how the orchestrator integrates
verification into its Step 2.5 flow; this chapter walks the
job-side mechanism the orchestrator depends on.

## When to relax verification

The strict defaults
(`allow_missing_devices=False`, `validate_topology=True`)
are correct for production runs.  There are a few cases
where a project legitimately relaxes them:

### Smoke runs

A "smoke" run wants to exercise the framework's plumbing
without requiring the whole Pod.  The `allow_missing_devices=True`
flag lets the run proceed with whatever devices come up;
unreachable devices are logged but don't fail activation.

A smoke run's scope is "did the framework start up cleanly
against this Pod?"  Not "did all the tests pass?"  Treating
the two as the same scope erases useful information.

### Inspection / audit runs

A run that only inspects the loaded configurations (without
running tests) can use
`disable_device_activation=True` and
`disable_service_activation=True` to skip the integration and
operational layers entirely.  Useful for "what does this
configuration look like?" workflows that don't touch the real
environment.

### Topology development

When a project is *developing* a `validate_topology` override,
it might want to run with `validate_topology=False` to skip
the override and observe what raw Topology data is loaded.
This is a development affordance, not a production setting.

In every case the relaxation is *explicit*.  The defaults
ensure that a production run accidentally relaxing
verification has to *deliberately* edit code or flags to do
so.  The framework refuses to silently downgrade verification.

## What the verification contract is *not*

Three things readers commonly conflate with verification that
are not part of it.

### It is not test discovery

Verification runs after test discovery.  The test discovery
machinery (Volume 1 chapter 2) walks the testroot and builds
a list of tests; verification runs after that list is
populated and decides whether the Pod is ready to run them.
A verification failure does not invalidate the discovery
results; the orchestrator can re-run discovery against the
same testroot if it wants.

### It is not test fixture setup

Test fixtures (Volume 1 chapter 3) run *per-test*; they
acquire and release resources for individual tests.
Verification runs *per-job*, before any test fixture has been
exercised.  A test fixture that relies on a service the
Landscape declares can assume the service is reachable
because verification already confirmed reachability.

### It is not orchestration's responsibility

Per Volume 3 chapter 21, the orchestration layer hands off
*names* to the job; the job resolves the names and verifies
them.  Orchestration does not run verification — it
*receives* the verification result (success or
structured-error) from the job and decides what to do next.

The separation matters because orchestration runs in a
different process from the job.  Putting verification in
orchestration would require the orchestrator to have all the
landscape activation machinery in its process — which would
duplicate code and break the layered design.  Putting
verification in the job lets the job own its own
preconditions; orchestration just receives the report.

## What the next chapter does

Chapter 8 walks the `DistributedTestSequencer` in detail.  The
sequencer is what *runs* the suite once verification passes —
the loop that walks each test in declaration order, calls the
test, captures the result, advances.  Chapter 8 specifically
addresses the question "why doesn't the distributed sequencer
parallelise tests?" and walks the differences between the
distributed sequencer and the base `TestSequencer`.

The full Part II of the volume — chapters 4 through 7 — has
walked the *customisation cascade* (chapter 4), the
*observation mechanism* (chapter 5), the *activation layers*
(chapter 6), and the *verification contract* (chapter 7).
Part III walks the *runtime machinery* — sequencer, job_seed,
the wire, credentials, data profiles.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/08-distributed-test-sequencer -->
# Chapter 8 — The `DistributedTestSequencer`

> *The base `TestSequencer` walks tests in declaration order;
> the `DistributedTestSequencer` does the same.  This chapter
> walks what the distributed sequencer adds — landscape
> awareness on the `attach_to_*` methods — and what it
> deliberately does not add: parallelism.*

The base `TestSequencer` from Volume 1 chapter 2 is the
test-execution loop.  It walks the discovered test tree,
acquires resource factories, runs each test in declaration
order, records results.  It is the heart of testbase.

The `DistributedTestSequencer` is the distributed test job's
sequencer.  A reader's natural assumption — that the
distributed sequencer parallelises tests across hosts or
workers — is the wrong one.  This chapter explains why, walks
what the distributed sequencer *actually* adds, and connects
the design to chapter 2's determinism commitment.

## Class declaration

**File:** `codecraftai-testplus/source/packages/ccai/testplus/distributedtestsequencer.py:33`

```python
class DistributedTestSequencer(TestSequencer):

    def __init__(self,
                 jobtitle: str,
                 root: str,
                 includes: Sequence[str],
                 excludes: Sequence[str],
                 metafilters: List[MetaFilter],
                 job_seed: int = None):
        super().__init__(
            jobtitle=jobtitle,
            root=root,
            includes=includes,
            excludes=excludes,
            metafilters=metafilters,
            job_seed=job_seed,
        )

        self._landscape = None
        return
```

Two things to notice:

1. The class extends `TestSequencer` directly.  It does not
   override the test-execution loop.  The base sequencer's
   `execute_tests()` (in
   `codecraftai-testbase/source/packages/ccai/testbase/sequencing/testsequencer.py:412`)
   walks tests in declaration order, one at a time, exactly as
   the single-host version does.
2. The constructor adds one new instance variable:
   `_landscape = None`.  This is the slot for the landscape
   the distributed sequencer will receive on
   `attach_to_environment` / `attach_to_framework`.  It is
   initialised to None per the Volume 0 chapter 2 discipline
   (every instance variable initialised in `__init__`).

## What the distributed sequencer adds: landscape-aware coupling attachment

```python
# Lines 57–70

def attach_to_framework(self, landscape=None):
    """
        Attach the framework's integrations to the landscape.
    """
    self._landscape = landscape

    integ_coupling_table = self._integration_couplings
    for integ_key, integ_coupling in integ_coupling_table.items():
        integ_coupling.attach_to_framework(landscape)

    return
```

```python
# Lines 72–106

def attach_to_environment(self, landscape=None):
    """
        Attach the framework's integrations to the environment.
    """
    self._landscape = landscape

    # Walk the integration couplings; let each one attach to the environment
    # given the landscape now available to them.
    integ_coupling_table = self._integration_couplings
    for integ_key, integ_coupling in integ_coupling_table.items():
        integ_coupling.attach_to_environment(landscape)

    # ... additional landscape-aware setup ...

    return
```

The two methods accept a `landscape` parameter, store it on
the sequencer, and propagate it to every registered
integration coupling.  This is the *only* substantive
difference between `DistributedTestSequencer` and base
`TestSequencer`.

The semantic difference: in the base sequencer, integration
couplings attach without a landscape (they get None or are
never called for landscape-using methods).  In the distributed
sequencer, integration couplings receive the landscape and
can use it to discover devices, fetch credentials, register
tandem validators against specific hosts, etc.

The flow that delivers the landscape:

```
DistributedTestJob (testplus)
    └── _create_sequencer() returns a DistributedTestSequencer
        ↑
        │ called by TestJob.execute()
        │
InteropLibraryTestJob (interop)
    └── fire_attach_to_environment(tseq):
            landscape = self._get_landscape()
            tseq.attach_to_environment(landscape=landscape)  ← landed here
```

The sequencer never *constructs* a landscape.  It receives
one from the test job's `fire_*` overrides.  The separation is
chapter 4's customisation cascade in action: the test job
chooses which landscape (via `_get_landscape()`); the sequencer
just wires the landscape through.

## What the distributed sequencer does NOT add: parallelism

The framing question: *if the distributed sequencer doesn't
parallelise tests, why does it exist?*

The answer matters because it's the framework's clearest
statement of the determinism model from chapter 2.  The
distributed sequencer:

- Does not parallelise tests within a Pod.
- Does not have a concept of "workers" or "concurrent test
  execution."
- Does not contain a thread pool, an asyncio event loop, or
  any parallelism primitive for test execution.

It exists to be **landscape-aware**, not landscape-parallel.
The landscape gives integration couplings the information they
need to operate against multiple hosts; the *test-execution
loop* still runs one test at a time because the determinism
commitment requires it.

A reader looking for parallelism in the source will find it in
two places:

1. **Inside the wire layer** (Volume 5).  When a test calls
   into multiple hosts, the SSH agent (chapter 10 of this
   volume) may issue concurrent commands across hosts.  This
   is *intra-test* concurrency — the test, as the single
   actor, distributes its own actions.  The Pod's other
   actors are still excluded.
2. **Across Pods, via orchestration** (Volume 3).  Multiple
   Specifications produce multiple Pods; multiple
   `DistributedTestJob` processes run concurrently against
   independent Pods.  This is the *Pod-level parallelism* of
   chapter 3.

Neither of these is in the sequencer.  The sequencer's job is
serial test execution within a Pod.

## What a distributed run looks like through the sequencer

Walking the path from `DistributedTestJob.__enter__` to the
first test:

1. `__enter__` calls `begin()`.  The job constructs the
   recorder.
2. `begin()` calls `_create_sequencer()` — returns a
   `DistributedTestSequencer`.
3. The job fires `fire_attach_to_environment(tseq)`.  The
   `InteropLibraryTestJob` override fetches the landscape via
   `_get_landscape()` and passes it to
   `tseq.attach_to_environment(landscape=landscape)`.
4. The sequencer's `attach_to_environment` stores the
   landscape, walks every integration coupling, and calls
   `integ_coupling.attach_to_environment(landscape)`.  Each
   coupling now has the landscape and can do its work.
5. The job fires `fire_attach_to_framework(tseq)`.  Same shape
   — sequencer propagates the landscape to couplings'
   `attach_to_framework`.
6. The job fires the three `fire_activate_*` hooks.  Each
   delegates to the landscape (chapter 6's three layers).
   After these complete, the Pod is *operationally live*.
7. The job fires `fire_diagnostic_capture_pre_testrun`.  The
   sequencer captures pre-test diagnostic state.
8. The sequencer enters its *Traversal* phase.  For each test
   in declaration order:
   - The recorder receives a *test_started* event.
   - The test runs.  Tandem validators (chapter 5) attached to
     the test scope run in tandem.
   - The test returns; tandem validators finalise.
   - The recorder receives *test_finished* and per-validator
     events.
9. The sequencer's traversal ends.  The job fires
   `fire_diagnostic_capture_post_testrun`.
10. `__exit__` calls `finalize()`.  The recorder writes
    summary and consolidated documents.  The job tears down.

Step 8 is sequential.  N tests in the suite mean N iterations
of the traversal loop, one at a time.  The framework does not
expose a knob to make step 8 concurrent.

## Why this design holds up at scale

Three properties make the serial-within-Pod design hold up
even on large suites:

### Test bodies dominate framework overhead

Volume 1 chapter 10 measured the runner's per-test overhead at
single-digit milliseconds.  A test body that takes 5 seconds
spends 99.98% of its time in the body, not the framework.
Adding parallelism to the framework's loop would optimise the
0.02%; the cost (broken determinism) outweighs the benefit.

### Test concurrency lives inside the test

A test that needs to drive ten hosts in parallel does so
*inside the test body*, by issuing SSH commands concurrently
through the SSH agent (chapter 10).  The test, as the single
actor, distributes its own actions.  The other actors (the
ten hosts) are not framework actors; they are the system
under test responding to the actor's commands.

### Pod fan-out is the right granularity for horizontal scale

When a campaign needs more parallelism, the answer is more
Pods.  Volume 3 chapter 4's Specifications are how Pods are
provisioned; chapter 3 of this volume walked why Pod-level
parallelism is the only safe parallelism level.

So: the sequencer stays serial; the *test body* gets
intra-test concurrency through the wire; *Pod fan-out*
provides horizontal scale.  Three layers, each appropriate.

## Differences from the base sequencer summarised

| Feature | Base `TestSequencer` | `DistributedTestSequencer` |
|---|---|---|
| Test discovery | Yes | Yes (inherited) |
| Resource factory acquisition | Yes | Yes (inherited) |
| Sequential test execution | Yes | Yes (inherited; not overridden) |
| `job_seed` reproducibility | Yes | Yes (inherited) |
| `attach_to_framework(landscape)` | Without landscape param | With landscape param, propagates to couplings |
| `attach_to_environment(landscape)` | Without landscape param | With landscape param, propagates to couplings |
| `_landscape` instance variable | No | Yes (initialised None) |
| Parallel test execution | No | No |

The table is short because the difference is small.  The
distributed sequencer is the base sequencer with a landscape
slot and two methods that take a landscape parameter.
Everything else — including the determinism-preserving serial
execution — is inherited verbatim.

## Implications for custom sequencers

A project that wants a custom sequencer subclasses
`DistributedTestSequencer` (or, for non-distributed but
landscape-aware scenarios, `TestSequencer`).  The custom
sequencer plugs into a `DistributedTestJob` subclass via the
`_create_sequencer()` template method (chapter 4).

Reasons to subclass:

- **Custom integration handling.**  The default `attach_to_*`
  walks every coupling and calls its method; a subclass can
  intercept to add ordering, filtering, parallelism *of the
  attachment* (different from parallelism of test execution),
  or extra logging.
- **Custom diagnostic capture.**  The base capture is
  delegated to the recorder; a subclass can add per-host
  snapshots taken at phase boundaries.
- **Custom traversal logging.**  Adding rich per-test
  pre-flight checks at the sequencer level (rather than
  fixture level) when those checks span couplings.

What *not* to do in a custom sequencer subclass: introduce
test-level parallelism.  The framework's contract with the
test author is that tests run serially within a Pod.  A
sequencer that violates the contract violates chapter 2's
commitment and breaks every downstream assumption.

## What the next chapter does

Chapter 9 walks `job_seed` and reproducibility.  Volume 1
chapter 4 introduced the per-test seed
(SHA-256(job_seed + ":" + test_name)) for property-based
tests; chapter 9 of this volume walks how a single seed
survives the trip across machines in a distributed run, and
why deterministic input generation across N hosts requires
exactly the discipline the sequencer's serial execution
provides.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/09-job-seed-and-reproducibility -->
# Chapter 9 — `job_seed` and Reproducibility

> *A distributed run that produces different inputs on
> different hosts is not reproducible.  A distributed run that
> produces the same inputs across hosts and across runs is.
> The single seed that travels with the job is what makes the
> second case possible.*

Volume 1 chapter 4 walked property-based testing on a single
host: the per-test seed is derived from the job seed and the
test name via SHA-256, the runner caps `max_examples` per
runtime configuration, the seed survives across runs because
re-running with the same job seed reproduces every per-test
seed.

This chapter walks the same machinery in a distributed
context.  When the test runs across N hosts — with different
hosts generating different inputs, observing different
states, returning different results — the question "what does
*reproducibility* even mean?" becomes load-bearing.

The framework's answer: **reproducibility is per-Pod, not
per-host.**  The job seed travels with the job; every host
sees inputs derived from the same seed; across runs of the
same suite with the same seed against the same Pod, the
inputs are identical.

## The job seed across machines — what travels and what doesn't

The `job_seed` is a single integer, set once at the start of
the test job, and carried by the `DistributedTestJob` through
its construction:

```python
# DistributedTestJob from chapter 4

def __init__(self, ..., job_seed=None, ...):
    super().__init__(..., job_seed=job_seed)
    # ...
    return
```

The seed is stored on the job instance and propagated to the
sequencer:

```python
# From DistributedTestJob._create_sequencer()

inst = DistributedTestSequencer(
    self.title,
    self._testroot,
    includes=self.includes,
    excludes=self.excludes,
    metafilters=self.metafilters,
    job_seed=self.job_seed,    # ← here
)
```

The sequencer carries the seed to every property-based test
runner (Volume 1 chapter 4) it spawns.  Each runner derives
its per-test seed via
`SHA-256(job_seed + b":" + test_name)` and feeds it into the
strategy generators.

What travels:

- The `job_seed` integer itself.
- Implicitly, the *test name* — derived from the test's
  module path and function name.
- The test's monikers and pivots (which influence the
  result's identity but not the input generation).

What does *not* travel:

- The *host identity*.  A test running on Host A and the same
  test running on Host B (different runs of the same suite
  against different Pods) would derive *the same per-test
  seed* — because the seed derivation does not include host
  information.
- Random state from the framework's own RNG (no such state
  exists; the framework derives all randomness from the job
  seed).
- Wall-clock time.  Tests that depend on `time.time()` for
  randomness are non-reproducible by construction; the
  framework cannot help.

## How the per-test seed reaches the test on a single host

Volume 1 chapter 4 walked this:

```python
# codecraftai-testbase/source/packages/ccai/testbase/properties/propertyrunner.py:82

def derive_test_seed(job_seed: bytes, test_name: str) -> int:
    payload = job_seed + b":" + test_name.encode("utf-8")
    digest = hashlib.sha256(payload).digest()
    rtnval = int.from_bytes(digest[:8], byteorder="big")
    return rtnval
```

The function takes the job seed (as bytes) and the test name
and produces a 64-bit per-test seed.  For a given
`(job_seed, test_name)` pair, the output is deterministic.

## How the per-test seed reaches the test in a distributed context

In a distributed run, the test code runs on the *controller*
— the host running the `DistributedTestJob`.  When the test
issues calls into the Pod (via SSH, REST, or another wire), it
sends data the controller generated locally.  The hosts under
test do not generate test inputs themselves; they respond to
the controller's actions.

This means:

- **The per-test seed is computed once** on the controller
  using `derive_test_seed(job_seed, test_name)`.
- **The strategy generators run on the controller**, producing
  inputs deterministically from the seed.
- **The inputs are sent to the hosts** as command arguments,
  REST payloads, fixture data, etc.

The hosts under test never see the seed.  They see the
*generated inputs*.  The hosts have no role in randomness;
they are deterministic responders.

This is consistent with chapter 2's single-actor model.  The
controller is the single actor.  Random number generation is
an *actor* operation (the actor decides what to do next).
The hosts are the system under test, not actors; they don't
generate random numbers for the test.

## Cross-run reproducibility

Re-running the suite with the same job_seed against the same
Pod reproduces every input the controller generates, because:

- The job_seed is the same.
- The test names are the same (same suite, same discovery
  order).
- The per-test seeds are deterministic functions of those.
- The strategy generators are deterministic functions of the
  per-test seed.

If the *Pod* is also in the same state, the responses from the
hosts will be the same, and the test outcomes will be the
same.  The ", same Pod state" caveat is what verification
(chapter 7) provides — verification confirms the Pod state
matches the Topology's claim, so re-running against a freshly-
verified Pod is re-running against the same starting state.

## What happens when a property-based test fails distributedly

A property-based test in a distributed run might fail because:

- A specific input combination triggers a real bug in the
  system under test.
- A specific input combination triggers a flake (e.g., a
  timing race in the Pod that doesn't occur with other
  inputs).
- A network partition mid-test produced an unexpected error
  that's not the property the test was checking.

The framework's reporting (Volume 1 chapter 4 walked the
single-host shape) carries:

- The input that failed.
- The seed used.
- The number of shrinks attempted.

In a distributed run, this is enriched with:

- The host(s) involved in the failure (extracted from the
  call paths or the JSOS records — chapter 13 walks the
  aggregation).
- The Pod's state at failure time (if the framework's
  diagnostic capture caught it).

A re-run with the same job_seed reproduces the same input.
Whether the failure reproduces depends on the failure mode —
real bugs reproduce, flakes are flakes regardless of seed.
The tool the framework gives the SDET is the *separation*: a
property-based failure with a recorded seed is reproducible
with one command; if the failure doesn't reproduce, it's a
flake worth investigating, not a phantom.

## Reproducibility across re-provisioned Pods

A subtlety: re-running against a *re-provisioned* Pod (CI
provisions a fresh Pod for each run) is *not* the same as
re-running against the same Pod.

- **Same Pod, same seed.**  Inputs the same, responses the
  same, outcomes the same.  Full reproducibility.
- **New Pod, same seed.**  Inputs the same.  Responses
  potentially different (different physical hardware, different
  IP addresses, different MAC addresses).  Outcomes mostly
  the same for tests that don't depend on hardware-specific
  values; potentially different for tests that do.

This is *not a framework limitation*.  It's a property of the
distributed system being tested.  A test that depends on
hardware specifics has hidden non-reproducibility; a test that
doesn't is reproducible across Pods provided the Pods
*verify* (chapter 7) against the same Topology.

The test author's discipline: when a test fails in a CI run,
re-run with the same seed against a *Pod with the same
Topology*.  If it reproduces, it's a real bug.  If it
doesn't, the test depends on Pod-specific state (a hardware
fingerprint, an IP address baked into a test) and the test
itself needs to be made Pod-agnostic.

## When to set the seed manually

The default behaviour: each run uses a fresh job_seed.  This
is correct for normal CI — every PR gets fresh inputs, the
test space is broadly covered across runs, regressions surface
on subsequent runs even when individual runs cap
`max_examples` low.

When to override:

### Replaying a recorded failure

A property-based test failed in CI run #4823 with seed
`0xDEADBEEF12345678` at test `test_replication_invariant` with
input `(nodes=3, partition_at=2)`.  An SDET wants to reproduce
the failure locally:

```bash
testbase run --testroot tests/ \
             --include test_replication_invariant \
             --job-seed 0xDEADBEEF12345678
```

The same seed against a Pod with the same Topology reproduces
the same input.  If the failure reproduces, the SDET can
debug.  If it doesn't, the failure was Pod-state-dependent
and the SDET needs the Pod's state too (Volume 8's
time-travel debugging is the production-grade tool for this).

### Comparing two implementations

A team compares "old code path" and "new code path" by
running the suite against each with the same seed.  Inputs
are identical; differences in outcomes isolate to the code
change.  This is the classic A/B-test pattern at the property
level.

### Investigating a flake

A test passes 95% of the time.  An SDET runs it with seed
`X`, captures the inputs it generated, runs it again with seed
`X`.  The same inputs reproduce.  If the *outcome* still
varies, the test is non-deterministic for reasons other than
input generation — a real flake worth investigating, not a
seed problem.

## What seed metadata the JSOS records carry

Per Volume 1 chapter 5, JSOS records can carry rich metadata.
For property-based tests, every iteration's record includes:

- The iteration number (1 through `max_examples`).
- The strategy values for each parameter.
- The per-test seed (so the iteration can be replayed with the
  same input).

A consumer reading the JSOS stream can see exactly which
iteration of which test failed and what input triggered it.
The agents CLI (Volume 1 chapter 6) `agents testresult
failures` subcommand surfaces this for AI-agent or human
review.

In a distributed run, the JSOS records also carry host
identity for each call the test made — chapter 13 walks the
aggregation.  A failure on iteration 7 of
`test_replication_invariant` with input `(nodes=3, partition=2)`
that surfaced as "Host C did not respond" is a complete
forensic record: same seed, same input, identifiable host,
specific failure.

## Volume 0's foundation in play

Two pieces of Volume 0's foundation tier hold up the
reproducibility story:

- **Configuration loading** (Volume 0 chapter 9) is what
  determines the per-Pod seed-relevant inputs.  The
  Specifications, Landscapes, Topologies, Runtimes are all
  loaded deterministically; same files → same loaded
  structures → same seed-relevant inputs.
- **Runtime configuration** (Volume 0 chapter 9; Volume 3
  chapter 7) controls the runtime parameters that influence
  behaviour.  A test that depends on a timeout uses the
  timeout from the runtime; same runtime → same timeout →
  reproducible test.

Without these, even a fixed seed wouldn't reproduce — because
the *configuration* the test ran against would vary.  The
configuration discipline is part of the reproducibility
discipline.

## What the next chapter does

Chapter 10 walks the remote-execution wire — the SSH-based
mechanism that carries the controller's actions to the hosts
under test.  The Aspects pattern from Volume 5 chapter 1
shows up here as the behaviour-parameterisation knob for
timeout, retry, connection failure handling on every wire
call.  The chapter also surveys the `SshAgent` and
`SshSession` machinery from `codecraftai-protocol-ssh`.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/10-the-remote-execution-wire -->
# Chapter 10 — The Remote-Execution Wire

> *Distributed automation lives or dies on the wire.  The
> controller's actions reach the hosts through SSH, REST, gRPC
> or a stranger protocol; whatever the wire, the framework's
> requirements are the same: every call has a bounded timeout,
> a parameterised retry policy, an explicit failure mode, and
> a result type that fits in the JSOS stream.*

The previous chapters walked the framework's *control plane* —
the lifecycle, the activation layers, the verification
contract, the sequencer.  This chapter walks the *data plane*
— the actual wire that carries commands from the controller
to the hosts and results back.

This volume's coverage of the wire is deliberately
introductory.  Volume 5 (*Interop and Protocols*) walks the
wire's machinery in depth: the SSH transport, the REST client
foundations, the protocol extensions, the per-protocol agents.
This chapter establishes *how the wire fits into a distributed
test job* — what API a test calls, what the framework
guarantees about the call, what failures look like, how
results land in the JSOS stream.

The forward references to Volume 5 are deliberate; the back
references to Volumes 0 and 1 are the foundation.

## The Aspects pattern — behaviour parameters on every call

Volume 0 chapter 4 introduced the Aspects pattern at the
philosophy level: behaviour knobs (timeout, retry, sync flag,
logging policy) live as *parameters*, not as decorators or
implicit defaults.  Volume 5 chapter 1 walks the pattern in
depth.

For distributed wire calls, every call accepts an `Aspects`
parameter (or the protocol-specific subclass).  In SSH:

```python
# codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshaspects.py:77

class Aspects(Freezable):
    """
        Aspects are utilized with the interop APIs and agents such as the :class:`SSHAgent`
        to modify the behavior of APIs with respect to retry parameter such as timeout, interval,
        looping patterns
    """
```

The class is declared `Freezable` — once an `Aspects` instance
is bound to a call, it cannot be mutated.  This prevents the
classic bug where a shared `Aspects` instance gets modified
mid-call by another thread.

The default values from the same file:

```python
DEFAULT_COMPLETION_TIMEOUT = 600
DEFAULT_COMPLETION_INTERVAL = 10
DEFAULT_CONNECTION_TIMEOUT = 16
DEFAULT_INACTIVITY_TIMEOUT = 600
DEFAULT_INACTIVITY_INTERVAL = .5
DEFAULT_ALLOWED_ERROR_CODES: Tuple[int] = []
```

Six constants.  Each one is a default a project will want to
override for some calls and accept for others.  An `Aspects`
instance can change any of them while leaving the rest at
default:

```python
# A custom Aspects for a long-running call.

slow_aspects = AspectsCmd(
    completion_timeout=3600,    # one hour
    inactivity_timeout=300,     # five minutes
    # connection_timeout, completion_interval, etc. → default
)

ssh_session.run_cmd("apt-get dist-upgrade", aspects=slow_aspects)
```

The Aspects parameter on `run_cmd` is what makes the call
*tunable per invocation* without proliferating method
signatures.  Every distributed wire call works the same way.

## The SSH stack — what a test calls

The SSH machinery in `codecraftai-protocol-ssh` exposes three
levels:

### `SshSession` — context manager around an SSH client

**File:** `codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshsession.py:45`

```python
class SshSession(SshBase):
    def __init__(self,
                 host: str,
                 primary_credential: SshCredential,
                 users: Optional[Dict[str, SshCredential]] = None,
                 port: int=22,
                 jump: Union[str, SshJumpParams, None] = None,
                 pty_params: Optional[dict] = None,
                 session_user=None,
                 interactive=False,
                 basis_session: Optional["SshSession"]=None,
                 called_id: Optional[str]=None,
                 look_for_keys: bool = False,
                 primitive: Optional[bool] = None,
                 aspects: AspectsCmd=DEFAULT_CMD_ASPECTS):
```

A session represents one open SSH connection.  Used as a
context manager:

```python
with SshSession(host="10.0.0.1", primary_credential=cred) as session:
    status, stdout, stderr = session.run_cmd("uptime")
    session.file_push(local_path, remote_path)
```

`run_cmd` returns `(int, str, str)` — status code, stdout,
stderr.  The integer is *the exit status*; the strings are
captured output.  Failed commands return a non-zero status —
they do *not* raise, except for transport-level failures
(timeouts, disconnects).

The choice to return status rather than raise is deliberate.
A test that expects a non-zero status (e.g., "this command
should fail") can check `status != 0` directly.  Raising would
force every caller to wrap in try/except even for expected
failures.

### `SshAgent` — vending sessions for a host

**File:** `codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshagent.py:38`

```python
class SshAgent(SshBase):
    """
        The :class:`SshAgent` provides an extension of the :class:`SshBase` class interface and api(s).
        The :class:`SshAgent` manages connection and interop settings that are used to perform operations
        and interact with a remote SSH service...
    """

    def open_session(self, user: str = None, primitive: bool = False, pty_params: Optional[dict] = None,
                     interactive=False, basis_session: Optional[SshSession] = None,
                     aspects: Optional[AspectsCmd] = None) -> SshSession:
        """
            Provides a mechanism to create a :class:`SshSession` object with derived settings...
        """
```

An `SshAgent` is bound to a host and credentials.  It vends
`SshSession` instances tailored to specific tasks or users.
The agent's `open_session()` lets a caller override user,
primitive mode, PTY params, and aspects per session.

In the framework's distributed model, every device with a
`network/ssh` deviceType has an attached `SshAgent` (chapter
6 walked the activation flow that attaches it).  A test
working with the device calls into the device's
ProtocolExtension (`device.ssh`) which delegates to the
`SshAgent`.

### `SshChannel` — raw channel ops

**File:** `codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshconnection.py:14`

For tests that need direct channel-level control (request a
PTY, push raw bytes, drive an interactive shell), the
`SshChannel` class is the API.  Most tests don't need this —
`run_cmd` and `file_push` / `file_pull` cover the common cases.

Tests reaching for `SshChannel` are typically driving an
interactive remote shell, debugging a connection, or
implementing a custom protocol over SSH.  The lower the
level, the more responsibility on the test author for
correctness; the framework's higher-level APIs take care of
the common-case correctness.

## The four error classes a wire call can raise

Distributed wire calls have four classes of failure, each
handled differently:

### 1. Authentication failures — `SshAuthError`

```python
# codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshauth.py:11

class SshAuthError(Exception):
    pass
```

Raised when SSH authentication fails.  Verification (chapter
7) catches this at activation time; if it surfaces during a
test, the credentials rotated mid-run or the host's auth
config changed.

### 2. SFTP failures — `SshSFTPError`

```python
# codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/sshsftp.py:26

class SshSFTPError(Exception):
    pass
```

Raised on SFTP protocol violations.  Less common than
`SshAuthError`; usually surfaces when a file operation hits a
permissions problem or a remote-path issue the framework
can't recover from.

### 3. Timeouts — `TimeoutError`

Raised when a `completion_timeout`, `connection_timeout`, or
`inactivity_timeout` from the call's Aspects elapses.  The
test's response: typically retry (with new Aspects), fail the
test (the wire is unreliable enough to fail), or escalate to
the orchestrator (this Pod is unhealthy).

### 4. Connection failures — `ConnectionError`

Raised when the underlying SSH transport drops.  Different
from a timeout (the connection is *gone*, not just slow).
The framework's response is similar: retry, fail, or escalate
based on the `Aspects` policy.

The four error classes give the test author *typed dispatch*
on failure: a `try / except SshAuthError / except TimeoutError`
block can take different actions based on the failure class.
Volume 0 chapter 3's exception-handling discipline applies —
catch only what the code can recover from; let everything else
bubble.

## How wire results land in the JSOS stream

Volume 1 chapter 5 walked the JSOS streaming format.  Wire
calls don't produce *separate* JSOS records — they're
ordinary Python operations from the framework's perspective.
A test that calls `device.ssh.run_cmd(...)` produces:

- A *test_started* record when the test begins.
- (No record for the wire call itself; the wire is internal
  to the test body.)
- A *test_finished* record when the test ends, with the
  outcome derived from the test's assertions.

The wire calls' *results* (status codes, stdout, stderr) are
visible in the test's logs and in any diagnostic captures,
but they don't get their own JSOS records by default.  This
is a deliberate choice — wire calls are part of the test's
plumbing, not part of the test's shape.

A project that wants per-wire-call records can attach a
*tandem validator* (chapter 5) that intercepts wire calls
through the device's ProtocolExtension and records each one.
The validator's record-per-call shape lands in the JSOS
stream alongside the test's regular records.

## Cross-host concurrency inside a test

Chapter 8 noted that the distributed sequencer doesn't
parallelise tests, but the *test body* may legitimately use
concurrency to drive multiple hosts at once.  Here is what
that looks like in practice:

```python
import concurrent.futures

def test_apply_config_to_cluster():
    """
        Verify that applying a configuration to all cluster nodes
        succeeds on all nodes.
    """
    nodes = LandscapeSingleton().devices_by_role("cluster_node")
    config = build_test_config()

    with concurrent.futures.ThreadPoolExecutor(max_workers=len(nodes)) as executor:
        futures = {
            executor.submit(node.ssh.run_cmd,
                            f"apply-config {config.path}",
                            aspects=DEFAULT_CMD_ASPECTS): node
            for node in nodes
        }
        results = {
            futures[fut]: fut.result()
            for fut in concurrent.futures.as_completed(futures)
        }

    rtnval = all(status == 0 for status, _, _ in results.values())
    assert rtnval, "config application failed on at least one node"
    return
```

The test:

- Fans out an SSH command to N nodes concurrently.
- Waits for all to complete.
- Asserts success across the fan-out.

This is *intra-test* concurrency, driven by the test (the
single actor) distributing its own work.  Chapter 2's
single-actor model is preserved: the test, as the actor, is
issuing commands; the nodes are responders, not actors.  The
fan-out finishes before the next test starts.

A note on the Aspects: each fan-out call uses the *same*
`Aspects` instance.  This is safe because Aspects are
`Freezable` — none of the concurrent calls can mutate the
shared instance.  A project wanting per-call Aspects creates
a fresh instance per call.

## A worked example — orchestration through a single agent

A test that needs to orchestrate work across the Pod often
goes through a single agent rather than fanning out to many.
The classic pattern is the *task agent*:

```python
def test_rolling_restart_via_taskerserver():
    """
        Verify rolling restart through the taskerserver agent.
    """
    tasker_node = LandscapeSingleton().device("taskerserver-1")
    target_nodes = LandscapeSingleton().devices_by_role("worker")

    for target in target_nodes:
        # Issue the restart command through the tasker; the tasker
        # restarts the target and waits for health.
        status, stdout, stderr = tasker_node.tasker.restart_and_wait(
            target=target.name,
            timeout=120,
        )
        assert status == 0, f"restart of {target.name} failed: {stderr}"

    rtnval = all_nodes_healthy(target_nodes)
    assert rtnval
    return
```

The test issues commands through one tasker rather than
fanning out.  The tasker handles the per-node sequencing
(restart node A, wait for health, then node B, etc.).  This
is a common pattern when the *system under test* has a
control surface (the tasker) that the test should exercise.

In this pattern, the `SshAgent` for the tasker is what gets
used; the worker nodes' SSH access is incidental (used by
verification at activation time, not used by the test).

## Where Volume 5 picks up

This chapter establishes the wire's role in distributed
testing.  Volume 5 walks:

- The full SSH protocol stack (auth, transport, channels,
  SFTP, tunnels).
- The REST client foundations
  (`codecraftai-restclient-base`).
- The serial / power / mDNS / UPnP protocols and their
  agents.
- The Aspects pattern in depth, with the patterns for
  composing aspects across protocols.
- The extension model for adding new protocols.

A reader of Volume 2 should know *how the wire surfaces in a
test job* — what API to call, what to expect from a call,
what failure modes to handle.  Volume 5 is for the reader who
needs to *implement* a new protocol or debug the wire itself.

## What the next chapter does

Chapter 11 walks credentials at scale: how the Volume 3
credentials story (resolution, rotation, scoping) plays out
when a single test job needs credentials for twelve hosts and
the resolver lives in one process while the consumers are
spread across many.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/11-credentials-at-scale -->
# Chapter 11 — Credentials at Scale

> *Volume 3 walks credentials at the orchestration layer:
> resolution, rotation, scoping, environment spanning.  This
> chapter walks the same vocabulary at the job layer — when one
> test job needs credentials for twelve hosts and fifteen
> services, what does the framework do, and how does the
> activation chain make it look simple?*

Volume 3 Part III (chapters 9 through 12) is the canonical
treatment of credentials in the `codecraftai-*` portfolio.  It
walks the credential vocabularies (basic auth, SSH keypairs,
API tokens, OAuth, certificates, derived credentials), the
resolution model (vault, file, environment-as-fallback,
prompt, derived), rotation in flight, and credential portability
across local-dev / CI / staging / prod.

This chapter is the *job-layer* view of the same machinery.
A distributed test job's credentials story has three concerns
that the orchestration story doesn't fully address:

- How credentials get from the orchestration to the job at
  hand-off (chapter 7's verified contract sets it up; this
  chapter walks the consumption side).
- How a single job uses credentials for *many hosts* without
  the wiring becoming N×M complex.
- How credentials flow from the controller (where they're
  resolved) to the hosts under test (where they're used) —
  *or do they not flow at all?*

The answers are clean.  Credentials live on the controller;
the controller authenticates *to* the hosts; the hosts never
see the credential material.  The framework's machinery makes
this idiomatic.

## Credentials in the Landscape — declared, not resolved

A device declaration in the Landscape (Volume 1 chapter 9 walked
the shape) references credentials *by name*:

```yaml
- deviceType: network/ssh
  host: 192.168.1.30
  credentials:
      - casey-node      # ← named reference, not material
  features:
      isolation: true
```

The `casey-node` string is a *credential reference*.  The
actual material — the SSH keypair, the password, the API token
— lives in the Credentials configuration category (Volume 3
chapter 9).  At activation time, the framework's
`CredentialManager` resolves the reference to the material.

This separation is critical.  The Landscape file can be
checked into version control because it has no secret material;
only references.  The Credentials configuration is what
actually has secrets, and it's encrypted (Volume 3 chapter 11
walks Fernet encryption) or fetched from a vault.

## The CredentialManager — one resolver per Pod

`CredentialManager` (in `codecraftai-landscaping`) is the job's
credential lookup service.  Initialised by
`LandscapeConfigurationLayer.initialize_credentials()` during
`fire_activate_configuration()` (chapter 6 walked the flow),
it holds the resolved credentials for the duration of the job.

The lookup is by name:

```python
manager = LandscapeSingleton().credentials_manager
ssh_cred = manager.lookup_by_name("casey-node")
```

The returned `ssh_cred` carries the actual auth material:
private key, optional passphrase, username, etc.  The
framework's `SshAgent` for any device declaring `casey-node`
gets the credential from the manager and uses it to
authenticate.

Two properties of the manager:

1. **Single-pass resolution.**  The manager resolves all
   credentials at activation time.  A name that doesn't
   resolve fails activation (chapter 7's Landscape
   verification catches it).  Once activation succeeds, the
   manager's lookups are pure dict reads — no further
   resolution-side failures during the test.
2. **Per-Pod scope.**  Each Pod has its own manager.  The
   credentials for Pod A and Pod B are independent.  A
   credential rotation that affects Pod A doesn't touch Pod
   B's manager mid-run.

## How a credential reaches a host

A test issuing an SSH call against a device:

```python
device = LandscapeSingleton().device("casey-01")
status, stdout, stderr = device.ssh.run_cmd("uptime")
```

The flow underneath:

1. `device.ssh` is the device's SSH ProtocolExtension,
   attached at activation time (chapter 6).
2. The ProtocolExtension wraps an `SshAgent` (chapter 10).
3. The agent holds a reference to the device's resolved
   `SshCredential` (looked up by name from the
   `CredentialManager` at activation time).
4. The agent's `run_cmd` opens an `SshSession`; the session
   uses the credential to authenticate against the device's
   `host` (192.168.1.30 in our example).
5. The host validates the auth.  If valid, the session is
   established and the command runs.

The credential **never travels to the host as data**.  The
host receives an *authenticated SSH connection*; the auth
material stays on the controller.  This is the standard SSH
model — the client proves possession of the credential through
the auth handshake without sending the material.

The implication for the test author: *credentials are an input
to the controller's wire calls, not data the test ever sees*.
A test never has reason to pass a credential through its body;
the framework wires credentials to devices at activation time
and the test's calls inherit the auth.

## Composable credential lists in a device declaration

A device's `credentials:` is a list, not a single name:

```yaml
- deviceType: network/ssh
  host: 192.168.1.30
  credentials:
      - casey-node
      - emergency-recovery
      - ci-runner
```

The list is *composable* per the universal-composability
property (Volume 3 chapter 3, Vol 0 ch.9).  At activation, the
framework loads each named credential and the SshAgent has
*all of them* available.  Different operations can use
different credentials:

```python
# Default — uses primary_credential (first in the list).
device.ssh.run_cmd("uptime")

# Specific user — opens a session with a different credential.
admin_session = device.ssh.open_session(user="ci-runner")
admin_session.run_cmd("systemctl reload service")
```

The first name in the list is the *primary* credential.  The
others are alternates the test can switch to.  The list is
useful when:

- Different tasks need different privilege levels (regular
  user for most operations; root for some).
- A primary credential might rotate during a long run; the
  alternate is the fallback.
- Different test phases have different identity requirements
  (CI runner during setup; user-level during tests).

## Credentials per service vs per device

The Landscape's `services` section (Volume 1 chapter 9) carries
its own credentials:

```yaml
infrastructure:
    services:
        - serviceType: network/service
          name: someservice
          credentials:
              - adminuser
```

Service credentials work the same way as device credentials —
named, resolved at activation, never flowing to remote hosts.
The framework exposes service credentials through the service's
ProtocolExtension (e.g., `service.api.client`) just as device
credentials are exposed through `device.ssh`.

A test using both a device and a service:

```python
device = LandscapeSingleton().device("casey-01")
service = LandscapeSingleton().service("someservice")

# Device call uses device's primary credential (casey-node).
device.ssh.run_cmd("verify-state")

# Service call uses service's primary credential (adminuser).
service.api.post("/admin/refresh", data={...})
```

Two different credentials, two different remote endpoints,
zero credential material in the test code.

## Cross-host concurrency and credential safety

Chapter 10 walked intra-test concurrency — fanning a command
out to N hosts via a `ThreadPoolExecutor`.  The credential
implications:

- Each device's `SshAgent` holds *its own* credential.
  Concurrent calls from a fan-out to N devices use N
  independent credentials, with no cross-contamination.
- Credentials are *not* shared between threads in the
  thread pool.  Each thread's call goes through the device's
  agent, which uses the device's credential.  No mutex
  needed; no race.
- The `Aspects` object passed to each call is `Freezable`
  (chapter 10) — same instance shared across the fan-out
  cannot be mutated.

Credential safety in concurrent fan-out is therefore
*architectural*: the framework's design (per-device agents,
frozen aspects, no shared credential cache) makes the
parallelism safe by construction.  The test author writes
straightforward concurrent code; the framework holds the
invariants.

## Credential rotation during a long run

A four-hour integration suite outlives many credential
rotation windows.  Volume 3 chapter 11 walks rotation as a
configuration discipline; the job-side question is: *what
happens when a credential rotates mid-run?*

Three patterns the framework supports:

### TTL caching with rotation

The `CredentialManager` (per its Volume 3 ch.11 contract) can
cache resolved credentials with a TTL.  When the TTL expires
mid-run, the next lookup re-resolves through the underlying
backend (vault, file, etc.).  If the backend rotated, the new
material is fetched.

For SSH agents, the rotation is *transparent to the test*:
the next session opened against the device picks up the
freshly-resolved credential.  Sessions already open continue
with the old credential until they close.

### Composable list with rotation fallback

When a primary credential rotates and the SSH session fails
auth on next use, the agent can fall back to the *alternate*
credentials in the device's list.  The test sees the fall-back
transparently; the JSOS record can flag that a fallback
occurred for post-run review.

### Explicit rotation events

For tests that *exercise* rotation (testing the rotation
itself), the test is the actor that triggers the rotation.
The test's pattern:

```python
def test_rotation_does_not_disrupt_active_sessions():
    """
        Verify that rotating credentials does not disrupt active
        sessions.
    """
    device = LandscapeSingleton().device("casey-01")
    long_running_session = device.ssh.open_session()

    # Trigger rotation through the credential vault's API.
    rotate_credential("casey-node")

    # Verify the long-running session still works.
    status, _, _ = long_running_session.run_cmd("uptime")
    rtnval = (status == 0)
    assert rtnval

    # Open a new session — should use the rotated credential.
    new_session = device.ssh.open_session()
    status, _, _ = new_session.run_cmd("uptime")
    rtnval = (status == 0)
    assert rtnval
    return
```

The test, as the single actor, drives the rotation; the
framework's credential machinery handles the consequences
(active sessions hold old creds; new sessions get fresh ones).

## What the controller owns vs what the host owns

The clear ownership separation that the framework imposes:

| Concern | Controller (where the test runs) | Host (system under test) |
|---|---|---|
| Credential material | Has it (resolved by `CredentialManager`) | Never sees it |
| Auth handshake | Initiates | Validates |
| Session establishment | Owns the session object | Owns the SSH daemon |
| Command execution | Sends commands | Executes commands, returns status / stdout / stderr |
| Credential rotation | Re-resolves through the backend | Has new auth-validating material delivered out-of-band |

The line is sharp.  The host never has data the controller
gave it from the credential.  The controller never has data
the host gave it about its own auth state.  The two sides
trust the SSH handshake to validate identity without exchanging
secrets in either direction.

This is the foundation of the determinism model from chapter
2.  If credentials flowed bidirectionally, the test could
become non-deterministic depending on what the host sent
back about its auth state.  The unidirectional ownership keeps
the test's behaviour entirely a function of the controller's
state.

## What this chapter set up

The credentials story at the job layer is short because the
framework's design does most of the work:

- Credentials are declared by name in the Landscape.
- The `CredentialManager` resolves them at activation.
- ProtocolExtensions hold credential references; agents use
  them.
- Credential material never flows to remote hosts.
- Concurrent fan-out is safe by construction.
- Rotation is handled at the manager level, transparent to
  most tests; tests that exercise rotation drive it as the
  actor.

A reader who understands chapter 6 (activation), chapter 10
(the wire), and Volume 3's credential vocabulary has the full
picture.  This chapter assembled the parts.

## What the next chapter does

Chapter 12 walks data profiles in remote workers — the
analogous story for connection profiles to data stores.  Where
credentials are about *authentication*, data profiles are
about *connection topology* (which database, which queue,
which cache).  The framework's discipline is the same: declare
by name in the Landscape, resolve at activation, expose
through ProtocolExtensions.  The chapter is short because the
discipline is uniform.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/12-data-profiles-in-remote-workers -->
# Chapter 12 — Data Profiles in Remote Workers

> *Volume 3 chapter 8 walks data profiles at the orchestration
> layer.  This chapter walks the analogue at the job layer:
> when a distributed test job needs to talk to a database, a
> message queue, or a cache, what does the framework do?  The
> answer is short — it follows the same pattern as credentials
> from chapter 11, applied to a different category.*

A *data profile* in the `codecraftai-*` portfolio is a
*connection profile* for a data store.  Where a credential
answers "how do I authenticate," a data profile answers "where
do I connect."  The two go together — most data-store calls
need both — but they're different categories with different
configuration files.

This chapter is short.  The discipline that holds for
credentials (chapter 11) holds for data profiles, with the
same shape and the same boundaries.  The brevity is the
payoff of universal patterns: when the same shape applies,
the documentation is the same shape too.

## Declared by name, resolved at activation, never a hardcoded URI

A data profile is declared in the Landscape's `services`
section (or, equivalently, referenced from a device or as a
top-level Landscape entity, depending on the project's
convention):

```yaml
infrastructure:
    services:
        - serviceType: database/postgres
          name: orders-db
          connection_profile: orders-db-profile
```

The `connection_profile: orders-db-profile` is a *named
reference* to a data profile.  The actual connection details
(host, port, database name, schema, connection-pool size) live
in the Data Profiles configuration category (Volume 3 chapter
8) — composable via `MergeMap` like every other category.

A representative data profile file:

```yaml
# data-profiles/orders-db-profile.yaml
name: orders-db-profile
type: database/postgres
host: db.staging-east.internal
port: 5432
database: orders
schema: public
pool:
    min_connections: 2
    max_connections: 10
    timeout_seconds: 30
```

The file has *no credentials*.  Authentication is a separate
concern (chapter 11); the data profile says only "where to
connect."  Credentials are referenced from the same service
declaration:

```yaml
- serviceType: database/postgres
  name: orders-db
  connection_profile: orders-db-profile
  credentials:
      - orders-db-app    # ← the role the test connects as
```

Two named references in the service declaration.  The
framework resolves both at activation; the test gets a
connection-ready service object without ever seeing host
strings or auth material in test code.

## The DataProfileManager — the parallel to CredentialManager

`DataProfileManager` (analogous to `CredentialManager` from
chapter 11; in `codecraftai-dataprofiles`) is the job's
data-profile lookup service.  Initialised during landscape
activation.  Holds the resolved profiles for the duration of
the job.

```python
manager = LandscapeSingleton().dataprofiles_manager
profile = manager.lookup_by_name("orders-db-profile")

# profile carries the connection details
db_host = profile.host
db_port = profile.port
```

In practice the test rarely calls `lookup_by_name` directly.
The framework wires the profile through the service's
ProtocolExtension:

```python
service = LandscapeSingleton().service("orders-db")

# The service has a 'db' ProtocolExtension wired by the
# database-Coordinator at activation time.
result = service.db.execute("SELECT count(*) FROM orders")
```

Same shape as `device.ssh.run_cmd(...)` from chapter 11 — the
ProtocolExtension is the test's surface; the underlying
machinery (connection pool, cursor, transaction) is hidden.

## Composable data profile lists

Data profiles are composable like every other category.  A
service can reference multiple profiles:

```yaml
- serviceType: database/postgres
  name: orders-db
  connection_profile:
      - orders-db-base
      - orders-db-staging-overrides
```

The framework merges them via `MergeMap` (later names override
earlier names where keys collide) and the service uses the
merged result.  Common patterns:

- **Base + environment overrides.**  A `base-postgres-profile`
  with sensible pool defaults; a `staging-postgres-profile`
  with staging-specific host and timeout adjustments.
- **Pool size variants.**  A `small-pool` for unit-test
  databases; a `large-pool` for integration runs.
- **Schema variants.**  A `read-only` profile pointing at a
  read replica; a `read-write` profile pointing at the primary.

The composability is what makes the four-environment ladder
from Volume 3 chapter 13 work.  Local-dev uses
`local-postgres + dev-overrides`; CI uses `local-postgres +
ci-overrides`; staging uses `local-postgres +
staging-overrides`.  Same suite of tests, four profiles,
three differences.

## What flows from the controller to the host

A connection profile leads to a *connection from the
controller to the data store*.  The flow:

- Controller resolves the profile via the
  `DataProfileManager`.
- Controller's data-store ProtocolExtension uses the profile's
  host / port / database / schema to construct a connection.
- Controller authenticates using the credential (chapter 11
  describes this side).
- Connection is established; queries run from the controller
  to the store.

The data profile's *content* is not sensitive (the same way
credentials are).  Hosts under test that need their own
connections to the data store typically do so through their
*own* configuration — the framework's job is to give the
*controller* a connection for *its* test code, not to push
connection profiles to the hosts.

If a test exercises a host's connection to a data store
(e.g., "does this app server connect to the database?"), the
test:

- Uses the controller's connection (via the data profile) to
  inspect the database state.
- Uses the host's API or admin interface to check the host's
  connection state.
- Compares.

The framework's data profiles are *the controller's*, not the
host's.  The host has its own configuration story, owned by
the project.

## Per-test isolation through profile selection

A common pattern: a test wants its *own* sandbox connection.
The framework supports this through profile-name selection
at the test level:

```python
@testbase.attach.parameter(orders_db_profile, identifier="db")
def test_can_query_orders(db):
    """
        Verify a query against the orders database.
    """
    rtnval = db.execute("SELECT count(*) FROM orders").rowcount
    assert rtnval >= 0
    return
```

The `orders_db_profile` here is the originator that yields a
profile-bound connection.  The Volume 1 chapter 3
resource-factory model applies: per-test scoping means each
test gets a fresh connection (with whatever transaction
isolation the database provides) and the connection is
released at test end.

A project that wants per-test isolation more strongly (e.g., a
fresh schema per test) writes a custom originator:

```python
@testbase.factories.resource_factory(scope=ResourceLifespan.Test)
@testbase.attach.parameter(orders_db_profile, identifier="profile")
def isolated_db_session(profile):
    """
        Yields a per-test isolated DB session with a fresh schema.
    """
    schema = f"test_schema_{uuid.uuid4().hex[:8]}"
    conn = profile.connect()
    conn.execute(f"CREATE SCHEMA {schema}")
    conn.execute(f"SET search_path TO {schema}")
    try:
        yield conn
    finally:
        conn.execute(f"DROP SCHEMA {schema} CASCADE")
        conn.close()
    return
```

The originator is per-test (lifespan Test), gets the profile
from the framework, creates a fresh schema, yields the
connection, drops the schema on cleanup.  The test sees a
clean isolated database.  The Volume 1 chapter 3 dependency
graph composes the `orders_db_profile` (yields a profile) →
`isolated_db_session` (yields a per-test connection) chain
correctly.

## Streaming-data profiles for queues and event buses

Data profiles aren't database-specific.  Message queues, event
buses, key-value stores, time-series databases all use the
same shape:

```yaml
# A Kafka topic profile
- serviceType: messaging/kafka
  name: orders-events
  connection_profile: orders-events-profile
  credentials:
      - kafka-consumer
```

```yaml
# data-profiles/orders-events-profile.yaml
name: orders-events-profile
type: messaging/kafka
brokers:
    - kafka-1.staging-east.internal:9092
    - kafka-2.staging-east.internal:9092
topic: orders.events.v2
consumer_group: ci-test-runner
auto_offset_reset: latest
```

The framework's discipline is uniform: declare by name,
resolve at activation, ProtocolExtension wires the connection,
credential authenticates.  The test uses
`service.kafka.consume(...)` exactly as it uses
`service.db.execute(...)`.

## What the framework does NOT do

A short list of things the framework deliberately stays out
of, leaving them to the test or to the data store's own
machinery:

- **Connection pool management at the data-store level.**  The
  framework gives the test a connection (or a pool, depending
  on the ProtocolExtension); how the data store handles the
  pool is the data store's concern.
- **Transaction boundaries.**  A test that wants explicit
  transaction control uses the connection's transaction API;
  the framework doesn't impose a per-test transaction
  envelope.
- **Schema migrations.**  Bringing the database to a known
  state is *test fixture* territory.  The framework can yield
  a connection; it doesn't run migrations.

These are project / domain concerns, not framework concerns.
The framework's job is to *connect*; the project's job is to
*use*.

## What the next chapter does

Chapter 13 walks result aggregation — the JSOS streams from N
workers across the Pod arriving at one coordinator, the
preservation of per-host timelines, the failure-mode where one
worker's stream stops mid-run.  Where Volume 1 chapter 5
walked single-host streaming, chapter 13 walks the
multi-host aggregation.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/13-result-aggregation -->
# Chapter 13 — Result Aggregation

> *Volume 1 chapter 5 walked single-host JSOS streaming.  In a
> distributed run, the controller's stream is the source of
> truth for what the *test* did; the per-host streams (when they
> exist) are the source of truth for what the *hosts* did.
> Aggregation is the discipline of preserving both timelines
> without forcing them into one global timeline.*

The single-host JSOS story from Volume 1 chapter 5 is clean.
One Python process; one recorder; one stream file; per-test
events flushed as they happen.  A consumer tails the stream,
reads records as they appear, builds an incremental view of
the run.

In a distributed run, the picture has a controller and N
hosts.  The controller runs the test job; the hosts execute
the controller's commands and respond.  The question:
*where do the streams come from, and how does a consumer
make sense of them?*

The framework's answer is short: **the controller produces
the JSOS stream.  The hosts produce their own logs,
referenced from the JSOS records but not merged into them.**
This chapter walks why.

## Where the JSOS stream comes from

The `DistributedTestJob` runs on the controller.  It owns:

- The `JsonResultRecorder` (Volume 1 chapter 5) — but it does
  not *install* that recorder itself.  The JSOS-to-disk recorder
  is supplied by the `IntegratedTestJob` layer it inherits from
  (`codecraftai-integration-runtime`); plain `testbase` `TestJob`
  defaults to a stdout-only recorder and writes nothing to a
  results directory.  See "The recorder in distributed mode"
  below.
- The `testrun_results_stream.jsos` file the recorder writes to
  (rooted under the activated application's presence folder by
  `IntegratedTestJob`'s output context).
- All the test-shaped events: `test_started`, `test_finished`,
  `validator_started`, `validator_finished`, `phase_started`,
  `phase_finished`, `diagnostic_capture`, `coupling_event`.

Every test-shaped event the framework emits during the run is
written to the controller's JSOS stream.  No event is written
by the hosts; the hosts don't have a recorder.

Why?  Because the hosts are not actors (chapter 2).  Hosts
respond to the controller's commands; they don't make
decisions about test outcomes.  The test outcome is decided
*on the controller* by the controller-side code.  The JSOS
stream records the controller's view of the run, which is the
authoritative view of what *tests* did.

## What the hosts produce

Each host produces its own *operational* logs — syslog,
application logs, audit trails.  These are the system under
test's own records, not the framework's.  The hosts don't
know they're being tested; they're just running, logging
ordinary state.

The framework's JSOS records *reference* host logs but don't
contain them:

```json
{
  "kind": "test_finished",
  "test_name": "test_replication_recovery",
  "outcome": "failed",
  "host_logs": {
    "casey-01": "results/20260501-153012/host-logs/casey-01/syslog.log",
    "casey-02": "results/20260501-153012/host-logs/casey-02/syslog.log",
    "casey-03": "results/20260501-153012/host-logs/casey-03/syslog.log"
  },
  "diagnostic_captures": ["pre_testrun", "post_testrun"]
}
```

The record references *paths* — files in the results directory
where the controller has fetched the host's logs.  A consumer
reading the JSOS record can walk to the file for the actual
log content.

This is the same separation Volume 1 chapter 5 set up for
single-host runs: the JSOS stream carries *structured* data;
the directory carries *blob* data.  Distributed runs follow
the same model with per-host blob data added.

## How host logs reach the results directory

The framework fetches host logs via the SSH wire (chapter 10)
during the diagnostic capture phases.  The flow:

1. `fire_diagnostic_capture_pre_testrun` runs (Volume 1
   chapter 2's lifecycle).
2. The framework's diagnostic capture, configured per-Pod,
   walks each device and pulls relevant log files via
   `device.ssh.file_pull`.
3. Files land in `results/<run_id>/host-logs/<device_name>/`.
4. The JSOS record for the diagnostic event references the
   directory.

Same flow at `fire_diagnostic_capture_post_testrun`.  Two
captures per run (pre and post); a project can configure
additional captures (e.g., post-failure) by overriding the
relevant hooks.

The capture is *host-pull*, not host-push.  The controller
pulls; the hosts don't initiate.  Two reasons:

- **Single-actor consistency.**  The controller is the actor;
  pulling logs is one of the actor's actions.  A host
  pushing logs would be a second actor causing effects (the
  push) in the environment.
- **Reliability.**  The controller can re-attempt a pull that
  fails; a host that fails to push has no controller-side
  recovery path.

## Per-host timelines vs one global timeline

A subtle question: should the framework merge the controller's
JSOS records and the hosts' log files into one global
timeline?

The framework's answer: **no, preserve per-host timelines as
separate streams.**

The reason is clock drift (chapter 1's failure mode #4).  Host
A's syslog timestamps and host B's syslog timestamps come from
independent clocks.  Aligning them onto a global timeline
requires assuming the clocks agree — and on real fleets, they
don't.  A per-host millisecond can be a multi-second offset
from the controller's clock, and the offset can drift during
the run.

The framework's discipline:

- The **JSOS stream** uses the *controller's* clock for every
  record.  Test events have controller timestamps.
- **Host logs** use each *host's* clock for their entries.
  Host log entries have host timestamps.
- When aggregating, **don't merge the two**.  Present them
  side-by-side, each with its own timeline, and let the
  consumer reason about offsets if they need to.

For the consumer (a human doing a post-mortem, or an AI agent
investigating a failure), this is more useful than a forced
global timeline.  The forced merge would lie subtly; the
side-by-side presentation is honest about what's known and
what isn't.

## The failure mode where a host's stream stops

Distributed runs have a failure mode that single-host runs
don't: a host can lose connectivity mid-run and the framework
loses access to its logs from that point.

The framework's response:

- The JSOS record for the failed test (or phase) marks the
  host as *unreachable* in its `host_logs` reference.
- The post-run diagnostic capture re-attempts the host log
  fetch; if it fails again, the JSOS record carries a
  `truncated: true` flag for that host's logs.
- The aggregate run report (Volume 1 chapter 6's agents CLI;
  chapter 14 of this volume) preserves the partial state.

A consumer reading a partial run sees:

```json
{
  "summary": {
    "total": 87,
    "passed": 82,
    "failed": 4,
    "errored": 1,
    "host_log_status": {
      "casey-01": "complete",
      "casey-02": "complete",
      "casey-03": "truncated_at_post_capture",
      "casey-04": "complete",
      "casey-05": "complete"
    }
  }
}
```

The summary is honest.  Tests' outcomes are recorded
correctly (the controller's view).  Host logs are
acknowledged as partial where they are partial.

This is the partial-run honesty chapter 14 walks in detail.

## The recorder in distributed mode

A subtle implementation detail worth pinning down: the
`JsonResultRecorder` from Volume 1 chapter 5 is the same
recorder used in distributed mode.  There is no
`DistributedJsonResultRecorder` subclass.  Why?

Because the recorder is *controller-side* and the controller
is doing the same thing it did on single-host: emit JSOS
records as test events fire.  The fact that the test events
involve cross-host calls doesn't change the recorder's job.
The recorder is downstream of the test execution; it doesn't
need to know whether the execution was distributed or local.

But note *which* job installs that recorder.  Plain `testbase`
`TestJob` does **not** write JSOS-to-disk by default: its
`_create_recorder()` returns a `StdoutResultRecorder`
(`ccai.testbase.results.recorders.stdoutresultrecorder`,
testjob.py:564), which prints the run summary to stdout and
reuses the JSOS/JSON serialisation (so the stream and summary
files are still written *when an output directory is in play*)
but skips the rendered HTML summary.  The full controller-side
recorder — JSOS streaming plus rendered HTML — is installed one
layer up, by `IntegratedTestJob` in `codecraftai-integration-runtime`,
whose `_create_recorder()` returns the `JsonResultRecorder`
(integratedtestjob.py:79).  `IntegratedTestJob` is also what
roots those files under the activated application's presence
folder (via its `RuntimeOutputContext`) and stamps them with
CI/build metadata (via its `RuntimeRunMetadata`).  In the
*design* chain `IntegratedTestJob` sits between `TestJob` and
`InteropLibraryTestJob`, so `DistributedTestJob` picks up the
full JSOS-to-disk + HTML recorder, the presence-folder output
context, and the `RuntimeVariables` metadata **by inheritance** —
not because `DistributedTestJob` (or `testbase`) supplies it.

> *In-flight note — read this before assuming the inheritance.*
> The re-parent has **not yet landed**.  In today's interop
> source `InteropLibraryTestJob` still extends `TestJob`
> directly (`ccai.interop.testing.testjob`, testjob.py:22),
> so the live chain `DistributedTestJob → InteropLibraryTestJob →
> TestJob` does **not** pass through `IntegratedTestJob` yet.  As
> built, a `DistributedTestJob` therefore inherits `TestJob`'s
> `StdoutResultRecorder` + standalone seams — it does *not* yet
> produce the presence-folder JSOS/HTML aggregation this chapter
> describes.  That aggregation is produced by running an
> `IntegratedTestJob` directly, and — once the Phase-5 re-parent
> of `InteropLibraryTestJob` onto `IntegratedTestJob` lands — by
> any `DistributedTestJob`.  The rest of this chapter describes
> the `IntegratedTestJob`-supplied recorder's output: the target
> state for distributed runs.

The recorder's outputs (the files from Volume 1 chapter
5, written by the `IntegratedTestJob`-supplied recorder):

- `testrun_summary.json` — high-level summary.  Distributed
  runs add the `host_log_status` field shown above.
- `testrun_results_stream.jsos` — the streaming record.
  Distributed-test records carry richer host metadata
  (per-call references to host logs, per-validator host
  identity).
- the rendered HTML summary — the human-readable view
  `IntegratedTestJob`'s recorder adds on top of the JSOS
  stream (the stdout-only `testbase` default skips it).

A consumer parsing the stream uses the same JSOS parser
(Volume 1 chapter 6's `agents` CLI) for both single-host and
distributed runs.  The parser doesn't need to be aware of
distribution; it just walks records.

## Streaming to external services in distributed mode

Volume 1 chapter 5 introduced `ResultStreamer` for pushing
events to an external dashboard URL.  In distributed mode, the
streamer runs on the controller — same as the recorder.  The
external dashboard receives the controller's view of the
distributed run.

For dashboards that want per-host data in real time, the
framework's pattern is to add *tandem validators* (chapter 5)
that emit per-host events.  A `LogTailValidator` attached to
a test could subscribe to a host's syslog through SSH and
emit JSOS records for noteworthy entries; the streamer would
push those records to the dashboard alongside the test events.

The framework doesn't ship a built-in `LogTailValidator` (it
would have too many project-specific decisions about what
counts as "noteworthy").  But the validator scaffolding from
chapter 5 is the right shape for projects that want this.

## What the agents CLI sees

The `testbase agents` subcommands from Volume 1 chapter 6
work on distributed JSOS streams unchanged:

- `agents summary extract` — same shape; adds
  `host_log_status` to the output.
- `agents testresult list` — same shape; per-test records
  may have richer metadata about which hosts were involved.
- `agents testresult failures` — same shape; the failure
  records reference host logs via paths.
- `agents config extract` — same shape; the configuration
  reflects the distributed Pod's Landscape and Topology.

The unchanged shape is the framework's commitment to a
*structured surface* for consumers.  An AI agent reading a
single-host run and a distributed run uses the same parsing
code; the agent's reasoning code may handle the per-host
metadata differently (offering to walk to host logs on
demand) but the input shape is identical.

## What the next chapter does

Chapter 14 walks partial-run semantics — what "the suite
passed" means when one of five hosts crashed mid-run, when
verification caught a Topology mismatch on one cluster but
not another, when the framework activated only some of the
declared services because `allow_missing_devices=True`.  The
framework's commitment is *honesty about scope*: a partial
run reports what ran, what didn't, and why, without
overstating success.

Chapter 15 then walks debugging a distributed failure using
all the machinery established so far — the audit-diary
pattern from the six-pass landscaping audit as the reference
shape.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/14-partial-run-semantics -->
# Chapter 14 — Partial-Run Semantics

> *A distributed run that completed every test against every
> host with every validator passing is the easy case.  The
> hard case is the run where one of five hosts crashed
> mid-suite, where verification caught a Topology mismatch on
> one cluster but not another, where the framework activated
> only some of the declared services.  The framework's
> commitment is honesty about scope: report what ran, what
> didn't, and why, without overstating success.*

Single-host test frameworks have a clean answer to "did the
suite pass?" — every test ran; some passed, some failed; count
the failures.  Distributed runs make the question more
complicated.  A test that *didn't run because its host was
unreachable* is not a passing test, but it's also not a failing
test in the same sense as a test whose assertions failed.  A
test whose validator caught a steady-state violation while the
test's own assertions passed is a complicated outcome.  A
verification step that failed for one cluster but not another
means part of the Pod was usable.

This chapter walks the framework's vocabulary for these
outcomes and the discipline for reporting them honestly.

## The four outcome categories per test

The base testbase outcome enum (`ResultCode` from
`codecraftai-testbase/source/packages/ccai/testbase/results/model/resultcode.py`)
distinguishes the per-test states.  The four that matter for
the per-test verdict are:

| Outcome | Meaning |
|---|---|
| **Passed** | The test ran and all its assertions succeeded |
| **Failed** | The test ran and an assertion failed |
| **Errored** | The test ran but raised an unexpected exception |
| **Skipped** | The test did not run (e.g., `@skip_if`, deactivation) |

The four categories cover the single-host case.  A distributed
run adds a fifth implicit category that the framework
represents through *test runs that never started*:

| Implicit | **Not attempted** | The test was discovered but the framework never reached it (run aborted, host unreachable for activation, verification failed) |

The framework's reporting acknowledges all five states
explicitly.

## The validator outcome — separate from the test outcome

Chapter 5 walked tandem validators: a test with three
validators is one test with four recorded outcomes (the test
plus three validators).  Each validator has its own outcome
category — passed, failed, errored.  Validators don't get a
"skipped" category because validators only run when their test
runs.

The aggregate outcome of a test-with-validators is:

```
test outcome = "Passed" if test.outcome == Passed AND all validators passed
test outcome = "Failed" if test.outcome == Failed OR any validator failed
test outcome = "Errored" if test.outcome == Errored OR any validator errored
test outcome = "Skipped" if test.outcome == Skipped
```

A test passes its own assertions while a validator catches a
violation — the aggregate is *Failed*.  This is the framework's
commitment: validators are not advisory.  Their failures count.

## Phase outcomes — coarser-grained than test outcomes

Volume 1 chapter 2 walked the sequencer phases (Initial,
Discovery, Collection, Graph, Traversal).  In a distributed
run, the activation phases (Configuration, Integration,
Operational from chapter 6 of this volume) and the verification
step (chapter 7) also have outcomes:

| Phase | Possible outcomes |
|---|---|
| Discovery | success / partial (some test files failed to import) / failed |
| Collection | success / failed (originator resolution failure) |
| Graph | success / failed (cycle detected) |
| Activation: Configuration | success / failed (file load / parse error) |
| Activation: Integration | success / partial (some devices failed init) / failed |
| Activation: Operational | success / partial (some devices unreachable) / failed |
| Verification: Landscape | success / failed (reachability check raised) |
| Verification: Topology | success / partial (some clusters mismatched) / failed |
| Traversal | success / partial (some tests skipped or not attempted) / failed |

Each phase gets its own JSOS phase_started / phase_finished
record (Volume 1 chapter 5).  The aggregate run outcome is
derived from the phase outcomes:

- If any phase failed (no recovery), the run is *aborted*.
- If any phase is partial, the run is *partial*.
- If all phases succeeded, the run is *complete*.

Combined with the test outcomes, the run report has:

- A run-level outcome (aborted / partial / complete).
- A phase-by-phase breakdown.
- A test-by-test breakdown.

## When a partial run is honest

A partial run is honest when the report acknowledges what was
*not* tested.  The framework's reporting is structured to
prevent the reader from accidentally treating a partial run
as a clean run.

> **Who writes these files.** The outcome *model* — the
> Passed/Failed/Errored/Skipped categories and the
> not-attempted/partial-run honesty — lives in testbase's
> results model (`ResultCode` and the recorder serialisation).
> But the on-disk summary and per-host log layout shown below
> is an *integration-runtime* artefact.  A plain `testbase`
> run uses `StdoutResultRecorder`, which prints the run summary
> to stdout and (when an output directory is in play) writes
> the JSOS stream and JSON summary, but skips HTML rendering.
> The presence-folder *results directory* — the well-known
> location these files land in, the per-host logs, and the
> rendered HTML summary — is produced by `IntegratedTestJob`
> (`codecraftai-integration-runtime`), whose `_create_recorder`
> returns the HTML-rendering `JsonResultRecorder` and whose
> `_create_output_context` returns `RuntimeOutputContext`
> (results under the activated app's presence folder via
> `ccai.thisapp.paths`).  The honest summary *shape* below
> is the same regardless of recorder; only its persistence is
> the integration runtime's job.

A representative partial-run summary (the JSON summary written
by `IntegratedTestJob`'s recorder):

```json
{
  "run_id": "20260501-153012-abc123",
  "outcome": "partial",
  "phases": {
    "configuration": "success",
    "integration": "success",
    "operational": "partial",
    "verification": {
      "landscape": "partial",
      "topology": "success"
    },
    "traversal": "partial"
  },
  "tests": {
    "passed": 78,
    "failed": 3,
    "errored": 1,
    "skipped": 5,
    "not_attempted": 0
  },
  "operational_partial_reason": "1 of 12 devices unreachable",
  "landscape_partial_reason": "1 of 12 devices unreachable (same)",
  "traversal_partial_reason": "5 tests skipped because their declared host was unreachable",
  "host_log_status": {
    "casey-01": "complete",
    "casey-02": "complete",
    "casey-03": "complete",
    "casey-04": "complete",
    "casey-05": "complete",
    "casey-06": "complete",
    "casey-07": "complete",
    "casey-08": "complete",
    "casey-09": "complete",
    "casey-10": "complete",
    "casey-11": "complete",
    "casey-12": "unreachable_at_activation"
  },
  "missing_devices": ["casey-12"]
}
```

The summary tells the reader:

- 78 tests passed, 3 failed, 1 errored, 5 skipped because of
  the missing device.
- The Pod was 11/12 hosts.
- Verification caught the missing device at the operational
  phase; the run was allowed to continue because
  `allow_missing_devices=True` was in effect.

A consumer reading this can decide:

- "78 / 87 declared tests passed, but only 87 of 92 declared
  tests were attempted, and 1 of 12 hosts was unreachable" —
  is that acceptable for the use case?
- A nightly full-sweep run treats this as a *failure* (we
  expected 12 hosts; we got 11).
- A smoke run treats it as a *success* (the framework worked
  against 11 hosts; the failures are real and shippable
  signal).

The framework's job is not to decide which.  The framework's
job is to report honestly so the orchestrator (Volume 3) or
the human can decide.

## When a partial run is dishonest

A partial run is *dishonest* when the report:

- Reports "passed" without acknowledging that some tests
  weren't attempted.
- Aggregates outcomes such that a `not_attempted` count is
  hidden.
- Omits the phase outcomes, presenting only test outcomes.
- Reports test outcomes without the validator outcomes that
  failed.

The framework prevents these by *structurally* including the
honest fields in every summary.  A consumer that wants only a
boolean "did all the declared tests pass" must compute it from
the explicit fields:

```python
def did_all_declared_tests_pass(summary):
    rtnval = (
        summary["tests"]["passed"]
        == summary["tests"]["passed"]
            + summary["tests"]["failed"]
            + summary["tests"]["errored"]
            + summary["tests"]["skipped"]
            + summary["tests"]["not_attempted"]
    ) and summary["outcome"] == "complete"
    return rtnval
```

The boolean is computable but the consumer has to ask
explicitly.  No accidental "passed."

## The `allow_missing_devices` honesty trade-off

`LandscapeActivationParams.allow_missing_devices=True`
(chapter 6) lets activation proceed with unreachable devices.
The trade-off:

- **Benefit:** smoke runs can complete against partial Pods,
  catching framework bugs without requiring a perfect lab.
- **Cost:** a partial run is harder to interpret than an
  aborted run.  A reader must look at the report to know
  what was tested.

The framework's honesty discipline is what makes the trade-off
acceptable.  Without honest reporting, partial runs would be
misleading; with honest reporting, the trade-off is the
project's to make per use case.

## What partial-run failures look like in the JSOS stream

Volume 1 chapter 5's JSOS stream from the recorder carries
the phase events with their outcomes.  The JSOS serialisation
is shared: both testbase's default `StdoutResultRecorder` and
integration-runtime's `JsonResultRecorder` emit the same
record shape (so the `testbase agents` machine CLI reads
either one).  What differs is *where* the stream lands — a
plain testbase run echoes its summary to stdout and writes
the stream only when an output directory is configured, while
`IntegratedTestJob` persists it to the activated app's
presence-folder results directory alongside the rendered HTML
summary:

```
{kind: phase_started, name: configuration, ...}
{kind: phase_finished, name: configuration, outcome: success, ...}
{kind: phase_started, name: integration, ...}
{kind: phase_finished, name: integration, outcome: success, ...}
{kind: phase_started, name: operational, ...}
{kind: phase_finished, name: operational, outcome: partial,
 reason: "1 of 12 devices unreachable",
 missing_devices: ["casey-12"], ...}
{kind: phase_started, name: traversal, ...}
{kind: test_started, name: test_replication_in_majority, ...}
{kind: test_finished, name: test_replication_in_majority, outcome: passed, ...}
... (more tests) ...
{kind: test_skipped, name: test_failover_to_casey_12,
 reason: "Required host casey-12 was not in operational landscape", ...}
... (more tests, including the four other skipped ones) ...
{kind: phase_finished, name: traversal, outcome: partial,
 skipped_tests: [
   "test_failover_to_casey_12",
   "test_drift_includes_casey_12",
   "test_quorum_with_casey_12",
   "test_membership_query_casey_12",
   "test_health_check_all_hosts"
 ], ...}
{kind: run_finished, outcome: partial, ...}
```

A consumer parsing the stream sees the partial outcome at
each layer.  An AI agent triaging the run can ask "why was
the run partial?" and trace the answer through the phase
events.  Volume 1 chapter 6's `agents testresult failures`
extracts the failure subset; a similar `agents testresult
notattempted` (a project addition, not shipped) would
extract the not-attempted subset for review.

## The run report — three audiences

Different consumers of a run report want different things:

### CI dashboards

Want a green / red summary.  The right boolean for a CI
dashboard is *strict*: the run is "green" only if `outcome ==
"complete"` AND `failed + errored == 0`.  A partial run is
"yellow" or "red" depending on the project's stance.

### Triaging engineers

Want the failure list and the not-attempted list.  The agents
CLI's `testresult failures` and (project-supplied)
`testresult notattempted` are the surfaces.  The host log
references in the JSOS records lead to the per-host logs in
the results directory — which, for an integration run, is the
presence-folder directory `IntegratedTestJob` resolves through
`RuntimeOutputContext`.

### Replay tools

Want enough information to *re-run* the suite.  The
`agents config extract` (Volume 1 chapter 6) produces the
exact invocation that triggered the run, including the
job_seed.  Combined with the partial run's
`missing_devices` list, a replay tool can decide whether to
re-attempt against the same Pod or escalate for
re-provisioning.

### AI agents

Want all of the above as structured data.  The framework's
JSOS stream and agents CLI shape are designed for this — same
shape, same fields, every agent reads the same way.

## What the next chapter does

Chapter 15 walks debugging a distributed failure.  Where this
chapter walked the *vocabulary* for partial outcomes, chapter
15 walks the *workflow* for diagnosing them: the audit-diary
pattern from the six-pass landscaping audit (the real
production-grade audit pattern from the portfolio's history),
the cross-host log-correlation discipline, the bisection
pattern for reproducing flakes, and the forward-reference to
Volume 8's time-travel debugging for the production-grade
post-mortem.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/15-debugging-a-distributed-failure -->
# Chapter 15 — Debugging a Distributed Failure

> *Distributed automation produces failures that don't reproduce
> on a laptop.  The framework's debugging story is therefore
> not "step through the code"; it's "extract enough structured
> data from the failed run to reason about what happened across
> hosts and produce a hypothesis you can test."  This chapter
> walks the audit-diary pattern, with the six-pass landscaping
> audit as the reference example.*

A distributed test failed in CI.  The summary says one test
errored on host C, two validators caught steady-state
violations, and host F's log fetch was truncated.  The CI
runner has been torn down.  Re-running may or may not
reproduce.  How does an SDET diagnose what happened?

This chapter is the framework's answer.  It is *not* a
universal-debugging chapter — the framework can't replace
domain knowledge of the system under test.  It is a chapter
on *what the framework's structured data lets you do* and the
*audit-diary pattern* for working through it.

The full production-grade post-mortem story — replay-driven
debugging, time-travel reconstruction, pod instrumentation —
lives in Volume 8.  This chapter establishes the workflow at
the framework level; Volume 8 provides the tooling that makes
the workflow easier in production.

## The structured data the framework provides

The structured-data debugging story in this chapter is the
story of an *integration* run — a run driven by
`IntegratedTestJob` (in `codecraftai-integration-runtime`).  Plain
`testbase` runs standalone and records only to **stdout** (its
default recorder is `StdoutResultRecorder`); it has no presence
folder, no `RuntimeVariables`, and no on-disk results-directory
layout to debug from.  The presence-folder results directory,
the JSOS-to-disk stream, the rendered HTML summary, and the
CI/build metadata are all the responsibility of
`IntegratedTestJob`, which overrides three `TestJob` provider
hooks: `_create_output_context()` returns `RuntimeOutputContext`
(artefacts land under the activated app's well-known presence
folder via `ccai.thisapp.paths` — activation-gated),
`_create_run_metadata()` returns `RuntimeRunMetadata` (build /
pipeline / job from `RuntimeVariables`), and `_create_recorder()`
returns `JsonResultRecorder` (JSOS streaming plus the rendered
HTML summary).  The audit-diary and evidence discipline below
therefore applies to `IntegratedTestJob` runs — that's where the
files exist to reason about.

An integration run that failed leaves a results directory shaped
roughly:

```
results/20260501-153012-abc123/
├── testsummary.json                # high-level
├── testresults_stream.jsos         # streaming records
├── testresults.json                # consolidated
├── startup-configuration.json      # run inputs (Vol 1 ch.6)
├── host-logs/
│   ├── casey-01/
│   │   ├── syslog.log
│   │   ├── auth.log
│   │   └── application.log
│   ├── casey-02/
│   │   └── ...
│   └── casey-12/                   # may be partial — host went down
│       └── pre_only.log
├── diagnostics/
│   ├── pre_testrun/
│   │   ├── package-versions.json
│   │   ├── environment.json
│   │   └── per-host/
│   │       ├── casey-01.json       # host metadata snapshot
│   │       └── ...
│   └── post_testrun/
│       └── ...
└── tc-by-products/
    ├── test_replication_in_majority/
    │   ├── stdout.log
    │   ├── stderr.log
    │   └── per-host-output/
    │       ├── casey-01.log        # commands run on casey-01
    │       └── ...
    └── ...
```

That is the framework's gift to the debugger.  Every
structured fact about the run is recoverable from these
files.  The debugger's job is to reason about what
combination of facts produced the failure.

## The audit-diary pattern

The portfolio's debugging discipline — applied across multiple
audits in real history — is the **audit diary**.  An audit
diary is a structured note an SDET keeps while diagnosing a
failure.  It has six sections:

1. **Symptom.**  What the run reported.  Quote the JSOS
   record.
2. **Hypothesis-1.**  The first plausible explanation.
3. **Evidence for / against H1.**  What the structured data
   says.
4. **Hypothesis-2 (if H1 ruled out).**  Repeat.
5. **Diagnosis.**  The root cause once a hypothesis fits all
   the evidence.
6. **Remediation.**  The fix and how it's verified.

The discipline is not unique to this framework — it's a
generic engineering pattern.  The framework's contribution is
*making each section answerable* through the structured data.
Symptoms come from JSOS records; evidence comes from per-test
logs and host logs; remediation is verified by re-running the
suite with the same `job_seed` (chapter 9).

## A worked audit diary

A real-shape example.  Suppose a run failed with:

```json
{
  "run_id": "20260501-153012-abc123",
  "outcome": "partial",
  "tests": {"passed": 47, "failed": 1, "errored": 1, "skipped": 3},
  "phases": {
    "operational": "partial",
    "verification": {"landscape": "partial", "topology": "success"}
  },
  "missing_devices": ["casey-12"]
}
```

And the failure record:

```json
{
  "kind": "test_finished",
  "test_name": "test_quorum_after_partition",
  "outcome": "errored",
  "exception": {
    "type": "TimeoutError",
    "message": "Timeout waiting for cluster to converge",
    "traceback": [
      "  File \"tests/test_quorum.py\", line 87",
      "    wait_for_quorum(cluster, timeout=60)",
      "  File \"ccai/agentkit/cluster.py\", line 142, in wait_for_quorum",
      "    raise TimeoutError(...)"
    ]
  },
  "host_log_status": {
    "casey-01": "complete", "casey-02": "complete",
    "casey-03": "complete", "casey-04": "complete",
    "casey-05": "complete", "casey-06": "complete",
    "casey-07": "complete", "casey-08": "complete",
    "casey-09": "complete", "casey-10": "complete",
    "casey-11": "complete", "casey-12": "unreachable_at_activation"
  }
}
```

### Symptom

> `test_quorum_after_partition` errored with `TimeoutError` —
> "Timeout waiting for cluster to converge."  The Pod was 11/12
> hosts (casey-12 unreachable from activation).

### Hypothesis-1

> The test depends on casey-12 for quorum.  With casey-12
> down, the cluster cannot reach quorum and the
> `wait_for_quorum` times out.  Root cause: the test should
> have been skipped (like `test_failover_to_casey_12` was) but
> wasn't.

### Evidence for H1

- The test has no `@skip_if(host_unavailable("casey-12"))`
  decoration.  (Read the test source.)
- Tests that *were* skipped have explicit declarations.
  (Read the JSOS skip records: each carries
  `reason: "Required host casey-12 was not in operational
  landscape"` — the framework's deactivation chain caught
  them.)
- The test's host requirements are implicit — it walks the
  full cluster expecting all 12 hosts.

### Diagnosis

> The test's host requirements are implicit; the framework
> couldn't deactivate it because the framework didn't know
> what the test required.  The test ran against an 11-host
> cluster expecting a 12-host quorum.

### Remediation

> Add explicit host requirements to the test.  Either through
> a `@skip_if(host_unavailable(...))` decoration matching the
> ones on the explicitly-host-bound tests, or by reading the
> integrated landscape and skipping when the configured
> majority cannot be reached.  Re-run with the same `job_seed`
> against an 11-host Pod (recreating the failure
> deterministically); the test should now skip rather than
> error.

The audit-diary takes 15-30 minutes for a clear case like
this.  The framework's structured data is what makes it
possible to answer each section without re-running the suite.

## The cross-host log-correlation discipline

When a failure spans multiple hosts (a network partition
test, a replication test, a leader-election test), the audit
diary needs to correlate events across host logs.  The
framework's discipline:

- **Use the JSOS record's timestamps as the controller's
  reference clock.**  Volume 1 chapter 5 said the controller's
  clock is the JSOS clock.
- **Identify each host's offset from the controller** at the
  pre-test diagnostic capture.  The capture includes
  per-host time queries; the offset is `host_time -
  controller_time` at that moment.
- **Apply the offset (with caveats about drift) when reading
  host logs.**  A controller-side JSOS record at
  `T_controller = 15:32:00.500` corresponds to roughly
  `T_host_C = T_controller + offset_C` in casey-03's syslog.

The framework does *not* automatically do this correlation —
chapter 13 explained the design choice to keep timelines
side-by-side rather than forced-merged.  But the diagnostic
capture provides the offset values, and a debugging script
(or AI agent) can compute the correlation when needed.

## The bisection pattern for reproducing flakes

A test that fails 5% of the time is a flake.  The audit diary
for a flake has a different shape than for a deterministic
failure.

Three phases:

### Phase 1 — Reproduce the failure deterministically

Run the test repeatedly with random seeds until you have a
seed that triggers the failure.  Record the seed.  Now you
have a *reproducible* failure (same seed → same input → same
test behaviour, modulo Pod-state non-determinism).

### Phase 2 — Bisect on Pod state

Re-run the failure-inducing seed against a fresh Pod.  If the
failure reproduces, the failure is *Pod-state-independent*
(it's about the test or the system under test, not the
specific Pod state).  If it doesn't, the failure is
*Pod-state-dependent* — re-running won't help; you need
Volume 8's time-travel tooling to reconstruct the original
Pod state.

### Phase 3 — Bisect on inputs

For Pod-state-independent failures, the failure-inducing seed
gives you a specific input.  Use the framework's shrinking
machinery (Volume 1 chapter 4) to find the smallest input
that still triggers.  Add that input as a regression test
case.

The pattern is universal but the tools differ between
Pod-state-dependent and Pod-state-independent flakes.  Most
distributed flakes turn out to be Pod-state-dependent, which
is why Volume 8's tooling is so important for production
debugging.

## Reference: the six-pass landscaping audit

The portfolio's history includes a six-pass audit of
`codecraftai-landscaping` that fixed 15+ silent runtime bugs.
The audits are real production-grade examples of the
audit-diary pattern at scale.  The commits:

- **Pass 1 (`42f9c86`)** — six refactor-leftover bugs,
  including Liskov-substitution violations (three protocol
  extension subclasses lacked `**kwargs` forwarding), an
  uninitialized `ControlExtension._control_id`, three
  string-concatenation bugs, a mutable default argument in
  `factories.create_landscape()`, and duplicate dataclass
  definitions of `DatacenterSummary` and `FolderSummary`.
- **Pass 2 (`a92e5d2`)** — two attribute-name mismatches: a
  `LandscapeService.__init__` declaring `_primary_ssh_credential`
  but the search loop and property using `_ssh_credential`
  (silent `AttributeError` on credential lookup); an
  `OrchestrationJob.begin()` writing to `_orch_results_dir`
  but reading the nonexistent `_test_results_dir`.
- **Pass 3 (`1679fe7`)** — two more refactor-leftover bugs:
  `LandscapeIntegrationLayer.__init__` initialising pool
  attributes to `None` instead of `{}` (breaking `@property`
  accessors that called `.copy()` unconditionally); a
  `ClientSourcePackager` leaking file handles in zip-file
  digest computation.
- **Pass 4 (`1545f67`)** — five logic / behavioural bugs:
  exclude-filter "duplicate-add" bug at four call sites
  (missing `break`, wrong semantics — items that passed N
  filters were added N times); `FriendlyIdentifier.__eq__`
  always returning True (compared `self._full_identifier` to
  itself rather than `other._full_identifier`);
  `FriendlyIdentifier.identity` property dereferencing regex
  groups without null checks; wrong return-type annotations
  on `get_devices()` and `get_services()` (annotated to return
  single items instead of lists); inconsistent exception type
  in `ClientSourcePackager` (`Exception` instead of
  `RuntimeError`).
- **Passes 5 and 6** — incremental, integrated into specific
  test fixtures.

Three lessons from the six-pass arc:

### 1. Audits surface progressively

Each audit pass caught bugs the previous passes missed.  The
first pass caught structural bugs (Liskov violations,
uninitialised attributes, mutable defaults).  Later passes
caught semantic bugs (filter logic, equality semantics,
type annotations).  The progression reflects the reviewer's
*lens shift* — each pass looks for a different class of
issue.

### 2. Silent failures are common

Every bug fixed across the six passes was *silent at import
time*.  None of them caused an `ImportError`; none of them
broke the package's smoke tests.  They surfaced only when
specific code paths exercised the bug — and the code paths
were sometimes exercised only in distributed scenarios that
were rare in single-host tests.

The lesson: the framework's structured-data discipline is
critical for catching silent failures.  An audit diary working
from JSOS records can spot inconsistencies (a test that should
have been skipped wasn't; a credential that should have
resolved didn't) that no individual test exposed.

### 3. Tests added per fix

Every audit pass added regression tests for the bugs it
caught.  The Volume 1 chapter 10 case study mentioned that
testbase's own test suite runs around 3,700 tests; the
landscaping audit contributed +101 tests across 11 test files
to that count.  The tests are now permanent regressions
ensuring the same classes of bugs can't recur.

## What's not in this chapter — Volume 8

Production-grade post-mortem debugging — replaying a failed
run, walking the Pod's state at a specific moment,
reconstructing what happened with full instrumentation — is
Volume 8 (*Time-Travel Debugging in Production*).  Volume 8's
tools assume:

- The Pod was instrumented during the run.
- The instrumentation produced a replay-able event log.
- A debugger can replay the event log against a reconstructed
  Pod state.

For Pod-state-dependent flakes (the hard ones), Volume 8 is
the right tool.  This chapter walks what an SDET can do with
*just the framework's structured data*; that's enough for
~70% of distributed failures and a starting point for the
remaining 30% that need Volume 8's tooling.

## What the next chapter does

Chapter 16 is the volume's case study: a 5-host integration
test for a sysman feature.  The chapter walks the entire
volume's machinery in one worked example — Pod provisioning,
inheritance chain, landscape activation, verification, tandem
validators, the wire, credentials, partial-run semantics,
audit diary.

By the end of the case study, a reader who started Volume 2
cold should be able to read a real-world distributed test
job's source and predict its behaviour.  That fluency is the
volume's contract.


<!-- https://code-craft.ai/references/codecraftai/02-distributed-automation/16-case-study -->
# Chapter 16 — Case Study: A 5-Host Integration Test for a Sysman Feature

> *Fifteen chapters established the framework, the discipline,
> and the machinery.  This chapter watches them work together.
> The subject is a sysman feature integration test running
> against a 5-host Pod — controller, four DUTs of different
> OSes — verifying the feature behaves correctly across the
> fleet.  Every concept the volume introduced lands in this
> single run.*

The case study is concrete.  A real-shape distributed test
exercising a sysman feature (Volume 4 covers sysman in depth;
this chapter just uses one as the worked target).  The
feature: *deploying a managed network configuration to a
fleet*.  The hosts: a controller running the test, four DUTs
running RHEL, Ubuntu, macOS, and Windows respectively.  The
goal: verify the feature applies the configuration to all four
DUTs, each DUT enters the expected state, and the controller
can verify the state from outside.

Reading order: each section walks one part of the volume's
machinery as it appears in this run.  By the end, every
chapter (1 through 15) has its concept exercised at least
once.

## The Pod (chapters 1, 3, 7)

A Specification declares the Pod:

```yaml
# spec-cross-os-network-config.yaml
pod:
    name: cross-os-network-config-pod
    resources:
        - controller-runner-1
        - dut-rhel-1
        - dut-ubuntu-1
        - dut-macos-1
        - dut-windows-1
```

CI tooling (Volume 3 chapter 4) reads the Specification, checks
out the five resources from the lab pool, emits the Landscape
and Topology files for the next stage.  The CI run continues
with the orchestration triggering a `DistributedTestJob`
against the emitted files.

The Pod is one unit.  Five hosts.  One actor (the
controller).  Four DUTs as responders.  Tests within this Pod
will run sequentially (chapter 2 / 3 / 8 commitments).  If
the campaign needs cross-OS coverage in less wall-clock time,
*more Specifications* (a separate Pod per OS) is the answer
(chapter 3); not parallel tests within this one.

## The Landscape (chapters 9, 11)

The emitted Landscape:

```yaml
environment:
    label: integration-cross-os-config

apod:
    clients:
        -   deviceType: network/client-linux
            name: controller-runner-1
            host: 10.0.0.1
            role: taskerserver
            credentials:
                - lab-runner

infrastructure:
    services:
        -   serviceType: network/service
            name: lab-vault
            credentials:
                - lab-vault-admin

    devices:
        -   deviceType: network/ssh
            name: dut-rhel-1
            host: 10.0.1.10
            platform: rhel
            credentials:
                - dut-root
            features:
                serial: serial-concentrator: 1
                power: lpc934: 1
                isolation: false
            skip: false

        -   deviceType: network/ssh
            name: dut-ubuntu-1
            host: 10.0.1.11
            platform: ubuntu
            credentials:
                - dut-root
            features:
                serial: serial-concentrator: 2
                power: lpc934: 2
                isolation: false
            skip: false

        # ... dut-macos-1, dut-windows-1 similarly ...
```

The `apod` is one host (the controller).  The `infrastructure`
is one service (a credentials vault) plus four devices (the
DUTs).  Each DUT has Features for serial console and power
control routed through a shared serial concentrator and power
controller.

The credential references resolve at activation time
(chapter 11): `lab-runner`, `lab-vault-admin`, `dut-root`.
None of the auth material flows to the DUTs; the controller
authenticates via SSH using the resolved credentials.

## The Topology (chapter 7, with verification)

The emitted Topology:

```yaml
environment:
    label: integration-cross-os-config

clusters:
    -   name: managed-fleet
        group: configuration-targets
        nodes: [dut-rhel-1, dut-ubuntu-1, dut-macos-1, dut-windows-1]
        spares: []

roles:
    controller: controller-runner-1
    fleet_members: [dut-rhel-1, dut-ubuntu-1, dut-macos-1, dut-windows-1]
```

The Topology says four nodes form a `managed-fleet` cluster, no
spares.  The project has subclassed `Topology` (Volume 6
chapter 7's customisation mechanism — applied here) to
recognise the `roles` section and validate that the
controller's role is uniquely assigned and the fleet members
are reachable as a cluster.

The verification override:

```python
class CrossOSConfigTopology(Topology):

    def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
        errors = []
        warnings = []

        # Inherit framework cluster verification
        framework_errors, framework_warnings = super().validate_topology(topology_info)
        errors.extend(framework_errors)
        warnings.extend(framework_warnings)

        # Project-specific role verification
        roles = topology_info.get("roles", {})
        controller_name = roles.get("controller")
        if controller_name is None:
            errors.append("Topology declares no controller role.")
        elif controller_name not in self.devices:
            errors.append(
                "Topology declares controller '{}' but device not in landscape.".format(
                    controller_name
                )
            )

        fleet_members = roles.get("fleet_members", [])
        for member in fleet_members:
            if member not in self.devices:
                errors.append(
                    "Topology declares fleet member '{}' but device not in landscape.".format(
                        member
                    )
                )

        return errors, warnings
```

When the framework calls `validate_topology` during the
`InteropLibraryTestJob.fire_activate_configuration` hook
(chapter 6), this override runs.  Errors abort activation;
warnings log but don't abort.

## The custom `DistributedTestJob` subclass (chapter 4)

The project's job class:

```python
class CrossOSConfigTestJob(DistributedTestJob):

    name = "Cross-OS Network Config"
    description = "Verify managed network configuration deployment across the fleet."

    def _get_landscape(self) -> Any:
        # The custom CrossOSConfigLandscape is registered through
        # the factory protocol, so LandscapeSingleton() returns it.
        landscape = LandscapeSingleton()
        return landscape

    # Inherit _create_sequencer from DistributedTestJob — no override needed
```

The `_get_landscape()` returns the project's `CrossOSConfigLandscape`
subclass (registered via the factory protocol in
`codecraftai-landscaping/wellknown.py:LandscapeSingleton`).  The
landscape's `Topology` subclass is our `CrossOSConfigTopology`
above.  The framework's chain plugs both in.

The entry point:

```python
# tests/cross_os_config/run.py
from ccai.testplus.entrypoints import distributed_test_entrypoint
from myproject.testjobs import CrossOSConfigTestJob

if __name__ == "__main__":
    distributed_test_entrypoint(job_type=CrossOSConfigTestJob)
```

A CI orchestration's bridge task (Volume 3 chapter 25) invokes
this entry point, passing the includes / excludes / runtime /
landscape / topology names.  The test job picks up from there.

## Activation — the three layers in action (chapter 6)

The job starts.  `__enter__` runs `begin()`, which constructs
the recorder and the sequencer.  Then the lifecycle hooks
fire:

1. **`fire_attach_to_environment(tseq)`** — the
   `InteropLibraryTestJob` override fetches the landscape and
   passes it to the sequencer's `attach_to_environment(landscape=...)`.
   The sequencer propagates to integration couplings.
2. **`fire_attach_to_framework(tseq)`** — same shape.
3. **`fire_activate_configuration()`** — calls
   `landscape.activate_configuration()`:
   - `LandscapeConfigurationLayer.load_landscape` reads the
     emitted Landscape file.
   - `load_topology` reads the emitted Topology file.
   - `initialize_credentials` resolves `lab-runner`,
     `lab-vault-admin`, `dut-root` through the credentials
     backend.
   - `validate_topology` runs the project's override.  Roles
     check out; cluster shape checks out.  Configuration phase
     completes.
4. **`fire_activate_integration()`** — calls
   `landscape.activate_integration()`:
   - `LandscapeIntegrationLayer.initialize_landscape` walks the
     Landscape's devices and instantiates `LandscapeDevice`
     objects for each.
   - For each DUT, the `serial` and `power` Features are
     resolved against the `serial-concentrator` and `lpc934`
     services.  ProtocolExtensions are wired:
     `dut.serial`, `dut.power`.
   - The lab-vault service is instantiated.
5. **`fire_activate_operations()`** — calls
   `landscape.activate_operations()`:
   - `LandscapeOperationalLayer.activate_coordinators` brings
     each device online (probe + metadata + extension
     attachment).
   - `establish_connectivity` verifies every device responds.
     SSH handshakes against `dut-rhel-1` (10.0.1.10),
     `dut-ubuntu-1` (10.0.1.11), `dut-macos-1`, `dut-windows-1`.
     All four respond; connectivity is established.

The Pod is now operationally live.  Verification (chapter 7)
has confirmed: every Landscape resource is reachable; the
Topology's cluster and role shape match the Pod's actual
configuration.

## The first test — the actor and its observers (chapters 2, 5)

The first test:

```python
@testbase.attach.parameter(landscape, identifier="landscape")
@testbase.attach.validator(
    DegradedDurationValidator(max_degraded_seconds=15.0),
    suffix="degraded_during_apply",
)
@testbase.attach.validator(
    LogScanningValidator(error_patterns=["network-config-failed"]),
    suffix="error_log_scan",
)
def test_apply_config_to_fleet(landscape: Landscape) -> None:
    """
        Verify that applying a managed network config to all four DUTs
        succeeds, the fleet stays connected, and no error logs appear.
    """
    fleet = [landscape.device(name) for name in [
        "dut-rhel-1", "dut-ubuntu-1", "dut-macos-1", "dut-windows-1"
    ]]
    config = build_test_network_config()

    # Single-actor model: the test (controller) drives the apply
    # in a fan-out across the fleet.
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(fleet)) as ex:
        futures = {
            ex.submit(apply_config_via_sysman, dut, config): dut
            for dut in fleet
        }
        results = {
            futures[fut]: fut.result()
            for fut in concurrent.futures.as_completed(futures)
        }

    # Verify success on every node.
    for dut, (status, _) in results.items():
        assert status == 0, "Config apply failed on {}: {}".format(dut.name, status)

    # Verify the fleet's reported state matches the config.
    for dut in fleet:
        actual = read_network_config_via_sysman(dut)
        rtnval = config_matches(config, actual)
        assert rtnval, "Config mismatch on {}".format(dut.name)

    return
```

The test:

- Has the test as the *single actor* — fan-out is the test's
  parallelism, each DUT is a responder (chapter 2's commitment
  preserved).
- Has two tandem validators attached: a `DegradedDurationValidator`
  watching for fleet-wide degradation during the apply, and a
  `LogScanningValidator` watching each DUT's logs for error
  patterns (chapter 5).
- Uses the `device.sysman` ProtocolExtension (a hypothetical
  per-OS sysman extension; Volume 4 walks the real sysman
  feature tier in detail).
- Asserts twice: every apply succeeded, every DUT's actual
  state matches the config.

While the test runs:
- The `DegradedDurationValidator`'s background thread polls
  fleet-wide health every 500ms.
- The `LogScanningValidator` tails each DUT's syslog over
  SSH and watches for the error patterns.
- The test's fan-out issues the four SSH commands
  concurrently; each completes; the test gathers results.

When the test finishes:
- The two validators' `finalize()` runs.  The
  `DegradedDurationValidator` stops its thread, computes the
  total degraded duration, asserts it's under 15.0 seconds.
  The `LogScanningValidator` stops tailing, asserts no error
  patterns matched.
- The recorder writes the test's `test_finished` record plus
  the two `validator_finished` records.

A passing run produces three "passed" records: the test, plus
each of the two validators.  A failing run might have any
combination — for example, the test's apply succeeded (test
passed) but a validator caught a degraded-duration violation
(validator failed).  The aggregate outcome is failed (chapter
14: any validator failure → test outcome failed).

## The wire and credentials (chapters 10, 11)

Each fan-out call uses the device's `SshAgent` (chapter 10).
The agent holds the resolved `dut-root` credential.  The SSH
session opens using the credential; the command runs.

The credential never travels to the DUT — only the
authenticated session.  The `Aspects` parameter on the
`run_cmd` call is a frozen instance with
`completion_timeout=120`, `connection_timeout=16`.

If a DUT's SSH session drops mid-call (network glitch), the
Aspects' `action_pattern` (e.g., `DO_UNTIL_SUCCESS`) drives
retry behaviour.  Real wire failures surface as
`ConnectionError` or `TimeoutError`; the test's body decides
how to handle each.

## The result aggregation (chapter 13)

As tests finish, the controller's `JsonResultRecorder` writes
JSOS records.  The `LogScanningValidator` produces *its own*
JSOS records as it observes log lines — the records carry
host identity (which DUT's log a line came from) without
forcing a global timeline.

After the suite completes, the controller pulls each DUT's
syslog into `host-logs/<dut_name>/` via the wire.  The JSOS
records reference the pulled log files by path.

The framework's recorder produces:

- `testsummary.json` — `passed: 8, failed: 0, errored: 0,
  skipped: 0, outcome: complete`.
- `testresults_stream.jsos` — every test, every validator,
  every phase recorded.
- `testresults.json` — consolidated.

## A failure case — partial-run semantics (chapter 14)

Suppose `dut-windows-1` becomes unreachable mid-suite (machine
crashed).  What happens?

- The next test's SSH call against `dut-windows-1` raises
  `ConnectionError`.
- The test's body catches the error and either fails or
  errors (depending on the test's logic).
- The recorder writes the test's `test_finished` record with
  `outcome: errored` and the exception.
- Subsequent tests that depend on `dut-windows-1` either
  also error or are skipped (depending on whether the test
  framework can tell ahead of time).
- The `fire_diagnostic_capture_post_testrun` hook runs.
  Pulling `dut-windows-1`'s syslog times out; the JSOS
  record marks `dut-windows-1` as
  `unreachable_at_post_capture`.

The summary reflects the partial state:

```json
{
  "outcome": "partial",
  "tests": {"passed": 5, "failed": 0, "errored": 2, "skipped": 1},
  "host_log_status": {
    "dut-rhel-1": "complete",
    "dut-ubuntu-1": "complete",
    "dut-macos-1": "complete",
    "dut-windows-1": "unreachable_at_post_capture"
  },
  "missing_devices_observed_during_run": ["dut-windows-1"]
}
```

A consumer reading this knows what was tested and what wasn't.
The audit diary (chapter 15) for this run starts with the
symptom, hypothesises the network failure or the OS crash,
walks the available DUT logs to narrow it down.

## What the case study has touched

Every chapter's concept has appeared:

- **Chapters 1, 3** — the Pod, the determinism table, the
  Pod-level parallelism story.
- **Chapter 2** — the single-actor model in the fan-out.
- **Chapter 4** — the custom `DistributedTestJob` subclass with
  `_get_landscape()` returning the project's landscape.
- **Chapter 5** — the two tandem validators in the test.
- **Chapter 6** — the three-layer activation walked through
  the run.
- **Chapter 7** — the verification of Landscape and Topology
  before the first test.
- **Chapter 8** — the `DistributedTestSequencer` walking tests
  serially within the Pod.
- **Chapter 9** — `job_seed` reproducibility (implicit in the
  CI invocation).
- **Chapter 10** — the SSH wire with Aspects.
- **Chapter 11** — credential resolution at activation; safe
  cross-host concurrency.
- **Chapter 12** — data profiles (vault service is the
  example).
- **Chapter 13** — JSOS aggregation with per-host log
  references.
- **Chapter 14** — partial-run semantics on the failure case.
- **Chapter 15** — audit diary for the failure case.

The case study is the proof that the volume's machinery
composes.  A reader who reached this point should be able to
take a real distributed test job in a project, identify each
construct's framework concept, and predict its behaviour
without running it.

## End of Volume 2

The volume started by asking what's different about
distributed automation (chapter 1) and ended by watching a
real cross-OS integration suite use every piece of the
framework's machinery (this chapter).

The chapters that come next are in other volumes.  Volume 3
extends this volume's hand-off contract through orchestration.
Volume 5 walks the wire's machinery in depth.  Volume 6 walks
the Landscape and Topology schemas in depth.  Volume 8
provides the production-grade post-mortem tooling for the
Pod-state-dependent flakes chapter 15 named.

Volume 2's contribution to the series is the *implementation
of the contract*.  The contract was set up at the seam in
Volume 1 chapter 9 (the five `fire_*` hooks as no-op stubs);
this volume gave them bodies.  Every later volume that
references "the distributed test job" is referencing what
was built here.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration -->
# Volume 3 — Integration Automation and CI Orchestration

*Typed Configurations as the Contract Between Orchestrations and Jobs*

## About this volume

The orchestration layer is structurally distinct from the job layer, and the thesis that unifies the portfolio's integration story is
*typed configurations as the contract between orchestrations and jobs*.  Volumes 1, 2, 4, 5 and 6 all refer to Part IV ("Workflow
Across Environments"), because profiles are how you eliminate `os.environ.get('CI')` branches across a codebase.  This is the headline
volume of the series and its largest non-AI volume.

## Who it is for

Platform engineers, DevOps and QA infrastructure engineers running multi-step pipelines that need to provision, run, observe and tear
down across multiple machines and configurations.  Also any Python engineer who wants a development workflow that scales from a local
laptop to a nightly full sweep without rewriting code at any boundary.

## Source material

- `codecraftai-taskplus` — the task model and the FlowEngine
- `codecraftai-configuration`, `codecraftai-credentials` — the typed configuration categories and credential handling
- Parts of `codecraftai-this-app`, `codecraftai-landscaping` and `codecraftai-provisioning`

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — The Discipline | Foreword + 1, 2, 3 | The orchestration-vs-jobs layered model; the six-category contract |
| II — Configuration Categories | 4, 5, 6, 7, 8 | Specifications, Landscapes, Topologies, Runtimes, Data Profiles |
| III — Credential Management | 9, 10, 11, 12 | Vocabulary, resolution discipline, in-flight lifecycle, environment spanning |
| IV — Workflow Across Environments | 13, 14, 15, 16, 17 | The four-environment ladder, profile composition, local-dev tax cancellation, CI-failure replay, profile promotion |
| V — taskplus + FlowEngine | 18, 19, 20, 21, 22, 23 | Task model, built-in tasks, YAML grammar, info-passing contract, flow control, extension recipe |
| VI — Real-World Wiring | 24, 25, 26, 27 | CI-vendor shim, bridges to other volumes, case-study capstone, honest scoping |

- [Foreword](chapters/00-foreword.md)
- [Chapter 1 — What Integration-Level Automation Is](chapters/01-what-integration-automation-is.md)
- [Chapter 2 — Why Most CI YAML Grows Monstrously](chapters/02-why-ci-yaml-grows.md)
- [Chapter 3 — The Contract: Six Typed Configuration Categories](chapters/03-the-six-categories.md)
- [Chapter 4 — Specifications](chapters/04-specifications.md)
- [Chapter 5 — Landscape Files](chapters/05-landscape-files.md)
- [Chapter 6 — Topology Files](chapters/06-topology-files.md)
- [Chapter 7 — Runtime Files vs Environment Variables](chapters/07-runtime-files-vs-env-vars.md)
- [Chapter 8 — Data Profiles](chapters/08-data-profiles.md)
- [Chapter 9 — Credential Vocabularies](chapters/09-credential-vocabularies.md)
- [Chapter 10 — Credential Resolution](chapters/10-credential-resolution.md)
- [Chapter 11 — Credentials in Flight](chapters/11-credentials-in-flight.md)
- [Chapter 12 — Credentials Across Environments](chapters/12-credentials-across-environments.md)
- [Chapter 13 — The Development-Environment Ladder](chapters/13-the-development-environment-ladder.md)
- [Chapter 14 — Configuration Profiles as a Discipline](chapters/14-configuration-profiles-as-a-discipline.md)
- [Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It](chapters/15-the-local-dev-tax.md)
- [Chapter 16 — Reproducing CI Failures Locally](chapters/16-reproducing-ci-failures-locally.md)
- [Chapter 17 — Profile Promotion](chapters/17-profile-promotion.md)
- [Chapter 18 — The taskplus Task Model](chapters/18-the-taskplus-task-model.md)
- [Chapter 19 — Built-in Task Types](chapters/19-built-in-task-types.md)
- [Chapter 20 — Workflow YAML Grammar](chapters/20-workflow-yaml-grammar.md)
- [Chapter 21 — Passing Information to Jobs](chapters/21-passing-information-to-jobs.md)
- [Chapter 22 — Flow Control as Data](chapters/22-flow-control-as-data.md)
- [Chapter 23 — Extending the Task Model](chapters/23-extending-the-task-model.md)
- [Chapter 24 — Wiring to a Real CI Vendor](chapters/24-wiring-to-a-real-ci-vendor.md)
- [Chapter 25 — Bridges to Other Layers](chapters/25-bridges-to-other-layers.md)
- [Chapter 26 — Case Study: Porting a Real Pipeline](chapters/26-case-study.md)
- [Chapter 27 — Where This Approach Is *Not* the Right Fit](chapters/27-where-not-to-fit.md)

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/00-foreword -->
# Foreword

There is a standard story about CI pipelines that goes like this: in the
beginning a team writes a fifty-line YAML file in their CI vendor's
syntax.  The file describes three steps — build, test, deploy — and it
fits on one screen.  Six months later it is six hundred lines.  Forty
of those lines are environment-variable exports, half of which have
unclear ownership.  Twelve of them are `if-else` branches keyed off
`$CI_PIPELINE_SOURCE`.  Three are commented-out blocks marked
`TODO: clean this up`.  The team avoids touching the file because
nobody can predict what will break.  When a pipeline fails, the
standard response is to retry it; the standard response when it fails
again is to find the engineer who last touched it.

This book is not about that file.  This book is about the discipline
that keeps that file from happening in the first place.

The thesis is that integration-level automation — the layer above
individual jobs and below the CI vendor — has six structured concerns:
**Specifications**, **Landscapes**, **Topologies**, **Runtimes**,
**Credentials**, and **Data Profiles**.  Each is a typed configuration.
Each lives in a file (or a database) with a schema.  Each has a name
that other configurations and orchestrations refer to.  None of them
is an environment variable.

When those six concerns have first-class typed expression, the
hundred-line vendor YAML collapses to thirty.  Most of the thirty is
the workflow itself, expressed in a tool-neutral form that can run on
GitLab CI, GitHub Actions, Jenkins, or your laptop.  The "it works in
CI but not locally" problem disappears, because there is nothing in CI
that wasn't on your laptop — there are only different *profiles* that
select different *named* configurations of the same shape.

A word the reader will meet repeatedly: a **Pod** is the runtime
realisation of a Landscape and a Topology — the actual collection of
interoperating resources a job runs against.  Pods are the unit of
horizontal CI parallelism (Volume 2 chapter 3, Volume 6 chapter 9):
within one Pod, a job runs sequentially because the Pod is a shared
environment; across independent Pods, jobs run in parallel because
Pods are independent.  When the rest of this volume talks about
"more horizontal scale," the answer is *more Pods* — provisioned
through more Specifications — not more parallel tests inside one
Pod.

The portfolio of `codecraftai-*` packages implements this discipline.
The configuration loader supports five of the six categories today
(the sixth is in flight).  The orchestration layer is `codecraftai-taskplus`.
The job layer below it is `codecraftai-testbase` and its distributed
extension `codecraftai-testplus`.  The credentials story is
`codecraftai-credentials`.  The book draws its examples from these
packages because the discipline is implemented there, not because the
discipline depends on them — the same patterns translate to any
toolkit a reader might be using.

A note on voice.  The series is narrated by CodeCraftX, the persona
that opens every `codecraftai-*` repository's `AGENTS.md` and that
embodies the platform's rules: debuggability paramount, explicit over
implicit, errors should never pass silently, properties as simple data
accessors, every function ending in an explicit `return`.  Those rules
are introduced in Volume 0 of this series; they are referenced here.
A reader who has not yet read Volume 0 will still follow the argument
of this volume, but the rationale for some of the apparently
unconventional choices — the rejection of decorator-based retry, for
example, or the insistence that `SemanticError` always bubble — lives
there.

A note on order.  The volume is structured into six parts.  Parts I
through IV present the discipline before the implementation.  Part V
is where the implementation lands — the `taskplus` task model,
workflow YAML, and FlowEngine.  Part VI wires everything to a real CI
vendor and walks through a worked port of an unmaintainable
fifty-step YAML to a thirty-line workflow plus five named
configurations.  A reader who is impatient and wants to see the code
first can read Part V before Parts II–IV; the discipline-first
ordering is the recommendation, not a requirement.

Finally: this is not the only viable approach to integration
automation.  Chapter 27 is honest about where the discipline does not
fit — heavy parallel fan-out, dynamic workflow generation from
streaming events, sub-second event-driven flows.  The book is opinionated
because the alternative is bland; the chapter where the opinions are
qualified comes at the end, where it should.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/01-what-integration-automation-is -->
# Chapter 1 — What Integration-Level Automation Is

> *Most CI failures are not test failures.  Most CI failures are
> environment failures, configuration failures, or coordination
> failures.  The discipline that separates the three is integration
> automation.*

A pipeline run is conventionally drawn as a stack of layers:

```
┌─────────────────────────────────────────────────────┐
│  CI provider (GitLab CI / GitHub Actions / Jenkins) │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│  Orchestration                                      │  ← integration
│   "what high-level steps run, in what order,        │     automation
│    with what fallbacks?"                            │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│  Jobs                                               │
│   "given step N is 'run tests', execute the         │
│    test suite (possibly across N hosts)."           │
└─────────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────┐
│  Tasks                                              │
│   individual tests, sysman feature calls,           │
│   interop ops, the things the jobs are made of      │
└─────────────────────────────────────────────────────┘
```

This chapter is about the second layer.  Most teams conflate it with
the first or the third — they put orchestration logic into vendor YAML,
or they put orchestration logic into job code as conditional branches
keyed off environment variables.  Both choices have predictable failure
modes.  Both choices are how the fifty-line YAML grows to six hundred
lines.

## What "integration-level" means

Three jobs to be done sit at the orchestration layer:

1. **Sequencing.**  Which steps run, in what order, with what
   parallelism, with what fallback when one fails.
2. **Parameterisation.**  Which configuration is in scope for this run
   — which landscape, which runtime profile, which credential set,
   which data profiles.  This is not "set forty environment variables";
   this is "select named typed configurations."
3. **Coordination.**  How information flows between steps — the output
   of step *N* informing the input of step *N+1*; the artifacts of
   build informing the inputs of test; the results of test informing
   the deploy gate.

A well-built orchestration layer has clean expression of all three.  A
typical CI YAML expresses (1) clearly, (2) badly, and (3) by accident
— the accidental expression being string-substitution into shell
commands of values that came from environment variables that were
exported by an earlier step that nobody remembers anymore.

## What "integration-level" doesn't mean

Three things that are *not* integration-level automation, despite often
being conflated with it:

- **Build automation.**  Compiling code, linking artifacts, packaging
  containers — these are tasks an orchestration may *invoke*, but they
  are not orchestration.  A build script is a single step in a larger
  flow.  When the build script tries to also be the orchestration, the
  Makefile is fifteen hundred lines and contains a function called
  `is_running_in_ci`.
- **Job execution.**  Running a test suite, running a deployment,
  running a sysman feature against a fleet — these are jobs.  Jobs
  may run on multiple hosts; jobs may have internal sequencing of
  their own (Volume 2 on distributed automation is about exactly this).
  But a job is invoked *by* an orchestration; it is not an orchestration.

  A point of vocabulary worth pinning down here: the *test job*
  hierarchy in Volume 2 (`TestJob` → `InteropLibraryTestJob` →
  `DistributedTestJob`) and the *orchestration job* class
  (`OrchestrationJob` in `codecraftai-landscaping/source/.../orchestrationjob.py:34`)
  are **separate hierarchies**.  `OrchestrationJob` inherits from
  `ContextUser`, not from `TestJob`.  This volume treats orchestration
  jobs; Volume 2 treats test jobs.  They meet at the bridge tasks of
  chapter 25 and not before.
- **Provisioning.**  Creating cloud resources, allocating containers,
  setting up databases — these are *resources* an orchestration
  consumes, expressed declaratively as Specifications (chapter 4).
  When a CI YAML contains forty lines of `aws ec2 create-instance`
  shell, those lines are misplaced; the orchestration should consume a
  resource by name, not by recipe.

The discipline of integration automation is to keep these three out
of the orchestration layer.  The orchestration says "this Specification
must be live" or "run this Job" or "publish this artifact."  It does
not contain the recipe for any of the three.

## Why this matters

Three concrete consequences flow from getting the layer separation
right:

### Pipelines become readable

A workflow that invokes a Job by name (rather than inlining seventy
lines of shell to do the Job's work) is short.  Short pipelines fit in
code review; long pipelines do not.  Pipelines that fit in code review
get reviewed; pipelines that do not, do not.  The first place
discipline pays off is in the simple act of reading what the pipeline
does on a Tuesday afternoon when something has gone wrong.

### Pipelines become portable

A workflow expressed in `codecraftai-taskplus`'s YAML is identical
whether it runs on GitLab CI, GitHub Actions, Jenkins, or a developer's
laptop.  The CI vendor's YAML shrinks to a trigger and a runner image;
everything else moves into the portable workflow.  The portable
workflow can be checked into the same repo as the code; it can be
versioned with the code; it can be diffed; it can be tested in
isolation.

### Pipelines become debuggable

When a pipeline fails, the question "what configuration was in scope
when it failed?" has an answer.  The answer is the names of the typed
configurations the orchestration selected: landscape `staging-east`,
runtime `verbose`, credentials `ci-runner`.  Those are typed objects
with schemas; they are not the union of forty environment variables
exported in unknowable order.  An engineer reproducing the failure
locally selects the same names.  This is the subject of Part IV.

## The shape of this volume

The volume has six parts.  This chapter — and the next two — present
the discipline.  Part II names the six configuration categories and
introduces each.  Part III is the credentials chapter (which is a
thicker subject than the other five categories combined and earns its
own part).  Part IV is the local-dev → CI → staging → prod story —
how the discipline pays off in real workflows.  Part V is the
implementation in `codecraftai-taskplus`.  Part VI is the case study and
the wiring to real CI vendors.

A reader who learns nothing else from this volume should leave with
this: the orchestration layer is not the place for environment
variables.  Environment variables are the symptom of a missing
configuration category.  Every time you find yourself reaching for an
`export FOO=bar` in a pipeline, ask which of the six categories the
value belongs in — and then put it there instead.

## Summary

- Integration-level automation is the orchestration layer between the
  CI provider and the jobs that do work.
- It has three jobs: sequencing, parameterisation, coordination.
- Most teams conflate it with build automation, job execution, or
  provisioning.  All three conflations are how pipelines grow
  unmanageably.
- The discipline of this volume is to keep the orchestration layer
  small and to express its parameterisation as named typed
  configurations rather than as environment variables.
- The six configuration categories — Specifications, Landscapes,
  Topologies, Runtimes, Credentials, Data Profiles — are introduced in
  Part II.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/02-why-ci-yaml-grows -->
# Chapter 2 — Why Most CI YAML Grows Monstrously

> *Every CI YAML file that reaches six hundred lines was once a
> fifty-line file.  No one ever set out to write the six-hundred-line
> version.  The growth is the symptom of a category mistake at the
> first commit.*

This chapter is about how a CI configuration file gets out of hand.
The argument is not that vendor YAML is bad — GitLab CI, GitHub
Actions, and Jenkins have all done genuinely good engineering on the
expressiveness of their configuration formats.  The argument is that
vendor YAML is asked to do six things at once, and the absence of
typed structure for any of them is what makes the file unreadable
after a year.

If you understand the six failure modes in this chapter, you understand
why the rest of the volume insists on naming and typing the things
those failure modes blur together.

## Failure mode 1: the environment-variable accretion

The first symptom of trouble is `export`.

A team adds `BUILD_VERSION` to set the version stamp on a build.  The
next quarter, someone adds `STAGING_HOST` for a deployment step.  Six
months later there is `DB_CONN_STRING`, then `RETRY_LIMIT`, then
`USE_FAST_PATH`, then `LANDSCAPE_OVERRIDE_FILE`.  Each export was
added by a different engineer; each was added because at the time it
was the path of least resistance.

The accreted environment-variable set has four problems:

1. **No schema.**  No one can list the full set of variables the
   pipeline reads without grepping the entire codebase.  When a
   variable is removed, no one notices for months.
2. **No types.**  Every value is a string.  `RETRY_LIMIT=3` and
   `RETRY_LIMIT=three` and `RETRY_LIMIT=` and `RETRY_LIMIT=`
   (trailing whitespace) all behave differently and none of them fail
   loudly.
3. **No namespacing.**  `HOST` could mean the build host or the
   target host or the database host.  When two scripts export `HOST`
   for different meanings, the second one wins; whichever script ran
   second gets to define what `HOST` means for the rest of the
   pipeline.
4. **No provenance.**  When a pipeline behaves differently than
   expected and the engineer asks "why did `RETRY_LIMIT` end up at
   1?", the answer requires reading every step that ran before the
   failing one to find which one re-exported it.  There is no audit
   trail.

The deeper problem is that **environment variables are the wrong
data structure for parameterising orchestrations.**  They are a
process-isolation mechanism repurposed as a configuration system, and
they fail at configuration in all the ways above.  Chapter 7 makes
the affirmative case for runtime configurations as the right data
structure.

## Failure mode 2: the conditional thicket

The second symptom is `if`.

A pipeline that needs to behave one way in CI and another way locally
grows a conditional.  Then the staging environment gets its own
conditional.  Then the nightly long-run gets one.  After a year:

```yaml
script:
  - if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
      ./scripts/run-fast.sh;
    elif [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
      if [ "$NIGHTLY_FULL_SWEEP" = "1" ]; then
        ./scripts/run-everything.sh;
      else
        ./scripts/run-medium.sh;
      fi;
    elif [ "$CI_COMMIT_REF_NAME" = "main" ]; then
      ./scripts/run-staging.sh;
    else
      ./scripts/run-fast.sh;
    fi
```

This is a composite of four orthogonal concerns smeared into one
shell expression:

- *Which test scope?* (fast / medium / full)
- *Which target environment?* (local / CI / staging)
- *Which trigger?* (MR / push / schedule)
- *Which special-mode override?* (the `NIGHTLY_FULL_SWEEP` flag)

Each concern wants its own typed configuration.  The conditional
thicket exists because the team had no place to put four named
profiles, so they hid the profile selection inside a shell
conditional.  Chapter 14 names this shape and proposes the discipline.

## Failure mode 3: the inlined recipe

The third symptom is forty lines of `aws ec2 ...` in a deploy step.

This is a pipeline that has absorbed provisioning logic.  The
pipeline does not consume a *cloud VM* by name; it contains the
recipe for creating one.  The recipe is hand-rolled, idempotent only
on best-effort, and copied between three pipelines that all want a
similar VM.  When the cloud SDK ships a new minor release, three
pipelines break.

The inlined recipe is the orchestration layer absorbing work that
belongs in a Specification (chapter 4).  A well-built orchestration
*declares* the resources it consumes — "I need an EC2 instance
matching specification `runner-medium`" — and a separate provisioning
layer turns that declaration into an actual instance.  When the
cloud SDK changes, the provisioning layer changes; the pipelines do
not.

## Failure mode 4: the credential leak through env

The fourth symptom is `DB_PASSWORD: $CI_DB_PASSWORD` in the YAML.

Vendor CI systems all provide secrets management — masked variables
that don't print to logs.  But the moment a secret is exposed as an
environment variable, three things go wrong at once:

1. The secret is now in the process environment of every step that
   runs after it is exported, including steps that should not have it.
2. The secret can be accidentally logged when a child process echoes
   `env` for debugging — the masking applies to the CI system's log
   capture but not to the inner subprocess.
3. The secret has no scope, no rotation, no audit.  It is a string
   in the bag of strings.

A typed credential (chapter 9) has an identity, a type, a scope, and
a lifetime.  It is fetched from a credential store at the moment of
use and not earlier.  It is never an environment variable; it is a
typed object.  The vendor's masked-variable feature is the *first*
line of defence, not the *only* line of defence.

## Failure mode 5: the host name in the YAML

The fifth symptom is a hardcoded hostname or IP address.

A pipeline that is going to run a test against a staging database
contains `STAGING_DB_HOST: db-staging-east-1.internal`.  Six months
later there is also `STAGING_DB_HOST_FAILOVER`,
`STAGING_DB_HOST_AS_OF_2026Q1`, and a comment explaining which is
current.  Eventually someone copies the pipeline to test against
prod-readonly and forgets to update the host; the test runs against
staging by accident, no one notices, and the test was meaningless for
three months.

The problem is the absence of a Landscape (chapter 5) and a Data
Profile (chapter 8).  A landscape names hosts; a data profile names
*the database* without saying which host it lives on; a runtime
configuration says which landscape and which profile are in scope for
this run.  The pipeline references `staging-db` (a profile name).
The profile is resolved at runtime to the current host.  When the
host changes, the profile changes; the pipeline does not.

## Failure mode 6: the topology buried in step ordering

The sixth and most subtle symptom is correct step order with no
explanation.

A pipeline runs `prepare-db` before `start-app` because the app
expects the schema; it runs `start-app` before `run-smoke-tests`
because the smoke tests hit the app; it runs `run-smoke-tests` before
`tear-down` because tear-down needs the smoke results.  The
*topology* of the system — the dependencies between components — is
encoded as the order of steps in a flat list.  When someone wants to
add a step, they must reason about the implicit graph from a flat
list.  When the graph has fifteen edges, the list reordering becomes
error-prone.

A Topology (chapter 6) makes the graph explicit.  Steps declare which
components they need to be live; the engine sequences them.  The flat
list reappears as an *output* of topology resolution rather than as
the *input* it had to be.

## The pattern across all six

Each failure mode is the absence of a typed configuration category:

| Failure mode | Missing category |
|---|---|
| 1 — env-var accretion | All six (the env vars are the symptom) |
| 2 — conditional thicket | Runtimes (chapter 7) |
| 3 — inlined recipe | Specifications (chapter 4) |
| 4 — credential leak | Credentials (chapters 9–12) |
| 5 — host name in YAML | Landscapes (chapter 5) + Data Profiles (chapter 8) |
| 6 — topology in ordering | Topologies (chapter 6) |

The six categories are not arbitrary.  Each one is the answer to a
recurring failure mode of CI YAML.  The next part of the volume
introduces them in turn.

## Why this matters even if you keep using vendor YAML

A reader who finishes Volume 3 may not adopt `codecraftai-taskplus` — and
that's fine.  The discipline does not depend on the toolkit.  The
discipline says:

- Express each of the six categories as a typed file.
- Reference those files by name in your pipeline.
- Resolve the references at runtime, not at YAML-write time.
- Compose configurations through overlay, not through string
  substitution.

A team that does this in GitLab YAML has a shorter, clearer YAML.  A
team that does this with a tool-neutral workflow runner like
`taskplus` has a portable workflow.  Either is better than the
six-hundred-line file.  The book makes the case for the tool-neutral
path because portability has compound benefits — local-dev parity,
CI-vendor migration, debug reproducibility — but the discipline
itself is the larger lesson.

## Summary

- Six common failure modes turn fifty-line CI YAML into six-hundred-line
  CI YAML.  Each failure mode is the absence of a specific typed
  configuration category.
- Environment variables are the wrong data structure for orchestration
  parameterisation.  They lack schema, types, namespacing, and
  provenance.
- Conditional thickets, inlined recipes, leaked credentials,
  hardcoded hostnames, and implicit topologies are all instances of
  the same problem: a missing typed configuration.
- The next part of the volume names the six categories and shows how
  each one closes a failure mode.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/03-the-six-categories -->
# Chapter 3 — The Contract: Six Typed Configuration Categories

> *Every parameter to every pipeline belongs in exactly one of six
> categories.  When you find yourself reaching for an environment
> variable, ask which of the six the value belongs in — and then put
> it there instead.*

The previous chapter named six failure modes of CI YAML and pointed
out that each one is the absence of a typed configuration category.
This chapter names the six categories.  The chapters in Part II
introduce them one at a time with file-format examples and worked
patterns.  The chapters in Part III dig into Credentials, the most
substantial of the six, in dedicated detail.  This chapter is the
table of contents for everything that follows in Parts II–III.

## The six categories at a glance

| Category | What it describes | What it answers | Implementation in the portfolio |
|---|---|---|---|
| **Specifications** | Resources to create or check out before the orchestration runs | What needs to *exist* — VMs, containers, accounts, repos, databases | `codecraftai-provisioning` (`ProvisioningEngine`) |
| **Landscapes** | The environment of hosts, devices, networks, services | What's *available* — the inventory under automation | `codecraftai-configuration` (Landscape category) + `codecraftai-landscaping` |
| **Topologies** | The relationships between landscape entities | How the inventory is *connected* — clusters, dependencies, ordering | `codecraftai-configuration` (Topology category) |
| **Runtimes** | How processes execute — timeouts, retry, parallelism, log levels, sentinels | How everything *runs* — the knobs | `codecraftai-configuration` (Runtime category) |
| **Credentials** | Authentication material, scoped to hosts and services | How things *authenticate* | `codecraftai-credentials` (vocabulary) |
| **Data Profiles** | Connection profiles for data stores | Where data *lives* | `codecraftai-configuration` (DataProfile category) + `codecraftai-dataprofiles` |

Five of the six categories are loaded by `codecraftai-configuration`.
Specifications is the exception — it is consumed by the provisioning
engine before the orchestration starts and is therefore handled by a
different package.  The discipline is unified at the orchestration
layer; the implementation has a seam at the provisioning boundary.

Each category is a separate concern.  They compose, but they do not
overlap.  A landscape file does not contain credentials; a credentials
file does not contain runtimes; a topology does not contain
infrastructure listings.  The separation is what makes them
independently versionable, swappable, and reviewable.

A note on a *seventh* file the reader will encounter in
`codecraftai-docs/userguide/03-07-user-file.rst`: **`user.yaml` is
not a seventh category.**  It is the **default Runtime overlay
for local-user runs** — the bottom layer of the runtime
composition stack on a developer laptop, with explicitly-
selected Runtime files merging on top.  CI runs do not include
`user.yaml`.  Functionally the User file is a Runtime; it is
documented separately because its *role* is operator-personal
(my preferences on my laptop), not orchestration-shared.

**A property worth naming up front: all six categories are
uniformly composable.**  Every category resolver in
`codecraftai-configuration` takes a *list* of names and a *list*
of files and merges them via `MergeMap` overlay; later names
override earlier names where keys collide.  Composability is
what enables profile composition (Part IV) — the same machinery
that overlays `UPNP Diagnostics + Bluetooth Diagnostics` for a
Runtime overlays `Checkout Resources + Environment Resources`
for a Landscape and `lab-wide + experiment-overrides` for
Credentials.  One pattern, six categories.

## What every category has in common

Five rules apply to all six categories:

1. **Typed file at rest.**  Each category is one or more YAML or JSON
   files.  The loader tries `.yaml`, `.yml`, `.json` in that order.
   This is implemented in `codecraftai-configuration`'s
   [`DirectorySource`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/sources/directorysource.py)
   for filesystem sources; the same fallback applies to HTTP, CouchDB,
   and MongoDB sources.

2. **Lookup by name, not by path.**  Pipeline code says "use landscape
   `staging-east`," not "open `/etc/ccai/landscapes/staging-east.yaml`."
   The mapping from name to file (or to database document) is the
   loader's job.  This is what makes the same pipeline runnable in
   local-dev (where `staging-east` resolves to a file in `~/.ccai/`),
   in CI (where it resolves to an HTTP URL on a config service), and
   in a debug replay (where it resolves to a snapshot inside a tarball).

3. **Encryption is a property of the file, not the category.**  Any
   category can be Fernet-encrypted by setting an `"encrypted_content"`
   key with the ciphertext and a `"format"` field for the inner
   payload's format.  The loader transparently decrypts when given a
   key or keyphrase.  This is done at
   [`ConfigurationLoader`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationloader.py).

4. **Composition through MergeMap, not through string substitution.**
   Each category's resolved configuration is a
   [`MergeMap`](https://github.com/code-craft-ai-company/codecraftai-xmodules/blob/main/source/packages/ccai/xmodules/xcollections/mergemap.py),
   which holds an ordered list of source maps and composes them with
   overlay semantics — later maps shadow earlier maps key by key, but
   the lower-priority entries are preserved and inspectable.  This is
   what makes profile composition (chapter 14) work.

5. **Resolved into the application context.**  After loading, each
   category (except Credentials) is inserted into the shared
   serializable application context at a known
   [`ContextPath`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/contextpaths.py).
   This is how the configuration travels with the process and reaches
   distributed workers.  Credentials are deliberately *not* inserted
   into the context — that's a security boundary, covered in
   chapter 11.

## How the categories relate to each other

The six categories form a layered model.  Specifications create the
inventory.  Landscapes describe the inventory.  Topologies describe
the relationships within the inventory.  Runtimes parameterise the
processes that operate on the inventory.  Credentials authenticate
the processes to the inventory.  Data Profiles tell the processes
where to find data within the inventory.

```
                ┌──────────────────────────────────────────┐
                │  Specifications                          │
                │  "what should exist"                     │
                │  consumed by codecraftai-provisioning       │
                │  before the orchestration starts         │
                └──────────────────────┬───────────────────┘
                                       │ creates
                                       ▼
                ┌──────────────────────────────────────────┐
                │  Landscapes                              │
                │  "what's there"                          │
                │  hosts, devices, networks, services      │
                └──────────────────────┬───────────────────┘
                                       │
                ┌──────────────────────┴───────────────────┐
                │                                          │
                ▼                                          ▼
   ┌─────────────────────────┐         ┌──────────────────────────┐
   │  Topologies             │         │  Data Profiles           │
   │  "how it's connected"   │         │  "where data lives"      │
   │  clusters, dependencies │         │  per-store connection    │
   └─────────────────────────┘         │  profiles                │
                                        └──────────────────────────┘
                                       
                ┌──────────────────────────────────────────┐
                │  Runtimes                                │
                │  "how processes run"                     │
                │  timeouts, retry, log levels, parallelism│
                └──────────────────────────────────────────┘
                
                ┌──────────────────────────────────────────┐
                │  Credentials                             │
                │  "how to authenticate"                   │
                │  per-type, per-scope, per-lifetime       │
                └──────────────────────────────────────────┘
```

The two boxes at the bottom — Runtimes and Credentials — are
orthogonal to the layered model.  Runtimes apply to processes
regardless of which landscape they're in; credentials apply to access
attempts regardless of which topology is in scope.  They are
parameterisations *of the things that act on* the inventory, not
descriptions *of* the inventory.

## A first taste of each category

The chapters in Part II will introduce each category in detail.  As a
preview — just to fix mental images before the full treatment — here
is what each looks like as a YAML fragment:

### Specifications (chapter 4)

```yaml
# spec-runner-medium.yaml — declarative resource manifest
environment:
  region: us-east-1
  zone: us-east-1a
apod:
  name: ci-runner-pool
  members:
    - role: runner
      type: ec2
      size: t3.medium
      count: 3
      image: ami-0abcd...
```

A specification is consumed by `ProvisioningEngine.create_provisioning_engine(specification=...)`
before the orchestration runs.  The orchestration assumes the
specification's resources exist; the provisioning step makes that
assumption true.

### Landscapes (chapter 5)

```yaml
# landscape-staging-east.yaml — inventory of what's there
environment:
  label: staging-east
apod:
  clients:
    - name: ci-runner-1
      role: runner
      hostname: runner-1.staging.internal
infrastructure:
  services:
    - name: api
      hostname: api.staging.internal
      port: 8443
```

A landscape is loaded by name — `landscape-staging-east` — and
resolved into the
[`LANDSCAPE_CONFIGURATION_MAP`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationvariables.py).
Anywhere in the pipeline that needs to know "what hosts are
participating," the answer comes from the resolved map, not from a
hardcoded list.

### Topologies (chapter 6)

```yaml
# topology-staging-east.yaml — relationships
environment:
  label: staging-east
clusters:
  - name: api-cluster
    members: [api-1, api-2, api-3]
    coordinator: api-1
dependencies:
  - source: smoke-tests
    targets: [api-cluster]
  - source: api-cluster
    targets: [database]
```

A topology is loaded alongside a landscape; the landscape says what
exists, the topology says how things relate.  A pipeline that wants
to "tear things down in dependency order" reads the topology, not a
hand-maintained shutdown sequence.

### Runtimes (chapter 7)

```yaml
# runtime-fast-ci.yaml — the knobs
breakpoints: []
debugger: none
logging:
  levels:
    console: WARNING
    logfile: INFO
parallelism:
  max_workers: 8
timeouts:
  default_seconds: 30
property_test_caps:
  max_examples: 10
version: 1.0.0
```

A runtime profile names how processes run.  A different profile —
`runtime-full-sweep.yaml` — sets `max_examples` to unset and
`max_workers` to 1 for a thorough overnight run.  Same code, different
profile, different behaviour.  No environment variables.

### Credentials (chapter 9–12)

```yaml
# credentials-ci-runner.yaml — typed credentials
- identifier: ci-runner-ssh
  type: SshCredential
  username: runner
  keyfile: /run/secrets/ssh/ci-runner
  categories: [runner-fleet]
- identifier: ci-runner-aws
  type: AwsAccessKeyCredential
  keyid: AKIA...
  secret: ${VAULT:/secret/aws/ci-runner}
  categories: [aws-staging]
```

Each entry maps to one of the eleven shipped credential types in
`codecraftai-credentials`.  Resolving the secret value (the `${VAULT:...}`
expansion above) is the reader's responsibility — Part III explains
how to build the resolution layer the portfolio leaves to the
consumer.

### Data Profiles (chapter 8)

```yaml
# dataprofile-staging-db.yaml — connection profiles
- name: staging-db-readwrite
  type: SnowflakeProfile
  account: my-org
  warehouse: staging-wh
  database: app
  schema: public
  credential: staging-snowflake-rw
- name: staging-redis
  type: DatabaseBasicTcpProfile
  hostname: redis.staging.internal
  port: 6379
  credential: staging-redis-default
```

A data profile is the named connection.  The pipeline references
`staging-db-readwrite` by name; at runtime the profile is resolved,
the credential reference is resolved, and the connection opens.  No
URLs in code.  No hostnames in environment variables.

## What the rest of the volume does with this

Part II walks through Specifications, Landscapes, Topologies,
Runtimes, and Data Profiles in five chapters (chapters 4 through 8).
Part III gives Credentials four chapters of its own (chapters 9
through 12) because the credential discipline has more moving parts
than the other five combined.  Part IV (chapters 13 through 17) shows
how to use these categories together as profiles that span the
local-dev → CI → staging → prod ladder.  Part V (chapters 18 through
23) introduces `codecraftai-taskplus` as the workflow engine that
references these configurations by name from a portable workflow YAML.
Part VI (chapters 24 through 27) wires the workflow to a real CI
vendor and walks a worked port end-to-end.

The reader who has finished Part I has the discipline.  The rest of
the book is the implementation.

## Summary

- Six configuration categories — Specifications, Landscapes,
  Topologies, Runtimes, Credentials, Data Profiles — are the
  orchestration layer's typed inputs.
- Five live in `codecraftai-configuration`; Specifications lives in
  `codecraftai-provisioning` because it runs before the orchestration
  rather than alongside it.
- Each category has the same five properties: typed at rest, looked
  up by name, optionally encrypted, composed via `MergeMap`, resolved
  into the shared application context (Credentials excepted).
- The categories form a layered model: Specifications create the
  inventory; Landscapes describe it; Topologies describe its
  relationships; Runtimes and Credentials parameterise the processes
  that act on it; Data Profiles name the data inside it.
- Each subsequent chapter of Parts II and III takes one category and
  shows it in detail.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/04-specifications -->
# Chapter 4 — Specifications

> *A pipeline that contains forty lines of `aws ec2 create-instance` is
> two pipelines pretending to be one.  The first is a provisioner; the
> second is the actual orchestration.  The Specification separates
> them.*

The previous chapter named six configuration categories.  This chapter
introduces the first one: **Specifications** — the declarative
description of the resources that must exist before an orchestration
runs.

Specifications are the only one of the six categories that does not
live inside `codecraftai-configuration`.  They live in
[`codecraftai-provisioning`](https://github.com/code-craft-ai-company/codecraftai-provisioning/tree/main/source/packages/ccai/provisioning).
The reason is structural: the other five categories are loaded *by*
the orchestration as it starts; Specifications are consumed *before*
the orchestration starts.  They describe a state of the world that the
orchestration assumes is true.

## The provisioner-orchestration confusion

Most CI pipelines that automate cloud-backed environments contain two
concerns smeared into one file: how to produce the environment, and
what to do with it once produced.  When the two are mixed, three
predictable failures follow:

1. **Recipes drift.**  The thirty lines of `aws ec2 create-instance`
   in pipeline A are subtly different from the thirty lines in
   pipeline B.  The differences are accidental, accumulated over
   months of small fixes nobody propagated across all three
   pipelines that need similar VMs.

2. **Re-runs are not idempotent.**  Imperative provisioning code is
   hard to make idempotent, because imperative code does not have a
   clean way to say "if the resource already exists in the right
   shape, leave it alone."  Re-running an imperative provisioning
   step on a partially-provisioned environment leaves a mess.

3. **The orchestration cannot run against a pre-existing environment.**
   When the imperative provisioning is part of the orchestration,
   you cannot easily say "skip provisioning today; the environment
   is already up."  The orchestration is welded to its provisioner.

The discipline of this chapter is to factor the two concerns apart.
The orchestration declares the resources it needs.  A separate
provisioning step makes the declaration true.  The orchestration
itself is unchanged whether the provisioning step ran or whether a
matching environment was already up.

## The Specification format

A Specification is a YAML file with two top-level sections:

```yaml
environment:
  # how to provision: where, in which account, with which credentials,
  # under which logical groupings
  venues: [...]
  groups: [...]

apod:
  # what to provision: the concrete devices that make up the
  # automation pod, each referencing a profile that describes the
  # resource shape

  devices: [...]

profiles:
  # the parameterised resource templates referenced by devices

  - {...}
  - {...}
```

The structure is parsed by
[`ProvisioningEngine.create_provisioning_engine(specification=...)`](https://github.com/code-craft-ai-company/codecraftai-provisioning/blob/main/source/packages/ccai/provisioning/provisioningengine.py)
which loads the YAML and instantiates four kinds of model objects.
The four are layered abstractions, not a flat list.  Understanding
the layering is the key to writing maintainable specs.

### Layer 1 — CloudVenue: where we provision

A
[`CloudVenue`](https://github.com/code-craft-ai-company/codecraftai-provisioning/blob/main/source/packages/ccai/provisioning/model/cloud/cloudvenue.py)
describes a target account in a cloud provider:

```yaml
environment:
  venues:
    - name: aws-staging
      provider: aws
      management_url: https://ec2.us-east-1.amazonaws.com
      subscription: "123456789012"   # AWS account ID
      region: us-east-1
      credential: ci-runner-aws
```

A venue says: *this is an AWS account in us-east-1, accessed with the
`ci-runner-aws` credential.*  The `credential` field is a *reference*
into the credentials category (chapters 9–12) — the venue does not
embed secret material; it names a credential the resolution layer
will look up at runtime.

Multiple venues can coexist in one Specification — you can provision
across AWS and Azure in a single spec — but the typical case is one
venue per spec, with multiple specs for multi-cloud setups.  Per
the per-provider provisioners shipped under
[`venues/{aws,azure,google}/`](https://github.com/code-craft-ai-company/codecraftai-provisioning/tree/main/source/packages/ccai/provisioning/venues),
the supported providers are AWS, Azure, and Google Cloud.

### Layer 2 — CloudGroup: how resources are organised

A
[`CloudGroup`](https://github.com/code-craft-ai-company/codecraftai-provisioning/blob/main/source/packages/ccai/provisioning/model/cloud/cloudgroup.py)
describes a logical grouping with associated tags:

```yaml
environment:
  groups:
    - name: ci-runner-pool
      description: "Worker pool for the integration test suite"
      tags:
        - key: cost-center
          value: engineering-test
        - key: lifecycle
          value: ephemeral
```

Groups are the unit of cleanup.  When the orchestration is finished
and the provisioning step is asked to tear down, it tears down by
group.  Devices that share a group share a fate.  Tags travel with
the devices into the cloud provider's billing and management
systems, so the cost-center attribution is automatic.

### Layer 3 — CloudResourceProfile: what kind of resource

A
[`CloudResourceProfile`](https://github.com/code-craft-ai-company/codecraftai-provisioning/blob/main/source/packages/ccai/provisioning/model/cloud/cloudresourceprofile.py)
is a parameterised template for resources of a particular shape:

```yaml
profiles:
  - name: runner-medium
    description: "Standard medium-sized integration runner"
    credential: ci-runner-aws
    parameters:
      instance_type: t3.medium
      image: ami-0abcd1234
      subnet: subnet-0123456789abcdef0
      security_groups:
        - sg-runner-default
      iam_profile: runner-instance-profile
      ebs_volumes:
        - device: /dev/sda1
          size_gb: 30
          type: gp3
    tags:
      - key: role
        value: runner
```

A profile is the *recipe*, but it is the recipe expressed as data,
not as imperative code.  The provisioner for the venue's provider
knows how to turn the parameters into the right calls — the AWS
provisioner reads `instance_type` and calls `RunInstances`; the
Azure provisioner reads the same parameter and calls
`compute.virtual_machines.begin_create_or_update`.  The same
profile, with provider-specific parameter blocks, can target either
cloud.

### Layer 4 — CloudDevice: which specific resources

A
[`CloudDevice`](https://github.com/code-craft-ai-company/codecraftai-provisioning/blob/main/source/packages/ccai/provisioning/model/cloud/clouddevice.py)
is a single instance to be provisioned, referencing a group, a
profile, and a role:

```yaml
apod:
  devices:
    - identifier: runner-1
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags:
        - key: index
          value: "1"
    - identifier: runner-2
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags:
        - key: index
          value: "2"
    - identifier: runner-3
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags:
        - key: index
          value: "3"
```

Three devices, each pointing at the `runner-medium` profile, all in
the `ci-runner-pool` group.  The provisioner instantiates three
EC2 instances of the same shape, tags them per-device, and
collectively tears them down when the group is dismissed.

## A complete worked example

Here is the assembled Specification for a three-runner integration
pool, end to end:

```yaml
# spec-ci-runner-pool.yaml
environment:
  venues:
    - name: aws-staging
      provider: aws
      management_url: https://ec2.us-east-1.amazonaws.com
      subscription: "123456789012"
      region: us-east-1
      credential: ci-runner-aws
  groups:
    - name: ci-runner-pool
      description: "Worker pool for the integration test suite"
      tags:
        - key: cost-center
          value: engineering-test
        - key: lifecycle
          value: ephemeral

profiles:
  - name: runner-medium
    description: "Standard medium-sized integration runner"
    credential: ci-runner-aws
    parameters:
      instance_type: t3.medium
      image: ami-0abcd1234
      subnet: subnet-0123456789abcdef0
      security_groups:
        - sg-runner-default
      iam_profile: runner-instance-profile
      ebs_volumes:
        - device: /dev/sda1
          size_gb: 30
          type: gp3
    tags:
      - key: role
        value: runner

apod:
  devices:
    - identifier: runner-1
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags: [{key: index, value: "1"}]
    - identifier: runner-2
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags: [{key: index, value: "2"}]
    - identifier: runner-3
      category: ec2-instance
      group: ci-runner-pool
      profile: runner-medium
      role: runner
      tags: [{key: index, value: "3"}]
```

The file is consumed by:

```python
from ccai.provisioning.provisioningengine import ProvisioningEngine

engine = ProvisioningEngine.create_provisioning_engine(
    specification="config/specs/spec-ci-runner-pool.yaml"
)

# The engine has parsed the spec and instantiated:
#   1 CloudVenue   (aws-staging)
#   1 CloudGroup   (ci-runner-pool)
#   1 CloudResourceProfile (runner-medium)
#   3 CloudDevice  (runner-1, runner-2, runner-3)
```

The provisioning step, run before the orchestration, ensures three
EC2 instances exist in the `aws-staging` venue, in the
`ci-runner-pool` group, matching the `runner-medium` profile.  If
they already exist with matching identifiers and tags, the step is a
no-op.  If they exist with the wrong shape, the step replaces them.
If they don't exist, the step creates them.

The orchestration that runs *after* provisioning sees a Landscape
(chapter 5) populated with three runners.  The orchestration does
not know — and does not need to know — whether they were created
fresh or pre-existed.

## Idempotence is the design contract

The single most important property of a Specification is that
*applying it is idempotent*.  Apply the same Specification twice,
and the second apply is a no-op.

The reason this matters is operational.  In a healthy CI system,
many things go wrong: a network blip mid-provision, a partial
rollout, a cancelled job that left half a runner pool created.  The
recovery story for a non-idempotent provisioner is bespoke debugging
every time.  The recovery story for an idempotent provisioner is
*re-apply*.

Idempotence is achievable because the Specification is *declarative*.
The spec says "three EC2 instances should exist with these
properties."  The provisioner inspects the current state, computes
the diff, and converges.  Imperative provisioning code does not
admit this analysis.

A consequence of declarative-and-idempotent: a Specification can be
the source of truth for an environment's existence.  Re-running the
provisioner on a schedule keeps the environment in the desired
state, repairing drift.  This is the same operating model
Terraform, Pulumi, and other infrastructure-as-code systems use; the
`codecraftai-provisioning` package brings it inside the portfolio so
the same toolchain that runs the orchestration can also run the
provisioner.

## Composition with the rest of the categories

Specifications sit at the top of the configuration stack and feed
into the others:

```
   Specification
        │
        │ ProvisioningEngine.create_provisioning_engine
        │ (applied)
        ▼
   ┌────────────────────────────┐
   │  Live cloud resources      │
   │  (3 EC2 instances tagged   │
   │   ci-runner-pool / runner) │
   └──────────────┬─────────────┘
                  │
                  │ landscape generation
                  │ (or hand-authored landscape that
                  │  references the provisioned resources)
                  ▼
   ┌────────────────────────────┐
   │  Landscape                 │
   │  (apod members =           │
   │   runner-1, runner-2,      │
   │   runner-3)                │
   └──────────────┬─────────────┘
                  │
                  │ Topology references
                  │ landscape members
                  ▼
   ┌────────────────────────────┐
   │  Topology                  │
   │  (runner-cluster:          │
   │    members = runner-1,     │
   │      runner-2, runner-3)   │
   └────────────────────────────┘
```

The `apod` section in the Specification mirrors the `apod` section
in the Landscape (chapter 5).  This is intentional: the
provisioner's output is the landscape's input.  The shared `apod`
shape is the seam between the two stages of the pipeline.

The credentials referenced by the venue (`ci-runner-aws`) and by the
profile travel through the resolution layer (chapters 9–12) at
provisioning time and again at orchestration time.  The same
credential set is used by both — provisioning to create the
resources, orchestration to access them.

The runtime profile (chapter 7) parameterises *how* the provisioner
runs (timeouts, retry, log levels for the provisioning step itself)
without touching the Specification.  Same provisioner, same spec,
different runtime: useful for distinguishing production-grade
provisioning from local-dev rapid iteration.

## What Specifications is *not*

Three things to keep out of a Specification, every one of which a
team will be tempted to put in:

### Not the orchestration

A Specification is consumed *before* the orchestration runs.  It
describes the static shape of the environment.  It does not
describe what to do with the environment.  Things like "run smoke
tests after provisioning" or "deploy the application image" are
orchestration concerns; they belong in `taskplus` workflows
(chapter 20), not in the spec.

The boundary is sharp: the Specification's output is *resources
exist*.  The orchestration's input is *resources exist*.  The two
do not overlap.

### Not a build script

Building a container image, compiling code, packaging artifacts —
these are not provisioning.  Provisioning creates the substrate;
build steps run on top.  When a Specification grows a `build_image`
section, the section is misplaced; either the image is built once
out-of-band and referenced by the spec's `image: ami-0abcd1234`
field, or the build is its own orchestration step that produces an
artifact the spec consumes.

### Not a runtime config

The Specification carries *structural* data — what hardware shape,
what region, what security group.  It does not carry *behavioural*
data — log levels, retry counts, parallelism.  When a Specification
sprouts a `log_level` field, the field belongs in the runtime
configuration (chapter 7) for the orchestration that runs against
the provisioned environment.

The test of "does this belong in the spec?" is: *would the answer
to this question change if the orchestration ran twice against the
same environment?*  If the answer changes, it's runtime
configuration.  If the answer doesn't change, it's structural and
belongs in the spec.

## Migration: from inlined recipes to Specifications

A pipeline that has accumulated forty lines of `aws ec2 ...` migrates
to a Specification in three steps:

1. **Lift the recipe.**  Take the imperative provisioning shell and
   rewrite it as a Specification YAML.  Each `aws ec2 run-instances`
   call becomes a CloudDevice referencing a CloudResourceProfile.
   Hostnames and IPs that were hardcoded become outputs the
   provisioner returns.

2. **Replace the inline shell with a provisioner invocation.**  The
   pipeline's shell call becomes:
   ```bash
   codecraftai-provisioning apply --spec config/specs/spec-ci-runner-pool.yaml
   ```
   The migration is mechanical; the spec carries the same intent the
   shell carried.

3. **Generate the landscape from the provisioner output.**  The
   provisioner emits the apod section of a landscape file as part of
   its run.  The orchestration consumes the landscape, not the
   provisioner output directly.

After migration, the pipeline's provisioning shell is gone.  The
forty lines have moved to the Specification, which is now versioned,
diffable, idempotent, and shareable across pipelines.

## Where Specifications doesn't fit

Honest scoping notes:

- **Bare-metal labs.**  The portfolio's provisioning model assumes
  cloud APIs.  A lab full of physical hardware needs a different
  provisioning approach (PXE, Ansible against a static inventory, or
  the kind of orchestrated lab-power package
  [`codecraftai-interop`](https://github.com/code-craft-ai-company/codecraftai-interop) ships).  Treat the
  static inventory as the Landscape (chapter 5) and skip the
  Specification.
- **SaaS-only environments.**  If the environment is "an account in
  a SaaS product," there is no provisioner — the provisioning is
  manual, one-time, and out of band.  The spec doesn't apply; the
  orchestration consumes a Landscape that names the SaaS endpoints.
- **Local-dev environments.**  A developer running the pipeline
  against `docker-compose up` is not provisioning cloud resources.
  The Specification is empty (or omitted); the Landscape names the
  local containers.  The orchestration runs unchanged.

The discipline is robust to these cases: when there is no
Specification, the orchestration sees the Landscape directly.  The
spec layer is *additive* — it earns its keep when there's cloud
infrastructure to provision.

## Summary

- A **Specification** is a declarative manifest of the resources that
  must exist before an orchestration runs.
- It lives in `codecraftai-provisioning`, separate from the five other
  configuration categories that live in `codecraftai-configuration`,
  because it is consumed *before* the orchestration rather than *by*
  the orchestration.
- The format has four layered abstractions: **CloudVenue** (where),
  **CloudGroup** (how organised), **CloudResourceProfile** (what kind),
  **CloudDevice** (which specific instances).
- Applying a Specification is **idempotent**: apply twice, no-op the
  second time.  This is the design contract that makes Specs
  recoverable from partial failure.
- The Specification's output (live cloud resources) is the
  Landscape's input.  The shared `apod` shape is the seam.
- Specifications does *not* contain orchestration steps, build
  recipes, or runtime configuration.  Each has its own category.
- Migration from inlined CI shell to Specification is mechanical:
  lift the recipe, swap the inline call for `codecraftai-provisioning
  apply --spec ...`, generate the landscape from the provisioner.
- Where there's no cloud to provision (bare-metal, SaaS-only,
  local-dev), the discipline degrades gracefully — the
  Specification is empty or omitted, and the orchestration runs
  against a static Landscape directly.

The next chapter introduces the Landscape — the inventory of what's
in scope for a pipeline run, whether produced by a Specification or
hand-authored.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/05-landscape-files -->
# Chapter 5 — Landscape Files

> *A test that runs against staging-db doesn't need to know that
> staging-db lives at db-staging-east-1.internal.  The test needs to
> know that staging-db exists.  Where it lives is a property of the
> Landscape, not of the test.*

The previous chapter ended with a Specification that produced three
EC2 instances tagged `runner` in a group called `ci-runner-pool`.
Three instances exist; the orchestration is about to start; the
orchestration needs to know which instances they are and how to
reach them.

That is the job of the Landscape.

A Landscape is the **inventory of what's in scope for a pipeline
run**.  It is the second of the six configuration categories, the
first that lives inside `codecraftai-configuration` (chapter 4's
Specification was the exception).  Every orchestration consumes
exactly one resolved Landscape — the resolution is the result of
loading one or more landscape files by name and merging them through
the standard
[`MergeMap`](https://github.com/code-craft-ai-company/codecraftai-xmodules/blob/main/source/packages/ccai/xmodules/xcollections/mergemap.py)
mechanism introduced in chapter 3.

## The structure of a Landscape file

A Landscape file has two top-level concerns: **who runs the
automation** and **what the automation runs against**.  These are the
`apod` and `infrastructure` sections respectively.  A third
top-level key — `environment` — names and labels the landscape
itself.

The simplest possible landscape:

```yaml
# landscape-minimal.yaml
environment:
    label: minimal

apod:
    clients: []

infrastructure:
    services: []
```

A label, no clients, no services.  This is a valid file — the
orchestration will load it, the resolved landscape will have zero
participants, and the orchestration's first job-resolution step will
report that the landscape is empty.  Useful as a starting template
or as a no-op landscape for syntax-only test runs; not useful for
actual work.

A real landscape — the one shipped at
[`codecraftai-automation-kit/userguide/examples/landscape.yaml`](https://github.com/code-craft-ai-company/codecraftai-automation-kit/blob/main/userguide/examples/landscape.yaml)
— populates both sections:

```yaml
# landscape.yaml — shipped example
environment:
    label: production

apod:
    clients:
    -   deviceType: network/client-linux
        name: casey-01
        host: 192.167.1.21
        role: taskerserver
        credentials:
            - pi-cluster
        features:
            isolation: false
        skip: false

    -   deviceType: network/client-linux
        name: casey-02
        host: 192.167.1.22
        role: taskerserver
        credentials:
            - pi-cluster
        features:
            isolation: false
        skip: false

    # ... casey-03, casey-04 ...

infrastructure:
    services:
        - serviceType: network/service
          name: someservice
          credentials:
              - adminuser
```

Four Linux clients participating in the automation pod, all sharing
the `pi-cluster` credentials, all serving the `taskerserver` role;
one infrastructure service named `someservice` reachable through the
`adminuser` credentials.  That is the landscape under which the
orchestration will run.

The two-section split is deliberate.  **`apod`** (the
**automation pod** — yes, this is where the word *Pod* in the
"Pod model" comes from; Volume 6 chapter 9 walks the runtime
realisation in detail) is the hosts that *participate in* the
automation: runners, drivers, controllers.  **`infrastructure`**
is the systems that the automation *operates against*: services
under test, supporting databases, fixtures.  The distinction
answers a real operational question — *which hosts can the
orchestration reboot, reset, or re-provision without disrupting
itself?*  Answer: only `apod` hosts.  An orchestration that
tried to reboot an `infrastructure` host without coordination
would interrupt the very service its tests are checking; an
orchestration that tried to leave an `apod` host alone forever
could never recover from a runner crash.  The two answers
require two sections.

This Landscape, paired with a Topology that says how these
resources are organised (chapter 6), realises into a **Pod** at
runtime — the actual collection of interoperating apod clients
and infrastructure services a job runs against.  Volume 6
chapter 9 walks the realisation; this chapter ends at the
declaration.

## The `apod` — who runs the automation

The **apod** (automation pod) names the hosts that *participate in
the automation*.  These are the runners, the drivers, the controllers
— the machines doing the work.  Each apod client has a small set of
fields:

| Field | Purpose | Example values |
|---|---|---|
| `deviceType` | Classification path identifying what kind of device this is | `network/client-linux`, `network/client-windows`, `serial/device-arm` |
| `name` | Friendly identifier the orchestration uses to refer to this client | `casey-01`, `runner-east-1`, `dut-mac-mini-3` |
| `host` | The reachable address (IP or DNS name) | `192.167.1.21`, `runner-east-1.staging.internal` |
| `role` | What this client *does* in this landscape | `taskerserver`, `runner`, `coordinator`, `dut` |
| `credentials` | List of credential identifiers used to reach the client | `[pi-cluster]`, `[ci-runner-ssh, ci-runner-aws]` |
| `features` | Per-host feature flags | `{isolation: false}`, `{hardware_acceleration: true}` |
| `skip` | Whether to exclude this client from the current run | `false`, `true` |

Three of these — `deviceType`, `role`, `credentials` — are the
points where the landscape composes with other categories:

- `deviceType` selects which device-handler implementation will be
  instantiated to drive this client.  The handler is registered
  through `codecraftai-extension`'s factory protocol; the landscape
  says "make me a `network/client-linux`," and the factory machinery
  resolves to the right concrete class.
- `role` is application-defined.  It exists for the orchestration
  to filter clients by purpose ("give me all `taskerserver` clients
  in the pod").  The role vocabulary is yours; the framework does
  not constrain it.
- `credentials` references credential identifiers (chapters 9–12).
  The credential material lives in a credentials configuration; the
  landscape names what to look up, not what to use.

The `skip: false` field is the on-the-fly inclusion control.  A
landscape with one client marked `skip: true` is a shorter pod for
this run without the client being deleted from the file.  Useful
during incidents — exclude a flaky runner from one CI run without
permanently removing it.

## The `infrastructure` — what the automation runs against

The **infrastructure** names the *systems under test*: the
applications, services, databases, devices, anything the apod
*operates on* during the run.  Each infrastructure entry has a
similar but smaller field set:

| Field | Purpose | Example values |
|---|---|---|
| `serviceType` | Classification path identifying what kind of service this is | `network/service`, `database/postgres`, `web/api` |
| `name` | Friendly identifier the orchestration uses to refer to this service | `someservice`, `staging-api`, `metrics-collector` |
| `credentials` | List of credential identifiers used to access the service | `[adminuser]`, `[staging-api-token]` |

Service entries can carry additional fields the
`serviceType`-specific handler knows how to read — a
`database/postgres` service might carry `hostname`, `port`,
`database`, `schema` fields; a `web/api` service might carry
`endpoint`, `version`, `auth_scheme` fields.  The shipped example
keeps the fields minimal because the example is illustrative.  The
schema for each `serviceType` is owned by the handler; the landscape
file is a thin envelope.

The deliberate separation between `apod` and `infrastructure` is the
chapter's central architectural claim.  A test runner is not a
system under test, even when both run on the same kind of hardware.
The runner's job is to *operate*; the service's job is to be
*operated upon*.  When a single configuration tries to describe
both without distinguishing them, the orchestration cannot answer
the question "which hosts can I freely reboot, and which hosts must
I never touch?"  With the separation, the answer is structural:
apod members are reboot-safe, infrastructure members are not.

## The `environment` block

The third top-level section, `environment`, names the landscape
itself:

```yaml
environment:
    label: staging-east
```

The `label` is the human-readable identity of the landscape: it
appears in logs, in result reports, in time-travel debugging
sessions.  When an orchestration runs against three different
landscapes through the year, the labels distinguish the runs in
historical analysis.

The `environment` block can carry additional metadata fields
(region, datacenter, cost-center) that aren't fixed by the framework
but travel with the landscape into observability systems.  Like the
service-type extension fields, these are owned by the consuming
code, not by the landscape format.

## The `codecraftai-landscaping` runtime model

The Landscape file is the configuration; the
[`Landscape`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/landscape.py)
class in `codecraftai-landscaping` is the runtime model the
orchestration interacts with.  Volume 6 of this series covers the
runtime model in depth; Chapter 5 of this volume cares only about
the file-to-runtime boundary.

In the runtime model, each apod `client` becomes a
[`LandscapeDevice`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/landscapedevice.py),
each infrastructure `service` becomes a
[`LandscapeService`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/landscapeservice.py),
and the whole landscape is wrapped in a
[`Landscape`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/landscape.py)
singleton with checkin / checkout primitives for safe coordinated
access.  The mapping is one-to-one: every entry in the YAML produces
exactly one runtime object.  No magic, no inference.

## A complete worked example

Building on the Specification from chapter 4 — three EC2 instances
tagged `runner` in `ci-runner-pool` — here is the corresponding
Landscape that consumes the provisioned resources:

```yaml
# landscape-staging-east.yaml
environment:
    label: staging-east
    region: us-east-1
    cost_center: engineering-test

apod:
    clients:
        - deviceType: network/client-linux
          name: runner-1
          host: ec2-runner-1.staging.us-east-1.internal
          role: runner
          credentials:
              - ci-runner-ssh
          features:
              parallelism_max: 4
          skip: false

        - deviceType: network/client-linux
          name: runner-2
          host: ec2-runner-2.staging.us-east-1.internal
          role: runner
          credentials:
              - ci-runner-ssh
          features:
              parallelism_max: 4
          skip: false

        - deviceType: network/client-linux
          name: runner-3
          host: ec2-runner-3.staging.us-east-1.internal
          role: runner
          credentials:
              - ci-runner-ssh
          features:
              parallelism_max: 4
          skip: false

infrastructure:
    services:
        - serviceType: database/postgres
          name: staging-db
          host: staging-db.us-east-1.internal
          port: 5432
          database: app_staging
          credentials:
              - staging-db-readwrite

        - serviceType: web/api
          name: staging-api
          endpoint: https://api-staging.us-east-1.internal
          version: v2
          credentials:
              - staging-api-token
```

Three runners in the apod, two services in the infrastructure.  The
hostnames and IPs are pinned to specific machines; the credential
identifiers are pinned to specific credential records the resolution
layer (chapter 10) will look up.  Nothing in this file embeds a
hostname in code, a credential in plaintext, or a behaviour knob
(parallelism, log level, retry count) where it doesn't belong.

The orchestration that loads this landscape sees a populated
`Landscape` object with three devices (each a `LandscapeDevice`) and
two services (each a `LandscapeService`).  The orchestration's
test-job resolution decides which devices to use for which steps;
the orchestration's data-access layer routes through the services.
The orchestration code itself is unchanged whether it runs against
staging-east, staging-west, or local-dev — only the landscape file
selected by `AM_LANDSCAPE_NAMES` differs.

## Multiple landscapes per project

The Landscape category is plural by design.  A real automation
project will have several landscape files, each named for a
deployment target:

```
config/landscapes/
├── landscape-local-dev.yaml          # one runner, mocks for services
├── landscape-staging-east.yaml       # three runners, real staging services
├── landscape-staging-west.yaml       # mirror of east, different region
├── landscape-staging-canary.yaml     # smaller pod for canary deploys
├── landscape-prod-readonly.yaml      # read-only access for prod debugging
```

The orchestration selects one by name through the `AM_LANDSCAPE_NAMES`
environment variable (the fixed-interface principle from chapter 7).
The plumbing — code, Dockerfiles, CI YAML — references the variable;
the variable's value names the file; the file describes the
landscape.  Adding a new landscape is a single-file change.

A landscape can also be *generated* rather than hand-authored.  Per
the Specification → Landscape seam from chapter 4, a provisioner can
emit the `apod.clients` section directly from the resources it
created.  Hand-authored landscapes describe pre-existing
environments; generated landscapes describe environments the
provisioner brought into existence for this run.  The orchestration
does not distinguish the two.

## Composition with the rest of the categories

The Landscape sits at the inventory level.  Other categories layer
on top of it:

```
                  Landscape
            (who and what; structural)
                       │
        ┌──────────────┼──────────────┐
        │              │              │
        ▼              ▼              ▼
   Topology    Data Profiles    Credentials
   (chapter 6)  (chapter 8)     (chapters 9-12)

       │              │              │
       │ references   │ references   │ resolves
       │ landscape    │ landscape    │ credential
       │ members      │ members      │ identifiers
       │              │              │
       └──────────────┴──────────────┘
                       │
                       ▼
              Runtime profile
              (chapter 7)
              parameterises how
              orchestration uses
              the layered model
```

- **Topology (chapter 6)** says how landscape members relate.  The
  topology references members by name (`runner-1`, `staging-db`).
  Without the landscape, the topology has nothing to refer to.
- **Data Profiles (chapter 8)** carry the connection details for
  data stores reachable in the landscape.  A profile references a
  landscape service by name; the service's `host` and `port` plus
  the profile's connection options yield a connection.
- **Credentials (chapters 9–12)** are referenced by every layer.
  The landscape names credential identifiers; the resolution layer
  produces materialised credentials at runtime; the orchestration
  uses them.
- **Runtime profile (chapter 7)** parameterises the orchestration's
  *use* of the layered model — timeouts, retry, parallelism — but
  does not alter the model itself.

The composition is one-directional.  The landscape is the structural
truth; everything else references it.  Changing a landscape ripples
into the orchestration only at the seams where the orchestration
queries the landscape (which client to run on, which service to call
into).  Topologies, profiles, and credentials referencing the
landscape need their own updates; this is the cost of explicit
references and it is paid up-front rather than as a runtime
mystery.

## What a Landscape is *not*

The Landscape file is restricted by design.  Three things to keep
out:

### Not a topology

Landscape says *what's there*; topology says *how it's connected*.
A landscape lists three runners; a topology over that landscape says
"runner-1 is the cluster coordinator; runner-2 and runner-3 join the
cluster runner-1 forms."  The cluster relationship is not in the
landscape file.

A common mistake: adding a `cluster_members` field to a landscape
client.  Resist this.  When the relationship between two clients
varies by test scenario — runner-1 coordinates in scenario A but is
a worker in scenario B — embedding the relationship in the
landscape makes the same physical landscape need two different
files.  Topology files (chapter 6) are the place for varying
relationships.

### Not a runtime configuration

Landscape says *what's there*; runtime says *how processes run*.  A
landscape lists three runners; a runtime profile says "give the
integration job a 30-second default timeout."  The timeout is not in
the landscape file even though it controls how the landscape is
exercised.

The test of "does this belong in the landscape?" is: *would two
different orchestration runs against the same physical environment
need different values of this field?*  If yes, it's runtime; if no,
it's structural and belongs in the landscape.

### Not a credential store

Landscape says *what credentials to look up*, by identifier.  It
does not store credential material.  A common mistake is to embed a
plaintext password as a `password:` field on a client.  Resist this
forever.  The chapters of Part III explain the credential resolution
discipline; the landscape's role is to name credentials, not to
contain them.

## Migration: from hardcoded hostnames to Landscape references

A pipeline that has accumulated hostnames in shell scripts, in test
fixtures, and in CI YAML migrates to a Landscape in three steps:

1. **Extract the inventory.**  Walk every place in the codebase that
   references a hostname.  Group them: test runners on one side,
   systems under test on the other.  Each group becomes one or more
   landscape entries.

2. **Replace direct references with name lookups.**  Code that did
   `requests.get("https://api-staging.us-east-1.internal/v2/...")`
   now does
   `requests.get(f"{landscape.get_service('staging-api').endpoint}/...")`.
   The orchestration code asks the resolved landscape for the
   service it wants by name; the landscape returns the current host
   and port.

3. **Add per-environment landscape files.**  Once one
   landscape exists, add a `landscape-local-dev.yaml` for the
   developer running the same code against local mocks; add a
   `landscape-staging-west.yaml` for the failover region.  The
   pipeline now runs against any of them by setting
   `AM_LANDSCAPE_NAMES`.

After migration, hostnames in code are gone.  Hostnames in CI YAML
are gone.  When a service moves — staging-api migrates from
us-east-1 to us-east-2 — the change is one line in one landscape
file.

## Where Landscapes don't fit

Honest scoping notes:

- **Trivial single-host scripts.**  A one-off script that just runs
  on the developer's machine doesn't need a landscape.  The
  discipline applies when the same code needs to run against
  multiple environments or with multiple participants.
- **Highly dynamic environments.**  Landscapes that change shape
  every few seconds — autoscaling pools, ephemeral build farms,
  event-driven worker spawning — strain the file model.  The
  portfolio's pattern for this is to *generate* the landscape from
  a service-discovery query at orchestration start, then treat the
  generated file as the inventory for the rest of the run.  This is
  out of scope for this chapter but is consistent with the
  discipline.
- **Configuration that's truly global.**  A "fleet manager"
  configuring thousands of devices across a global deployment is
  beyond what a single landscape file expresses well.  The pattern
  there is to compose multiple landscape files (regional fleets,
  composed at the `MergeMap` layer) — see chapter 14 on profile
  composition.

The discipline scales from one-host local-dev to dozens of
hosts-across-regions cleanly.  Above that scale, the *file* gives
way to file-of-files via composition; the underlying model
continues to apply.

## Summary

- A **Landscape** is the inventory of what's in scope for an
  orchestration run.  It has two participant categories — the
  **apod** (who runs the automation) and the **infrastructure**
  (what the automation runs against) — separated deliberately.
- Apod clients carry `deviceType`, `name`, `host`, `role`,
  `credentials`, `features`, and `skip` fields.  Infrastructure
  services carry `serviceType`, `name`, and `credentials` plus
  service-type-specific extension fields.
- Credential identifiers in landscape entries are *references*; the
  credential material lives in the credential category and is
  resolved at runtime.
- Landscapes are plural by design — one file per environment.  The
  orchestration selects by name through `AM_LANDSCAPE_NAMES`.
- A Landscape does not contain topology (how things relate), runtime
  configuration (how processes run), or credential material (the
  secrets themselves).  Each has its own category.
- Migration from hardcoded hostnames is mechanical: extract the
  inventory, replace direct references with name lookups, add
  per-environment landscape files.
- The runtime model in `codecraftai-landscaping` produces one
  `LandscapeDevice` per apod client and one `LandscapeService` per
  infrastructure service.  The mapping is one-to-one; the file is
  the structural truth.

The next chapter introduces Topology — the relationships between
landscape members, expressed as a separate file so that a single
physical landscape can host multiple orchestration scenarios.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/06-topology-files -->
# Chapter 6 — Topology Files

> *Step ordering is a flat list.  Component dependency is a graph.
> When a flat list pretends to be a graph, inserting a step in the
> middle becomes a logic puzzle.*

The previous chapter introduced Landscape — the inventory of who
and what is in scope for an orchestration run.  A Landscape lists
participants.  It does not say *how those participants relate to
each other*.

Two test runners might both be in the apod, but in one orchestration
they form a redundant cluster (the loss of one is tolerable) and in
another they are a leader-follower pair (the loss of the leader is
not).  Two infrastructure services might both be in the landscape,
but one orchestration has a hard ordering between them ("staging-db
must be live before staging-api can start") while another has no
such constraint.

Relationships of this kind are the third configuration category:
**Topology**.

## Topology is the relationships, not the participants

The boundary between Landscape and Topology is sharp:

| Question | Category |
|---|---|
| Is this host part of the run? | Landscape |
| Does this host depend on that host? | Topology |
| What hostname does this host have? | Landscape |
| What role does this host play in this orchestration? | Topology (when role varies by orchestration) |
| What is this host? | Landscape (`deviceType`) |
| How does this host relate to its peers? | Topology |

The same physical Landscape can host multiple Topologies.  The four
runners in a staging-east apod can be:

- **Topology A — flat:** four independent workers, no coordinator,
  loss-tolerant.
- **Topology B — leader-follower:** `runner-1` is leader; `runner-2`,
  `runner-3`, `runner-4` are followers.
- **Topology C — two clusters:** `runner-1` and `runner-2` form a
  Tier-1 high-availability pair; `runner-3` and `runner-4` form a
  Tier-2 batch pool.

Same hosts.  Same Landscape file.  Three different Topology files.
A pipeline switches between them by setting `AM_TOPOLOGY_NAMES`
(per the fixed-interface principle from chapter 7).  Without the
separation, every relationship change would require duplicating the
Landscape — the four-runner Landscape would become three near-copies,
and any change to the underlying hosts would have to be propagated
across all of them.

This is the chapter's central architectural claim: *Topology is its
own file because relationships vary independently from inventory.*

## A note on the schema

A Landscape file has a shipped schema and a shipped example
([`codecraftai-automation-kit/userguide/examples/landscape.yaml`](https://github.com/code-craft-ai-company/codecraftai-automation-kit/blob/main/userguide/examples/landscape.yaml)).
A Topology file at the time of writing has the *configuration
plumbing* in place — `codecraftai-configuration` loads topology files
through the standard `MergeMap` mechanism and exposes them through
[`AM_CONFIG_TOPOLOGY_NAMES`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationvariables.py)
and the `TOPOLOGY_CONFIGURATION_MAP`, and the
[`TopologyIntegrationLayer`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/layers/topologyintegrationlayer.py)
class is shipped as the runtime hook — but the *schema content* is
consumer-defined.  Different orchestration teams have different
notions of what relationships matter to them.

There is **one exception** to the consumer-defined schema rule.
The framework consumes a `clusters` section with a fixed shape:
each cluster has a `name`, a `group`, a list of `nodes`, and a
list of `spares`.  The shape is consumed by
[`LandscapeOperationalLayer._create_clusters`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/layers/landscapeoperationallayer.py)
which materialises `LandscapeDeviceCluster` objects from the
data.  This is the framework's only structural requirement on
the Topology file's content — every other top-level section
the user defines is parsed as raw `MergeMap` data and passed
through to the user's `Topology` subclass for interpretation.

That single canonical `clusters` shape is also what enables the
verification contract from chapter 21: the framework knows how
to verify a `clusters` section ("are these nodes really cluster
members?  Is the spare really standing by?") because it knows
the shape.  User-defined sections require user-supplied
verification logic in a `Topology` subclass.

This chapter therefore proposes a canonical schema with three
concerns: **clusters** (the framework's shape, extended
slightly), **dependencies**, and **topology-specific roles**.
These three cover the relationship questions that recur across
every orchestration the author has built.  A reader who needs
more (routing tables, replication topologies, sharding
strategies) extends the schema additively; the
`TOPOLOGY_CONFIGURATION_MAP` is a `MergeMap` of arbitrary YAML,
so new top-level keys are free to add — but the user is then
responsible for subclassing `Topology` to interpret them
(Volume 6 chapter 7 walks the subclassing mechanism).

## The proposed Topology schema

A Topology file has four top-level sections: `environment` (which
landscape this Topology applies to), `clusters` (groups that act
together), `dependencies` (directional ordering), and `roles`
(member roles within this Topology).

```yaml
# topology-staging-east-leader-follower.yaml
environment:
    label: staging-east-leader-follower
    landscape: staging-east

clusters:
    - name: runner-cluster
      members:
          - runner-1
          - runner-2
          - runner-3
      coordinator: runner-1
      cardinality:
          min: 2
          max: 4

dependencies:
    - source: smoke-tests
      requires:
          - staging-api
          - staging-db
    - source: staging-api
      requires:
          - staging-db

roles:
    - member: runner-1
      role: cluster-leader
    - member: runner-2
      role: cluster-worker
    - member: runner-3
      role: cluster-worker
```

Walk each section.

### `environment` — naming and binding

```yaml
environment:
    label: staging-east-leader-follower
    landscape: staging-east
```

Two fields:

- `label` — the human-readable identity of this Topology.  Appears
  in logs, in result reports, in time-travel debugging sessions.
  Distinct from the Landscape's label so that "ran against
  Landscape `staging-east` with Topology
  `staging-east-leader-follower`" is unambiguous in retrospective
  analysis.
- `landscape` — which Landscape this Topology applies to.  The
  loader does not enforce the binding (a single Topology file can
  describe relationships that apply across multiple Landscapes); the
  field is documentary, telling a reader which Landscape the
  Topology was *designed* against.  Practically, the
  cross-validation step at orchestration startup checks that every
  member name referenced in `clusters`, `dependencies`, and `roles`
  exists in the resolved Landscape.

### `clusters` — members that act together

A cluster is a named group of Landscape members that the
orchestration treats as a logical unit.  The
[`LandscapeDeviceCluster`](https://github.com/code-craft-ai-company/codecraftai-landscaping/blob/main/source/packages/ccai/landscaping/landscapedevicecluster.py)
class in `codecraftai-landscaping` is the runtime model the
orchestration interacts with; the Topology file is the
configuration that produces the runtime cluster.

```yaml
clusters:
    - name: runner-cluster
      members:
          - runner-1
          - runner-2
          - runner-3
      coordinator: runner-1
      cardinality:
          min: 2
          max: 4
```

Four fields per cluster:

- `name` — the cluster's identifier; orchestrations refer to
  clusters by this name.
- `members` — a list of Landscape member names.  These must resolve
  in the bound Landscape.
- `coordinator` (optional) — the member that plays the coordinator
  role for this cluster.  If unset, the cluster is leaderless.  When
  set, the orchestration's coordinator-aware steps use this field
  rather than electing one at runtime.
- `cardinality` (optional) — soft and hard bounds on the cluster
  size.  `min` is the threshold below which the cluster is
  considered failed; `max` is informational and lets growth-aware
  steps know when to stop adding members.  Either may be omitted.

A Landscape member can belong to multiple clusters in the same
Topology — `runner-1` could be in both a `runner-cluster` and a
`monitoring-cluster` if the orchestration treats it both as a worker
and as a metrics collector.  The discipline does not prohibit this;
it merely requires that each cluster name resolves to a clear
membership.

### `dependencies` — directional ordering

A dependency is a one-directional "X requires Y" relationship
between Topology entities.  Members, services, *and clusters* can
all appear on either side:

```yaml
dependencies:
    # smoke-tests can't run until staging-api and staging-db are live
    - source: smoke-tests
      requires:
          - staging-api
          - staging-db

    # staging-api can't start until staging-db is live
    - source: staging-api
      requires:
          - staging-db

    # the runner cluster (the cluster as a whole) can't operate
    # until staging-api is reachable
    - source: runner-cluster
      requires:
          - staging-api
```

The orchestration uses dependencies for two things:

1. **Sequencing.**  Steps that operate on `staging-api` are not
   started until steps that bring `staging-db` to a ready state have
   completed.  The orchestration's flow engine resolves the
   dependency graph and emits a topologically-sorted execution
   order.  This is what "the flat list of steps becomes an *output*
   of topology resolution" from the chapter's opening means.

2. **Tear-down.**  When the orchestration ends, dependencies run in
   reverse: tear-down of `staging-db` is gated on tear-down of
   `staging-api` (so the API isn't left dangling without its
   database).  Implicit tear-down ordering through hand-maintained
   shutdown sequences is one of the most error-prone patterns in
   pipeline engineering; making the graph explicit here puts the
   graph through code review.

The graph must be **acyclic**.  A cyclic dependency
(A requires B; B requires A) is a configuration error caught at
load time.  The error message names the cycle so the engineer can
break it.

### `roles` — topology-specific assignments

A Landscape member already carries a `role` field — `taskerserver`,
`runner`, `dut`.  That role is the member's *Landscape-time*
identity, the thing that's true regardless of which orchestration
runs.  A Topology overlay can introduce a *Topology-time* role that
varies by orchestration:

```yaml
roles:
    - member: runner-1
      role: cluster-leader
    - member: runner-2
      role: cluster-worker
    - member: runner-3
      role: cluster-worker
```

The same `runner-1` is `runner` in the Landscape and `cluster-leader`
in this Topology.  In a different Topology
(`topology-staging-east-flat.yaml`) the same `runner-1` is just
`worker`, with no leadership status.

The role vocabulary is application-defined.  The framework makes no
distinction between `cluster-leader` and `quorum-witness` and
`canary-runner`; those are names the orchestration code recognises
and acts on.  The Topology file is where the names are bound to
members.

A common usage pattern: orchestration code queries the resolved
Topology for "all members with role X" and dispatches work
accordingly.  When `cluster-leader` work needs to happen, the code
asks the Topology, gets `runner-1`, and routes the call.  The
Landscape is unchanged.

## A complete worked example

Here is a Topology that pairs with the staging-east Landscape from
chapter 5.  The Landscape had three runners and two services
(`staging-db`, `staging-api`).  This Topology arranges them as a
leader-follower runner cluster with a clear database-then-API
ordering:

```yaml
# topology-staging-east-leader-follower.yaml
environment:
    label: staging-east-leader-follower
    landscape: staging-east

clusters:
    - name: runner-cluster
      members:
          - runner-1
          - runner-2
          - runner-3
      coordinator: runner-1
      cardinality:
          min: 2
          max: 3

dependencies:
    # services come up in dependency order:
    # staging-db must be live before staging-api starts
    - source: staging-api
      requires:
          - staging-db
    # the cluster needs the api before any work happens
    - source: runner-cluster
      requires:
          - staging-api
    # smoke tests run last, after everything else is live
    - source: smoke-tests
      requires:
          - runner-cluster
          - staging-api
          - staging-db

roles:
    - member: runner-1
      role: cluster-leader
    - member: runner-2
      role: cluster-worker
    - member: runner-3
      role: cluster-worker
```

The orchestration that loads this Topology obtains a graph it can
walk:

```
staging-db ─────► staging-api ─────► runner-cluster
       │                  │                  │
       └──────────────────┴──────────────────┴──────► smoke-tests
```

The flow engine's startup sequence is `[staging-db, staging-api,
runner-cluster, smoke-tests]`.  The tear-down sequence is the
reverse.  Neither was hand-maintained.

Now consider the alternate Topology — same Landscape, flat
arrangement:

```yaml
# topology-staging-east-flat.yaml
environment:
    label: staging-east-flat
    landscape: staging-east

clusters:
    - name: runner-cluster
      members:
          - runner-1
          - runner-2
          - runner-3
      cardinality:
          min: 1   # any one runner is enough

dependencies:
    - source: smoke-tests
      requires:
          - runner-cluster
          - staging-api
          - staging-db

roles:
    - member: runner-1
      role: cluster-worker
    - member: runner-2
      role: cluster-worker
    - member: runner-3
      role: cluster-worker
```

No coordinator.  Lower cardinality threshold.  All members play the
same role.  The Landscape file is identical; only the Topology file
differs.  The pipeline runs against either by setting
`AM_TOPOLOGY_NAMES=staging-east-flat` or
`AM_TOPOLOGY_NAMES=staging-east-leader-follower` — no other change.

## Composition with the rest of the categories

Topology references Landscape members by name.  It is *referenced
by* nothing else; the resolved Topology is consumed by the
orchestration's flow engine and by orchestration-aware code
querying for roles and clusters.

```
Landscape  ◄──── references ──── Topology
   │                                 │
   │                                 │
   │ also referenced by              │ consumed by
   │                                 │
   ▼                                 ▼
Data Profiles, Credentials      Orchestration flow engine
                                Cluster-aware orchestration code
                                Role-based dispatch
```

The references go one way: Topology to Landscape.  This is what
makes Landscape the structural truth and Topology a layered overlay.
Changing a Topology never invalidates a Landscape; changing a
Landscape may invalidate the Topologies built on top of it (the
cross-validation step at startup catches dangling member references).

The Runtime profile (chapter 7) parameterises *how* the
orchestration walks the Topology — timeouts on dependency-readiness
checks, parallelism within cluster operations — without altering the
Topology.  Same Topology, different Runtimes give different
operational behaviour over the same relationship graph.

Credentials (chapters 9–12) are referenced by the Landscape, not
the Topology.  Topology is the relationship layer; access control
remains a Landscape-and-credential concern.

## Multiple Topologies per Landscape: the architectural payoff

The volume's earlier chapters argued for separation of concerns
across the six categories.  Topology is the chapter where the payoff
is most concrete.

Without Topology files, an orchestration that needs leader-follower
behaviour today and flat behaviour tomorrow has two options:

1. **Two Landscape files** that duplicate the inventory but differ
   in some structural field (`role` per client).  This forces every
   change to the underlying inventory — a host's IP changes, a
   credential rotates — to be made twice.  Eventually the two files
   drift, and one orchestration is run against a stale copy.

2. **A single Landscape with a code-side switch** (an environment
   variable, a CLI flag, a hardcoded conditional) that selects
   between leader-follower and flat behaviour.  This is the Chapter
   2 "conditional thicket" failure mode in fresh costume.

Topology files make this a non-problem.  One Landscape, multiple
Topologies, the relationship choice expressed as data and selected
by name.  An orchestration that needs to support both modes selects
between them by changing one line in the CI workflow:

```yaml
# .gitlab-ci.yml — leader-follower run
integration:
  variables:
    AM_LANDSCAPE_NAMES: "staging-east"
    AM_TOPOLOGY_NAMES: "staging-east-leader-follower"
    AM_RUNTIME_NAMES: "fast-ci"

# .gitlab-ci.yml — flat run
integration:
  variables:
    AM_LANDSCAPE_NAMES: "staging-east"
    AM_TOPOLOGY_NAMES: "staging-east-flat"
    AM_RUNTIME_NAMES: "fast-ci"
```

Three categories each named by their `AM_*_NAMES` variable, each
selectable independently.  This is the configurations-as-content
discipline from chapter 7 applied to the relationship layer.

## What Topology is *not*

Three things to keep out of a Topology file.  Each will be
tempting; resist all three.

### Not a Landscape

Topology references Landscape members; it does not declare them.  A
Topology that contains a `host: 192.167.1.21` field for a member is
a Topology that has absorbed Landscape data.  Resist.  The reason
is the same as in the previous chapter — when one file is the
source of truth for inventory, every other file references it,
without duplication.

If a member appears in a Topology but not in the bound Landscape,
the cross-validation step at startup raises a `ConfigurationError`
naming the missing member.  This is the contract that lets readers
trust a Topology in isolation: every reference resolves, or load
fails loudly.

### Not a workflow

Topology says *what depends on what*; the workflow (chapter 20)
says *what to do, in what order, with what fallbacks*.  These are
related but distinct: the dependency graph from a Topology is an
*input* to workflow planning, not the workflow itself.

A Topology that contains imperative steps (`- run: deploy.sh`) has
crossed into workflow territory.  Resist.  The workflow YAML is
where steps live; Topology is purely declarative relationship data.

### Not a runtime configuration

Topology says *what the relationships are*; runtime says *how the
orchestration treats them*.  A `timeout_seconds` field on a cluster
is misplaced; the timeout applies to *how the orchestration verifies
cluster readiness*, which is runtime.  The Topology should say
"cluster `runner-cluster` exists with these members"; the runtime
profile should say "give each cluster-readiness probe 30 seconds."

The test of "does this belong in the Topology?" is the same as for
Landscape: *would two different orchestration runs against the same
relationship graph need different values of this field?*  If yes,
it's runtime; if no, it's structural and belongs in the Topology.

## Migration: from implicit step ordering to explicit Topology

A pipeline that has accumulated step ordering through hand-maintained
sequences migrates to a Topology in three steps:

1. **Extract the dependency graph.**  Walk the existing pipeline's
   step list and identify the implicit dependencies.  When step *N*
   is "wait for the database to be ready," that's a dependency from
   step *N+1* to a `staging-db` Landscape entry.  When step *M* is
   "deploy the API after the database," that's a dependency from
   `staging-api` to `staging-db`.

2. **Identify the clusters and roles.**  Wherever the existing
   pipeline assumes that some hosts coordinate or that one host is
   the leader, those assumptions become cluster definitions and
   role assignments in the Topology.

3. **Replace the step list with workflow steps that consume the
   Topology.**  The `taskplus` workflow (chapter 20) goes from a
   list of explicit ordering to a set of steps each of which
   declares its required Topology entities; the FlowEngine sequences
   them automatically.

After migration, the pipeline's ordering is no longer imperative.
Adding a step is a Topology change ("smoke-tests requires
runner-cluster") plus a workflow change ("here's the smoke-tests
step"); the position of the step in the execution order is computed
from the graph, not specified by hand.

## Where Topology doesn't fit

Honest scoping notes:

- **Trivial single-step pipelines.**  A pipeline with three
  sequential steps and no branching does not need an explicit
  Topology.  The discipline applies when relationships are
  non-trivial — multiple participants, multiple dependencies, role
  variation across runs.
- **Fully ad-hoc workflows.**  A one-time investigation or a
  developer's debug session does not need a Topology file.  The
  loader's `default-topology` fall-through (which produces an empty
  graph) handles this case — the orchestration runs without Topology
  constraints, and the workflow's explicit step ordering carries
  the day.
- **Streaming graph generation.**  Topologies that change shape
  every few seconds — autoscaling clusters where membership shifts
  during a run — strain the file model.  The pattern there is to
  *generate* a snapshot Topology at orchestration start (from the
  service-discovery output), then treat it as the Topology for the
  run.  Out of scope for this chapter; consistent with the
  discipline.

The discipline scales from "no relationships, single host" to "many
participants with varied relationships across multiple Topologies."
Above that scale, Topologies compose through the standard `MergeMap`
overlay (chapter 14), the same way Landscapes do.

## Summary

- A **Topology** is the relationships between Landscape members,
  expressed as a separate file because the same physical inventory
  can host multiple relationship arrangements.
- The proposed schema has three concerns: **clusters** (members that
  act together, optionally with a coordinator and cardinality
  bounds); **dependencies** (one-directional "X requires Y"
  relationships used for sequencing and tear-down); **roles**
  (Topology-time role assignments distinct from Landscape-time
  roles).
- The configuration plumbing for Topology — `MergeMap` loading,
  `AM_TOPOLOGY_NAMES`, the `TopologyIntegrationLayer` — is shipped;
  the schema content is consumer-defined, and this chapter
  proposes the canonical shape.
- Topology references Landscape members by name; the cross-
  validation step at startup catches dangling references.  No data
  flows from Topology back into Landscape.
- The architectural payoff: one Landscape can host many Topologies.
  Switching between leader-follower and flat behaviour is a
  one-line change to `AM_TOPOLOGY_NAMES`, not a duplicated Landscape
  file.
- A Topology is not a Landscape (no inventory), not a workflow (no
  imperative steps), and not a runtime configuration (no behaviour
  knobs).
- Migration from implicit step ordering to explicit Topology is
  mechanical: extract the dependency graph, identify clusters and
  roles, replace the imperative step list with Topology-aware
  workflow steps.

The next chapter — already drafted — is the thesis chapter on
Runtime files vs Environment Variables.  After that, Part II
finishes with chapter 8 on Data Profiles.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/07-runtime-files-vs-env-vars -->
# Chapter 7 — Runtime Files vs Environment Variables

> *Configuration files allow arbitrary parameterisation of runtime
> behaviour without modifying the automation plumbing code.  This is
> the central engineering claim of the volume.  Every other chapter
> builds on it.*

This is the thesis chapter.  The chapters in Parts II and III have
introduced the configuration categories one by one and shown what
each looks like as a YAML file.  This chapter steps back and explains
*why* the discipline is expressed as files rather than as
environment variables.

The argument is not that environment variables are bad.  Environment
variables are a primitive of operating systems and process trees;
they are useful, well-understood, and unavoidable in any serious
deployment.  The argument is that environment variables are the
wrong place to put **the variation that distinguishes one run from
another**.  Variation belongs in configuration files.  The reason has
nothing to do with aesthetics and everything to do with *the cost of
changing the plumbing that runs the code*.

## The boundary between plumbing and content

Every automation pipeline has two parts:

- **Plumbing.**  The code that reads configuration, the Dockerfile
  that builds the image, the docker-compose file that wires the
  services, the CI YAML that runs the build, the Kubernetes manifest
  that deploys the result, the developer's `.env` file.  The plumbing
  is the *machinery* that runs the code.  It is read by build
  systems, by container runtimes, by orchestrators.  It is owned by
  multiple teams.  It changes slowly.
- **Content.**  The actual values that distinguish one run from
  another.  Log levels.  Retry counts.  Hostnames.  Database names.
  Timeouts.  Parallelism settings.  Feature flags.  Test scopes.
  Content is the *parameters* the machinery runs with.  It is read
  by the application code at startup.  It is owned by the engineers
  who write the code that consumes it.  It changes fast.

The discipline of this volume is: **the boundary between plumbing
and content should fall in a place where the plumbing is small and
fixed; the content carries all the variation.**

When the boundary is in the wrong place — when content leaks into
plumbing — every new behaviour knob requires a plumbing change.  The
plumbing change is expensive because the plumbing spans repos,
teams, formats, and deployment targets.  When the boundary is in the
right place, every new behaviour knob is a single-file change to
content.

This is not a stylistic preference.  It is a scaling property.  A
team adding its tenth behaviour knob in a small project may not
notice the difference.  A team adding its hundredth knob in a large
project lives the difference daily.

## Why the boundary matters: the plumbing tax

Suppose you want to add a new behaviour knob to an automation
pipeline.  Call it `RETRY_LIMIT`: the number of times the integration
job should retry a flaky test before failing.

Adding `RETRY_LIMIT` as an environment variable is the path of least
resistance for the engineer adding it.  It is *not* the path of least
total cost across the system.  Walk through what actually has to
change:

### Layer 1 — the application code

The code that runs the integration job has to read the variable:

```python
retry_limit = int(os.environ.get("RETRY_LIMIT", "3"))
```

This is the cheap layer.  It is also the layer the engineer
remembers.

### Layer 2 — the Dockerfile

If the integration job runs inside a container, the Dockerfile has
to declare the variable so the build records it as part of the image
contract:

```dockerfile
# Dockerfile
ENV RETRY_LIMIT=3
```

Or — more often — the Dockerfile has to *not* declare it, and
instead the runtime is expected to inject it.  Either way, someone
on the container team has to know the variable exists.

### Layer 3 — the docker-compose file

If the developer-facing equivalent of the pipeline runs through
docker-compose, the compose file has to pass the variable through:

```yaml
# docker-compose.yaml
services:
  integration-runner:
    environment:
      RETRY_LIMIT: "${RETRY_LIMIT:-3}"
```

Now the variable exists in three repositories: the application code,
the container build, and the local-dev compose stack.

### Layer 4 — the CI vendor's YAML

The CI pipeline that runs the integration job has to set the
variable explicitly, or has to inherit it from the vendor's secret
store:

```yaml
# .gitlab-ci.yml
integration:
  variables:
    RETRY_LIMIT: "5"
  script:
    - docker run -e RETRY_LIMIT runner-image:latest integration-job
```

If the team uses GitHub Actions and Jenkins as well, the same
variable has to be plumbed through each of them.  Every CI vendor
has its own YAML for environment variables and its own visibility
rules.

### Layer 5 — the Kubernetes manifest

If the integration job is deployed to a Kubernetes cluster — or if
its container is reused by a long-running service that does the same
work — the deployment manifest has to expose the variable:

```yaml
# integration-job.yaml
spec:
  template:
    spec:
      containers:
        - name: runner
          env:
            - name: RETRY_LIMIT
              value: "5"
```

### Layer 6 — the developer's local environment

The developer who is going to debug the integration job locally
needs the variable on their workstation:

```bash
# ~/.zshrc or .envrc or .env
export RETRY_LIMIT=3
```

And there has to be documentation in the project's README explaining
that this is required.

### What it cost to add one knob

Six layers.  Six different file formats.  Three to five
repositories.  Two to four teams (application, container, CI,
infrastructure).  And the engineer who added the variable has now
acquired an obligation: every future change to `RETRY_LIMIT` — its
default, its name, its removal — requires another walk through all
six layers.

This is the plumbing tax.  It is paid once per environment variable.
A pipeline with fifty environment variables has paid the tax fifty
times, and the tax is *cumulative*: when an engineer wants to
understand which environment variables a job actually reads, the
authoritative answer requires reading code in five repositories.

## The fixed-interface principle

The discipline of this volume is to charge the plumbing tax exactly
once and never pay it again.

The plumbing layers above all reference a *small, fixed* set of
environment variables.  These variables do not change when behaviour
changes.  They name the *configurations* the runtime should load:

- `AM_RUNTIME_NAMES` — which runtime profile to use.
- `AM_LANDSCAPE_NAMES` — which landscape to use.
- `AM_CREDENTIAL_NAMES` — which credential set to use.
- `AM_TOPOLOGY_NAMES` — which topology to use.
- `AM_DATAPROFILE_NAMES` — which data profile set to use.

These five environment variables are the fixed interface between the
plumbing and the content.  They are plumbed through the code, the
Dockerfile, the compose file, the CI YAML, the Kubernetes manifest,
and the developer's environment exactly once.  After that, **the
plumbing is finished**.

When `RETRY_LIMIT` needs to exist, it does not become a sixth
environment variable.  It becomes a field inside the runtime
configuration file:

```yaml
# runtime-staging.yaml
retry_limit: 5
logging:
  levels:
    console: WARNING
timeouts:
  default_seconds: 30
```

To add `RETRY_LIMIT`, the engineer:

1. Adds the field to the runtime YAML for the environments where it
   should be set.
2. Adds the read in the application code.

That's two layers — application and config file — instead of six.
The Dockerfile does not change.  The compose file does not change.
The CI YAML does not change.  The Kubernetes manifest does not
change.  The developer's `.env` does not change.  None of them have
to know `RETRY_LIMIT` exists.

This is what *scale* means in the thesis.  Scale is not the number
of variables.  Scale is the number of layers each variable touches.

## What stays in environment variables: the fixed interface

A small set of environment variables earns its place in the plumbing
forever.  The discipline does not eliminate environment variables;
it *minimises* and *stabilises* them.

### Configuration selection

The five `AM_*_NAMES` variables above are the canonical example.
Each names a configuration set the runtime should load.  Their
*names* never change; their *values* differ between runs.  When a
new runtime profile is added (`runtime-staging-east-canary.yaml`),
nothing in the plumbing changes — only the value of
`AM_RUNTIME_NAMES`.

### Process identity

Variables that identify the running process for observability —
`AM_RUN_ID`, `AM_JOB_ID`, `AM_PIPELINE_ID` — also belong in the
plumbing.  They are read by logging, tracing, and metrics machinery
that has its own fixed interface.

### Vendor-mandated bootstrapping

A few environment variables are required by external tooling and
cannot be replaced: `KUBECONFIG`, `AWS_REGION`, `LANG`,
`PYTHONPATH`.  These are the interface to *other* systems, not the
interface to your application's behaviour.  They are also the
plumbing.

Every other environment variable should be a candidate for migration
into a configuration file.  When you find yourself reaching for
`export` for the *content* of a run — log levels, retry counts,
hostnames, parallelism — you are paying the plumbing tax.

## What moves into configuration files: the content

Everything else.  Concretely, the runtime YAML in
[`codecraftai-testbase`](https://github.com/code-craft-ai-company/codecraftai-testbase/blob/main/source/testroots/report-tests/result-tests/v2/example2/runtime-declared.yaml)
— a real shipped file — has the following top-level keys:

```yaml
breakpoints: [testrun-start]
debugger: debugpy
logging:
  levels:
    console: INFO
    logfile: DEBUG
paths:
  output: /var/log/ccai
results-configuration:
  format: jsos
testroot: source/testroots/ccai
version: 1.0.0
```

A team that wants to switch on a new debugger, change a log level,
or alter the test root path edits the YAML.  Nothing else changes.
A team that wants to ship a *new* runtime profile — say, a
`runtime-staging-canary.yaml` — adds the file and references it by
name from `AM_RUNTIME_NAMES`.  Still nothing else changes.

The file format is arbitrary.  It can have any keys the application
code knows how to read.  The schema can grow without coordination
across repositories or teams.  This is what "arbitrary
parameterisation of runtime behaviour" means in the chapter's
opening claim.

## The boundary case: TESTBASE_MAX_EXAMPLES_CAP

The portfolio ships one well-known environment variable that
controls runtime behaviour:
[`TESTBASE_MAX_EXAMPLES_CAP`](https://github.com/code-craft-ai-company/codecraftai-testbase/blob/main/userguide/10-04-03-testing-frameworks-testbase.rst).
It caps the number of cases each property test will run.  Set
`TESTBASE_MAX_EXAMPLES_CAP=10` and a property declared with
`@given(constraints={"max_examples": 50})` runs only 10 cases.
Leave it unset and the property runs at full strength.

Why does the discipline tolerate this one environment variable?

Because it is *one knob* that controls *one well-defined dimension
of behaviour* — fast-CI vs full-sweep — and it has been plumbed
through the test framework, the CI YAML for several portfolio
projects, and the testbase documentation.  The tax has been paid;
the variable is stable; the value the variable controls is exposed
through a single read in `propertyrunner.py`.

This is the boundary case that proves the rule.  One knob via
environment variable scales fine.  Two knobs scales fine.  Twelve
knobs is where the tax compounds.  The discipline says: when you
catch yourself reaching for the second or third behaviour knob via
environment variable, stop and add a runtime configuration file
instead.  The very next knob would have been the one that crossed
the threshold.

In the long term, even `TESTBASE_MAX_EXAMPLES_CAP` will likely
migrate into a runtime configuration field
(`property_test_caps.max_examples`).  It will keep its environment-
variable form as a backward-compatible override, but the canonical
location will move into the runtime YAML.  The variable will then
be the boundary case in the other direction — a legacy environment
variable that survives because it was reasonable when it was added.

## A migration walk-through

Consider a CI pipeline with the following environment-variable
exports.  This is a composite of patterns common in real
maintained pipelines, not a contrived example:

```yaml
# .gitlab-ci.yml — before
integration:
  variables:
    LOG_LEVEL: "INFO"
    LOG_FILE_LEVEL: "DEBUG"
    RETRY_LIMIT: "3"
    RETRY_BACKOFF_MS: "500"
    PARALLELISM: "8"
    TIMEOUT_DEFAULT: "30"
    TIMEOUT_DB: "60"
    TIMEOUT_API: "10"
    OUTPUT_DIR: "/var/log/integration"
    DEBUGGER: "none"
    BREAKPOINT_AT: "none"
    LANDSCAPE: "staging-east"
    DB_HOST: "db-staging-east-1.internal"
    REDIS_HOST: "redis-staging-east-1.internal"
  script:
    - integration-runner $TEST_SUITE
```

Fourteen environment variables.  Each carries a small piece of
content.  Each was added at a different time by a different
engineer.  Each has been plumbed through the layers above.

The migration is mechanical.  Every variable except `LANDSCAPE` is
content.  `LANDSCAPE` is a configuration *selector* — it names a
landscape file — so it stays.  Every other variable becomes a key in
either the landscape file or a new runtime profile:

```yaml
# .gitlab-ci.yml — after
integration:
  variables:
    AM_RUNTIME_NAMES: "fast-ci"
    AM_LANDSCAPE_NAMES: "staging-east"
  script:
    - integration-runner $TEST_SUITE
```

```yaml
# config/runtime/fast-ci.yaml — new
logging:
  levels:
    console: INFO
    logfile: DEBUG
retry:
  limit: 3
  backoff_ms: 500
parallelism: 8
timeouts:
  default_seconds: 30
  db_seconds: 60
  api_seconds: 10
paths:
  output: /var/log/integration
debugger: none
breakpoints: []
```

```yaml
# config/landscape/staging-east.yaml — new (or updated)
environment:
  label: staging-east
infrastructure:
  services:
    - name: db
      hostname: db-staging-east-1.internal
    - name: redis
      hostname: redis-staging-east-1.internal
```

The CI YAML went from fourteen variables to two.  The two that
remain are stable: their *values* will change between runs and
profiles, but their *names* will never change again.

The application code that read `LOG_LEVEL` from the environment now
reads it from the resolved runtime configuration.  The resolution
machinery — `RuntimeVariables`, `RuntimeVariablesDesc`, and
`resolve_runtime_variables` — is owned by `ccai.thisapp` (it moved
there from the now-retired `codecraftai-runtime` package, whose modules
are thin re-export shims during the migration).  The resolved values
are exposed through the `RuntimeVariables` accessor set, which reads
from the application context that the runtime profile was merged into:

```python
# Before
log_level = os.environ.get("LOG_LEVEL", "INFO")

# After
from ccai.thisapp.runtimevariables import RuntimeVariables
log_level = RuntimeVariables.AM_LOG_LEVEL_CONSOLE
```

> *Two ownership levels, one variable surface.*  This rich resolution
> — runtime profile YAML merged into the application context, the
> `RuntimeVariables` accessor set, and the activation that lights up
> the well-known presence folder — belongs to the **integration**
> layer.  `codecraftai-integration-runtime` performs it during its
> [`initialize`](https://github.com/code-craft-ai-company/codecraftai-integration-runtime/blob/main/source/packages/ccai/integrationruntime/initialize.py)
> step (`resolve_runtime_variables` + `activate_profile_job`) and only
> `IntegratedTestJob` consumes it.  The standalone `testbase`
> test-execution core (the `TestJob` / `TestSequencer` / recorder path)
> does *not* import `ccai.thisapp`; it reads only its handful
> of core execution knobs (`AM_TESTROOT`, `AM_OUTPUT_DIRECTORY`,
> `AM_RUN_ID`, `AM_TESTRUN_TIMEOUT`, the `AM_DEBUG_*`/`AM_DIAGNOSTICS_*`
> family) straight from `os.environ` through the tiny dependency-free
> [`ccai.testbase.environment`](https://github.com/code-craft-ai-company/codecraftai-testbase/blob/main/source/packages/ccai/testbase/environment.py)
> module.  The integration layer sets those same environment variables,
> so the fixed-interface variable *names* are identical whether a job
> runs standalone or under the runtime — only the resolution machinery
> behind them differs.

The Dockerfile, the compose file, and the Kubernetes manifest are
all unaffected.  They knew how to pass `AM_RUNTIME_NAMES` and
`AM_LANDSCAPE_NAMES` through; they now pass them through with
different values.  No change to the plumbing.

When the team next wants to add a new behaviour knob — say, a flag
that controls whether the integration job uploads its results to
the central dashboard — the change is one line in the runtime YAML
and one line in the application code.  The plumbing tax does not
get charged.

## The migration pattern: retiring environment variables without breakage

A pipeline cannot migrate fourteen variables in one commit without
risk.  The pattern that works in practice is: introduce the
configuration file, support both forms in parallel for a release,
then remove the environment-variable form.

Use
[`override_variable_value`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/thisapplication.py)
on `ThisApplicationSingleton()` to wire each environment variable to
its corresponding runtime configuration field as a fallback.  Both the
singleton and the variable descriptors live in `ccai.thisapp` —
`ThisApplicationSingleton` in
[`ccai.thisapp.wellknown`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/wellknown.py)
and the `RuntimeVariablesDesc` descriptor set in
[`ccai.thisapp.runtimevariables`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/runtimevariables.py)
(formerly `ccai.runtime.runtimevariables`):

```python
from ccai.thisapp.wellknown import ThisApplicationSingleton
from ccai.thisapp.runtimevariables import (
    RuntimeVariables,
    RuntimeVariablesDesc,
    resolve_runtime_variables,
)


def _bootstrap_runtime_overrides() -> None:
    # Phase 1: resolve the runtime profile into the application context
    resolve_runtime_variables()

    # Phase 2: legacy environment variables override profile values
    if "LOG_LEVEL" in os.environ:
        ThisApplicationSingleton().override_variable_value(
            RuntimeVariablesDesc.AM_LOG_LEVEL_CONSOLE,
            os.environ["LOG_LEVEL"],
        )

    return
```

This is the migration shim.  During the transition release:

- New code reads from the runtime configuration.
- Pipelines that still set `LOG_LEVEL` continue to work — the shim
  promotes the env-var value into the resolved configuration.
- Pipelines that have migrated to the runtime profile work without
  the env var.

Once every pipeline has migrated, the shim is removed.  The
environment variable is gone.  No reach into the plumbing of
multiple repositories was required.

## What environment variables are still good for

The chapter has argued for *minimising* environment variables, not
eliminating them.  In a healthy automation system, the surviving
environment variables fall into three categories:

1. **Configuration selectors.**  The `AM_*_NAMES` family.  These
   name configurations rather than carrying configuration.
2. **Process identity.**  `AM_RUN_ID`, job IDs, pipeline IDs.  Read
   by observability machinery; not behaviour-controlling.
3. **External-system interfaces.**  `KUBECONFIG`, `AWS_REGION`,
   `PYTHONPATH`.  Required by tools the application embeds; the
   discipline does not extend to them.

A pipeline whose environment-variable surface fits in those three
categories is in a healthy state.  A pipeline whose
environment-variable surface includes log levels, retry counts,
hostnames, timeouts, debug flags, output paths, parallelism, or
feature flags has content in the wrong place.  The migration above
is how it gets fixed.

## What the rest of the volume does with this

The thesis of this chapter — *configuration files allow arbitrary
parameterisation of runtime behaviour without modifying the
automation plumbing code* — recurs throughout the rest of the
volume.

- Part III on credentials applies the same boundary: the
  *credential identifier* is plumbing; the *credential material* is
  content.  The plumbing references credentials by name; the
  content is fetched by the resolution layer.
- Part IV on profiles is the discipline of *composing* the content
  side.  A profile is a named bundle of runtime + landscape +
  topology + credentials + data-profile selections.  Switching
  profiles changes the plumbing's *values* (the `AM_*_NAMES`) but
  not the plumbing's *shape*.
- Part V on `taskplus` shows the orchestration layer playing the
  same role: the workflow YAML references jobs and tasks by *name*;
  the job-and-task definitions are content.  Adding a new task type
  does not require modifying the workflow grammar.

The discipline scales because the plumbing is small and the content
is unbounded.

## Summary

- Every automation pipeline has a *plumbing* layer (code, container,
  compose, CI YAML, manifests, dev env) and a *content* layer (the
  values that distinguish one run from another).
- When variation lives in environment variables, every new
  behaviour knob is a plumbing change across six layers, multiple
  repos, and multiple teams.  This is the plumbing tax.
- The discipline keeps the environment-variable interface small and
  fixed: a handful of `AM_*_NAMES` that select configurations, plus
  process identity, plus external-system interfaces.  Everything
  else moves into configuration files.
- A configuration file can be edited freely — new fields, new
  profiles, new schema additions — without touching any plumbing.
  This is what "arbitrary parameterisation of runtime behaviour
  without modifying the automation plumbing code" means.
- `TESTBASE_MAX_EXAMPLES_CAP` is the boundary case: one
  environment-variable knob is fine; many are the failure mode the
  discipline prevents.
- The migration pattern is to introduce the runtime profile,
  support both forms via `override_variable_value` for a transition
  release, then remove the environment variables.
- The thesis recurs throughout the volume: keep the plumbing small
  and fixed; let the content carry the variation.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/08-data-profiles -->
# Chapter 8 — Data Profiles

> *A connection string in environment-variable form is a hostname, a
> port, a username, a password, a database name, and three optional
> flags concatenated into a string nobody can parse reliably.  A data
> profile is the same six items as named, typed fields.*

This chapter introduces the fifth and final category in Part II:
**Data Profiles** — the configuration that names where data lives,
how to reach it, and which credential to use against it.  After this
chapter Part II is complete; Part III then digs into Credentials in
the dedicated detail they deserve.

## The connection-string disease

Every long-lived automation pipeline has, at some point, contained
a line like this:

```bash
export DATABASE_URL="postgresql://app_user:Tr0ub4dor3@db-staging-east-1.internal:5432/app?sslmode=require&pool_size=20"
```

This is the connection string as configuration.  It is also the
connection string as bug-magnet.  Six failure modes follow predictably:

1. **Credential leak.**  The password is embedded in the variable.
   It will appear in `env` dumps, in CI logs, in error tracebacks, in
   the developer's shell history.  Masking helps but never completely.
2. **Untyped concatenation.**  Hostname, port, database, query
   parameters all concatenated.  No schema; one missing slash and
   the whole string parses wrong.
3. **Single source of truth, scattered.**  Three tests, two scripts,
   and one CI step each parse the URL with their own regex.  When
   the URL changes shape — Snowflake adds a `warehouse` segment,
   Mongo Atlas changes the `srv` form — every regex has to be
   updated.
4. **Mixed transport semantics.**  TCP databases, Unix-socket
   databases, document stores, and SaaS endpoints all share the
   "URL string" format with subtly different conventions.  Code
   that worked against TCP Postgres breaks against socket-only Postgres.
5. **No environment portability.**  The local-dev DATABASE_URL is
   `postgresql://localhost/dev`; the staging DATABASE_URL is the
   string above; the prod DATABASE_URL is something else.  Three
   different strings, three different failure modes when the wrong
   one leaks into the wrong run.
6. **Credentials and connection details fused.**  When the password
   rotates, you change the URL.  When the host moves, you also
   change the URL.  Two unrelated lifecycles share one variable;
   each rotation requires touching the other.

The discipline of this chapter replaces the connection string with a
**Data Profile** — a typed object naming each component as a
separate field, with the credential reference stored *separately*
from the credential material.

## What a Data Profile is

A Data Profile is a named, typed description of a data source.  It
has three universal fields and a category-specific extension:

```yaml
- identifier: staging-db-readwrite
  category: basic-tcp
  host: db-staging-east-1.internal
  port: 5432
  dbtype: postgres
  dbname: app
  credential: staging-db-rw
```

The three universals come from
[`BaseDataProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/basedataprofile.py):

| Field | Purpose |
|---|---|
| `identifier` | The name the orchestration uses to refer to this profile |
| `category` | The category discriminator that selects the concrete profile class |
| `credential` | A reference to a credential identifier (chapters 9–12) — never the credential value |

Everything else depends on the category.  Five categories ship in
[`codecraftai-dataprofiles`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/tree/main/source/packages/ccai/dataprofiles);
each is implemented as a concrete class inheriting from
`BaseDataProfile` with its own additional fields and its own
`validate` classmethod.

## The five shipped categories

### `basic-tcp` — TCP-connected databases

For databases reached over TCP with host and port, regardless of
engine.  The shipped class is
[`DatabaseBasicTcpProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/databasebasictcpprofile.py).

```yaml
- identifier: staging-db-readwrite
  category: basic-tcp
  host: db-staging-east-1.internal
  port: 5432
  dbtype: postgres
  dbname: app
  credential: staging-db-rw
```

Required fields: `credential`, `dbtype`, `host`, `port`.  Optional
fields: `dbname`.  The `dbtype` field carries the engine type
(`postgres`, `mysql`, `mariadb`, `redis`, etc.) so consuming code can
select the right driver.

### `basic-socket` — Unix-socket databases

For databases reached over a Unix domain socket — same engine
catalogue as `basic-tcp` but with a filesystem path instead of host
and port.  The shipped class is
[`DatabaseBasicSocketProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/databasebasicsocketprofile.py).

```yaml
- identifier: local-postgres
  category: basic-socket
  socket: /var/run/postgresql/.s.PGSQL.5432
  dbtype: postgres
  dbname: app_dev
  credential: local-postgres-dev
```

Required fields: `credential`, `dbtype`, `dbname`, `socket`.

The split between `basic-tcp` and `basic-socket` is deliberate.  A
single "database profile" category that accepted either a host/port
or a socket would force consuming code to branch on which fields
were present.  Two categories make the dispatch a single
`category: ...` lookup.

### `couchdb` — CouchDB

For CouchDB instances.  The shipped class is
[`CouchDbProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/couchdbprofile.py).

```yaml
- identifier: staging-couchdb
  category: couchdb
  host: couchdb.staging.internal
  port: 5984
  scheme: https
  dbname: configurations
  credential: staging-couchdb-rw
```

Required fields: `credential`, `dbname`, `host`, `port`.  Optional:
`scheme` (defaults to `http`).  CouchDB has its own profile category
because its connection-string format is non-standard (the database
name lives in the path, the auth lives in the userinfo) and because
the
[`codecraftai-configuration`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/sources/couchdbsource.py)
loader supports CouchDB as a *configuration source* — the same
profile shape doubles for "database we read from" and "config store
we read configuration from."

### `mongodb-atlas` — MongoDB Atlas

For MongoDB Atlas deployments.  The shipped class is
[`MongoDBAtlasProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/mongodbatlasprofile.py).

```yaml
- identifier: staging-atlas
  category: mongodb-atlas
  connection: "mongodb+srv://<username>:<password>@cluster0.abcde.mongodb.net/?retryWrites=true"
  credential: staging-atlas-rw
```

Required fields: `credential`, `connection`.  The `connection` field
is the Atlas connection *template* — the literal string MongoDB Atlas
gives you in its console — with `<username>` and `<password>`
placeholders.  At runtime the credential is materialised and the
template is expanded by
[`MongoDBAtlasProfile.connection_string(credobj)`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/mongodbatlasprofile.py).

This category exists separately from `basic-tcp` because Atlas's
`mongodb+srv://` scheme expands to a list of hosts via DNS at
runtime — there is no fixed host/port to put in a profile.  The
template-with-placeholders form is the lowest-fidelity capture of
what Atlas actually requires.

### `snowflake` — Snowflake

For Snowflake databases.  The shipped class is
[`SnowflakeProfile`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/snowflakeprofile.py).

```yaml
- identifier: staging-snowflake
  category: snowflake
  account: my-org-staging
  warehouse: COMPUTE_WH_STAGING
  database: APP_STAGING
  schema: PUBLIC
  parameters:
    role: ANALYST
    timezone: UTC
  credential: staging-snowflake-rw
```

Required fields: `account`, `warehouse`, `database`, `schema`,
`credential`.  Optional: `parameters` (a dictionary of additional
connection options that pass through to the Snowflake connector).

Snowflake gets its own category because the connection model is
genuinely different from TCP databases — `account`, `warehouse`,
`database`, `schema` are independent dimensions you select at
connection time, and the same Snowflake account hosts many of each.

## A complete worked example

Building on the staging-east Landscape from chapter 5 (which had a
`staging-db` service and a `staging-api` service), here is the
matching Data Profile file:

```yaml
# dataprofiles-staging-east.yaml
dataprofiles:

    # Primary application database — the one staging-api reads/writes
    - identifier: staging-db-app-rw
      category: basic-tcp
      host: db-staging-east-1.internal
      port: 5432
      dbtype: postgres
      dbname: app
      credential: staging-db-rw

    # Same database, different credential — read-only for analytics
    - identifier: staging-db-app-readonly
      category: basic-tcp
      host: db-staging-east-1.internal
      port: 5432
      dbtype: postgres
      dbname: app
      credential: staging-db-readonly

    # Redis cache the API uses for sessions
    - identifier: staging-redis
      category: basic-tcp
      host: redis-staging-east-1.internal
      port: 6379
      dbtype: redis
      credential: staging-redis-default

    # Snowflake warehouse for analytical queries
    - identifier: staging-warehouse
      category: snowflake
      account: my-org-staging
      warehouse: COMPUTE_WH_STAGING
      database: APP_STAGING
      schema: PUBLIC
      credential: staging-snowflake-rw

    # CouchDB used for storing test artifacts
    - identifier: staging-test-artifacts
      category: couchdb
      host: couchdb.staging.internal
      port: 5984
      scheme: https
      dbname: test-artifacts
      credential: staging-couchdb-rw
```

Five profiles, five categories represented.  Notice three things:

- **The same physical database appears twice** (`staging-db-app-rw`
  and `staging-db-app-readonly`) with different credentials.  The
  Data Profile is the *access intention*, not just the location.
- **Every credential is a reference**, never an inline value.  The
  five credential identifiers
  (`staging-db-rw`, `staging-db-readonly`, `staging-redis-default`,
  `staging-snowflake-rw`, `staging-couchdb-rw`) are looked up by
  the resolution layer at runtime.
- **No connection strings appear anywhere.**  The components are
  named fields; the connection string — when one is needed — is
  built at runtime by the profile class's `connection_string()`
  method.

The orchestration consumes profiles by name:

```python
from ccai.dataprofiles.wellknown import DataProfileManagerSingleton

profile = DataProfileManagerSingleton().lookup_profile(
    "staging-db-app-rw"
)

# profile is a DatabaseBasicTcpProfile instance with
# host/port/dbname/dbtype/credential typed fields

cred = CredentialManagerSingleton().lookup_credential(profile.credential)
conn_str = f"postgresql://{cred.username}:{cred.password}@{profile.host}:{profile.port}/{profile.dbname}"
```

Or, for the categories that ship a `connection_string()` helper:

```python
profile = DataProfileManagerSingleton().lookup_profile("staging-couchdb")
cred = CredentialManagerSingleton().lookup_credential(profile.credential)
conn_str = profile.connection_string(cred)
```

The connection string is built at the point of use, with the
materialised credential, and discarded immediately.  It never
becomes a process-environment value.

## The `category` field as discriminator

Every Data Profile has a `category` field that selects which
concrete class to instantiate when the file is loaded.  The
[`DataProfileManager`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/dataprofilemanager.py)
performs this dispatch:

```python
if category == DatabaseBasicSocketProfile.category:    # "basic-socket"
    profobj = DatabaseBasicSocketProfile(**profile)
elif category == DatabaseBasicTcpProfile.category:     # "basic-tcp"
    profobj = DatabaseBasicTcpProfile(**profile)
elif category == CouchDbProfile.category:              # "couchdb"
    profobj = CouchDbProfile(**profile)
elif category == MongoDBAtlasProfile.category:         # "mongodb-atlas"
    profobj = MongoDBAtlasProfile(**profile)
elif category == SnowflakeProfile.category:            # "snowflake"
    profobj = SnowflakeProfile(**profile)
else:
    logger.warning(f"Unknown category '{category}' ...")
```

This is the standard ccai discriminator pattern: a string field
on the data, a class attribute on the implementations, a dispatch
that matches them at load time.  The pattern shows up in
`codecraftai-credentials` (chapter 9), in
`codecraftai-provisioning` (chapter 4 — `category` on
`CloudDevice`), and elsewhere.  Adding a new Data Profile category —
say, `redis-sentinel` for Redis Sentinel deployments where the
single-host model breaks down — means adding a new class and adding
a new branch in the dispatch.

The `validate` classmethod on each concrete class enforces the
category-specific required fields at load time.  A profile of
category `basic-tcp` missing the `port` field fails to load with a
clear error message naming the missing field; the orchestration
never starts with a malformed profile.

## Composition with credentials and landscape

The Data Profile sits at a specific composition point: it bridges
*where data lives* (the landscape) and *who's allowed to access it*
(credentials).

```
       Landscape
       ────────
       host: db-staging-east-1.internal       ◄─── inventory truth
       (the host is in the infrastructure)


       Data Profile                            ◄─── access intention
       ────────────
       identifier: staging-db-app-rw
       host: db-staging-east-1.internal
       port: 5432
       credential: staging-db-rw   ◄─────────  reference


       Credential                              ◄─── access material
       ──────────
       identifier: staging-db-rw
       type: BasicCredential
       username: app_user
       password: <looked up at runtime>
```

Three deliberate boundaries:

1. **The Data Profile points at hosts named in the Landscape.**  The
   profile's `host` field is the same hostname the Landscape's
   infrastructure section names (`db-staging-east-1.internal`).
   When the host moves, the Landscape's infrastructure entry
   changes — and the Data Profile's `host` field changes alongside.
   The two changes are kept in sync because the same person
   editing the same environment's configuration touches both.

   The Data Profile does *not* resolve the host through the
   Landscape automatically.  This is a deliberate design choice
   from `codecraftai-dataprofiles`: the profile is self-contained
   data; the Landscape is queried separately.  The result is
   that a Data Profile is a *complete* description of how to reach
   a data source, even if the corresponding Landscape isn't loaded.

2. **The Data Profile names a credential identifier.**  It does not
   contain the credential.  The resolution layer (chapter 10)
   produces the credential at the moment of use.  The credential's
   lifecycle is independent of the profile's.

3. **The Landscape's role and the Data Profile's role are
   different.**  The Landscape says "there is a service called
   `staging-db` reachable at `db-staging-east-1.internal:5432`."
   The Data Profile says "to access the `app` database on that
   host, use the `staging-db-rw` credential."  Both can exist; both
   are necessary; they describe orthogonal aspects.

A common confusion: why does the Data Profile have a `host` field if
the Landscape already has it?  The answer is that the Data Profile
is for data sources that may *not* be in the Landscape at all.  A
SaaS data store like Snowflake or MongoDB Atlas isn't in the
infrastructure section of any Landscape — it's a third-party service
the orchestration calls into.  The Data Profile is the *only* place
where its access details live.

For internal data stores that *are* in the Landscape, the
duplication is the cost of keeping the Data Profile self-contained.
A future evolution could allow Data Profile fields to reference
Landscape entries by name (`host: ${landscape:staging-db.host}`), at
the cost of making profiles dependent on a particular Landscape.
The current shipped form keeps the abstraction simple at the price
of one duplicated hostname.

## The `DataProfileManager` and lookup-by-name

At runtime the orchestration interacts with profiles through the
[`DataProfileManagerSingleton`](https://github.com/code-craft-ai-company/codecraftai-dataprofiles/blob/main/source/packages/ccai/dataprofiles/wellknown.py):

```python
from ccai.dataprofiles.wellknown import DataProfileManagerSingleton

manager = DataProfileManagerSingleton()
profile = manager.lookup_profile("staging-db-app-rw")
```

Three behaviours of the manager worth noting:

- **Lookup by identifier returns the typed object.**  The orchestration
  receives a `DatabaseBasicTcpProfile` (or whichever class matches
  the category), not a generic dictionary.  Type-checked attribute
  access (`profile.host`, `profile.port`) replaces dictionary
  fetches with the chance of typos.
- **Missing identifier raises `ConfigurationError`.**  The error
  message includes the list of source URIs the manager loaded from,
  so the engineer can immediately see where to add the profile.  No
  silent `None` returns; loading and lookup both fail loudly.
- **Validation runs at load time.**  Errors and warnings are
  collected from each profile's `validate` classmethod; if any
  errors exist, the load fails before any profile is instantiated.
  The manager never holds a partially-valid profile set.

## What a Data Profile is *not*

Three things to keep out, every one of which a team will be
tempted to put in:

### Not a credential

The most common mistake.  A Data Profile names a credential by
identifier.  It does not embed username, password, token, key, or
any secret material.  When you find yourself adding a `password`
field to a profile, stop — the password belongs in the credential
record, not the profile.

A symptom of getting this wrong is that profile rotation requires
editing the profile.  Done right, password rotation is invisible
to the profile — the credential record changes, the profile is
unchanged, the next connection picks up the new password
automatically.

### Not a Landscape entry

A Data Profile points at data sources; a Landscape lists hosts and
services.  The two have similar shapes but different purposes.
Putting a Data Profile in a Landscape ("infrastructure: services:
... profile fields ...") confuses the inventory with access details.
Putting Landscape entries in a Data Profile file ("hosts: ... ports:
...") loses the apod-vs-infrastructure distinction.

The test of "does this belong in a Data Profile?" is: *is this
about how to reach a data source?*  If yes, profile.  If no, it
belongs elsewhere.

### Not a runtime configuration

A Data Profile carries connection parameters that are intrinsic to
the data source — host, port, account, database name.  It does
*not* carry behaviour parameters like connection-pool size,
statement timeout, or retry count, even though those are commonly
passed to the same connection-builder code.  Behaviour parameters
go in the runtime configuration (chapter 7); the Data Profile is
the *what to connect to*, not the *how to behave when connected*.

The test: *would two different orchestrations against the same
data source need different values of this field?*  If yes, runtime.
If no, profile.

## Migration: from connection strings to Data Profiles

A pipeline that has accumulated connection strings as environment
variables migrates to Data Profiles in three steps:

1. **Enumerate every connection string.**  Walk the codebase, the
   Dockerfiles, the docker-compose files, the CI YAML, and the
   developer's `.env` files.  List every variable that looks like
   a URL pointing at a data source.  Group them by destination —
   `DATABASE_URL`, `STAGING_DB_URL`, `PROD_DB_RO_URL`, etc.

2. **Decompose each string into typed fields.**  For each URL,
   identify the host, the port, the database, the database type,
   the credential.  Each becomes a field in a Data Profile entry.

3. **Replace the URL parsing in code with profile lookups.**  Code
   that did:
   ```python
   url = os.environ["DATABASE_URL"]
   conn = sqlalchemy.create_engine(url)
   ```
   becomes:
   ```python
   profile = DataProfileManagerSingleton().lookup_profile("staging-db-app-rw")
   cred = CredentialManagerSingleton().lookup_credential(profile.credential)
   conn = sqlalchemy.create_engine(
       f"postgresql://{cred.username}:{cred.password}@{profile.host}:{profile.port}/{profile.dbname}"
   )
   ```
   The application code now reads a typed profile and a typed
   credential, builds the connection string at the point of use,
   and never holds the password in a long-lived variable.

After migration, the `DATABASE_URL` and its kin are gone.  The
plumbing tax from chapter 7 has been paid once for `AM_DATAPROFILE_NAMES`;
every new data source after that is a one-file change to the
profile YAML.

## Where Data Profiles don't fit

Honest scoping notes:

- **Truly ephemeral data sources.**  A pipeline that creates a
  one-time database, populates it, queries it, drops it — all in a
  single step — does not need a Data Profile.  The connection
  details are local to the step; promoting them to a profile adds
  ceremony without payoff.
- **Highly dynamic discovery.**  Pipelines that discover data
  sources at runtime through service discovery (Consul, etcd, K8s
  service mesh) typically generate the equivalent of a profile in
  memory.  The shipped `DataProfileManager` doesn't model dynamic
  registration; the pattern is to translate the discovery output
  into a profile dictionary at orchestration start and pass it to
  `load_datasource_profiles`.
- **In-memory or filesystem-only data.**  A pipeline whose "data"
  is a JSON file in the repository doesn't need a Data Profile.
  The file is in the codebase; reading it is direct.

The discipline scales from "two databases" to "dozens of services
across multiple categories"; below that scale the discipline is
optional.

## Summary — and end of Part II

- A **Data Profile** is a typed, named description of a data
  source: where it lives, how to reach it, which credential to use.
- The five shipped categories are `basic-tcp`, `basic-socket`,
  `couchdb`, `mongodb-atlas`, and `snowflake`.  Each is a concrete
  class inheriting from `BaseDataProfile` with category-specific
  fields and category-specific validation.
- Three universal fields — `identifier`, `category`, `credential` —
  are required on every profile.  The `credential` field is a
  *reference*; the credential material lives in the credential
  category and is resolved at runtime.
- The `DataProfileManager` loads profiles, validates them at load
  time, and exposes lookup-by-identifier through the
  `DataProfileManagerSingleton`.
- Profiles compose with the Landscape (the profile's host
  parallels the Landscape's infrastructure host but is
  self-contained) and with credentials (the profile names a
  credential; the resolution layer materialises it).
- A Data Profile is not a credential (no embedded secrets), not a
  Landscape entry (no inventory listing), and not a runtime
  configuration (no behaviour parameters).
- Migration from connection-string env vars is mechanical:
  enumerate, decompose into fields, replace URL parsing with
  profile lookups.

This is the last chapter of Part II.  The volume has now introduced
five of the six categories — Specifications, Landscapes, Topologies,
Runtimes, Data Profiles — with file formats, code anchors, and
worked examples grounded in real portfolio code.

The sixth category — **Credentials** — earns four chapters of its
own, which is the next part of the volume.  Part III digs into
credential vocabularies, the resolution discipline (the most
substantial design exercise in the volume since the portfolio ships
a vocabulary layer but no resolution backends), credentials in
flight (rotation, encryption at rest, audit trails), and credentials
across environments (the same vocabulary, different backends, per
the Part IV profile-and-environment story).


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/09-credential-vocabularies -->
# Chapter 9 — Credential Vocabularies

> *There are eleven kinds of credential a pipeline ever needs.  This
> chapter names all eleven and explains why the right answer to "what
> about credential type X?" is almost always "use one of the eleven
> and add a `categories` tag."*

Welcome to Part III.  The five categories of Part II
(Specifications, Landscapes, Topologies, Runtimes, Data Profiles)
are introduced and finished.  Credentials — the sixth category —
gets four chapters of its own because the credential discipline has
more moving parts than the other five combined.  This chapter
establishes the vocabulary; chapter 10 is the resolution discipline
(how to materialise secrets at runtime); chapter 11 is credentials
in flight (rotation, encryption, audit); chapter 12 is credentials
across environments.

The portfolio's
[`codecraftai-credentials`](https://github.com/code-craft-ai-company/codecraftai-credentials/tree/main/source/packages/ccai/credentials)
package is the substantive subject of Part III.  An important
finding from the research that grounded this volume is worth stating
up front: **`codecraftai-credentials` is a pure vocabulary layer**.
It defines the typed credential classes and a registry that loads
them from configuration data.  It does not ship a single
resolution backend — no vault integration, no file backend, no
prompt-on-startup, no rotation API.  The shipped scope is
deliberately narrow.  This chapter is the catalogue of what *is*
shipped; chapter 10 is the discipline for everything that's not.

That narrowness is a feature.  A vocabulary with no opinion about
backends translates to any environment a reader is in.  The eleven
shipped credential classes cover the credential types every team
needs.  The backend layer is yours to build, and the chapter that
follows is the recipe for building it well.

## The three universal fields

Every credential is an instance of a class inheriting from
[`BaseCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/basecredential.py).
The base class declares three universal fields that every concrete
credential carries:

| Field | Purpose |
|---|---|
| `identifier` | Unique string used by other configurations to reference this credential |
| `categories` | List of category tags telling the framework what this credential can be used for |
| `role` | Optional role label (default `"priv"`) used to distinguish privilege levels |

A credential YAML entry looks like this in raw form:

```yaml
- identifier: staging-db-rw
  category: basic        # singular form; the loader translates to a list
  categories:            # plural form is also supported and preferred
    - basic
    - rest-basic
  role: priv
  username: app_user
  password: ${VAULT:/secret/staging/db/rw}
```

Three deliberate choices visible in the universals.

### `identifier` — the reference

The identifier is what every other configuration uses to reach a
credential.  A Landscape entry's `credentials: [pi-cluster]` field;
a Data Profile's `credential: staging-db-rw` field; a CloudVenue's
`credential: ci-runner-aws` field.  All three are *references* —
strings that the credential resolution layer (chapter 10) maps to
a concrete credential instance at runtime.

The identifier discipline is the same as for every other category:
unique, stable, descriptive, environment-agnostic.  A credential
called `staging-db-rw` exists in every environment that needs it —
the *same* identifier resolves to a *different* credential value
per environment.  This is the architecture chapter 12 is about.

### `categories` — the type-tag list

The categories field is a list of strings.  Each entry is a category
tag the framework recognises.  Every concrete credential class
*requires* a specific category tag to be present in the list and
raises `ConfigurationError` at construction time if it's missing.
For example,
[`SshCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/sshcredential.py)
requires the `ssh` category:

```python
if "ssh" not in categories:
    raise ConfigurationError(
        "The SshCredential should only be given credentials of category 'ssh'."
    )
```

The list form (rather than a single category string) lets a
credential carry *both* the framework-required category tag *and*
application-defined tags that orchestration code uses for grouping
and filtering.  A real credential entry typically looks like this:

```yaml
- identifier: ci-runner-ssh
  categories:
    - ssh                    # framework-required: enables SshCredential
    - runner-fleet           # application-defined: groups runner creds
    - rotated-quarterly      # application-defined: lifecycle metadata
  username: runner
  keyfile: /run/secrets/ssh/ci-runner
```

Orchestration code can ask for "all credentials in the
`runner-fleet` category" and get exactly the SSH and AWS and Azure
credentials the fleet uses — without the framework knowing what
`runner-fleet` means.  The category vocabulary is yours; the
framework only enforces the type-tag.

### `role` — the privilege label

The role field carries one string label, defaulting to `"priv"` for
*privileged*.  Other typical values are `"readonly"`, `"audit"`,
`"admin"`, or any application-defined role term.

Two credentials pointing at the same logical resource with different
roles is the common pattern — a `staging-db-rw` and a
`staging-db-readonly`, the same database, different access levels:

```yaml
- identifier: staging-db-rw
  categories: [basic]
  role: priv
  username: app_user
  password: ${VAULT:/secret/staging/db/rw}

- identifier: staging-db-readonly
  categories: [basic]
  role: readonly
  username: app_readonly
  password: ${VAULT:/secret/staging/db/ro}
```

A Data Profile (chapter 8) selects between them by *identifier*; the
`role` field is metadata that lets reporting and audit code
recognise the privilege tier without parsing the identifier name.

## The category-as-discriminator pattern

The `categories` list serves a second function beyond filtering:
the *first matching tag* tells the
[`CredentialManager`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/credentialmanager.py)
which concrete credential class to instantiate.  This is the
category-as-discriminator pattern shared with Data Profiles (chapter
8) and CloudDevices (chapter 4).

Each shipped credential class declares the category tag it claims:

| Class | Category tag claimed |
|---|---|
| `BasicCredential` | `basic` (also accepts `rest-basic`) |
| `SshCredential` | `ssh` |
| `ApiTokenCredential` | `api-token` |
| `PersonalApiTokenCredential` | `personal-api-token` |
| `SecretTokenCredential` | `secret-token` |
| `AwsAccessKeyCredential` | `aws-access-key` |
| `AzureClientSecretCredential` | `azure-client-secret` |
| `PublicKeyCredential` | `public-key` |
| `GithubTokenCredential` | `github-token` |
| `GithubAppCredential` | `github-app` |
| `WifiChoiceCredential` | `wifi-choice` |

The `CredentialManager` walks the list, dispatches to the matching
class, instantiates the credential, and registers it.  Application-
defined tags (`runner-fleet`, `rotated-quarterly`) are kept in the
list but not used for dispatch.

## The eleven shipped classes

Each class is a thin typed container.  None of them perform I/O,
none of them resolve secrets from a backend, none of them rotate or
expire — they hold the values they were constructed with and expose
them as properties.  That is by design: chapter 10 explains why the
backend layer is separate.

### Basic auth

[`BasicCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/basiccredential.py)
— the workhorse type for username/password authentication.  Used
for HTTP Basic, REST Basic, database `BasicCredential`-driven
auth.  Two category tags accepted: `basic` (general) and
`rest-basic` (REST-specific).

```yaml
- identifier: staging-api-basic
  categories: [basic]
  username: api_user
  password: ${VAULT:/secret/staging/api/basic}
```

Required fields: `username`, `password`.

### SSH

[`SshCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/sshcredential.py)
— SSH credential with multiple authentication modes.  The
constructor accepts any of: password, keyfile, raw key, or
SSH-agent flag, and validates that at least one mechanism is
provided.

```yaml
- identifier: ci-runner-ssh
  categories: [ssh]
  username: runner
  keyfile: /run/secrets/ssh/ci-runner-rsa
  keypasswd: ${VAULT:/secret/ssh/ci-runner-rsa-passphrase}
  allow_agent: false
  primitive: false
```

Required fields: `username` plus *one of* (`password`, `keyfile`,
`keyraw`, `allow_agent: true`).  `keyfile` and `keyraw` are
mutually exclusive.  `primitive: true` switches the consuming
SSH client into "use raw `ssh` commands instead of SFTP/exec
RPC" mode for environments that don't support the full SSH
subsystem.

### API tokens

Three flavours of token-based auth.

[`ApiTokenCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/apitokencredential.py)
— a single opaque token.  Used for service tokens that don't have a
notion of a per-user identity.

```yaml
- identifier: staging-jira-token
  categories: [api-token]
  token: ${VAULT:/secret/staging/jira/service-token}
```

[`PersonalApiTokenCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/personalapitokencredential.py)
— a token bound to a username.  Used for personal access tokens
where the username appears in audit logs (Atlassian, Jira, Bitbucket,
many SaaS APIs).

```yaml
- identifier: staging-jira-personal
  categories: [personal-api-token]
  username: ci-bot@company.com
  token: ${VAULT:/secret/staging/jira/ci-bot-pat}
```

[`SecretTokenCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/secrettokencredential.py)
— a token paired with the verification *endpoint* the consumer
must call.  Used for third-party verification services (hCaptcha,
reCAPTCHA, similar challenge-response APIs) where the token only
makes sense in the context of a particular service URL.

```yaml
- identifier: hcaptcha-secret
  categories: [secret-token]
  endpoint: https://hcaptcha.com/siteverify
  token: ${VAULT:/secret/services/hcaptcha-secret}
```

### Cloud-provider credentials

Two cloud-specific shapes.

[`AwsAccessKeyCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/awsaccesskeycredential.py)
— the AWS access-key-id + secret-access-key pair.

```yaml
- identifier: ci-runner-aws
  categories: [aws-access-key]
  keyid: AKIA...
  secret: ${VAULT:/secret/aws/ci-runner-secret}
```

[`AzureClientSecretCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/azureclientsecretcredential.py)
— Azure application credential (tenant id + client id + client
secret), used for non-interactive Azure access.

```yaml
- identifier: staging-azure-client
  categories: [azure-client-secret]
  tenant_id: 11111111-1111-1111-1111-111111111111
  client_id: 22222222-2222-2222-2222-222222222222
  client_secret: ${VAULT:/secret/azure/staging-app-secret}
```

GCP service-account credentials don't have a shipped class today —
this is one of the natural extensions a reader will likely add.
The pattern is the same: a new class inheriting from
`BaseCredential`, a new category tag, a new dispatch branch in
the `CredentialManager`.

### Generic asymmetric keys

[`PublicKeyCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/publickeycredential.py)
— a generic public/private keypair, not tied to any particular
protocol.  Used for application-level signing (JWT signing,
artifact signing, code-signing keypairs).

```yaml
- identifier: jwt-signing-key
  categories: [public-key]
  public: |
    -----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQ...
    -----END PUBLIC KEY-----
  private: ${VAULT:/secret/jwt/staging-private-key}
```

When the keypair is specifically for SSH, use `SshCredential`
instead — `SshCredential` understands the SSH-specific concerns
(agent, keypasswd, primitive mode) that `PublicKeyCredential`
doesn't model.

### GitHub-specific credentials

GitHub has two shipped classes because it has two distinct
authentication flows.

[`GithubTokenCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/githubtokencredential.py)
— a personal access token (PAT) with a username.  GitHub doesn't
verify the username for PATs, so the field defaults to `"git"`; it
exists for logging and tracking.

```yaml
- identifier: ci-bot-github
  categories: [github-token]
  username: ci-bot
  token: ${VAULT:/secret/github/ci-bot-pat}
```

[`GithubAppCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/githubappcredential.py)
— GitHub Apps authenticate using a private key (or list of keys
for rotation) signed against the App's identifier.  The credential
holds either keyfiles, raw keys, or both, with optional passphrase.

```yaml
- identifier: ci-github-app
  categories: [github-app]
  keyfiles:
    - /run/secrets/github/app-current.pem
    - /run/secrets/github/app-previous.pem      # for grace-period rotation
  keypasswd:
    - ${VAULT:/secret/github/app-current-passphrase}
    - ${VAULT:/secret/github/app-previous-passphrase}
```

The keyfiles are read at construction time and held in
`_loaded_keys`; the constructor raises `ConfigurationError` if a
declared keyfile is missing on disk.  This is the only credential
class that does *I/O at construction time* — a deliberate exception
because a GitHub App that can't read its key is a credential that
can never authenticate, and failing loudly at load is preferable to
failing silently at first use.

### WiFi networks

[`WifiChoiceCredential`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/wifichoicecredential.py)
— a list of SSID+password pairs the consumer can iterate through,
trying each in turn.  Used for IoT / embedded / mobile-device
automation where the client may move through several networks
during a run.

```yaml
- identifier: lab-wifi
  categories: [wifi-choice]
  networks:
    - ssid: lab-floor-3
      password: ${VAULT:/secret/wifi/lab-floor-3}
    - ssid: lab-floor-4
      password: ${VAULT:/secret/wifi/lab-floor-4}
    - ssid: guest
      password: ${VAULT:/secret/wifi/guest}
```

This is an unusual shape — most credentials are single-value, this
one is a list of value-sets.  It exists because the alternative
(eleven separate credentials, one per SSID) loses the *intent*
that they're alternatives.  The consumer's logic is "try each in
order until one connects"; a single credential captures that
semantics directly.

## A complete worked example

Here is a credentials file matching the staging-east scenario from
Chapters 4–8.  Every reference these earlier chapters made — to
`ci-runner-aws`, `ci-runner-ssh`, `staging-db-rw`,
`staging-snowflake-rw`, etc. — resolves to an entry below:

```yaml
# credentials-staging-east.yaml
credentials:

    # SSH credential for the runner pool (referenced by Landscape
    # apod entries and by the Specification's CloudVenue)
    - identifier: ci-runner-ssh
      categories:
        - ssh
        - runner-fleet
      username: runner
      keyfile: /run/secrets/ssh/ci-runner-rsa
      keypasswd: ${VAULT:/secret/ssh/ci-runner-rsa-passphrase}
      role: priv

    # AWS credential for provisioning the runner pool (referenced by
    # the CloudVenue in chapter 4's Specification)
    - identifier: ci-runner-aws
      categories:
        - aws-access-key
        - cloud-provisioning
      keyid: AKIA...
      secret: ${VAULT:/secret/aws/ci-runner-secret}
      role: priv

    # Database credentials (referenced by chapter 8's Data Profiles)
    - identifier: staging-db-rw
      categories:
        - basic
        - app-tier
      username: app_user
      password: ${VAULT:/secret/staging/db/app-rw}
      role: priv

    - identifier: staging-db-readonly
      categories:
        - basic
        - reporting-tier
      username: app_readonly
      password: ${VAULT:/secret/staging/db/app-ro}
      role: readonly

    - identifier: staging-redis-default
      categories:
        - basic
        - app-tier
      username: default
      password: ${VAULT:/secret/staging/redis/default}
      role: priv

    # Snowflake credential (Snowflake auth uses a basic-shape credential)
    - identifier: staging-snowflake-rw
      categories:
        - basic
        - analytics-tier
      username: ci_loader
      password: ${VAULT:/secret/staging/snowflake/ci-loader}
      role: priv

    # CouchDB credential
    - identifier: staging-couchdb-rw
      categories:
        - basic
        - app-tier
      username: app_couchdb
      password: ${VAULT:/secret/staging/couchdb/app-rw}
      role: priv

    # GitHub PAT for fetching repos as part of the orchestration
    - identifier: ci-bot-github
      categories:
        - github-token
        - source-control
      username: ci-bot
      token: ${VAULT:/secret/github/ci-bot-pat}
      role: priv
```

Eight credential entries; six distinct credential types
represented (`SshCredential`, `AwsAccessKeyCredential`,
`BasicCredential` × 5, `GithubTokenCredential`); every secret value
is a `${VAULT:...}` reference rather than an inline literal.

The `${VAULT:...}` syntax is **not part of the shipped
`codecraftai-credentials` package**.  The shipped package treats the
right-hand-side strings as opaque values — so a literal
`password: hunter2` would load and work.  The `${VAULT:...}`
expansion is a *resolution discipline* the next chapter establishes:
the consumer-supplied resolver inspects each loaded credential and
materialises any reference syntax against a real backend.  This
chapter shows the syntax in worked examples to anchor the discipline
in the form chapter 10 will give it.

## Composition with the rest of the categories

Credentials are referenced — never embedded — by every other category:

- **Specifications** (chapter 4): `CloudVenue.credential` and
  `CloudResourceProfile.credential` carry credential identifiers.
- **Landscapes** (chapter 5): each apod client and infrastructure
  service carries a `credentials` list of identifiers.
- **Data Profiles** (chapter 8): every profile carries a
  `credential` identifier.

Three different layers, all reaching into the same identifier
namespace.  This means a single credential record — say,
`ci-runner-aws` — appears once in the credentials file and is
referenced from the venue (for provisioning) and from the runtime
(for accessing the resulting EC2 instances).  Rotating the AWS
secret is a one-place change.

The Topology category (chapter 6) is the exception — it doesn't
reference credentials directly, because Topology is a relationship
layer rather than an access layer.  The credentials needed to act
on a Topology come from the Landscape entries the Topology references.

## The `CredentialManager` and lookup-by-identifier

The runtime equivalent of the Data Profiles' `DataProfileManager` is
the
[`CredentialManager`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/credentialmanager.py).
Same pattern, different domain:

```python
from ccai.credentials.wellknown import CredentialManagerSingleton

manager = CredentialManagerSingleton(...)
ssh_cred = manager.lookup_credential("ci-runner-ssh")
# ssh_cred is now an SshCredential with typed properties
# ssh_cred.username, ssh_cred.keyfile, ssh_cred.keypasswd
```

The lookup returns the typed object.  Lookups against missing
identifiers raise `ConfigurationError` with the source URIs in the
message, so the engineer can see where to add the missing
credential.  No silent `None` returns; loading and lookup both fail
loudly.

The dispatch logic inside `CredentialManager.load_credentials`
mirrors the Data Profiles dispatch from chapter 8 — walk the list,
match the category tag, instantiate the right concrete class.
Application-defined category tags (`runner-fleet`, `app-tier`) are
preserved on the instance so orchestration code can filter by them
later.

## Adding a new credential type

When the eleven shipped types don't cover a new authentication
shape, the extension recipe is short:

1. **Add a new category tag** the rest of the system can recognise.
   For a hypothetical `gcp-service-account` credential, the tag is
   `gcp-service-account`.

2. **Subclass `BaseCredential`.**  Add a class attribute or
   constructor check that requires the new category tag.  Define
   the new fields the credential needs (the GCP service-account
   JSON, the project ID, etc.).  Implement a `validate` classmethod
   for load-time checks.

3. **Add a dispatch branch in `CredentialManager.load_credentials`.**
   This is the one place in the package that knows about the new
   class.

4. **Document the new shape** in the project's credential schema
   reference.

The new type lives alongside the eleven shipped ones with no
special treatment.  Categories are open; the framework does not
constrain you to the shipped set.

## What credentials is *not*

Three things to keep out of credentials, every one of which a team
will be tempted to put in:

### Not access-control policy

A credential says "here is the material to authenticate."  It does
*not* say "this credential may access service X but not service Y."
Access-control policy is a property of the *resource* (or of an
external policy engine like AWS IAM, OPA, Azure RBAC), not of the
credential.

A credential that's tagged `[basic, readonly-tier]` is metadata
that helps your code *select* the right credential; it is not
enforcement.  The actual readonly access is enforced by the
database, not by the credential file.

### Not a secrets store

A credentials file with `password: hunter2` inline is a credentials
file pretending to be a secrets store.  It is not.  The shipped
`codecraftai-credentials` package will load such a file — the
constructor accepts any non-empty string for a password — but the
result is the same as if you had committed the password to the
repository.

The discipline is: every secret value is a *reference* to a
backend.  The format of the reference is your choice (the
`${VAULT:/path}` form used in this chapter is one convention; per-
backend formats like `vault://...` or `aws-secrets://...` work
equally well).  The chapter that follows is the discipline for
building the resolver that turns those references into materialised
values.

### Not a cache

Credentials are not the right place to cache short-lived auth
artefacts (OAuth access tokens, AWS STS tokens, Kerberos tickets).
The credential is the *long-lived* identity material; the
short-lived artefact is the *result of using* the credential.
Conflating the two leaks short-lived tokens into long-lived
storage and complicates rotation.

The pattern that works is: the credential is the long-lived seed;
the consumer derives short-lived auth artefacts at the point of
use; the artefacts live only in the consumer's process memory.
Chapter 11 addresses the rotation side of this.

## The "never log a credential" rule

Every credential class implements an `as_dict()` method that
serialises the credential to a dictionary — *including the secret
material*.  This is a deliberate trade-off: serialisation is
necessary for some legitimate operations (writing a credential
back to disk, transmitting it to a remote worker over an authenticated
channel), but it also means the secret can be accidentally exposed
through naive logging.

The discipline is unambiguous: **a credential never goes through a
logger, a print statement, an `f"{cred}"`, or a generic
exception's traceback.**  Two practices keep this in place:

1. **Override `__repr__` to elide the secret.**  Each shipped
   credential class should redact secret material in its `__repr__`
   so that `f"{cred}"` produces something safe.  (This is an audit
   finding from the credentials package research — most shipped
   classes do *not* currently override `__repr__`; an upcoming
   audit pass should fix this.)
2. **Audit the codebase for credential logging.**  Every
   `logger.info(cred)` or `print(cred)` is a candidate leak.
   Chapter 11 covers the audit pattern in detail.

## Summary

- `codecraftai-credentials` is a **pure vocabulary layer** — eleven
  typed credential classes with three universal fields
  (`identifier`, `categories`, `role`) and category-specific
  extensions.
- Every credential is dispatched at load time from the *first
  matching tag* in its `categories` list.  Application-defined
  tags coexist with the framework-required type tag.
- The eleven shipped types are: `BasicCredential`, `SshCredential`,
  `ApiTokenCredential`, `PersonalApiTokenCredential`,
  `SecretTokenCredential`, `AwsAccessKeyCredential`,
  `AzureClientSecretCredential`, `PublicKeyCredential`,
  `GithubTokenCredential`, `GithubAppCredential`,
  `WifiChoiceCredential`.  Adding a new type is a four-step recipe.
- All other categories — Specifications, Landscapes, Data Profiles
  — reference credentials by identifier; the credential file is the
  single place a credential's material lives.
- The shipped package treats secret values as opaque strings.  The
  *resolution* of a `${VAULT:...}`-style reference into a real
  secret is the next chapter's subject; it is the most substantial
  design exercise in the volume because the portfolio leaves it to
  the reader.
- Credentials are not access-control policy, not a secrets store,
  and not a cache for short-lived auth artefacts.  Each has its
  own place in the discipline.
- The "never log a credential" rule is the security discipline that
  keeps the typed-vocabulary approach safe.  Every credential class
  needs `__repr__` redaction; every codebase needs an audit
  guardrail against credential logging.

The next chapter is the resolution discipline — how to take the
`${VAULT:...}` references the chapter has shown and turn them into
materialised credentials at runtime, with backends the reader
designs to fit their environment.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/10-credential-resolution -->
# Chapter 10 — Credential Resolution

> *The `codecraftai-credentials` package gives you the vocabulary.  The
> package does not give you a way to fetch the secret value.  This
> chapter is about that gap and how to close it.*

The previous chapter introduced the eleven shipped credential
classes and showed worked examples in which secret values appeared
as `${VAULT:/secret/...}` references rather than literal strings.
That syntax was *not* part of the shipped package — it was a
preview.  This chapter is the discipline that makes the syntax
meaningful.

The chapter is the most substantial design exercise in the volume.
The reason is that the portfolio's deliberate decision to ship a
vocabulary layer with no resolution backends puts a real engineering
load on the reader: you will build the resolver yourself, and the
choices you make will shape your team's security posture for the
rest of the project's life.  The chapter makes those choices
concrete by proposing an architecture, the interfaces it exposes,
and a recipe for the most common backends.

## The gap to close

Recall the shape of the load path through the shipped package:

```
credentials.yaml ──► YAML parser ──► dict ──►
   CredentialManager.load_credentials(credential_info=dict) ──►
      validate() per credential ──►
      dispatch by category tag ──►
      construct typed credential object ──►
      register in self._credentials[identifier]
```

The
[`CredentialManager`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/credentialmanager.py)
takes a *parsed dictionary* — `credential_info` — and treats every
string value as opaque.  When the YAML contains
`password: hunter2`, the resulting `BasicCredential.password` is
the literal string `"hunter2"`.  When the YAML contains
`password: ${VAULT:/secret/staging/db/rw}`, the resulting password
is the literal string `"${VAULT:/secret/staging/db/rw}"`.  The
package does not interpret the syntax; it merely stores the value.

This is the gap.  Between the literal `${VAULT:...}` reference and
a usable secret, *something* has to fetch the value from a real
secrets store.  That something is the resolver layer.  It is not
shipped.  It is the reader's responsibility.

Three reasons for the choice:

1. **Backends are environment-specific.**  A team using HashiCorp
   Vault has different needs from a team using AWS Secrets Manager,
   from a team using a static encrypted file.  Shipping one would
   privilege one environment over others; shipping ten would bloat
   the package with dependencies most users don't need.
2. **Resolution is the security boundary.**  The point at which a
   credential reference becomes a credential value is the point at
   which audit, rotation, and access control happen.  Forcing the
   reader to design that boundary deliberately produces a better
   security posture than handing them a black-box default.
3. **The vocabulary stays portable.**  A credentials YAML written
   against the eleven shipped types and the `${SCHEME:path}` syntax
   is the same in every environment.  The resolver swaps; the
   credentials file does not.

## The reference syntax

The proposed convention is `${SCHEME:path}` — a bash-style
parameter-expansion form, where the `SCHEME` names a backend and
the `path` is the backend-specific lookup key.  Examples:

| Reference | Meaning |
|---|---|
| `${VAULT:/secret/staging/db/rw}` | Fetch from HashiCorp Vault at the given path |
| `${AWSSM:staging/db/rw}` | Fetch from AWS Secrets Manager by secret name |
| `${AZUREKV:staging-db-rw}` | Fetch from Azure Key Vault |
| `${GCPSM:projects/p/secrets/staging-db-rw/versions/latest}` | Fetch from GCP Secret Manager |
| `${FILE:/run/secrets/staging-db-rw}` | Read the value from a file path |
| `${ENV:STAGING_DB_PASSWORD}` | Read from a process env var (legacy bridge) |
| `${KEYCHAIN:com.company.app/staging-db-rw}` | Read from macOS Keychain (local dev) |
| `${PROMPT:Enter staging DB password}` | Prompt the user (local dev only) |
| `${DERIVED:other-credential-id/field}` | Use a field of another credential |
| `${LITERAL:hunter2}` | Literal value; the syntax is explicit (rare; for tests) |

Three things the syntax gets right:

- **The scheme is explicit.**  A grep for `${VAULT:` finds every
  Vault reference in a project.  No ambiguity, no implicit defaults.
- **The path is opaque to the parser.**  The resolver decides how
  to interpret it.  The reference syntax does not constrain backend
  semantics.
- **The reference is recognizable in logs.**  When a `${VAULT:...}`
  string accidentally appears in an error message or a debug dump,
  it is immediately distinguishable from a literal secret.  This
  is a security defence-in-depth — the syntax is loud, so a leak of
  a reference is catastrophically less bad than a leak of a value.

The syntax is bash-style on purpose.  Shell users recognise the
shape immediately.  YAML keeps the string intact (the curly braces
do not require escaping).  Most editors will not "helpfully" parse
the reference into something else.

## The Resolver interface

The resolver is the piece you build.  It has a small interface:

```python
from typing import Optional


class CredentialReference:
    """
        A parsed credential reference of the form ${SCHEME:path}.
    """
    def __init__(self, scheme: str, path: str, raw: str):
        self.scheme = scheme    # e.g. "VAULT"
        self.path = path        # e.g. "/secret/staging/db/rw"
        self.raw = raw          # the original "${VAULT:/secret/...}" string
        return


class CredentialBackend:
    """
        Abstract interface for a backend that resolves credential references.
    """

    SCHEME: str = None  # subclasses override; the dispatch key

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        """
            Given a credential reference whose scheme matches this backend,
            return the materialised secret value as a string.

            Return None if the reference cannot be resolved (the resolver
            then tries the next backend, or raises ConfigurationError if
            no backend resolves the reference).
        """
        raise NotImplementedError("CredentialBackend subclasses must implement resolve")


class CredentialResolver:
    """
        Walks the credential configuration data, finds ${SCHEME:path}
        references, and replaces each with the value the matching backend
        returns.
    """

    def __init__(self, backends: list):
        # backends are tried in order; the first whose SCHEME matches the
        # reference is asked to resolve.  If it returns None, the resolver
        # raises rather than falling through to a different backend with the
        # same scheme.
        self._backends = backends
        self._scheme_to_backend = {b.SCHEME: b for b in backends}
        return

    def resolve_dict(self, data: dict) -> dict:
        """
            Walk a parsed YAML dict, replace every ${SCHEME:path} string
            with the resolved value.  Return a new dict; do not mutate the
            input.
        """
        return self._walk(data)

    # ... _walk, _resolve_string implementation in the worked example below
```

The interface is small for a reason: every method has a single
responsibility, and the responsibilities don't grow.  Adding a new
backend means writing a new `CredentialBackend` subclass, not
modifying the resolver.

## The shipped backends (none) and the ones you build

The shipped scope of `codecraftai-credentials` is zero backends.  The
following sections walk through the most common backends a reader
will build.  Each subsection is a recipe — interface contract,
implementation sketch, when to use it, when not to.

### File backend

The simplest backend.  Reads the secret value from a file on disk.
Useful for:

- Containerised deployments where secrets are mounted as files
  (Kubernetes `Secret` mounted to `/run/secrets/...`, Docker
  Swarm secrets, AWS ECS secrets-as-files).
- Local development where the secret is in `~/.ccai/secrets/`
  protected by filesystem permissions.
- CI where the runner has been pre-populated with secret files at a
  well-known path.

```python
class FileBackend(CredentialBackend):
    SCHEME = "FILE"

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        path = os.path.abspath(
            os.path.expanduser(os.path.expandvars(reference.path))
        )
        if not os.path.exists(path):
            return None
        with open(path, "r") as fh:
            value = fh.read().rstrip("\n")
        return value
```

Two design decisions worth noting:

- **`expanduser` and `expandvars`** are applied to the path so
  `${FILE:~/.ccai/secrets/db}` and
  `${FILE:$XDG_RUNTIME_DIR/secrets/db}` both work.
- **Trailing newline stripping** because most tools that write
  secrets to files (heredocs, `echo`, vault's `vault read -field=...`)
  leave a trailing newline that breaks the credential value.

When *not* to use the file backend: when the secret needs to rotate
without a process restart.  A file value is read once at resolution
time; rotation requires re-resolution.  Chapter 11 covers rotation.

### Environment-variable backend

Reads the secret value from a process environment variable.  Useful
for:

- Migrating from existing env-var-based pipelines.  Lets you migrate
  the *configuration* to typed credentials before migrating the
  *backend*.
- CI vendors whose secret store exposes secrets as environment
  variables and not as files (some older Jenkins setups).
- Tests, where you want the secret to come from a controlled
  fixture.

```python
class EnvBackend(CredentialBackend):
    SCHEME = "ENV"

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        return os.environ.get(reference.path)
```

One design decision:

- **Returns None on missing var, not an empty string.**  A missing
  environment variable should fall through to the next backend (if
  any) or raise a clear "no backend resolved this reference" error.
  An empty string would be silently accepted as the secret value,
  which is almost certainly a bug.

When *not* to use the env-var backend: as the *primary* backend in
production.  It exists to bridge the gap from a previous discipline
to the new one.  Once migration is complete, the env-var backend
can be removed.

### Prompt backend (local-dev only)

Prompts the developer for the secret value at process start.
Useful for local development where the developer would rather type
a password than copy a token into a file.

```python
class PromptBackend(CredentialBackend):
    SCHEME = "PROMPT"

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        if not sys.stdin.isatty():
            return None  # not interactive; let other backends try
        return getpass.getpass(prompt=f"{reference.path}: ")
```

Two design decisions:

- **Returns None on non-TTY** so the same credentials YAML can be
  used in CI without the prompt firing.  A `${PROMPT:...}` reference
  in CI silently falls through to the next backend; a missing
  fall-through resolves to a clear error.
- **`getpass.getpass`** rather than `input()` so the secret is not
  echoed to the terminal.

When *not* to use prompt: in any non-interactive context.  The
fall-through above is the safety net.

### HashiCorp Vault backend

The most common production secrets backend.  Uses the `hvac`
library (or your team's wrapper).

```python
class VaultBackend(CredentialBackend):
    SCHEME = "VAULT"

    def __init__(self, vault_addr: str, vault_token: str):
        import hvac
        self._client = hvac.Client(url=vault_addr, token=vault_token)
        return

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        # path format: /secret/path/to/key#field
        path, field = reference.path.split("#", 1) if "#" in reference.path else (reference.path, "value")
        try:
            response = self._client.secrets.kv.v2.read_secret_version(
                path=path.lstrip("/")
            )
        except Exception:
            return None
        return response["data"]["data"].get(field)
```

Three design decisions:

- **The Vault path supports a `#field` suffix** so a single Vault
  secret can hold multiple fields and the credential reference
  picks one.  `${VAULT:/secret/staging/db#username}` and
  `${VAULT:/secret/staging/db#password}` resolve to the two fields
  of the same Vault entry.
- **The Vault client is constructed at backend creation time**, not
  per-reference.  Vault auth happens once; subsequent reads are
  cheap.
- **Exceptions are swallowed and converted to None.**  A real
  resolver would log the exception (with the reference path but
  *not* the response body) so the engineer can see why a Vault
  lookup failed without leaking the secret if it did succeed.

When *not* to use Vault: when your team doesn't have a Vault
deployment.  Vault is excellent but operationally non-trivial; for
teams that already have one, this backend is the right answer.  For
teams that don't, file + a separate secrets-distribution channel
(Kubernetes Secrets, cloud-native KMS) is often simpler.

### Cloud-native secret-manager backends

AWS Secrets Manager, GCP Secret Manager, Azure Key Vault — each
has its own client library and its own quirks, but the backend
shape is the same:

```python
class AwsSecretsManagerBackend(CredentialBackend):
    SCHEME = "AWSSM"

    def __init__(self, region: str):
        import boto3
        self._client = boto3.client("secretsmanager", region_name=region)
        return

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        # path format: secret-name#field (for JSON secrets) or just secret-name
        secret_name, field = reference.path.split("#", 1) if "#" in reference.path else (reference.path, None)
        try:
            response = self._client.get_secret_value(SecretId=secret_name)
        except Exception:
            return None
        if "SecretString" not in response:
            return None
        secret_string = response["SecretString"]
        if field is None:
            return secret_string
        try:
            data = json.loads(secret_string)
        except json.JSONDecodeError:
            return None
        return data.get(field)
```

Same pattern for GCP and Azure with the relevant SDK calls.  When
your environment is single-cloud, one backend is enough.  When
your environment is multi-cloud, ship both, and let the
`${SCHEME:path}` syntax route each reference to the right one.

### Keychain backend (macOS local dev)

For developers on macOS who want to keep secrets in the system
Keychain instead of in plain files:

```python
class KeychainBackend(CredentialBackend):
    SCHEME = "KEYCHAIN"

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        # path format: service/account
        try:
            service, account = reference.path.split("/", 1)
        except ValueError:
            return None
        try:
            result = subprocess.run(
                ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
                capture_output=True, text=True, check=True,
            )
        except subprocess.CalledProcessError:
            return None
        return result.stdout.rstrip("\n")
```

When the developer hasn't yet stored the secret in Keychain, the
resolver falls through to the next backend (typically prompt).
The Keychain entry then gets created by hand or via a setup script.

### Derived backend

For credentials that compose from other credentials.  A worked
example: a database connection where the password is the same as
the SSH password for the host the database runs on:

```yaml
- identifier: legacy-db-on-runner-1
  categories: [basic]
  username: app
  password: ${DERIVED:ci-runner-ssh#password}
```

The `DERIVED` backend looks up another credential in the
already-loaded set and returns one of its fields:

```python
class DerivedBackend(CredentialBackend):
    SCHEME = "DERIVED"

    def __init__(self, manager):
        # the manager parameter must be the partially-loaded credential
        # manager.  The derived backend is special: it can only resolve
        # references to credentials that have already been resolved.
        self._manager = manager
        return

    def resolve(self, reference: CredentialReference) -> Optional[str]:
        try:
            cred_id, field = reference.path.split("#", 1)
        except ValueError:
            return None
        try:
            cred = self._manager.lookup_credential(cred_id)
        except ConfigurationError:
            return None
        return getattr(cred, field, None)
```

The derived backend is order-sensitive: the credential it derives
*from* must already be loaded when the derivation is resolved.
The resolver enforces this by performing two passes (resolve all
non-derived references first, then resolve derived ones).

The derived backend is also the one place the resolver discipline
gets *cycle-prone*: a `cred-A` derived from `cred-B` derived from
`cred-A` is a cycle that would loop forever.  A real resolver
detects cycles and raises a clear error.

## The resolution-order recipe

When a `${SCHEME:path}` reference's scheme matches more than one
backend (which the proposed dispatch model prevents — exactly one
backend per scheme — but more generally, when a reference *could*
be resolved by multiple paths), the discipline follows a strict
preference order:

1. **Vault / cloud secret manager** preferred over everything.  If a
   secret can be in a centralised audited store, that's where it
   should be.
2. **File backend** preferred over env or prompt.  Files have
   filesystem permissions; environment variables don't.
3. **Environment variable** as a *bridge* during migration.  Should
   fade out of production over time.
4. **Prompt** only in interactive contexts.  Never the production
   answer.
5. **Derived** as a special case, resolved in a second pass.

The proposed dispatch model — exactly one backend per scheme —
makes this less an "order" question and more a "default scheme"
question.  Most credentials should reference the centralised store
directly.  The order matters when a credential *could* be looked up
multiple ways, in which case the discipline says: prefer the form
your secret-handling auditor would prefer.

## Pre-load vs post-lookup resolution

The resolver pattern can fit at two seams:

### Pre-load resolution (recommended default)

Resolve all references *before* calling `CredentialManager.load_credentials`.
The materialised values become the credential field values; the
manager and the rest of the orchestration never see the references.

```python
# orchestration startup
resolver = build_resolver(...)
with open("config/credentials/credentials-staging-east.yaml", "r") as fh:
    raw = yaml.safe_load(fh)

# Resolve every ${SCHEME:path} in the raw dict.  resolved_credentials
# now contains literal secret strings.
resolved = resolver.resolve_dict(raw)

# Hand the resolved dict to the manager.  The manager doesn't know
# that the values were ever references.
mgr = CredentialManagerSingleton(
    credential_info=resolved,
    credential_uris=["config/credentials/credentials-staging-east.yaml"],
)
```

Pre-load resolution is the recommended default because:

- The manager and the rest of the orchestration are simpler.
- Failure to resolve fails the orchestration at *startup*, not at
  the moment of first credential use.
- The audit log of "which secrets were fetched at startup" is a
  single resolved-batch event, not a stream of per-lookup events.

### Post-lookup resolution

Resolve references *on demand* the first time a credential is
looked up.  Wrap the manager:

```python
class LazyResolvingManager:
    def __init__(self, raw_manager: CredentialManager, resolver: CredentialResolver):
        self._raw = raw_manager
        self._resolver = resolver
        self._resolved_cache = {}
        return

    def lookup_credential(self, key: str):
        if key in self._resolved_cache:
            return self._resolved_cache[key]
        cred = self._raw.lookup_credential(key)
        # walk cred.as_dict(), resolve ${SCHEME:path} fields, rebuild
        materialised = self._materialise(cred)
        self._resolved_cache[key] = materialised
        return materialised
```

Post-lookup resolution is the right answer when:

- The orchestration uses only a small fraction of declared
  credentials per run.  Resolving everything at startup is wasted
  work.
- Secrets are short-lived and need to be re-fetched periodically
  (the cache becomes a TTL cache; the next chapter on rotation
  builds on this).
- The startup time budget is tight and the resolver is slow (some
  vault backends have noticeable latency at first connect).

Most teams should start with pre-load resolution and only move to
post-lookup if a specific need surfaces.

## Host scoping

The shipped `codecraftai-credentials` package has **no notion of host
scope** — a credential applies wherever its identifier is referenced.
This is sometimes too permissive.  A credential that lets you SSH to
the staging runner pool should not silently work against the
production runner pool just because both apods reference it by the
same identifier.

The proposed discipline: extend the categories tag with host-scope
annotations, and have the resolver enforce them at lookup time.

```yaml
- identifier: ci-runner-ssh
  categories:
    - ssh
    - host-scope:staging-east
    - runner-fleet
```

The resolver wraps `lookup_credential` to check that the active
landscape's label matches the host-scope annotation:

```python
def lookup_credential(self, key: str):
    cred = self._raw.lookup_credential(key)
    scopes = [c.split(":", 1)[1] for c in cred.categories if c.startswith("host-scope:")]
    if scopes:
        active_landscape = LandscapeSingleton().label
        if active_landscape not in scopes:
            raise ConfigurationError(
                f"Credential {key} is scoped to {scopes} but active landscape is {active_landscape}"
            )
    return cred
```

This is a discipline addition the reader builds.  It is one example
of why having the resolver layer be *yours* matters: the
host-scoping rule that fits your environment is not the same as the
rule that fits a different team's environment.

## Putting it together: a complete resolver

A complete `CredentialResolver` plus a small set of backends, wired
into orchestration startup, looks like this:

```python
import os
import re
import yaml
from typing import Any, Dict, List, Optional

# ... CredentialBackend, FileBackend, EnvBackend, VaultBackend
# ... defined as in the recipes above

REFERENCE_RE = re.compile(r"^\$\{([A-Z]+):(.+)\}$")


class CredentialResolver:
    def __init__(self, backends: List[CredentialBackend]):
        self._scheme_to_backend = {b.SCHEME: b for b in backends}
        return

    def resolve_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
        return self._walk(data)

    def _walk(self, value):
        if isinstance(value, dict):
            return {k: self._walk(v) for k, v in value.items()}
        if isinstance(value, list):
            return [self._walk(v) for v in value]
        if isinstance(value, str):
            return self._resolve_string(value)
        return value

    def _resolve_string(self, raw: str) -> str:
        m = REFERENCE_RE.match(raw)
        if not m:
            return raw  # not a reference, leave alone
        scheme, path = m.group(1), m.group(2)
        backend = self._scheme_to_backend.get(scheme)
        if backend is None:
            raise ConfigurationError(
                f"No backend registered for scheme {scheme} in reference {raw}"
            )
        ref = CredentialReference(scheme=scheme, path=path, raw=raw)
        value = backend.resolve(ref)
        if value is None:
            raise ConfigurationError(
                f"Backend {scheme} could not resolve reference {raw}"
            )
        return value


def build_resolver_for_environment(profile: str) -> CredentialResolver:
    """
        Build the resolver for a particular environment profile.
        The set of backends differs per profile — this is the
        environment-specific bit (chapter 12 expands on this).
    """
    if profile == "ci":
        return CredentialResolver([
            VaultBackend(vault_addr=os.environ["VAULT_ADDR"],
                         vault_token=os.environ["VAULT_TOKEN"]),
            FileBackend(),
            EnvBackend(),
        ])
    elif profile == "local-dev":
        return CredentialResolver([
            KeychainBackend(),
            FileBackend(),
            PromptBackend(),
        ])
    elif profile == "staging":
        return CredentialResolver([
            VaultBackend(vault_addr=...,
                         vault_token=...),
            FileBackend(),
        ])
    else:
        raise ConfigurationError(f"Unknown profile {profile!r}")


# Wired into orchestration startup
def bootstrap_credentials(profile: str, credentials_path: str) -> None:
    with open(credentials_path, "r") as fh:
        raw = yaml.safe_load(fh)

    resolver = build_resolver_for_environment(profile)
    resolved = resolver.resolve_dict(raw)

    CredentialManagerSingleton(
        credential_info=resolved,
        credential_uris=[credentials_path],
    )
    return
```

Sixty lines — including the reference-parsing regex, the recursive
walk, the per-backend dispatch, and the per-environment configuration
— for a complete resolver.  The same sixty lines work for every
project in your portfolio.

## What the resolver does *not* do

Three things to keep out of the resolver:

### Not authentication

The resolver fetches secrets.  It does *not* perform authentication
against systems using those secrets.  A `BasicCredential` whose
password the resolver materialised is still inert; the consumer's
HTTP client is what authenticates.

This matters because the resolver runs *once* per orchestration
startup, but authentication runs every time a credential is used.
Conflating the two would force authentication failures at the wrong
seam.

### Not access-control policy

The resolver fetches secrets.  It does *not* decide whether the
caller is *allowed* to fetch a particular secret.  That is a
property of the backend's own access control (Vault policies, IAM
roles, KV store ACLs).

A team's credential discipline should rely on the backend's
permission system to enforce who-can-read-what, not on the
resolver.  When the resolver fails to fetch (because the caller
lacks permission), the backend returns the failure; the resolver
surfaces it as a clear error.

### Not rotation

The resolver fetches the *current* value at the time it runs.  When
a credential rotates, the cached resolved value goes stale.  The
chapter on credentials in flight (chapter 11) is the rotation
discipline; the resolver is one of the building blocks but not the
whole story.

For long-running orchestrations (anything over an hour), the
post-lookup resolution model with TTL caching becomes important;
chapter 11 expands on the pattern.

## The migration path

For a team adopting the discipline against an existing pipeline:

1. **Stand up one backend.**  Pick the one that matches your
   environment — Vault if you have it, AWS Secrets Manager if
   you're on AWS, file backend backed by Kubernetes Secrets if you
   run on K8s.  Do not try to ship six backends on day one.
2. **Migrate the credential file** to use the
   `${SCHEME:path}` syntax for one credential at a time.  Keep
   the old environment variables as a fallback during migration
   by wiring the EnvBackend.
3. **Validate at every step.**  Each migrated credential is a
   separate change; each change is reviewable; each change can be
   rolled back independently.
4. **Remove the EnvBackend** once every credential has migrated.
5. **Add host-scoping annotations** as a second pass once the
   primary backends are stable.

The migration takes weeks, not days.  This is fine — the discipline
compounds over time.  A single rotated production credential that
didn't leak through environment variables to a CI log is, by itself,
worth the migration.

## Summary

- The `codecraftai-credentials` package treats secret values as opaque
  strings.  Resolution — turning `${VAULT:/path}` into the actual
  secret — is the consumer's responsibility, deliberately so.
- The proposed reference syntax is `${SCHEME:path}`, with each
  scheme corresponding to a backend.  The `SCHEME` is greppable, the
  `path` is opaque to the parser, and the form is loud enough to
  notice if it leaks into a log.
- The backend interface is small: one `resolve(reference)` method
  returning the materialised value or None.  Adding a backend is a
  new subclass and a new dispatch entry.
- Common backends a reader will build: File, Env (legacy bridge),
  Prompt (local dev), Vault, AWS Secrets Manager, Azure Key Vault,
  GCP Secret Manager, Keychain (macOS local dev), Derived
  (cred-from-cred).
- Pre-load resolution (resolve before `load_credentials`) is the
  recommended default; post-lookup resolution is the answer when
  rotation or sparse usage matters.
- Host-scoping is a discipline addition the reader builds on top of
  the categories vocabulary; the resolver enforces it at lookup
  time.
- The complete resolver — regex, recursive walk, per-environment
  backend selection, orchestration wiring — is roughly sixty lines
  and is reusable across projects.
- The resolver is not authentication, not access-control policy,
  and not rotation.  Each is a distinct concern with its own seam.

The next chapter is credentials in flight — rotation during long
runs, encryption at rest of credential files, audit trails, and the
"never log a credential" discipline made operational.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/11-credentials-in-flight -->
# Chapter 11 — Credentials in Flight

> *A credential at rest is a file you encrypt.  A credential in
> flight is a process attribute that lives only in the process's
> memory and never touches disk again.  Most credential bugs happen
> at the boundary between the two.*

The previous chapter showed how to fetch a secret from a backend
and turn it into a typed credential object.  The chapter ended
with the credential alive in process memory, ready to be used.
This chapter is about the lifecycle of that in-memory value: how
it stays safe while the process holds it, how it survives rotation
without breaking the run, how it leaves an audit trail, and — most
importantly — how it never accidentally leaks into a place that
outlives the process.

The chapter covers three bands of the lifecycle:

1. **Encryption at rest** — when the credential file itself is
   stored as ciphertext rather than plaintext.  The Fernet machinery
   shipped in `codecraftai-configuration` is the right tool here and
   the chapter explains how to use it correctly.
2. **Rotation in flight** — what happens when a credential changes
   while the orchestration is running.  Long-running pipelines (a
   nightly suite, a multi-hour data migration) outlive the lifetime
   of many cloud-native credentials, so rotation is operational, not
   theoretical.
3. **Audit and the "never log" rule** — the discipline that keeps
   typed credentials safe from the most common leak vector
   (accidental serialisation in logs and exception messages).

## Encryption at rest

The shipped cryptography module is
[`codecraftai-configuration/source/packages/ccai/configuration/cryptography.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/cryptography.py).
Four functions, all using Fernet symmetric encryption:

```python
generate_fernet_key(keyphrase: str) -> str
encrypt_content(key: str, plain_content: str) -> str
decrypt_content(key: str, encrypted_content: str) -> str
create_encrypted_configuration(key: str, plain_configuration: str,
                               format: str = ConfigurationFormat.JSON) -> dict
```

The convention they implement: an encrypted credential file is a
*configuration document* with three top-level fields:

```yaml
version: "1.0"
format: yaml
encrypted_content: "BASE64-CIPHERTEXT-HERE"
```

The `codecraftai-configuration` loader recognises the
`encrypted_content` key at load time, asks the consumer for a key
or keyphrase, decrypts the ciphertext, and parses the resulting
plaintext as the file's actual content.  The encrypted file looks
identical from the outside whether the inner content is a
credentials YAML, a runtime profile, or a landscape — the same
machinery encrypts any of the six categories.

### The encryption flow

To encrypt an existing credentials file:

```python
import yaml
from ccai.configuration.cryptography import (
    create_encrypted_configuration,
    generate_fernet_key,
)
from ccai.configuration.configurationformat import ConfigurationFormat

# Read the plaintext credentials file
with open("credentials-staging-east.yaml", "r") as fh:
    plain_yaml = fh.read()

# Derive the Fernet key from a keyphrase
key = generate_fernet_key("the-team's-shared-keyphrase-2026q2")

# Wrap the plaintext into an encrypted document
encrypted_doc = create_encrypted_configuration(
    key=key,
    plain_configuration=plain_yaml,
    format=ConfigurationFormat.YAML,
)

# Write the encrypted document
with open("credentials-staging-east.encrypted.yaml", "w") as fh:
    yaml.safe_dump(encrypted_doc, fh)
```

The output file looks like:

```yaml
version: "1.0"
format: yaml
encrypted_content: "Z0FBQUFBQm5UZ0NWUUI4Z0lqdjcyTWVfWWNlcEFTeFRtRC4uLg=="
```

To decrypt at orchestration startup, the loader is given the same
keyphrase (or directly the Fernet key) and the original plaintext
is reconstructed transparently — by the time the credentials reach
`CredentialManager.load_credentials`, the file's content is already
plain.

### A note on the keyphrase

The shipped
[`generate_fernet_key`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/cryptography.py)
is **deterministic**: the same keyphrase always produces the same
key.  The implementation is `random.Random(keyphrase).randbytes(32)`
followed by URL-safe base64.

Determinism is a feature, not a bug, in this context.  It means a
team that shares a keyphrase out-of-band can decrypt files without
distributing key material separately.  The keyphrase is the
shared secret; the key derivation is reproducible.

It also means the keyphrase is the entire security story.  Two
implications worth being explicit about:

1. **A weak keyphrase is a weak key.**  `random.Random(keyphrase)`
   is *not* a key-derivation function — there is no salt, no
   iteration count, no resistance against offline brute force.  A
   keyphrase like `"changeme"` produces a key an attacker can
   reproduce in milliseconds if they get the encrypted file.  Use
   keyphrases with the entropy of a real password (long randomly-
   generated strings, password-manager output, or human-chosen
   passphrases of four-or-more random words).
2. **Keyphrase compromise is total.**  A keyphrase that an
   attacker learns gives them access to every file ever encrypted
   with it.  Rotate keyphrases on a schedule (chapter 12 expands
   on rotation across environments) and re-encrypt files with
   the new keyphrase.

For teams whose security posture requires stronger key derivation,
the Fernet primitives accept any 32-byte URL-safe base64 key — the
team is free to derive that key with PBKDF2, scrypt, Argon2, or a
hardware HSM and pass the result directly to `encrypt_content`.
The shipped `generate_fernet_key` is a convenience for the common
case; the underlying machinery is general.

### When to encrypt at rest

The discipline is straightforward:

- **Encrypt** when the credentials file lives in a place where
  unauthorised access is plausible: a shared filesystem, a Git
  repository (even private), an artifact store, a backup.
- **Don't encrypt** when the file lives behind a stronger access
  boundary than encryption-at-rest would provide — Vault, AWS
  Secrets Manager, K8s Secrets with the K8s-side encryption-at-
  rest enabled.  Double-encrypting adds operational complexity
  without security gain.
- **Always encrypt** when the credentials file is committed to a
  repository, even temporarily.  The Git history is forever; an
  unencrypted credential committed once is leaked forever.

The right answer for most teams: keep credentials *out* of the
repository entirely (Vault, AWS Secrets Manager, K8s Secrets), use
the resolver layer from chapter 10 to fetch them at runtime, and
keep the encryption-at-rest machinery as a fallback for the cases
where in-repo storage is unavoidable.

## Rotation in flight

The shipped credential classes are immutable after construction.
A `BasicCredential` instantiated with `password="hunter2"` will
return `"hunter2"` from `cred.password` for the rest of its life;
there is no setter.  This is a deliberate API choice.

Rotation, then, is not a property of the credential object — it is
a property of the *resolution layer* that produces credential
objects.  A long-running orchestration with rotating credentials
needs the resolver to re-fetch the current value periodically.

### When rotation matters

Three patterns make rotation operational:

1. **Long-running pipelines.**  An orchestration that runs for more
   than the credential's TTL must re-fetch.  Cloud-native
   credentials (AWS STS tokens, Azure AAD tokens) typically have
   1-12 hour lifetimes.  Database credentials with HashiCorp Vault
   dynamic-secret leases default to one hour.
2. **Distributed orchestrations.**  When the orchestration spawns
   workers that outlive the original resolver invocation, each
   worker may need its own rotation strategy.  Volume 2's
   distributed-test-job chapters address this; the pattern reduces
   to "each worker re-resolves at startup, plus periodically."
3. **Mid-run rotation events.**  An on-call engineer who rotates a
   compromised credential during an active orchestration shouldn't
   have to abort the run.  The orchestration should pick up the
   new value at the next resolution.

### The TTL cache pattern

Chapter 10 introduced post-lookup resolution as one of the two
seams the resolver fits at.  Rotation is the canonical reason to
choose it.  The shape:

```python
import time
from typing import Optional


class TTLCachingResolvingManager:
    """
        Wraps a CredentialManager and a CredentialResolver.  Caches
        resolved credentials with a per-credential TTL.  Beyond the
        TTL, the next lookup re-resolves from the backend.
    """

    DEFAULT_TTL_SECONDS = 300   # 5 minutes; per-credential override below

    def __init__(self, raw_manager, resolver):
        self._raw = raw_manager
        self._resolver = resolver
        self._cache = {}   # ident -> (resolved_credential, expires_at)
        return

    def lookup_credential(self, key: str):
        now = time.time()
        if key in self._cache:
            cred, expires_at = self._cache[key]
            if now < expires_at:
                return cred

        # Cache miss or expired — re-resolve.
        raw_cred = self._raw.lookup_credential(key)
        ttl = self._ttl_for(raw_cred)
        materialised = self._resolver.materialise(raw_cred)
        self._cache[key] = (materialised, now + ttl)
        return materialised

    def _ttl_for(self, cred) -> int:
        # Per-credential TTL via the categories tag.  E.g. a
        # category tag of "ttl:60" overrides the default to 60s.
        for cat in cred.categories:
            if cat.startswith("ttl:"):
                try:
                    return int(cat.split(":", 1)[1])
                except ValueError:
                    pass
        return self.DEFAULT_TTL_SECONDS
```

Three design decisions:

- **The cache is keyed by identifier**, not by credential object
  identity.  An updated value with the same identifier replaces the
  old one transparently.
- **Per-credential TTL via the categories tag.**  The shipped
  categories field doubles for routing and for metadata; adding a
  `ttl:<seconds>` tag is the natural place for the TTL.  Default
  five minutes; explicit override per credential.
- **No eager refresh.**  The cache invalidates lazily on the next
  lookup.  A credential that is never looked up is never re-fetched.
  This is the right behaviour for the common case (sparse access);
  for hot credentials, a background refresh thread is a small
  extension.

### Handling rotation failures

Rotation can fail for the same reasons resolution can: the
backend is unreachable, the credential has been revoked, the
caller has lost permission.  The discipline:

- **Old value remains usable until rotation succeeds.**  A failed
  re-fetch should *not* invalidate the cached value — keep using
  the old one until the new one is available.  Rotation that
  brittlely interrupts the run is worse than a slightly-stale
  credential.
- **Rotation failures emit a clear log event.**  The next chapter
  on auditing covers what to log; rotation failure is one of the
  events that should always reach the audit channel.
- **Cascading failures are bounded.**  When the secrets backend is
  down for an hour, every cached credential will eventually try to
  rotate and fail.  The TTL pattern naturally bounds this — failed
  re-fetches set a short retry interval rather than blocking, and
  the orchestration continues with stale credentials rather than
  cascading the backend outage into a pipeline outage.

A real production resolver implements rotation as a small state
machine per credential: `fresh → expiring → refreshing → fresh` or
`fresh → expiring → refresh-failed → fresh-but-stale`.  The
state-machine details are environment-specific; the pattern is
universal.

## The audit trail

Credentials in flight need an audit trail for two reasons.  The
first is operational: when an orchestration fails, "which
credential did we use?" should have an immediate answer.  The
second is regulatory: many environments require a log of every
credential access, with the *identifier* (not the value) and a
timestamp and a calling context.

### What to log

Three fields per audit event:

- **Credential identifier.**  Always logged.
- **Calling context.**  The orchestration step or job that
  requested the credential.  This is what makes the audit trail
  *useful* for debugging — "credential `staging-db-rw` was used by
  step `run-migrations` at 14:32 UTC" is actionable.
- **Resolution status.**  Whether the lookup hit the cache or
  re-fetched, whether re-fetch succeeded or failed, the latency.

Three fields per audit event that are **never logged**:

- **The credential value itself.**  No exceptions.
- **Anything derived from the credential value** (the resulting
  HTTP headers, the connection string, the SSH session token).
- **The credential's `as_dict()` output.**  The shipped
  `as_dict()` includes the secret material — see the next section.

A simple audit logger:

```python
class CredentialAuditLog:
    def __init__(self, sink):
        # sink is a logger or a structured-log writer; this class
        # never decides what backend the audit goes to, only what
        # fields to emit.
        self._sink = sink
        return

    def record_lookup(self, identifier: str, calling_context: str,
                      cache_hit: bool, latency_ms: float):
        event = {
            "type": "credential.lookup",
            "identifier": identifier,
            "context": calling_context,
            "cache_hit": cache_hit,
            "latency_ms": latency_ms,
        }
        self._sink.info(json.dumps(event))
        return

    def record_rotation(self, identifier: str, success: bool,
                        old_value_age_seconds: int):
        event = {
            "type": "credential.rotation",
            "identifier": identifier,
            "success": success,
            "old_value_age_seconds": old_value_age_seconds,
        }
        self._sink.info(json.dumps(event))
        return

    def record_resolution_failure(self, identifier: str, error_class: str,
                                  reference_path: str):
        event = {
            "type": "credential.resolution_failure",
            "identifier": identifier,
            "error_class": error_class,
            # Reference path is the BACKEND path (e.g.
            # "/secret/staging/db/rw"), not the credential value.
            "reference_path": reference_path,
        }
        self._sink.error(json.dumps(event))
        return
```

The audit logger is wired into the resolving manager — every
`lookup_credential` call emits an event, and the resolver emits an
event on every backend resolution and every failure.  The wiring is
a one-line addition per call site; the discipline is to never let a
credential lookup go unaudited.

## The "never log a credential" rule, made operational

Chapter 9 introduced the rule.  This is where it becomes an
operational discipline.  Three practices keep it in place.

### `__repr__` redaction

Every credential class should override `__repr__` to elide the
secret material.  An audit finding from chapter 9 noted that the
shipped classes mostly do *not* do this today — that's a defect
worth fixing in the package.  The pattern:

```python
class BasicCredential(BaseCredential):
    # ...

    def __repr__(self) -> str:
        return (
            f"BasicCredential(identifier={self._identifier!r}, "
            f"username={self._username!r}, password=<redacted>)"
        )

    __str__ = __repr__
```

After the override, `f"{cred}"` produces
`BasicCredential(identifier='staging-db-rw', username='app_user', password=<redacted>)`.
The same protection applies in tracebacks: when a `BasicCredential`
appears in a function's local scope and an exception is raised,
the variable's representation in the traceback no longer leaks the
password.

The same override is needed for every credential class that has
secret material — that is, all eleven shipped classes.

### Logging filters

Even with `__repr__` redaction, structured logging frameworks
sometimes serialise objects via `__dict__` or `as_dict()` rather
than `__repr__`.  A logging filter at the application boundary
catches this:

```python
import logging


class CredentialRedactingFilter(logging.Filter):
    """
        Inspects log records for fields that look like credential
        material (password, secret, token, key, keypasswd, private,
        client_secret) and redacts them.
    """

    REDACT_KEYS = frozenset({
        "password", "secret", "token", "keypasswd",
        "private", "client_secret", "keyraw",
    })

    def filter(self, record: logging.LogRecord) -> bool:
        for k in list(record.__dict__.keys()):
            if k.lower() in self.REDACT_KEYS:
                record.__dict__[k] = "<redacted>"
        # Also walk the args; same treatment.
        if isinstance(record.args, dict):
            for k in list(record.args.keys()):
                if k.lower() in self.REDACT_KEYS:
                    record.args[k] = "<redacted>"
        return True


logging.getLogger().addFilter(CredentialRedactingFilter())
```

The filter is defence-in-depth.  It runs on every log record,
catches accidental leaks the `__repr__` override missed, and
imposes essentially zero performance cost.

### Exception traceback handling

Python's default exception handling can include large amounts of
local-variable context in tracebacks (especially with libraries
like `rich` or `better-exceptions`).  When a credential is in
scope at the point of an exception, the credential's `__repr__`
goes into the traceback — which is why the redaction in
`__repr__` is the first line of defence.

The second line of defence is to install a custom exception hook
that strips local-variable contexts from credential-bearing frames:

```python
import sys
import types


def _make_credential_aware_excepthook(original):
    def _hook(exc_type, exc_value, exc_traceback):
        # Walk the traceback; for any frame whose locals contain a
        # BaseCredential, blank the locals before the original hook
        # serialises them.
        from ccai.credentials.basecredential import BaseCredential
        tb = exc_traceback
        while tb is not None:
            for var, val in list(tb.tb_frame.f_locals.items()):
                if isinstance(val, BaseCredential):
                    tb.tb_frame.f_locals[var] = "<credential redacted>"
            tb = tb.tb_next
        return original(exc_type, exc_value, exc_traceback)
    return _hook


sys.excepthook = _make_credential_aware_excepthook(sys.excepthook)
```

This is paranoid.  Many teams will not need it.  But for teams
with strict regulatory requirements (PCI, HIPAA, SOC 2 environments
where credential leakage is a reportable incident), the paranoia is
worth the small operational cost.

### The audit guardrail

Code review and static checks catch the patterns that runtime
filtering cannot.  Two guardrails worth adopting:

1. **Grep guardrail in CI.**  A pre-commit hook or CI step that
   greps for patterns like `print(*cred*)`, `logger.*\(*cred*\)`,
   `f".*{cred*}.*"` and fails the build if any match.  False
   positives are common (the patterns also match harmless variable
   names) and the hook needs an allowlist; that's the cost of the
   guardrail.
2. **AST-level lint.**  A custom lint rule that flags any `print`,
   `logger`, or f-string that takes an argument whose type
   annotation is `BaseCredential` or a subclass.  More accurate than
   grep, more work to set up.  Worth it for projects above ~10 KLOC.

The guardrails do not need to be perfect.  The redaction at
`__repr__` and the logging filter catch most leaks at runtime; the
guardrails catch the patterns the runtime defences miss before they
ever ship.

## The `as_dict` paradox

Every shipped credential class has an `as_dict()` method that
returns a dictionary representation of the credential — *including
the secret material*.  This is a deliberate trade-off:

- **For:** serialisation is necessary for some legitimate
  operations.  Re-encrypting a credentials file requires reading,
  modifying, and writing the credential records.  Sending a
  credential over an authenticated channel to a remote worker
  requires serialising it.  The shipped `as_dict` is the
  serialisation primitive that supports those use cases.
- **Against:** `as_dict` is *also* what naive serialisation paths
  call by accident.  Many JSON encoders, structured loggers, and
  introspection libraries call `as_dict` if it exists, on the
  reasonable assumption that a method named `as_dict` produces a
  representation appropriate for serialisation.  The result is a
  log entry containing the password.

The discipline:

- **`as_dict` is for *deliberate* serialisation only.**  Code that
  calls it should be rare, named clearly, and reviewed.  The places
  that legitimately call `as_dict` are: the credential file writer,
  the encrypted-config builder, the inter-process credential
  transport (rare).
- **Do not let auto-serialising libraries discover `as_dict`.**  If
  your structured-logging library serialises any object with an
  `as_dict` method, configure it not to.  If your JSON encoder
  treats `as_dict` as a hook, override that hook for credential
  classes specifically.  The redacting filter from the previous
  section is a good defence-in-depth here too.
- **Consider `to_safe_dict()` as a future addition.**  An audit
  finding from chapter 9 noted the absence of `__repr__` redaction;
  a parallel audit finding here is that the shipped credential
  classes would benefit from a `to_safe_dict()` method that returns
  the same shape as `as_dict()` with all secret fields elided.
  That would give the structured-logger a safe option that
  superficially resembles `as_dict()`, reducing the chance that a
  developer reaches for the wrong one.

## What credentials in flight is *not*

Three boundaries to keep clear:

### Not authentication

The chapter has talked about credentials in process memory, but
*using* the credential to actually authenticate is the consumer's
job — the HTTP client's, the database driver's, the SSH library's.
The credential object is passive material.  Authentication
failures (wrong password, expired token, revoked key) are detected
by the consumer at use time, not by the credential class.

This is also why rotation lives at the resolver layer rather than
inside the credential class: the credential class doesn't know
when authentication failed.  The orchestration's authentication-
aware code catches the failure and asks the resolver to re-fetch.

### Not access-control policy

Whether a particular credential is *allowed* to access a particular
resource is enforced by the resource (the database, the API
endpoint, the SSH server) and by the credential's backend access
controls (Vault policies, IAM roles).  The credential class does
not enforce policy; the resolver does not enforce policy; the
orchestration does not enforce policy.  Policy lives at the system
of record.

The discipline of "audit the credential identifier and calling
context" gives the policy enforcement points the information they
need to refuse improper accesses.  But the refusal happens at the
backend, not at the orchestration.

### Not a secret store

A credentials file with redacted `__repr__` and a logging filter
and `as_dict` discipline is *still not a secret store*.  It is a
fetched-and-cached projection of the secret store.  The system of
record for the secret is Vault (or AWS Secrets Manager, or your
chosen backend); the credential object is an in-process copy.

When you need to rotate a secret, rotate it at the backend.  When
you need to revoke access, revoke at the backend.  The credential
object's TTL cache will pick up the change at the next resolution
window.  Treating the credential object as the source of truth
inverts the right architecture.

## Putting it together

A complete in-flight credential discipline has six pieces:

1. **Encryption at rest** when the credentials file is in a
   plausible-leak location.  Fernet from `codecraftai-configuration`,
   keyphrase with real entropy, files re-encrypted on keyphrase
   rotation.
2. **TTL caching with rotation** at the resolver layer.  Per-
   credential TTL via the `categories` tag.  Stale-on-failure
   semantics; never cascade backend outages into pipeline outages.
3. **`__repr__` redaction** on every credential class.  The first
   line of defence against accidental logging.
4. **A logging filter** at the application boundary.  Defence in
   depth; catches what `__repr__` missed.
5. **An audit trail** that emits a structured event per lookup,
   per rotation, per failure.  Records the identifier and calling
   context; never the value.
6. **CI-level guardrails** (grep or AST-based) that catch
   credential-logging patterns before they ship.

Together, these form a credential discipline that is operationally
robust against the most common failure modes (leak through logging,
brittle rotation, credential drift between the backend and the
process) without imposing a heavy ceremony on the team's daily
workflow.

## Summary

- **Encryption at rest** uses the Fernet machinery in
  `codecraftai-configuration/cryptography.py`.  An encrypted file is
  a configuration document with `version`, `format`, and
  `encrypted_content` fields.  The shipped `generate_fernet_key` is
  deterministic from a keyphrase — strong-enough keyphrases are
  the entire security story.  For PBKDF2/scrypt/Argon2 derivation,
  pass a custom 32-byte URL-safe-base64 key directly to
  `encrypt_content`.
- **Rotation in flight** is a property of the resolver layer, not
  the credential object.  The TTL caching pattern from chapter 10
  is the natural place to implement rotation; per-credential TTL
  via the `categories` tag (e.g., `ttl:60`) lets short-lived
  credentials co-exist with long-lived ones.  Stale-on-failure
  semantics keep backend outages from cascading.
- **The audit trail** logs three fields per event: identifier,
  calling context, resolution status.  It never logs the value or
  anything derived from it.  Lookups, rotations, and failures all
  become structured audit events.
- **The "never log" rule** has four layers of defence:
  1. `__repr__` redaction on every credential class.
  2. A logging filter at the application boundary.
  3. Custom exception-hook handling for credential-bearing frames.
  4. CI guardrails (grep and/or AST lint) for credential-logging
     patterns.
- **The `as_dict` paradox** is a real trade-off — serialisation is
  necessary, but auto-serialising libraries make the method
  dangerous.  A `to_safe_dict()` parallel method is the proposed
  long-term fix; in the meantime, use the redacting filter as
  defence-in-depth.
- The discipline is *not* authentication, *not* access-control
  policy, *not* a secret store.  Each is the responsibility of a
  different layer; the credential object is passive in-process
  material.

The next chapter is the last in Part III: credentials across
environments — how the same credential vocabulary survives the
trip from local-dev to CI to staging to prod, with backends that
swap out beneath it.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/12-credentials-across-environments -->
# Chapter 12 — Credentials Across Environments

> *The same credential vocabulary works in local-dev, in CI, in
> staging, in prod.  What changes is the backend that resolves it.
> The pipeline does not know which backend resolved its
> credentials.*

This chapter closes Part III and bridges to Part IV.  Chapter 9
established the vocabulary, chapter 10 designed the resolver,
chapter 11 made the credential lifecycle operational.  This chapter
asks the question those three chapters set up: *how does the same
credential discipline survive the trip from a developer's laptop
through CI through staging into production?*

The answer is the discipline of this chapter: the credential file
is identical in every environment.  The credential identifiers are
identical.  The resolver interface is identical.  The audit logger
is identical.  What changes — *and only what changes* — is the
backend that materialises the secret values.

This is the credential-specific application of a broader principle.
Part IV will generalise it to every configuration category: same
files, same identifiers, same code paths, different *resolution*.
But credentials are the right place to introduce the discipline,
because credentials are where the security stakes are highest and
the cost of getting it wrong is most concrete.

## The four environments

A piece of automation visits four environments in its lifetime.

| Environment | Audience | Backend choices |
|---|---|---|
| **local-dev** | The engineer writing or debugging the code on their workstation | Keychain, file-on-disk, prompt |
| **integration-CI** | The CI pipeline running on every merge request | Vault, AWS Secrets Manager, file-from-K8s-Secret |
| **staging** | A pre-production environment running the same orchestration against real services | Vault with stricter access controls, audit logging always on |
| **prod-readonly** | An on-call engineer reading production state for incident debugging | Vault with read-only and audit-only credentials, mandatory time-bound access |

The engineer's debugging session and the CI pipeline and the
staging deployment and the production debugger all run *the same
code*.  They invoke the same orchestration with the same
credential references.  The difference is which `CredentialResolver`
the orchestration's bootstrap built, and that difference is one
profile-keyed function.

## What stays the same

Five things do not change between environments:

### The credentials YAML file

The same `credentials.yaml` — same identifiers, same categories,
same field names, same `${SCHEME:path}` references — is the
canonical credential declaration in every environment.  A new
credential added for a local-dev test run automatically becomes
available in CI and staging, because the file is the same file.

This is structurally identical to the landscape and topology
discipline from chapters 5 and 6: the *file* is the source of
truth; the *resolution* is the per-environment customisation.

In practice this often means the credentials file is checked into
the repository — not the secret values, only the credential
*identifiers and references*.  The references say `${VAULT:/secret/...}`
or `${FILE:/run/secrets/...}`; the actual secrets live in the
backends those references point at.

### The credential identifiers

Once an identifier is declared (`staging-db-rw`,
`ci-runner-ssh`, `staging-snowflake-rw`), it has the same meaning
in every environment.  Code that calls
`CredentialManagerSingleton().lookup_credential("staging-db-rw")`
gets the same *type* of credential everywhere.  The username, the
field shape, the role — all identical.  Only the secret value
varies.

Identifier stability is the single most important property of the
credential discipline.  When an identifier means one thing in
local-dev and a different thing in CI, the discipline has been
broken — and the next bug from that breakage will be hard to
reproduce.

### The resolver interface

The `CredentialResolver` class from chapter 10 is the same class
in every environment.  It takes a list of `CredentialBackend`
instances and walks the credentials dict.  The walking is identical;
the parsing of `${SCHEME:path}` references is identical; the
dispatch of references to backends is identical.

What changes is *which backends are passed in*.  The resolver
itself is environment-agnostic.

### The audit logger

The audit trail format from chapter 11 — the structured event with
identifier, calling context, resolution status — is the same shape
in every environment.  The audit *sink* may differ (a file in
local-dev, a centralised logging service in CI, a SIEM in
staging/prod) but the events flow into them with the same fields.

This means a developer reproducing a CI failure locally sees the
same event types they'd see in CI; the post-failure investigation
narrative reads consistently across environments.

### The "never log" defences

The four-layer defence from chapter 11 — `__repr__` redaction,
logging filter, exception hook, CI guardrails — runs in every
environment.  Local-dev is *especially* a place these matter: a
developer's laptop is not a hardened machine, the `~/.zsh_history`
is forever, and the casual `print(cred)` someone added during
debugging is the most common credential leak in any codebase.

The defences run in local-dev with the same vigilance as in
production.  They are not "production hardening" — they are
*always-on hygiene*.

## What changes

The differences between environments are confined to two places.

### The backend roster

The set of `CredentialBackend` instances passed to the resolver
differs per environment.  This is `build_resolver_for_environment`
from chapter 10:

```python
def build_resolver_for_environment(profile: str) -> CredentialResolver:
    if profile == "local-dev":
        return CredentialResolver([
            KeychainBackend(),
            FileBackend(),
            PromptBackend(),
        ])
    elif profile == "integration-ci":
        return CredentialResolver([
            VaultBackend(
                vault_addr=os.environ["VAULT_ADDR"],
                vault_token=os.environ["VAULT_TOKEN"],
            ),
            FileBackend(),  # for K8s-mounted secret files
        ])
    elif profile == "staging":
        return CredentialResolver([
            VaultBackend(
                vault_addr=STAGING_VAULT_ADDR,
                vault_token=fetch_vault_token_via_aws_iam(),
            ),
        ])
    elif profile == "prod-readonly":
        return CredentialResolver([
            VaultBackend(
                vault_addr=PROD_VAULT_ADDR,
                vault_token=fetch_short_lived_readonly_vault_token(
                    operator_email=os.environ["OPERATOR_EMAIL"],
                    ticket_id=os.environ["INCIDENT_TICKET_ID"],
                ),
            ),
        ])
    else:
        raise ConfigurationError(f"Unknown profile {profile!r}")
```

Three observations from this code:

- **The number of backends decreases** as the environment becomes
  more sensitive.  Local-dev has three backends with permissive
  fall-through; production has one backend with strict access
  control.  The discipline is to remove backends as you climb the
  ladder, not to add them.
- **The backend's *configuration* hardens** with the environment.
  The local-dev `VaultBackend` (if used) might point at a personal
  Vault instance; the production `VaultBackend` requires
  short-lived tokens fetched from AWS IAM, with operator identity
  and ticket ID required.  The same class; very different security
  posture.
- **The selection lives in one function.**  The rest of the
  orchestration code never branches on `profile`.  When the
  environment changes, only `build_resolver_for_environment`
  changes.

### Per-environment policy on the credential's category tags

Chapter 10 introduced host-scoping via category tags — a credential
tagged `[host-scope:staging-east]` only resolves successfully when
the active landscape is `staging-east`.  The same machinery
expresses environment-scoping:

```yaml
- identifier: prod-db-readonly
  categories:
    - basic
    - environment:prod
    - role:readonly
    - ttl:300
  username: ${VAULT:/secret/prod/db/readonly#username}
  password: ${VAULT:/secret/prod/db/readonly#password}
```

The `environment:prod` tag tells the resolver to refuse the
credential in any environment other than prod.  When local-dev
accidentally references `prod-db-readonly`, the resolver fails
loudly: *"Credential prod-db-readonly is scoped to environment 'prod'
but active environment is 'local-dev'."*

This is the most important environment-specific defence in the
chapter.  It is the structural answer to the question *"how do we
prevent a developer from accidentally connecting to production?"*
The answer is: the credential file declares the boundary, the
resolver enforces it, and the discipline is checkable in code review.

## The promotion path

A new credential added in local-dev should become available in CI
and staging without rewriting code.  The promotion path:

1. **Day 1 (local-dev).**  A developer adds an entry to
   `credentials.yaml`:
   ```yaml
   - identifier: local-redis
     categories: [basic, environment:local-dev]
     username: dev
     password: ${KEYCHAIN:com.company.app/local-redis}
   ```
   The credential resolves in their local-dev environment because
   the keychain backend is wired.  Other environments would refuse
   to resolve `${KEYCHAIN:...}` because they don't have a Keychain
   backend; the `environment:local-dev` tag prevents accidental
   activation.

2. **Day 7 (CI).**  The same credential is needed for CI testing.
   The developer adds a *parallel* entry — same identifier, but
   different `${SCHEME:path}` and different scope tag:
   ```yaml
   - identifier: ci-redis
     categories: [basic, environment:integration-ci]
     username: ci
     password: ${VAULT:/secret/ci/redis#password}
   ```
   And a CI-side Vault entry is created to hold the secret.

3. **Day 14 (staging).**  Same pattern, with staging's Vault path
   and `environment:staging` tag.

The same code references the credential by identifier-prefix or by
role tag; the resolver picks the right one based on the active
environment.  At no point did the application code change.  At no
point did the plumbing (Dockerfile, CI YAML, K8s manifest) change —
the AM_CREDENTIAL_NAMES environment variable still points at the
same credentials.yaml file.

This is the chapter 7 plumbing-vs-content principle applied to
credentials specifically: the plumbing is small and fixed (one
env var, one resolver-builder, one credentials file path), and
the content (the credentials and their environment-scoped scopes)
grows freely.

## Cross-environment isolation

The most important security property of the discipline is that a
credential from one environment can never accidentally work in
another.  Three layers enforce this:

### Backend-level isolation

The backend instances themselves are environment-scoped.  The
`VaultBackend` for local-dev points at a personal Vault.  The
`VaultBackend` for CI points at the team's CI Vault.  The two
Vaults have different network reachability, different access
controls, and (in well-run setups) different root keys.

A reference like `${VAULT:/secret/prod/db/readonly}` issued from a
local-dev resolver goes to the local-dev Vault and *misses* —
either with a "not found" error or a "permission denied" error.
The local-dev Vault doesn't have the production secrets.

### Token-level isolation

Even within a single Vault, the *token* the backend was instantiated
with limits what it can read.  A staging token cannot read prod
paths; a CI token cannot read on-call-bound paths.  The credentials
file can declare every credential the orchestration might want; the
backend's token controls which ones the current environment can
actually fetch.

This means a leaky credentials file (one that mentions production
secret paths) is not by itself catastrophic.  An attacker who
obtains the credentials file in local-dev still cannot read the
production secrets — they would also need a production Vault
token.

### Tag-level isolation

The `environment:<name>` category tag, enforced by the resolver, is
the third layer.  Even if the backend permitted the lookup and the
token had access, the resolver refuses to return the credential when
the tag mismatches.  This is defence-in-depth: a misconfigured
backend or a token with too much access is *still* caught by the
resolver before the credential reaches the application code.

The three layers are independent.  Defence-in-depth means that any
two failing simultaneously still leaves the third holding the line.

## Migration patterns

Four common operational patterns the discipline supports:

### Adding a new credential

The procedure described above.  Add the credential to the file
with a per-environment scope tag.  Create the backend entry
(Vault path, K8s Secret, etc.) for the environments that need it.
Test in local-dev first; promote up the ladder.

The migration is *additive* in every environment.  Removing a
credential is structurally similar — drop the file entry, drop the
backend entry, ensure no code still references the identifier.

### Rotating across environments

A credential rotation in production is a backend operation: change
the secret value at the Vault path, leave the credentials file
unchanged.  The resolver's TTL cache (chapter 11) picks up the new
value at the next resolution window.

When a credential rotates *and* the rotation needs to be applied to
multiple environments (e.g., a third-party API key rotated by the
provider — same key value applies everywhere), the procedure is to
update each environment's backend independently.  The credentials
file's references don't change.

### Decommissioning an environment

When a staging environment is retired, four things happen:

1. The credentials file entries with `environment:staging` are
   removed.
2. The Vault paths for staging are deleted.
3. The `build_resolver_for_environment("staging")` branch is
   removed.
4. CI YAML that set `AM_PROFILE=staging` (chapter 14) is updated.

Steps 2-4 are operational and reversible.  Step 1 is the *only*
code change — and it is a deletion of redundant entries, not a
restructuring of any active environment.

### Adding a new environment

Promotion of `staging` to `staging-east` and `staging-west` (one
environment becoming two regional siblings) is the inverse:

1. Duplicate the relevant entries in the credentials file with
   the new scope tags.
2. Provision the Vault paths for each new environment.
3. Add the new branches to `build_resolver_for_environment`.
4. The CI YAML grows two new profile values.

The application code remains untouched.  The orchestration code
remains untouched.  The plumbing layer was already prepared for
multiple values of `AM_PROFILE` per the chapter 7 fixed-interface
principle.

## What credentials across environments is *not*

Three boundaries to keep clear:

### Not multi-tenant

The discipline assumes the same *team* owns the credentials
across environments.  When a credential is shared across teams
(an inherited service account, a shared admin login), the
discipline does not address the team-vs-team coordination — that
is an organisational concern that lives outside the orchestration
layer.

The pattern that does work for multi-tenant credentials is to
treat each tenant as a separate environment with its own credential
identifiers — `tenant-acme-db-rw` and `tenant-globex-db-rw`
rather than a shared `tenant-db-rw`.  Each tenant's credentials
remain isolated through the same backend-and-tag-scoping machinery.

### Not shared accounts

The discipline strongly *discourages* shared accounts (one
credential identifier whose value is reused by every member of a
team).  Shared accounts undermine the audit trail — the calling
context records *who* invoked the lookup, but the underlying
credential records *only* "the team."

When a shared account is unavoidable (third-party SaaS that
charges per seat, legacy systems with no per-user credential
support), the credential file should declare the sharing
explicitly via a category tag (`shared:platform-team`) so the
audit trail at least records the team boundary.  The discipline
acknowledges the constraint without pretending it isn't a
weakness.

### Not migration off legacy environment variables on day one

Chapter 7 made the case for runtime profiles over environment
variables.  The transition is not instant.  During the transition,
the credentials file may have entries that resolve via the
`EnvBackend` — pulling values from existing CI environment
variables — alongside entries that resolve via Vault.  This is
acceptable as a *transitional* state.  The discipline is to mark
the env-backed entries with a category tag (`legacy:env-var`) so
they show up in regular audits and the team has visibility into
the remaining migration work.

The end-state has zero `legacy:env-var` tags.  Reaching it is a
function of the team's bandwidth, not the discipline's correctness.

## A complete worked example

Building on the credentials file from chapter 9 (the eight
staging-east credentials) and the resolver builder from chapter 10,
here is the complete cross-environment setup for the same project:

```yaml
# credentials.yaml — checked into the repository
credentials:

    # ============== local-dev =================
    - identifier: local-redis
      categories: [basic, environment:local-dev]
      username: dev
      password: ${KEYCHAIN:com.company.app/local-redis}

    - identifier: local-postgres
      categories: [basic, environment:local-dev]
      username: dev
      password: ${PROMPT:Local postgres password}

    # ============== integration-ci =================
    - identifier: ci-runner-ssh
      categories: [ssh, environment:integration-ci, runner-fleet, ttl:3600]
      username: runner
      keyfile: ${FILE:/run/secrets/ssh/ci-runner-rsa}
      keypasswd: ${VAULT:/secret/ci/ssh/ci-runner-rsa-passphrase}

    - identifier: ci-runner-aws
      categories: [aws-access-key, environment:integration-ci, ttl:900]
      keyid: ${VAULT:/secret/ci/aws/ci-runner#keyid}
      secret: ${VAULT:/secret/ci/aws/ci-runner#secret}

    # ============== staging-east =================
    - identifier: staging-db-rw
      categories: [basic, environment:staging-east, app-tier, ttl:600]
      username: app_user
      password: ${VAULT:/secret/staging-east/db/app-rw}

    - identifier: staging-db-readonly
      categories: [basic, environment:staging-east, reporting-tier, role:readonly]
      username: app_readonly
      password: ${VAULT:/secret/staging-east/db/app-ro}

    - identifier: staging-snowflake-rw
      categories: [basic, environment:staging-east, analytics-tier, ttl:1800]
      username: ci_loader
      password: ${VAULT:/secret/staging-east/snowflake/ci-loader}

    # ============== prod-readonly =================
    - identifier: prod-db-readonly
      categories: [basic, environment:prod, role:readonly, audit:required, ttl:300]
      username: ${VAULT:/secret/prod/db/readonly#username}
      password: ${VAULT:/secret/prod/db/readonly#password}
```

Three things visible in the file structure:

- **Identifier prefixes mirror the environment** (`local-`, `ci-`,
  `staging-`, `prod-`).  This is convention, not enforcement — the
  `environment:<name>` tag is what the resolver enforces — but the
  prefix makes the file readable at a glance.
- **TTL varies by environment.**  Local credentials have effectively
  no TTL (Keychain doesn't rotate).  CI credentials have short TTLs
  (15-60 minutes) because they roll on every job.  Staging
  credentials have longer TTLs (10-30 minutes).  Prod credentials
  have the shortest TTLs (5 minutes) because the audit cost of a
  stale lookup is highest there.
- **Prod credentials require `audit:required`.**  This tag is
  enforced by the resolver in prod environments — every prod
  credential lookup emits an audit event with operator-identity and
  ticket-id metadata.  Failing to log is a hard error.

The same `build_resolver_for_environment` from chapter 10 wires
each profile to its appropriate backends.  The orchestration code
that consumes credentials is unchanged across all four environments.

## Summary — and end of Part III

- Same credentials file, same identifiers, same resolver
  interface, same audit logger, same "never log" defences across
  every environment.  Only two things change: the backend roster
  and the per-credential scope tags.
- `environment:<name>` category tags express the per-credential
  scope; the resolver enforces them at lookup time as defence-in-
  depth alongside backend-level and token-level isolation.
- Cross-environment isolation has three layers: the backend's
  reachability, the token's permission scope, and the resolver's
  tag enforcement.  Any two failing leaves the third holding the
  line.
- The promotion path for a new credential is additive: declare the
  identifier in local-dev with a scope tag, add the per-environment
  parallel entries as the credential is needed in CI / staging /
  prod.  No application-code change at any step.
- Common operational patterns — adding, rotating, decommissioning,
  splitting an environment — are all supported as additive or
  deletional changes to the credentials file plus their backend
  counterparts.  The plumbing layer never changes.
- The discipline is *not* multi-tenant coordination, *not* a fix
  for shared-account weaknesses, and *not* an instant migration
  off env-var-based credentials.  Each is a separate concern.

This is the last chapter of Part III.  The credentials story now
has its full shape: a vocabulary (chapter 9), a resolver discipline
(chapter 10), a lifecycle (chapter 11), and an environment-spanning
strategy (chapter 12).

The next part of the volume — Part IV — generalises the
environment-ladder discipline to *every* configuration category.
What this chapter said about credentials specifically (same
vocabulary, different backends, scope tags for isolation, additive
promotion) becomes a uniform pattern across Specifications,
Landscapes, Topologies, Runtimes, Data Profiles, *and* Credentials.
The credential discipline was the right place to introduce the
pattern because the security stakes made it concrete; Part IV is
where the pattern earns its keep across the rest of the platform.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/13-the-development-environment-ladder -->
# Chapter 13 — The Development-Environment Ladder

> *There are four environments a piece of code visits in its
> lifetime: a developer's laptop, the CI integration pipeline, a
> staging deployment, and prod-readonly debugging.  Most teams
> treat these as four codebases that happen to overlap.  The
> discipline of this part is to treat them as four profiles of the
> same codebase.*

Welcome to Part IV.  Parts I, II, and III named six configuration
categories and showed how each one expresses a slice of an
orchestration's parameterisation as typed data.  Part III ended
with the credential-specific application of an environment-spanning
pattern: same file, same identifiers, different backends.  This
part generalises that pattern to *every* configuration category and
proposes the discipline that lets a single codebase scale from a
developer's laptop to production debugging without rewrites at any
boundary.

The first chapter of the part — this one — establishes the ladder
itself.  The four chapters that follow propose the machinery
(chapter 14 on profile composition), the local-dev story (chapter
15), the CI-failure-reproduction story (chapter 16), and the
profile-promotion story (chapter 17).  Together the five chapters
make the case that "works on my machine" and "works in CI but not
locally" are *symptoms of a missing typed configuration*, and that
the cure is the profile discipline.

## A note on shipped tooling

An earlier research pass that grounded this volume found that the
portfolio has solid building blocks for the profile discipline —
`override_variable_value`, `MergeMap`, `AppVarSet`, `ContextPaths`,
`get_variable_value`, the existing five `AM_CONFIG_*_NAMES`
descriptors — but does not yet ship the *user-facing tooling* a
profile system needs (no `ccai profile show` CLI, no
`profile diff` command, no file-level `extends:`/`overlay:` syntax,
no captured-profile-replay mechanism).

Part IV therefore presents the profile discipline as a *recipe the
reader implements* using the shipped primitives as the
substrate.  Each chapter calls out which pieces are already in
place and which the reader will need to build.  By the end of Part
IV the reader has a complete profile system in their portfolio,
all of it composable with the rest of the platform.

## The four environments

A piece of automation visits four environments in its lifetime.
The discipline is built around treating each as a *profile* — a
named bundle of category selections — rather than as a separate
codebase.

### local-dev

The developer's workstation.  A single host running a small set
of mock services.  Fast feedback, low cardinality, generous
timeouts (because the developer wants to set breakpoints), and
verbose logging.

What's distinctive:

- **One participant in the apod** (the developer's own machine).
- **Mocks or local containers** for the infrastructure services.
- **Filesystem-backed credentials** or interactive prompts.
- **Verbose logging**, often `DEBUG`-level on every component.
- **Small property-test sweeps** (`TESTBASE_MAX_EXAMPLES_CAP=10`).

The local-dev profile exists to support the inner loop: write
code, run a single test, see the output in seconds, fix the bug.
Anything that introduces friction at this layer slows the team
down on every change.

### integration-CI

The CI pipeline running on every merge request.  Multiple
participants in the apod (a runner pool); real or
realistic-stand-in services; medium cardinality; tight timeouts;
structured logging routed to a central system.

What's distinctive:

- **Several participants in the apod** (the runner cluster).
- **Real staging services** (or carefully isolated CI-only
  instances of them).
- **Vault or cloud-secrets-manager** for credentials.
- **Structured logging** routed to the team's log aggregation.
- **Medium property-test sweeps** (`max_examples=10` for fast
  feedback on every MR; full sweep on nightly runs).

The integration-CI profile is the *gate*: code that reaches
staging has passed integration-CI.  The profile's job is to be
faithful enough to staging that passing integration-CI is
predictive of staging working, while remaining fast enough to fit
in a per-MR feedback budget.

### staging

A pre-production environment that runs the same orchestration
against real services.  Larger apods, full cardinality, the same
tight timeouts as production, full structured logging.

What's distinctive:

- **Production-shaped apods**, often the same number of runners
  as production.
- **Real services**, often the same infrastructure as production
  but separated by namespace, account, or region.
- **The same Vault** as production (or a tightly-controlled
  staging Vault), with audit logging always on.
- **Full property-test sweeps** (no `TESTBASE_MAX_EXAMPLES_CAP`,
  multi-hour runs).

The staging profile catches the issues integration-CI's medium
cardinality misses — the `max_examples=100` property test that
finds the once-per-thousand-cases edge case the CI run misses; the
soak test that exposes a memory leak the per-MR feedback can't.

### prod-readonly

An on-call engineer reading production state for incident
debugging.  No write access.  Audit-mandatory.  Time-bound access.

What's distinctive:

- **No apod participants in the conventional sense**.  The
  on-call engineer's workstation is the only "runner."
- **Production services**, accessed read-only.
- **Short-lived Vault tokens** issued by the on-call workflow with
  the operator's identity and the incident ticket as part of the
  fetch context.
- **Mandatory audit** — every credential lookup emits an event
  with operator and ticket attribution.
- **Aggressive timeouts** so a misbehaving production query never
  blocks beyond a small bound.

The prod-readonly profile exists for *one* purpose: to let an
engineer debug a production incident with the same tools they use
in every other environment, without giving them the permissions
to make the incident worse.

## The codebase-treatment approach: where it goes wrong

The natural response to four environments is to introduce *four
codebases* that happen to overlap.  Concretely, this looks like:

- A `tests/local/` directory and a `tests/integration/` directory
  with parallel test files.
- Setup scripts named `setup-local.sh`, `setup-ci.sh`, `setup-staging.sh`.
- Three or four `docker-compose.*.yaml` files with mostly the same
  services.
- A `conftest.py` (if pytest) full of `if os.environ.get("CI")`
  branches that change fixture behaviour.

The branches start small.  An early one looks like this:

```python
def get_database_host():
    if os.environ.get("CI"):
        return "db-ci.internal"
    return "localhost"
```

After a year, the function looks like this:

```python
def get_database_host():
    if os.environ.get("CI"):
        if os.environ.get("CI_PIPELINE_SOURCE") == "schedule":
            return os.environ.get("NIGHTLY_DB_HOST", "db-nightly.internal")
        return os.environ.get("CI_DB_HOST", "db-ci.internal")
    if os.environ.get("STAGING_RUN"):
        return os.environ.get("STAGING_DB_HOST", "db-staging.internal")
    if os.environ.get("PROD_DEBUG"):
        # readonly only; refuse if not in incident mode
        if os.environ.get("INCIDENT_TICKET") is None:
            raise RuntimeError("PROD_DEBUG without INCIDENT_TICKET")
        return os.environ.get("PROD_RO_DB_HOST", "db-prod-ro.internal")
    return os.environ.get("LOCAL_DB_HOST", "localhost")
```

This is the chapter 2 conditional-thicket failure mode in fresh
costume.  Four orthogonal concerns — *which environment am I in*,
*which sub-mode of that environment*, *which database does this
environment have*, *what guardrails apply* — smeared into one
shell expression.

The cost compounds.  Three concrete failure modes:

### Test code becomes environment-aware

Tests that should describe behaviour now describe behaviour *and*
environment routing.  A test that wants to assert "the API
responds with HTTP 200 to a `GET /healthz`" needs to first decide
which API to talk to, which depends on which environment it's in,
which depends on the env-var soup.  The test grows a setup section
that's longer than the test itself.

### Setup scripts diverge

`setup-local.sh` and `setup-ci.sh` start as twins.  After six
months, `setup-ci.sh` has acquired three extra steps for things
that work-around CI-specific quirks; `setup-local.sh` has acquired
two different extra steps for developer-laptop quirks.  The
intersection of the two is shrinking, but neither team owns the
job of keeping them in sync.

### `if 'CI' in os.environ` becomes uncatchable

Every legitimate use of the env-var-as-flag pattern paves the way
for the *next* less-legitimate use.  Eventually, business logic
branches on `CI`.  A test discovers that production has a quirk CI
doesn't and adds an `if not os.environ.get("CI"):` branch to
"work around" it.  The branch survives for three years; the
production quirk gets fixed; the branch never gets removed; the
codebase now has dead code that still subtly diverges between
environments.

The deeper issue is that **environment differences are
unbounded under the codebase-treatment approach**.  Every
divergence is paid for once with a new branch and forever with the
maintenance.  The team's velocity on cross-environment changes
slows, then stops.

## The profile-treatment approach: what it asks of the code

The discipline of Part IV is the inverse: treat the four
environments as *profiles*, where a profile is a named bundle of
configuration selections.  The code does *not* know which profile
is active.  The code reads typed configurations through the
runtime API; the runtime configurations are different between
profiles; the code is identical.

The same `get_database_host` from above becomes:

```python
def get_database_host():
    profile = ThisApplicationSingleton().get_data_profile("primary-db")
    return profile.host
```

Two layers in this rewrite:

1. **The application code asks the runtime** for the data profile
   named `primary-db`.  It does not ask "which environment am I
   in?"
2. **The data profile** named `primary-db` exists in every
   environment; its content (the host, the port, the credential
   reference) differs.  The selection of which `primary-db`
   profile applies to this run was made when the profile was
   loaded — long before this code ran.

Three properties of the rewrite worth naming:

- **The code has no environment branches.**  The
  conditional-thicket failure mode cannot reach this function;
  there is no place in the function where an environment check
  could be added that the discipline wouldn't immediately reject.
- **Adding a fifth environment is a profile addition.**  When the
  team stands up `staging-east-canary` as a fifth deployment, the
  data profile gains a new entry; the code is unchanged.
- **Removing an environment is a profile deletion.**  When `staging`
  is decommissioned, the data profile entries with that scope are
  removed; the code is unchanged.

The discipline asks the code to be *passive* with respect to
environment.  The code reads typed configurations.  The
configurations carry the environment-specific differences.  The
plumbing — chapter 7's `AM_*_NAMES` family — selects which
configurations are loaded.

## The lifecycle of a single test

Trace a single test through the four environments.  The test is
modest: *create a user via the API, fetch the user back, assert
that the response matches the input*.

```python
# tests/test_user_lifecycle.py
def test_user_create_and_fetch():
    """
        Create a user; fetch it back; assert the response matches
        the input.
    """
    api = ThisApplicationSingleton().get_data_profile("staging-api")
    cred = CredentialManagerSingleton().lookup_credential(api.credential)

    payload = {"name": "alice", "email": "alice@example.com"}

    create_response = http_post(
        url=f"{api.endpoint}/users",
        headers={"Authorization": f"Bearer {cred.token}"},
        json=payload,
    )
    verification.assert_equal(create_response.status_code, 201)

    user_id = create_response.json()["id"]
    fetch_response = http_get(
        url=f"{api.endpoint}/users/{user_id}",
        headers={"Authorization": f"Bearer {cred.token}"},
    )
    verification.assert_equal(fetch_response.status_code, 200)
    verification.assert_equal(fetch_response.json()["name"], "alice")
    verification.assert_equal(fetch_response.json()["email"], "alice@example.com")

    return
```

The test's body never names an environment.  It asks the runtime
for `staging-api` (a Data Profile name) and the credential the
profile references; it makes the calls; it asserts the response.
That's it.

### Day 1 — local-dev

The developer is writing the test.  They run it on their laptop.
The relevant configurations:

```yaml
# config/dataprofiles/dataprofiles-local-dev.yaml
dataprofiles:
    - identifier: staging-api      # same identifier in every env
      category: web/api
      endpoint: http://localhost:8080
      credential: local-api-token

# config/credentials/credentials.yaml (already shown in chapter 12)
- identifier: local-api-token
  categories: [api-token, environment:local-dev]
  token: ${PROMPT:Local API token}

# config/runtime/runtime-local-dev.yaml
logging:
    levels:
        console: DEBUG
timeouts:
    default_seconds: 300        # generous; the developer wants to set breakpoints
property_test_caps:
    max_examples: 5             # fast feedback
```

The developer runs:

```bash
AM_PROFILE=local-dev pytest tests/test_user_lifecycle.py -v
```

The orchestration's startup reads `AM_PROFILE=local-dev`, builds
the resolver with the local-dev backend roster, loads
`dataprofiles-local-dev.yaml`, `credentials.yaml`, and
`runtime-local-dev.yaml`, and runs the test.  The test calls
`http://localhost:8080/users`, gets the prompted local API token,
and passes.

### Day 7 — integration-CI

The developer pushes the branch.  The CI pipeline picks it up.
The pipeline's vendor YAML sets `AM_PROFILE=integration-ci`:

```yaml
# .gitlab-ci.yml
test:
  variables:
    AM_PROFILE: "integration-ci"
  script:
    - ccai testbase test run tests/test_user_lifecycle.py
```

The relevant configurations:

```yaml
# config/dataprofiles/dataprofiles-integration-ci.yaml
dataprofiles:
    - identifier: staging-api
      category: web/api
      endpoint: https://api-ci.staging.internal
      credential: ci-api-token

# config/credentials/credentials.yaml — already includes ci-api-token

# config/runtime/runtime-integration-ci.yaml
logging:
    levels:
        console: INFO
timeouts:
    default_seconds: 30
property_test_caps:
    max_examples: 10
```

The same test runs.  Same code.  Different `staging-api` —
pointing at the CI staging API.  Different `runtime` — tighter
timeouts, INFO-level logging.  Different credential — the CI Vault
provides the API token.

### Day 14 — staging

Once the change merges, the nightly staging run picks it up.
`AM_PROFILE=staging-east`:

```yaml
# config/dataprofiles/dataprofiles-staging-east.yaml
dataprofiles:
    - identifier: staging-api
      category: web/api
      endpoint: https://api-staging-east.internal
      credential: staging-api-token

# config/runtime/runtime-staging-east.yaml
logging:
    levels:
        console: INFO
timeouts:
    default_seconds: 30
property_test_caps:
    max_examples: null    # unset; full sweep
```

The same test runs.  Different endpoint, different token, no
property-test cap (the nightly run sweeps every property at full
strength).  The test that passed in CI may now uncover an issue
the CI run's `max_examples=10` missed.

### Day 30 — prod-readonly

A production incident has occurred.  The on-call engineer wants
to confirm that the affected user record is in fact corrupted in
production.  They want to fetch the user — *not* create one, since
prod is read-only — using their on-call ticket as the authentication
context.

They cannot run the full `test_user_create_and_fetch` test
(production is read-only); they can run a *fetch-only* variant.
The discipline accommodates this with profile-keyed test selection,
which chapter 17 develops further.  For now, the relevant point:
the engineer types

```bash
AM_PROFILE=prod-readonly INCIDENT_TICKET=INC-12345 \
    ccai testbase test run tests/test_user_fetch_only.py
```

and the same machinery loads the prod-readonly profile, fetches a
short-lived audited Vault token tied to their identity and the
ticket, and runs the fetch-only test against production.  The
audit trail records every credential lookup with the operator and
the ticket.

### What changed and what didn't

In four environments the test went through, *the test's code was
identical*.  The configurations differed:

| | local-dev | integration-CI | staging-east | prod-readonly |
|---|---|---|---|---|
| `staging-api` endpoint | `http://localhost:8080` | `https://api-ci.staging.internal` | `https://api-staging-east.internal` | (production endpoint) |
| Credential identifier | `local-api-token` | `ci-api-token` | `staging-api-token` | `prod-api-token` |
| Credential backend | Prompt | Vault (CI) | Vault (Staging) | Vault (Prod, audited) |
| Logging level | DEBUG | INFO | INFO | INFO |
| Timeout default | 300s | 30s | 30s | 5s |
| Property-test cap | 5 | 10 | unset | (test is read-only) |

Six dimensions of difference; zero lines of conditional code.  The
environment differences are entirely expressed as content; the
code is plumbing-level passive.

This is the discipline.

### A note on the runtime realisation: the Pod model

The four environments above are *configurations* — names that
select a Landscape, a Topology, a Runtime, a set of Credentials.
The *runtime realisation* of any one of those configurations is
called a **Pod** — a concrete, interoperating set of resources
the test runs against (Volume 6 chapter 9 walks the realisation
in detail; Volume 2 chapter 3 covers it from the test side).  A
local-dev profile realises into a one-host laptop Pod; an
integration-CI profile realises into a Pod with the CI-pool
runners and the staging service; a staging-east profile
realises into a Pod with real staging hosts.  Same code, four
profiles, four Pods.

The Pod model also explains where horizontal CI scaling lives.
When a test campaign needs to run faster, the answer is *more
Pods* (more Specifications provisioning more independent
environments) — not parallel tests within one Pod.  Tests
within a Pod run sequentially because the Pod is a shared
environment; the framework's tandem-testing model from Volume
2 chapter 5 (one test = one driver, validators = observers
along for the ride) is what makes a single test deterministic
inside a single Pod.  Scaling up means scaling out the *Pod
count*, not the *test concurrency within a Pod*.

This volume's chapter 27 returns to the point.  Part IV's
discipline answers "how do we make the same test runnable
across four environments;" the Pod model answers "how do we
parallelise across many of those environments at once."

## What the discipline asks of the codebase

Three concrete commitments make the discipline possible:

### Read configurations through the runtime

Every configuration value the code consumes — every host, every
port, every timeout, every log level, every credential, every
landscape entry — comes through the runtime API.  No
`os.environ.get(...)` in application code.  No hardcoded URLs.
No conditional branches keyed off environment markers.

The acceptable plumbing-level uses of `os.environ` from chapter 7
remain (configuration selectors, process identity,
external-system bootstrapping).  The application-level uses of
environment variables for *content* are gone.

### Reference configurations by name

Code says "give me the data profile named `staging-api`," not
"give me the data profile for the current environment."  The
runtime decides which `staging-api` is in scope based on the
active profile; the code never branches.

Stable identifiers across environments are the most important
property.  When `staging-api` means one thing in CI and a different
thing in staging-east, the discipline has been broken; the code
will start to grow conditionals to "fix" the inconsistency, and
the team is back to the codebase-treatment failure mode.

### Treat profile selection as plumbing

The selection of which profile is active is a plumbing concern —
one environment variable (`AM_PROFILE` or the
chapter-7 `AM_*_NAMES` family).  It is set by the CI pipeline, by
the developer's shell, by the on-call workflow.  Application code
*never* sets the profile.

The line between "plumbing reads configuration selectors from the
environment" and "application reads configuration content from the
environment" is the line the discipline draws.  A function that
reads `AM_PROFILE` to decide what to do is plumbing; application
code never has business doing that.

## The shipped substrate

The portfolio's existing machinery is the foundation the next four
chapters build on.  Each chapter cites the building blocks it
extends:

- **`override_variable_value`**
  ([`codecraftai-this-app/.../thisapplication.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/thisapplication.py))
  — the primitive for runtime variable overrides.  Chapter 14 uses
  it to implement profile composition.
- **`MergeMap`**
  ([`codecraftai-xmodules/.../mergemap.py`](https://github.com/code-craft-ai-company/codecraftai-xmodules/blob/main/source/packages/ccai/xmodules/xcollections/mergemap.py))
  — the primitive for layered configuration with overlay
  semantics.  Chapter 14 uses it to implement file-level profile
  composition.
- **`AppVarSet`**
  ([`codecraftai-this-app/.../appvarset.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/appvarset.py))
  — iterating the active descriptor set.  Chapter 14 uses it as
  the basis for the proposed `ccai profile show` CLI command.
- **`ContextPaths`**
  ([`codecraftai-this-app/.../contextpaths.py`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/contextpaths.py))
  — the well-known-path enumeration where active configurations
  live.  Chapter 14 proposes adding `ACTIVE_PROFILE` here.
- **`AM_*_NAMES`**
  ([`codecraftai-configuration/.../configurationvariables.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationvariables.py))
  — the existing five environment-variable selectors for the
  configuration categories.  Chapter 14 extends the family with
  the proposed `AM_PROFILE`.

Each chapter of Part IV stands up a piece of the discipline on
top of these primitives.  The reader can implement them
incrementally; nothing in chapter 14 depends on chapter 16, and
nothing in chapter 17 depends on chapter 15.

## The chapters that follow

The four chapters that complete Part IV develop specific aspects
of the discipline.

- **Chapter 14 — Configuration profiles as a discipline.**  The
  mechanics: a profile is a named bundle of category selections;
  profiles compose via overlay (`base.yaml` plus `staging.yaml`);
  selection happens at process start with `--profile staging`.
  Proposes the `ACTIVE_PROFILE` context path and the `ccai
  profile show` CLI.
- **Chapter 15 — The local-dev tax and how typed configs cancel it.**
  Walks through everything that makes a fresh checkout
  unusable today (`.env.local`, `direnv`, `make setup`, README's
  "you might also need to..." section) and shows each piece's
  migration into a typed configuration.
- **Chapter 16 — Reproducing CI failures locally.**  The replay
  primitive: capture the resolved profile from the failing CI run
  (with secrets redacted), re-run the same job locally with the
  captured profile, debug in the IDE.  Builds on the audit trail
  from chapter 11 and the resolver from chapter 10.
- **Chapter 17 — Profile promotion.**  How `local-dev` becomes
  `staging-east-canary` becomes `staging-east` becomes `prod`,
  through additive overlay rather than rewrite.  The promotion
  model and the audit story for cross-environment changes.

## Summary

- A piece of automation visits four environments — **local-dev,
  integration-CI, staging, prod-readonly** — in its lifetime.
  Each is a *profile*, not a separate codebase.
- The codebase-treatment approach to environment differences
  produces unbounded growth: env-var-keyed conditionals smear
  orthogonal concerns into one expression, setup scripts diverge,
  business logic acquires environment branches.  The cost
  compounds; the team's velocity on cross-environment changes
  eventually stops.
- The profile-treatment approach asks the code to be *passive*
  with respect to environment.  Code reads typed configurations
  through the runtime API; the configurations differ between
  profiles; the code is identical across all four environments.
- Three commitments make the discipline possible: read
  configurations through the runtime, reference them by stable
  identifiers, and treat profile selection as plumbing rather than
  as application logic.
- A worked test trace through the four environments shows six
  dimensions of difference (endpoints, credentials, backends,
  log levels, timeouts, property-test caps) carried entirely as
  *content*, with zero lines of conditional code in the test
  itself.
- The shipped portfolio provides the substrate
  (`override_variable_value`, `MergeMap`, `AppVarSet`,
  `ContextPaths`, the existing `AM_*_NAMES` family) that the next
  four chapters build the user-facing profile system on top of.
- Chapters 14, 15, 16, 17 develop the mechanics, the local-dev
  story, the CI-replay story, and the promotion story.

The next chapter proposes the profile mechanism itself: what a
profile is, how profiles compose, how selection happens at process
start, and what the `ccai profile show` CLI command would look
like as an addition to the portfolio.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/14-configuration-profiles-as-a-discipline -->
# Chapter 14 — Configuration Profiles as a Discipline

> *A profile is a name plus a `MergeMap`.  This chapter proposes the
> convention that turns the existing `codecraftai-configuration`
> machinery into a user-facing profile system.*

The previous chapter established the four-environment ladder and
named the discipline that lets a single codebase scale across it.
This chapter is the mechanism: what a profile actually is, how
profiles compose, how selection happens at process start, what
piece of new tooling the discipline asks the reader to build, and
where each piece sits relative to the shipped portfolio
primitives.

The shipped substrate is half of the answer.  The shipped
`AM_*_NAMES` family already drives the per-category configuration
loading.  The shipped `MergeMap` already supports the layered-with-
overlay semantics profile composition needs.  The shipped
`override_variable_value` already provides the runtime override
primitive.  What's missing is the *user-facing convention* that
binds them — a profile YAML format, a process-start selection
mechanism, an introspection CLI.  This chapter proposes the
convention and shows how to wire it on top of the existing
machinery.

## What a profile is

A profile is a **named bundle of category selections** plus an
optional **overlay of per-category value patches**.  In the
simplest form, a profile is a YAML document like:

```yaml
# config/profiles/local-dev.yaml
profile:
    name: local-dev
    description: Single-host developer workstation

    select:
        landscape: local-dev
        topology: local-dev
        runtime: local-dev
        credentials: local-dev
        dataprofiles: local-dev
```

Six fields under `select:`, one per category — five for the
shipped `AM_CONFIG_*_NAMES` family, plus an entry for the
out-of-band Specification category from chapter 4.  Each field's
value is a name (or comma-separated list of names) that the
loader resolves against the corresponding category's directory.

The profile says: *for the local-dev environment, load the
landscape named `local-dev`, the topology named `local-dev`, the
runtime named `local-dev`*, and so on.  When the orchestration
starts with `AM_PROFILE=local-dev`, the profile resolver reads
this file and translates each `select:` entry into the
corresponding `AM_*_NAMES` setting.  After that translation, the
shipped configuration machinery takes over unchanged — the
discipline is *additive* on top of what already works.

## Profile composition: `extends`

A second profile, `staging-east`, can inherit from a `base`
profile and override only what differs:

```yaml
# config/profiles/base.yaml
profile:
    name: base
    description: Conservative defaults for any environment

    select:
        landscape: base
        topology: default-flat
        runtime: default
        credentials: base
        dataprofiles: base
```

```yaml
# config/profiles/staging-east.yaml
profile:
    extends: base
    name: staging-east
    description: Staging east region — production-shaped

    select:
        landscape: staging-east
        topology: staging-east-leader-follower
        runtime: staging-east
        credentials: staging-east
        dataprofiles: staging-east
```

The `extends:` field is the parent profile name.  Resolution walks
the chain top-down: load `base`, then layer `staging-east`'s
`select:` entries on top.  Any field absent from the child
inherits from the parent; any field present in the child shadows
the parent's value.

The chain has no fixed depth.  A `staging-east-canary` profile
extends `staging-east`; a `staging-east-canary-slow-cell` profile
extends `staging-east-canary`; the resolution still terminates
because the chain is acyclic and bounded.  The resolver detects
cycles at load time and raises a clear `ConfigurationError` naming
the cycle.

```yaml
# config/profiles/staging-east-canary.yaml
profile:
    extends: staging-east
    name: staging-east-canary
    description: Smaller-pod canary slice of staging-east

    select:
        landscape: staging-east-canary    # smaller landscape
        runtime: staging-east-canary      # tighter timeouts
        # topology, credentials, dataprofiles all inherited
```

## Profile composition: `overlay`

Profile composition is for selecting *which* configuration to
load.  Sometimes a profile needs to **patch values within an
already-selected configuration** without forking the configuration
file.  The `overlay:` field is for those cases:

```yaml
# config/profiles/staging-east-debug.yaml
profile:
    extends: staging-east
    name: staging-east-debug
    description: Staging-east with verbose logging for incident response

    # Same selections as staging-east; overlay patches specific values
    overlay:
        runtime:
            logging:
                levels:
                    console: DEBUG    # was INFO; bumped for this run
                    logfile: TRACE    # was DEBUG; bumped for this run
            timeouts:
                default_seconds: 60   # was 30; doubled for slow debugging
```

The overlay is a partial document with the same shape as the
named configuration it patches.  At resolution time the
profile system loads `runtime: staging-east` (inherited from
`staging-east`) and *layers the overlay on top* via the same
`MergeMap` machinery the shipped categories already use.  The
result is `staging-east`'s runtime with three keys replaced; the
unchanged keys flow through unchanged.

Overlays are powerful and come with a cost: an overlay diverges
the active runtime from any *named* runtime, which makes the
configuration harder to identify in retrospective analysis.  The
discipline:

- **Use `select:` first.**  When the change you want exists as a
  named configuration somewhere, select it.  A `runtime:
  staging-east-verbose` is more discoverable than an overlay that
  bumps a few log levels.
- **Use `overlay:` for one-off and incident-response cases.**  A
  profile that bumps a timeout for a specific debugging session
  doesn't need to become a permanent configuration file.  The
  overlay says "this run is special" without polluting the named
  catalogue.
- **Keep overlays small.**  An overlay that patches twenty fields
  has crossed the line into "this is a different runtime";
  promote it to a named runtime configuration.

## The proposed `AM_PROFILE` selector

The shipped `AM_*_NAMES` family selects per category.  A profile
is a *bundled* selection, so the natural extension is one more
environment variable that names the profile to load:

```bash
AM_PROFILE=staging-east ccai testbase test run tests/
```

When `AM_PROFILE` is set at process start, the profile resolver
runs *before* the existing configuration loading.  Its job is
mechanical:

1. Read `config/profiles/${AM_PROFILE}.yaml`.
2. Walk the `extends:` chain to assemble the final `select:` map.
3. For each entry in the merged `select:`, set the corresponding
   `AM_CONFIG_*_NAMES` variable by calling
   [`override_variable_value`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/thisapplication.py)
   on the appropriate descriptor.
4. For each entry in the merged `overlay:`, register the patch
   with the configuration loader so that it gets composed onto the
   loaded configuration via `MergeMap` after the named
   configuration is loaded.
5. Set `ContextPaths.ACTIVE_PROFILE` (a proposed context-path
   addition; see below) to the resolved profile name.

After step 5, control hands off to the existing configuration
loaders.  Each `resolve_*_configuration` function in
[`configurationmaps.py`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationmaps.py)
runs unchanged — it reads the `AM_CONFIG_*_NAMES` variables the
profile resolver just set, loads the named files, and inserts the
results into the shared application context.

The seam is small: the profile resolver is a thin layer that
*translates* a profile name into a concrete set of `AM_*_NAMES`
values plus a small overlay-patch registration.  It doesn't
replace the loaders; it *parameterises* them.

## The proposed `ACTIVE_PROFILE` context path

The shipped
[`ContextPaths`](https://github.com/code-craft-ai-company/codecraftai-this-app/blob/main/source/packages/ccai/thisapp/contextpaths.py)
enumeration has slots for `JOB_ID`, `JOB_NAME`, `PIPELINE_ID`,
`RUN_ID`, and similar process-identity fields.  It does not have
a slot for the active profile name.  Adding one is a one-line
change:

```python
class ContextPaths(str, Enum):
    # ... existing entries ...
    ACTIVE_PROFILE = "/runtime/active_profile"
```

The profile resolver inserts the resolved profile name at this
context path during step 5 of the resolution flow.  Code that
needs to know which profile is active reads it via the
`ThisApplicationSingleton().context.lookup(ContextPaths.ACTIVE_PROFILE)`
pattern the rest of the platform already uses.

Three reasons to put the profile name in `ContextPaths` rather
than leaving it as just an environment variable:

- **Distributed orchestrations** (Volume 2's territory) propagate
  the application context to remote workers as part of their
  setup.  Putting the profile name in the context means remote
  workers see the same profile name without needing to read the
  coordinator's `os.environ`.
- **Logging frameworks** that read context paths automatically
  pick up the profile name and include it in every log event.
- **The "never log a credential" defences from chapter 11** scan
  context-path values for sensitive content; having the profile
  name in a known location makes the audit trail richer without
  expanding the search surface.

## The proposed `ccai profile show` CLI

The single most-asked question once the profile system is in
place is: *what is the resolved configuration of the active
profile?*  The shipped portfolio has the building blocks for the
answer (`AppVarSet.items()` enumerates active variables and their
resolved values) but no CLI command exposes it.  The discipline
proposes one.

The proposed command:

```bash
ccai profile show
```

Output (for `AM_PROFILE=local-dev`):

```
Profile:        local-dev
Description:    Single-host developer workstation

Selections:
    landscape:      local-dev          (config/landscape/landscape-local-dev.yaml)
    topology:       local-dev          (config/topology/topology-local-dev.yaml)
    runtime:        local-dev          (config/runtime/runtime-local-dev.yaml)
    credentials:    local-dev          (config/credentials/credentials.yaml#local-dev)
    dataprofiles:   local-dev          (config/dataprofiles/dataprofiles-local-dev.yaml)

Overlays:
    (none)

Resolved variables:
    AM_CONFIG_LANDSCAPE_NAMES        local-dev
    AM_CONFIG_TOPOLOGY_NAMES         local-dev
    AM_CONFIG_RUNTIME_NAMES          local-dev
    AM_CONFIG_CREDENTIAL_NAMES       local-dev
    AM_CONFIG_DATAPROFILE_NAMES      local-dev
    AM_CONFIG_USE_LANDSCAPE          true
    AM_CONFIG_USE_TOPOLOGY           true
    AM_CONFIG_USE_RUNTIME            true
    AM_CONFIG_USE_CREDENTIALS        true
    AM_CONFIG_USE_DATAPROFILES       true
    AM_LOG_LEVEL_CONSOLE             DEBUG
    AM_LOG_LEVEL_LOGFILE             DEBUG
    ...

Resolved files:
    landscape:      /Users/dev/proj/config/landscape/landscape-local-dev.yaml
    topology:       /Users/dev/proj/config/topology/topology-local-dev.yaml
    runtime:        /Users/dev/proj/config/runtime/runtime-local-dev.yaml
    credentials:    /Users/dev/proj/config/credentials/credentials.yaml
    dataprofiles:   /Users/dev/proj/config/dataprofiles/dataprofiles-local-dev.yaml
```

Three sections matter:

- **Selections** — what categories the profile selects, plus the
  resolved file path for each.  The reader can see at a glance
  which configurations are in scope for this run.
- **Resolved variables** — every active `AppVarDesc` and its
  current value (with credential material elided per the
  "never log" rule).  This is the answer to "what is the
  effective parameterisation of this run?"
- **Resolved files** — the concrete paths the loader is reading.
  Useful for the chapter-15 use case ("which file do I edit to
  change this?").

Implementation sketch:

```python
class ProfileShowCommand(Command):
    def execute(self, context):
        this_app = ThisApplicationSingleton()
        active_profile = this_app.context.lookup(
            ContextPaths.ACTIVE_PROFILE,
            default="<no profile active>",
        )

        print(f"Profile:        {active_profile}")
        print(f"Description:    {self._get_description(active_profile)}")
        print()

        # ... dump selections, overlays, variables, files
```

The command lives in the `ccai` CLI tree alongside the existing
`ccai testbase`, `ccai am-interop` commands.  The
implementation is roughly a hundred lines and reuses
`AppVarSet.items()` plus the chapter-9 redacting filter to
produce the variables section safely.

## A worked four-file profile set

To illustrate composition end-to-end, here are four profile files
that together describe a real four-environment ladder:

### `base.yaml`

```yaml
# config/profiles/base.yaml
profile:
    name: base
    description: Conservative defaults that every environment inherits

    select:
        landscape: base
        topology: default-flat
        runtime: default
        credentials: base
        dataprofiles: base
```

`base` is the safety net.  It selects conservative configurations
that will run *somewhere* but not optimally anywhere.  It exists
so that every other profile can `extends: base` without having to
copy a complete `select:` block.

### `local-dev.yaml`

```yaml
# config/profiles/local-dev.yaml
profile:
    extends: base
    name: local-dev
    description: Single-host developer workstation

    select:
        landscape: local-dev
        topology: local-dev
        runtime: local-dev
        credentials: local-dev
        dataprofiles: local-dev
```

`local-dev` overrides every selection.  This is normal — the
local-dev environment is so different from production that
inheriting much from `base` would be misleading.  The profile
still inherits the *shape* of the schema (`select:` field names,
implicit `overlay:` absence) from `base` for consistency.

### `staging-east.yaml`

```yaml
# config/profiles/staging-east.yaml
profile:
    extends: base
    name: staging-east
    description: Staging east region — production-shaped pod and services

    select:
        landscape: staging-east
        topology: staging-east-leader-follower
        runtime: staging-east
        credentials: staging-east
        dataprofiles: staging-east
```

`staging-east` is the production-shaped profile.  Its selections
are all `staging-east` except for the topology (which carries the
relationship-specific suffix `leader-follower`).

### `staging-east-canary.yaml`

```yaml
# config/profiles/staging-east-canary.yaml
profile:
    extends: staging-east
    name: staging-east-canary
    description: Canary slice — three runners not nine, slower pace

    select:
        landscape: staging-east-canary    # smaller landscape file
        runtime: staging-east-canary      # tighter parallelism

    overlay:
        runtime:
            parallelism:
                max_workers: 2            # belt-and-braces; runtime
                                          # already says 2 but explicit
                                          # for canary visibility
            logging:
                levels:
                    console: DEBUG        # canaries log verbosely
```

`staging-east-canary` extends `staging-east`, overrides two
selections (smaller landscape, tighter runtime), and adds an
overlay that bumps console logging to DEBUG and explicitly pins
the parallelism cap.  Topology, credentials, and dataprofiles
inherit unchanged from the parent.

### Resolution trace

When `AM_PROFILE=staging-east-canary` is set, the profile
resolver walks:

```
staging-east-canary  (read)
       │
       ├── extends: staging-east
       │           │
       │           └── extends: base
       │                       │
       │                       └── (terminal)
       │
       ▼
   merged select:
       landscape:      staging-east-canary   (canary override)
       topology:       staging-east-leader-follower (from staging-east)
       runtime:        staging-east-canary   (canary override)
       credentials:    staging-east          (from staging-east)
       dataprofiles:   staging-east          (from staging-east)
   merged overlay:
       runtime: { ... }  (from staging-east-canary)
```

The merged result is what gets translated into `AM_CONFIG_*_NAMES`
overrides.  The shipped resolution machinery loads the named
configurations.  The overlay is registered with the runtime
loader, which composes it onto the loaded `runtime-staging-east-canary`
via `MergeMap` overlay semantics.

## How the profile resolver runs

The resolver is roughly forty lines of Python plus a YAML loader.
Sketch:

```python
class ProfileResolver:
    def __init__(self, profiles_dir: str):
        self._dir = profiles_dir
        return

    def resolve(self, profile_name: str) -> dict:
        """
            Walk the extends chain, merge selections and overlays.
            Return a dict with 'select' (final selections per category)
            and 'overlay' (composed overlays).
        """
        chain = self._walk_chain(profile_name, seen=set())

        # Merge top-down: base is leftmost, deepest descendant is rightmost.
        merged_select = {}
        merged_overlay = MergeMap()

        for profile in chain:
            for category, name in profile.get("select", {}).items():
                merged_select[category] = name
            overlay = profile.get("overlay")
            if overlay is not None:
                merged_overlay.maps.append(overlay)

        return {"select": merged_select, "overlay": merged_overlay}

    def apply(self, resolved: dict) -> None:
        """
            Translate resolved selections into AM_*_NAMES overrides
            and register the overlay with the configuration loader.
        """
        this_app = ThisApplicationSingleton()
        category_to_descriptor = {
            "landscape":    ConfigVariablesDesc.AM_CONFIG_LANDSCAPE_NAMES,
            "topology":     ConfigVariablesDesc.AM_CONFIG_TOPOLOGY_NAMES,
            "runtime":      ConfigVariablesDesc.AM_CONFIG_RUNTIME_NAMES,
            "credentials":  ConfigVariablesDesc.AM_CONFIG_CREDENTIAL_NAMES,
            "dataprofiles": ConfigVariablesDesc.AM_CONFIG_DATAPROFILE_NAMES,
        }
        for category, name in resolved["select"].items():
            descriptor = category_to_descriptor.get(category)
            if descriptor is not None:
                this_app.override_variable_value(descriptor, name)

        # Register overlay with the loader.  The loader's
        # resolve_*_configuration runs after this and picks up the
        # overlay maps to layer onto the loaded named config.
        register_profile_overlay(resolved["overlay"])

        # Set the ACTIVE_PROFILE context path.
        this_app.context.insert(
            ContextPaths.ACTIVE_PROFILE,
            resolved["name"],
        )
        return

    def _walk_chain(self, profile_name: str, seen: set) -> list:
        if profile_name in seen:
            raise ConfigurationError(
                f"Cycle detected in profile chain at {profile_name}"
            )
        seen = seen | {profile_name}

        with open(os.path.join(self._dir, f"{profile_name}.yaml")) as fh:
            profile = yaml.safe_load(fh)["profile"]

        parent_name = profile.get("extends")
        if parent_name is not None:
            chain = self._walk_chain(parent_name, seen)
            chain.append(profile)
            return chain
        return [profile]
```

The resolver is small because it does one thing.  The shipped
machinery does all the heavy lifting downstream.

## Wiring at process start

The profile resolver runs as part of orchestration startup,
before any of the existing `resolve_*_configuration` functions:

```python
def bootstrap_runtime() -> None:
    profile_name = os.environ.get("AM_PROFILE")
    if profile_name is not None:
        resolver = ProfileResolver(profiles_dir="config/profiles")
        resolved = resolver.resolve(profile_name)
        resolver.apply(resolved)

    # Now the existing machinery runs unchanged.
    establish_configuration_settings()
    resolve_configuration_variables()
    resolve_configuration_maps()
    return
```

When `AM_PROFILE` is unset, the bootstrap skips profile
resolution entirely.  The shipped machinery runs against whatever
`AM_*_NAMES` are set in the environment directly — exactly the
current shipped behaviour.  The profile system is *opt-in*; teams
not using it pay no cost.

## Profile selection at the CLI

A second selection mechanism — the CLI flag — is convenient for
ad-hoc runs:

```bash
ccai testbase test run --profile staging-east tests/
```

The `--profile` flag is sugar for `AM_PROFILE=staging-east`.
Implementation: every `ccai` CLI command's argument-parsing
intercepts `--profile` before any command-specific parsing,
sets `os.environ["AM_PROFILE"]`, and then runs the rest of the
command normally.  The shipped
[`arcli`](https://github.com/code-craft-ai-company/codecraftai-arcli/tree/main/source/packages/ccai/arcli)
machinery already supports global flags of this shape; adding
`--profile` is a small extension to the CLI tree's root.

The flag and the environment variable are equivalent.  Teams
using container-orchestration platforms typically prefer the
environment variable (it survives across `exec` boundaries
without explicit propagation); developers running ad-hoc commands
prefer the flag.  Both work.

## Profile diff: the natural follow-on tool

Once `ccai profile show` exists, the next-most-asked question
is: *what is the difference between two profiles?*  The proposed
`ccai profile diff` command answers it:

```bash
ccai profile diff staging-east staging-east-canary
```

Output:

```
Profile:        staging-east       →  staging-east-canary
Description:    Staging east region    Canary slice — 3 runners

Selections:
                                    landscape       staging-east → staging-east-canary
                                    runtime         staging-east → staging-east-canary
                                    (others unchanged)

Overlays:
                                    runtime.parallelism.max_workers (added: 2)
                                    runtime.logging.levels.console  (added: DEBUG)
```

Implementation reuses the resolver: resolve both profiles, walk
the merged selections side-by-side, walk the merged overlays
side-by-side, emit a diff.  Roughly a hundred lines.  Worth it
because *the most common operational question about profiles is
"what changed between two of them?"*

## What the discipline doesn't yet handle

Three things the proposed profile system *defers* rather than
solves.  Each is the subject of a later chapter or a future
extension.

### Captured profiles for replay

The resolver translates a profile name into a set of `AM_*_NAMES`
values plus an overlay.  Capturing the *resolved* state of a
particular run — every variable and its value, including those
chosen at startup — and replaying it later is chapter 16's
subject.  The resolver exposes the data the capture mechanism
needs; the capture mechanism itself is built on top.

### Per-user profile overrides

A team often has *team profiles* (the four-file set above) plus
*personal profiles* a developer keeps in their dotfiles.  The
pattern that works is to layer a personal profile on top of a
team profile via the same `extends:` mechanism, with personal
profiles living outside the repository.  The proposed resolver
supports this directly — profile files can come from multiple
search paths, with personal paths taking precedence.

### Cross-team profile sharing

When two teams share infrastructure, they often share *parts* of
profiles (the same Vault, the same staging hostnames) without
sharing whole profiles.  The pattern is to factor shared selections
into a `shared` profile that both teams `extends:`.  The proposed
resolver supports this with no special machinery; the discipline
just needs the team to draw the line.

## Summary

- A **profile** is a named bundle of category selections plus an
  optional overlay of value patches.  Profiles compose via
  `extends:`; selection happens via `select:`; granular value
  overrides happen via `overlay:`.
- The discipline is *additive* on top of the shipped portfolio.
  The profile resolver translates a profile name into
  `AM_*_NAMES` overrides and overlay registrations; the existing
  configuration-loading machinery runs downstream unchanged.
- Profile selection at process start uses the proposed
  `AM_PROFILE` environment variable (equivalently a `--profile`
  CLI flag).  When `AM_PROFILE` is unset, the system runs with
  the current shipped behaviour — the discipline is opt-in.
- The proposed `ContextPaths.ACTIVE_PROFILE` slot holds the
  resolved profile name.  Distributed orchestrations propagate it
  to remote workers; logging frameworks pick it up automatically.
- The proposed `ccai profile show` CLI command answers the
  most-asked operational question — *what is the resolved
  configuration of this run?*  The proposed `ccai profile
  diff` command answers the second-most-asked question — *what
  changed between two profiles?*
- The four-file worked example (`base`, `local-dev`,
  `staging-east`, `staging-east-canary`) shows additive overlay
  resolution end-to-end.  Cycles are detected at load time;
  resolution is bounded.
- The discipline defers captured-profile replay (chapter 16),
  per-user profile overrides (search-path extension), and
  cross-team profile sharing (multi-team `extends:` patterns) to
  later chapters and natural follow-ons.

The next chapter walks through every piece of "tribal knowledge"
that makes a fresh checkout unusable today (`.env.local`,
`direnv`, `make setup`, the README's "you might also need to..."
section) and shows how each piece migrates into a typed
configuration under the profile discipline.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/15-the-local-dev-tax -->
# Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It

> *The `.env.local` file is a confession.  Every team that has one
> is admitting that their pipeline depends on configuration the
> codebase doesn't name.*

The previous chapter built the proposed profile mechanism.  This
chapter applies it to a specific operational symptom: the
*local-dev tax* — the accumulated friction every developer pays on
a fresh checkout to get the project actually running.  Every piece
of that friction is a missing typed configuration.  This chapter
walks through the common pieces, names what each one is hiding,
and shows the migration path under the chapter-14 profile system.

The end state the chapter describes is the *boring* fresh
checkout: clone the repository, run one command, the test suite
passes.  No tribal knowledge, no README's "you might also need
to..." section, no half-day onboarding ritual.  Just code, profile,
results.

## The shape of the tax

A fresh checkout of a moderately-mature Python project typically
exposes a developer to seven or eight distinct sources of local-dev
tax.  Each has its own recognisable name and its own characteristic
failure mode.

### `.env.local` files

The most common form.  A file (committed or not) named `.env`,
`.env.local`, `.env.example` that contains a list of `export
NAME=value` lines.  The application reads them via a library
(`python-dotenv`, `direnv`'s shell hook, the developer's manual
`source .env.local`).

```bash
# .env.example
DATABASE_URL=postgresql://localhost:5432/dev
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=changeme
LOG_LEVEL=DEBUG
GITHUB_TOKEN=
SLACK_WEBHOOK_URL=
```

What the file is *hiding*:

- **A landscape entry** for the local-dev database (`localhost:5432`).
- **A landscape entry** for the local-dev Redis.
- **A credential** for the database (the password, currently empty
  because the local Postgres is set up with trust auth).
- **A runtime configuration** for the log level.
- **A credential reference** for `GITHUB_TOKEN` (with no
  resolution discipline — the developer is expected to paste their
  PAT into the file).
- **An infrastructure-service entry** for Slack notifications.

The migration: each piece of content moves into the appropriate
typed configuration file from chapters 4–9.  The file disappears.

### `direnv` and `.envrc`

A more sophisticated cousin of `.env.local`.  The developer
installs `direnv`; when they `cd` into the project directory, a
`.envrc` script auto-runs and exports a set of environment
variables for the shell:

```bash
# .envrc
export DATABASE_URL=postgresql://localhost:5432/dev
export REDIS_URL=redis://localhost:6379/0
source_up_if_exists  # cascade up the directory tree
use python 3.11      # auto-activate the project's Python
```

The same content as `.env.local`, plus a Python-version selection
on top.  The migration is the same: every `export` becomes a typed
configuration entry.  The Python version is a runtime configuration
field (not currently shipped in the runtime YAML schema, but a
natural addition).

### `.tool-versions` (asdf, mise)

A file declaring which versions of which tools the project uses:

```
# .tool-versions
python 3.11.7
nodejs 20.10.0
poetry 1.7.1
```

Tool-version selection is *legitimately* a local-dev concern —
it is about how the developer's *machine* is set up, not about
how the orchestration runs.  Most teams should keep this file
checked in.

What it is *not* hiding: nothing significant.  A `.tool-versions`
file is closer to a `pyproject.toml`'s Python-version constraint
than to an environment-variable export — it informs the developer
of the expected toolchain rather than parameterising the
orchestration.

The migration in this case is partial: keep the file as a
toolchain manifest; do not let it grow into an environment-variable
substitute.  A `.tool-versions` file with five tool versions is
fine.  A `.tool-versions` file with `DATABASE_URL` in it is the
same `.env.local` mistake in fresh costume.

### `make setup`

An imperative setup script the developer runs once after checkout:

```makefile
# Makefile
setup:
	./scripts/install-system-deps.sh
	poetry install
	./scripts/create-local-db.sh
	./scripts/seed-test-data.sh
	./scripts/generate-ssh-keys.sh
	cp .env.example .env.local
	@echo "Now edit .env.local with your GitHub token"
```

What `make setup` is hiding:

- **A specification** for the local environment (which system
  packages, which Python version, which database, which seed
  data).
- **A landscape entry** that says "the local-dev landscape includes
  these databases, these mock services."
- **A credential bootstrap** (the SSH keys, the `.env.local`
  template).
- **A README** the developer is expected to read after the
  imperative script completes.

The migration: the system-package and Python-version steps stay
imperative (they're machine-setup concerns).  Everything else
becomes typed configuration plus the chapter-14 profile machinery.

### The README's "you might also need to..." section

The plaintext resting-place of every workaround the team has
accumulated:

> *Fresh checkout?  You'll need:*
> *— Python 3.11 (use pyenv or asdf)*
> *— Postgres running on port 5432 (we use Postgres.app on
> macOS; Linux folks use Docker)*
> *— A GitHub PAT with `read:repo` scope*
> *— An entry in `/etc/hosts` for `dev.local`*
> *— Trust the local SSL cert (instructions below)*
> *— Run `make setup` (might fail on Apple Silicon; if so, see
>   issue #234)*
> *— Edit `.env.local` to add your PAT*
> *— Sign in to AWS via SSO (token expires daily)*
> *— Connect the VPN (only required if you're testing the
>   payments service)*

This section is the *manifest of tribal knowledge*.  Every line
is a piece of state the codebase requires but doesn't *name*.  A
new hire works through it for a half-day; a returning developer
works through it on each major OS upgrade; a senior engineer
swears at it when the seventh dependency in the list has shifted
without anyone updating the README.

Each line is a missing typed configuration:

| Line | What it really is |
|---|---|
| Python 3.11 (use pyenv) | Toolchain manifest (`.tool-versions`) |
| Postgres on port 5432 | Landscape entry: a local-dev `staging-db` infrastructure service |
| GitHub PAT with `read:repo` | Credential entry: `local-github-pat`, scope tag `environment:local-dev` |
| `/etc/hosts` entry for `dev.local` | Specification: the local-dev environment requires this name resolution; provisioning step ensures it |
| Trust local SSL cert | Specification: the local-dev environment requires this cert to be in the system trust store |
| `make setup` (might fail on Apple Silicon) | Specification: machine-setup imperative; bug to fix in issue #234 |
| Edit `.env.local` | Migrating already; remove this step |
| AWS SSO sign-in (daily) | Credential resolution: the `local-dev` AWS credential reference resolves to a fetched-on-demand AWS SSO token |
| VPN if testing payments | Topology: the payments topology requires VPN connectivity; document the dependency in the topology file |

Once each line has a typed home, the README's section *empties
out*.  The remaining entries are the genuine machine-setup
imperatives (toolchain, system packages) — short, stable, and
honest about being one-time work.

## The cumulative cost

Every piece of local-dev tax has the same general failure mode:
*it works on the developer who set it up; it fails on the
next developer*.

The cumulative cost across a team:

- **Onboarding time.**  A new hire spends a half-day to a full
  day getting the project actually running.  Multiply by team
  growth rate.
- **OS-upgrade churn.**  When macOS releases a new major version,
  every developer pays the local-dev tax again because some part
  of `make setup` doesn't work on the new OS.
- **Debugging time.**  When a test fails locally but passes in
  CI, the developer's first few hours of debugging are spent
  ruling out local-dev environmental differences.  When the
  difference is in the README's tribal knowledge, the bug is
  *only reproducible by people who set up their environment the
  same way*.
- **CI ↔ local divergence.**  As the README grows, local-dev
  diverges from CI.  Eventually some classes of failure can only
  be reproduced in CI; the developer has to push commits to
  iterate.  The inner-loop velocity collapses.

The discipline of this chapter cancels each of these costs by
*moving the configuration into the codebase*.  Once the local-dev
profile names every piece of state the project depends on, a
fresh checkout is `clone + ccai profile activate local-dev`.

## The migration walk-through

The walk-through that follows takes the fictitious project from
the section above through migration.  Each step adds one typed
configuration; each step is independently committable; each step
makes the README's section shorter.

### Step 1 — Inventory

Walk every file that participates in local-dev configuration:
`.env.local`, `.envrc`, the README, the `Makefile`, the project's
`docker-compose.yaml`, every `setup-*.sh`.  Catalogue every
distinct configuration value.  Group them by category:

| Value | Source | Category |
|---|---|---|
| `DATABASE_URL=postgresql://localhost:5432/dev` | `.env.local` | Data Profile + Credential + Landscape |
| `REDIS_URL=redis://localhost:6379/0` | `.env.local` | Data Profile + Landscape |
| `SECRET_KEY=changeme` | `.env.local` | Credential |
| `LOG_LEVEL=DEBUG` | `.env.local` | Runtime |
| `GITHUB_TOKEN=...` | `.env.local` | Credential |
| `SLACK_WEBHOOK_URL=...` | `.env.local` | Credential + Landscape (infrastructure service) |
| `python 3.11` | `.tool-versions` | Toolchain manifest (stays) |
| Postgres on port 5432 | README | Landscape (infrastructure service) |
| `/etc/hosts` for `dev.local` | README | Specification |
| Local SSL cert trusted | README | Specification |
| AWS SSO daily token | README | Credential |
| VPN for payments | README | Topology |

The inventory is the chapter's most important step.  An
inventory that misses something will leave the README with a
residual line, which is the same kind of tribal knowledge the
discipline aims to eliminate.

### Step 2 — Author the local-dev configurations

For each typed configuration, author the file:

**`config/landscape/landscape-local-dev.yaml`** — the local-dev
landscape:

```yaml
environment:
    label: local-dev

apod:
    clients:
        - deviceType: network/client-localhost
          name: developer-laptop
          host: 127.0.0.1
          role: runner
          credentials:
              - local-runner
          features:
              parallelism_max: 4
          skip: false

infrastructure:
    services:
        - serviceType: database/postgres
          name: staging-db          # same identifier as production
          host: 127.0.0.1
          port: 5432
          database: dev
          credentials:
              - local-postgres

        - serviceType: cache/redis
          name: staging-redis        # same identifier as production
          host: 127.0.0.1
          port: 6379
          credentials:
              - local-redis

        - serviceType: web/webhook
          name: slack-notifications  # same identifier as production
          endpoint: ${PROMPT:Slack webhook URL}
          credentials:
              - local-slack-webhook
```

**`config/dataprofiles/dataprofiles-local-dev.yaml`** — the
profiles:

```yaml
dataprofiles:
    - identifier: staging-db-rw       # same identifier as production
      category: basic-tcp
      host: 127.0.0.1
      port: 5432
      dbtype: postgres
      dbname: dev
      credential: local-postgres

    - identifier: staging-redis        # same identifier as production
      category: basic-tcp
      host: 127.0.0.1
      port: 6379
      dbtype: redis
      credential: local-redis
```

**`config/credentials/credentials.yaml`** — local-dev section
appended:

```yaml
credentials:
    # ... existing CI / staging / prod entries ...

    - identifier: local-postgres
      categories: [basic, environment:local-dev]
      username: dev
      password: ${KEYCHAIN:com.company.app/local-postgres}

    - identifier: local-redis
      categories: [basic, environment:local-dev]
      username: default
      password: ${PROMPT:Local Redis password}

    - identifier: local-runner
      categories: [ssh, environment:local-dev]
      username: $USER
      keyfile: ${FILE:~/.ssh/id_ed25519}
      keypasswd: ${KEYCHAIN:com.company.app/ssh-id-ed25519}

    - identifier: local-github-pat
      categories: [github-token, environment:local-dev]
      username: $USER
      token: ${KEYCHAIN:com.company.app/github-pat}

    - identifier: local-aws-sso
      categories: [aws-access-key, environment:local-dev, ttl:3600]
      keyid: ${AWSSSO:role/Developer/AccessKey#keyid}
      secret: ${AWSSSO:role/Developer/AccessKey#secret}

    - identifier: local-slack-webhook
      categories: [api-token, environment:local-dev]
      token: ${KEYCHAIN:com.company.app/slack-webhook}

    - identifier: local-app-secret-key
      categories: [api-token, environment:local-dev]
      token: ${LITERAL:changeme}     # explicit; not a real secret
```

**`config/runtime/runtime-local-dev.yaml`** — the runtime
configuration:

```yaml
breakpoints: []
debugger: debugpy
logging:
    levels:
        console: DEBUG
        logfile: DEBUG
parallelism:
    max_workers: 4
timeouts:
    default_seconds: 300       # generous for breakpoints
property_test_caps:
    max_examples: 5            # fast feedback
paths:
    output: ./.output
version: 1.0.0
```

**`config/specs/spec-local-dev.yaml`** — the specification for the
local environment:

```yaml
environment:
    venues:
        - name: localhost
          provider: local
          credential: null

groups:
    - name: local-dev
      description: "Local-dev environment requirements"

profiles:
    - name: local-postgres
      description: "Local Postgres install"
      parameters:
          provider: brew
          formula: postgresql@15
          port: 5432
          database: dev

    - name: local-redis
      description: "Local Redis install"
      parameters:
          provider: brew
          formula: redis
          port: 6379

    - name: local-hosts-entry
      description: "Local /etc/hosts entries"
      parameters:
          entries:
              - hostname: dev.local
                address: 127.0.0.1

    - name: local-trust-cert
      description: "Local SSL cert in system trust store"
      parameters:
          cert_path: ./certs/local-dev.crt

apod:
    devices:
        - identifier: local-postgres
          category: brew-service
          group: local-dev
          profile: local-postgres
          role: database
        - identifier: local-redis
          category: brew-service
          group: local-dev
          profile: local-redis
          role: cache
        - identifier: local-hosts
          category: hosts-file
          group: local-dev
          profile: local-hosts-entry
          role: dns
        - identifier: local-cert
          category: trust-store
          group: local-dev
          profile: local-trust-cert
          role: tls
```

The specification is consumed by `ccai provisioning apply`,
which (per chapter 4's discipline) is idempotent.  The first run
installs Postgres, Redis, edits `/etc/hosts`, and trusts the
cert.  Subsequent runs are no-ops if the state is already correct.

**`config/profiles/local-dev.yaml`** — the profile that ties
everything together:

```yaml
profile:
    extends: base
    name: local-dev
    description: Single-host developer workstation

    select:
        landscape: local-dev
        topology: local-dev
        runtime: local-dev
        credentials: local-dev
        dataprofiles: local-dev
```

### Step 3 — Wire the application code

Code that previously read environment variables now reads typed
configurations:

```python
# Before
import os
DATABASE_URL = os.environ["DATABASE_URL"]

# After
from ccai.dataprofiles.wellknown import DataProfileManagerSingleton
from ccai.credentials.wellknown import CredentialManagerSingleton

profile = DataProfileManagerSingleton().lookup_profile("staging-db-rw")
cred = CredentialManagerSingleton().lookup_credential(profile.credential)
DATABASE_URL = f"postgresql://{cred.username}:{cred.password}@{profile.host}:{profile.port}/{profile.dbname}"
```

The migration is incremental: one configuration value at a time.
Until every reader has migrated, the team can keep the env-var
fallback in place via the chapter-10 `EnvBackend` (which lets a
credential reference resolve from `$DATABASE_URL` if it isn't
otherwise resolvable).  This is the same migration shim from
chapter 7.

### Step 4 — Replace `make setup` with `ccai provisioning apply`

The imperative setup script becomes a wrapper around the spec:

```makefile
# Makefile (after migration)
setup:
	# Toolchain (the legitimate machine-setup imperative)
	asdf install
	poetry install

	# Everything else is declarative
	AM_PROFILE=local-dev ccai provisioning apply --spec config/specs/spec-local-dev.yaml
```

Two lines of toolchain installation; one line of declarative
provisioning.  The previous five-line imperative chain — install
deps, create db, seed data, generate keys, copy `.env.example` —
is gone, replaced by the spec-driven equivalent.

### Step 5 — Activate the profile

The new fresh-checkout sequence is two commands:

```bash
git clone <repo>
cd <repo>
make setup
AM_PROFILE=local-dev ccai testbase test run
```

That's it.  No `.env.local`, no README scavenger hunt, no
half-day onboarding ritual.  The developer's machine has the
toolchain (from `asdf install` and `poetry install`); the local
infrastructure is provisioned (from the spec); the configurations
are typed and per-environment (from the profile); the test suite
runs.

### Step 6 — Empty the README

The README's "you might also need to..." section, after migration:

> *Fresh checkout?*
> *— Install asdf or mise (one-time machine setup)*
> *— Run `make setup`*
> *— Run `ccai testbase test run --profile local-dev`*

Three lines.  None of them are tribal knowledge.  None of them
will silently rot when the seventh dependency shifts.  Each line
is *small enough to keep correct*.

## The pattern is universal

The migration above is concrete to one project, but the pattern
generalises:

1. **Inventory** every piece of configuration state the project
   currently reads from environment variables, `.env` files,
   shell scripts, the README, or imperative setup logic.
2. **Group** the inventory by category (Specifications,
   Landscapes, Topologies, Runtimes, Credentials, Data Profiles).
3. **Author** the local-dev configuration for each category
   under `config/<category>/<file>-local-dev.yaml`.
4. **Author** the local-dev profile that selects them as a
   bundle.
5. **Author** the local-dev specification that captures the
   one-time machine-setup imperatives.
6. **Migrate** application code to read typed configurations
   instead of environment variables.  Use the chapter-10
   `EnvBackend` as a transitional shim during the migration.
7. **Replace** `make setup`'s imperative steps with the
   specification-driven equivalent.
8. **Empty** the README of tribal knowledge; what remains is the
   genuine one-time machine-setup imperative (toolchain plus
   `make setup`).

The pattern is the same regardless of project size.  A small
project might compress steps 3 and 4 into one combined
configuration; a large project might split each category into
multiple files for clarity.  The discipline is the inventory and
the elimination of tribal-knowledge prose, not the file-count
pedantry.

## What about existing developers' workflows?

A common objection: *"the migration breaks every developer's
existing setup."*  Three responses to this objection.

### Existing setups keep working during migration

The chapter-10 `EnvBackend` and the chapter-14 profile system are
both *opt-in*.  When `AM_PROFILE` is unset, the system runs with
the existing shipped behaviour — every existing `os.environ.get(...)`
in the codebase still works.  The migration can run alongside
the existing setup for as long as needed.  Each developer migrates
to the profile system on their own schedule.

### The migration produces parity, not divergence

The local-dev profile *replaces* the `.env.local` content.  The
values in the profile are the same values the developer had in
`.env.local` (or in their direnv, or in their shell history).  No
behaviour change — only a behaviour *naming* change.  The first
run of `AM_PROFILE=local-dev ccai testbase test run` produces
the same results as the previous `pytest` invocation against
`.env.local`.

### The benefits accrue to *new* developers immediately

Existing developers see incremental improvement as the migration
progresses.  *New* developers see the full benefit on day one —
they never set up the old way at all.  A team that's hiring rapidly
sees the steepest payoff: every new hire's onboarding time drops
by a half-day, and the team's senior engineers stop being asked
the same setup questions every week.

## What this discipline is *not*

Three boundaries to keep clear:

### Not a replacement for tools that work

The chapter is critical of `.env.local` as a *content* mechanism.
It is not critical of file-based configuration as a *concept* —
the typed configuration files this chapter proposes are
themselves files in version control.  The difference is
*structure*: `.env.local` is untyped key-value pairs; a typed
configuration file is a structured document with a known schema.

Tools like `direnv` and `asdf` that solve real local-dev problems
(automatic Python-version selection, project-scoped tool
shimming) remain useful and complementary.  The discipline asks
those tools to stop carrying *application configuration* in their
files; the toolchain-management uses are unaffected.

### Not "one tool to rule them all"

The chapter has not proposed that every team adopt
`codecraftai-*` packages exclusively.  The *discipline* of typed
configuration is portable; teams that prefer different
implementations of the same pattern (Pydantic Settings, dynaconf,
Hydra) can apply the same discipline using their preferred
toolkit.  What matters is that configuration is structured, named,
and per-environment-scoped — not whose YAML loader does the
loading.

### Not a security boundary

The local-dev profile loads credentials and runs them through the
chapter-10 resolver.  This is *not* a security boundary against a
compromised developer machine.  A developer whose laptop has been
compromised can read every secret that resolves into their local
profile; the chapter's `${KEYCHAIN:...}` and `${PROMPT:...}`
backends are *operational* conveniences, not security defences.

For real protection of production secrets, the discipline of
chapter 12 applies: production secrets are never resolvable from
local-dev profiles, because the resolver in local-dev does not
have the production Vault token.  The local-dev profile *exists*
so developers don't accidentally try to use production
credentials; it is not a fence around them if the developer
attempts to.

## Summary

- The **local-dev tax** is the friction every developer pays on
  fresh checkout.  It manifests as `.env.local` files, `direnv`
  configs, `make setup` scripts, and the README's "you might
  also need to..." section.
- Every piece of the tax is a missing typed configuration.  The
  inventory groups each piece into one of the six categories.
- Migration produces a fresh-checkout flow of three commands:
  install the toolchain, run `make setup` (now declarative),
  activate the local-dev profile.  No tribal knowledge, no
  scavenger hunt, no half-day ritual.
- The migration is incremental: each configuration value can
  migrate independently using the chapter-10 `EnvBackend` as a
  transitional shim.  Existing developer workflows keep working
  during the migration; new developers see the full benefit on
  day one.
- The pattern is universal: inventory, group, author per category,
  author the profile, replace imperative setup with declarative
  specification, migrate code, empty the README.
- The discipline is *not* a replacement for tools that work
  (direnv, asdf for what they're good at), *not* "one toolkit
  exclusively," and *not* a security boundary against a
  compromised local machine.

The next chapter is the operational payoff of this discipline:
when a CI failure shows up that doesn't reproduce locally, the
captured profile from the failing CI run lets the developer
replay the exact configuration on their laptop and debug the
difference in their IDE.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/16-reproducing-ci-failures-locally -->
# Chapter 16 — Reproducing CI Failures Locally

> *When a test passes locally but fails in CI, the question is
> always "what's different about CI?".  With typed profiles, the
> answer is one filename.*

The previous chapter cancelled the local-dev tax by moving every
piece of fresh-checkout state into typed configuration files.  The
result is a local-dev profile that mirrors a real environment with
high fidelity.  This chapter is the operational payoff: when a CI
run fails for a reason that doesn't reproduce locally, the
discipline lets a developer *capture* the exact resolved
configuration of the failing run, replay it on their laptop, and
debug the difference in their IDE.

The chapter is short on argument and long on machinery.  By this
point in the volume the case for typed configuration has been
made.  This chapter is what doing it well *enables*.

## Where the gap comes from

Every team eventually hits the same scenario.  A test passes when
the developer runs it locally.  The same test fails when CI runs
it.  The developer reads the CI log, sees the failure, finds it
inscrutable, and re-runs the test on their machine.  It passes.
They re-trigger the CI run; it fails again.  Three hours go by;
the developer is frustrated; the CI lane is blocked.

The gap is not magical.  The gap is *configuration drift*.
Concretely, eight common sources:

1. **Different log levels.**  A test that fails because of a
   log-level-dependent code path (an `if logger.isEnabledFor(...)`
   branch, a debug-only side-effect) behaves one way in CI's
   `INFO` and another way in local-dev's `DEBUG`.
2. **Different timeouts.**  A flaky network call completes in
   local-dev's 300 second timeout but not in CI's 30 second
   timeout.
3. **Different parallelism.**  A race condition that requires four
   concurrent workers shows up in CI's 4-worker pool but not in
   local-dev's 1-worker run.
4. **Different property-test sweeps.**  CI's `max_examples=10`
   covers a different region of input space than local-dev's
   `max_examples=100`.  The failing case happens to fall inside
   one window but not the other.
5. **Different random seeds.**  The same property test with the
   same `max_examples` produces a different sequence of cases when
   the seed differs.
6. **Different landscape entries.**  CI's staging-api lives at one
   host; local-dev's staging-api lives at another.  A test that
   relies on a host-specific quirk passes against one and fails
   against the other.
7. **Different credentials' permissions.**  CI's database
   credential is read-write; local-dev's is read-only (or vice
   versa).  A test that writes data passes in one and fails in
   the other.
8. **Different data fixtures.**  CI's database has been seeded
   with the integration-test dataset; local-dev's database has
   only the smoke-test dataset.  A test that queries data missing
   from one of the seeds fails on that side.

Every one of these gaps is a *configuration difference*.  Every
configuration difference is captured by typed configuration files
plus the active profile.  If the developer can faithfully
reconstruct CI's resolved configuration locally, the gap collapses.

## The capture primitive

The proposed primitive: a CLI command that emits a snapshot of
every resolved configuration value at a given moment in a run.

```bash
ccai profile capture --output ci-failure-snapshot.json
```

Output (the snapshot file):

```json
{
    "profile_name": "integration-ci",
    "captured_at": "2026-04-30T14:32:11Z",
    "captured_for": {
        "run_id": "ci-12345",
        "job": "integration-test",
        "commit_sha": "abc123def",
        "operator": null,
        "vendor": "gitlab-ci",
        "vendor_url": "https://gitlab.example.com/team/project/-/jobs/12345"
    },
    "resolved_variables": {
        "AM_PROFILE": "integration-ci",
        "AM_CONFIG_LANDSCAPE_NAMES": "staging-east",
        "AM_CONFIG_TOPOLOGY_NAMES": "staging-east-leader-follower",
        "AM_CONFIG_RUNTIME_NAMES": "integration-ci",
        "AM_CONFIG_CREDENTIAL_NAMES": "integration-ci",
        "AM_CONFIG_DATAPROFILE_NAMES": "integration-ci",
        "TESTBASE_MAX_EXAMPLES_CAP": "10",
        "TESTBASE_PROPERTY_SEED": "1714492331",
        "AM_LOG_LEVEL_CONSOLE": "INFO",
        "AM_LOG_LEVEL_LOGFILE": "DEBUG"
    },
    "configuration_snapshots": {
        "landscape": {
            "environment": {"label": "staging-east"},
            "apod": {"clients": [...]},
            "infrastructure": {"services": [...]}
        },
        "topology": {
            "environment": {"label": "staging-east-leader-follower"},
            "clusters": [...],
            "dependencies": [...],
            "roles": [...]
        },
        "runtime": {
            "logging": {"levels": {"console": "INFO", "logfile": "DEBUG"}},
            "parallelism": {"max_workers": 4},
            "timeouts": {"default_seconds": 30}
        },
        "credentials": [
            {
                "identifier": "ci-runner-ssh",
                "categories": ["ssh", "environment:integration-ci"],
                "username": "runner",
                "keyfile": "<redacted-path>",
                "keypasswd": "<redacted>",
                "allow_agent": false,
                "primitive": false
            }
        ],
        "dataprofiles": [...]
    }
}
```

Five sections matter:

- **`profile_name`** — the active profile when the snapshot was
  taken.  Identifies the starting point for replay.
- **`captured_for`** — provenance.  Which CI run, which commit,
  which vendor.  Useful for retrospective analysis weeks later.
- **`resolved_variables`** — every active `AppVarDesc` with its
  resolved value.  This is the answer to "what was the effective
  parameterisation of this run?"  Includes the property-test
  seed (`TESTBASE_PROPERTY_SEED`) so a property test failing on
  case 7 of 10 reproduces deterministically.
- **`configuration_snapshots`** — the actual content of each
  loaded category, post-resolution and post-overlay.  This is the
  defence against "the named configuration changed between then
  and now"; the snapshot has the *content* the run saw, not just
  the names.
- **`credentials` (under snapshots)** — the credential identifiers
  and metadata, with all secret fields replaced by `<redacted>`
  markers.  The snapshot is *not* a credential leak; the
  replay layer re-resolves the redacted fields against the local
  backend.

The capture command is roughly seventy lines of Python: walk
`AppVarSet.items()` (chapter 14), serialise each loaded
`CONFIGURATION_MAPS` entry (chapter 3), apply the chapter-11
redacting filter to credential fields, write the result as JSON.
The implementation reuses every primitive the volume has built so
far.

## CI integration: when to capture

Three patterns for wiring capture into a CI pipeline.

### Capture on failure

The most common pattern.  The CI step's failure path captures the
snapshot before exiting:

```yaml
# .gitlab-ci.yml
integration-test:
  variables:
    AM_PROFILE: "integration-ci"
  script:
    - ccai testbase test run tests/ || (
        ccai profile capture --output failure-snapshot.json
        && exit 1
      )
  artifacts:
    when: on_failure
    paths:
      - failure-snapshot.json
    expire_in: 1 month
```

Three properties of this wiring:

- **The capture only runs when the test fails.**  The vast
  majority of CI runs don't need the snapshot, and the I/O
  overhead is not paid in the common case.
- **The snapshot is uploaded as a job artifact.**  The developer
  downloads it from the failed CI job's artifact view.  Vendor-
  specific paths exist for this in every major CI vendor.
- **The artifact has a TTL.**  After a month, the snapshot is
  auto-deleted.  Long-term storage of CI snapshots is a separate
  concern (an audit-log feed for incident response, perhaps); the
  artifact path is for short-term debugging.

### Capture on every run

Some teams want the snapshot regardless of pass or fail, for
historical reasons (the `flaky-tests` dashboard, the
`config-drift` analyser).  The pattern:

```yaml
integration-test:
  script:
    - ccai testbase test run-with-capture tests/ \
        --capture-output ./snapshots/integration-ci-${CI_JOB_ID}.json
```

The `run-with-capture` wrapper captures *before* the test runs,
runs the test, and writes the snapshot regardless of test
outcome.  The snapshot includes the test outcome as a top-level
field.

### Capture on demand

For incident response, an on-call engineer can capture the active
profile of a *running* orchestration on a remote host:

```bash
ccai profile capture --remote ssh://runner-1.staging.east.internal --output now.json
```

Implementation: the remote `ccai profile capture` runs in the
target process's context (which requires the orchestration to
expose a small introspection RPC, similar to Volume 8's
time-travel debugging hooks).  This pattern is more complex and
out of scope for the chapter's main thread; mentioned for
completeness.

## The replay primitive

The matching command on the developer side:

```bash
ccai testbase test run --replay-profile failure-snapshot.json tests/
```

The `--replay-profile` flag tells the orchestration's startup:

1. **Read** the snapshot file.
2. **Skip** normal profile resolution.  The snapshot *is* the
   resolved configuration; no `AM_PROFILE` lookup, no `extends:`
   chain walk.
3. **Apply** the snapshot's `resolved_variables` via
   `override_variable_value` calls.  Now every active variable has
   the value it had in CI.
4. **Register** the snapshot's `configuration_snapshots` as
   highest-priority `MergeMap` overlays for each category.  Now
   the loaded landscape, topology, runtime, and data profiles are
   the same content the CI run saw.
5. **Re-resolve** redacted credentials against the local backend
   roster.  For each credential whose `<redacted>` markers
   indicate missing secret material, the resolver looks up the
   credential identifier in the *local-dev* credential file and
   uses the local-dev value.

After step 5, the test runs with CI's exact non-secret
configuration — same landscape, same topology, same runtime, same
property-test seed, same `TESTBASE_MAX_EXAMPLES_CAP` — and
locally-resolvable credentials.

The credentials substitution is the security boundary.  The
developer cannot replay against production credentials they don't
have access to; the replay uses *their* local-dev credentials,
even when the snapshot was captured from CI.

## What the replay reproduces and what it doesn't

The replay reproduces every gap from the list at the top of the
chapter:

| Gap | Reproduced by replay? |
|---|---|
| Different log levels | ✓ — runtime configuration is in the snapshot |
| Different timeouts | ✓ — runtime configuration |
| Different parallelism | ✓ — runtime configuration |
| Different property-test sweeps | ✓ — `TESTBASE_MAX_EXAMPLES_CAP` is captured |
| Different random seeds | ✓ — `TESTBASE_PROPERTY_SEED` is captured |
| Different landscape entries | ✓ — landscape snapshot is in the file |
| Different credentials' *behaviour* | ✗ — local credentials replace CI credentials |
| Different data fixtures | ✗ — the data is in the database, not in the configuration |

The two gaps the replay does *not* reproduce are the two where
the configuration is not the source of truth:

- **Credential behaviour.**  A test that fails because the CI
  credential has different permissions than the local credential
  will pass under replay because replay uses local credentials.
  This is intentional (the security boundary); the discipline for
  this case is to ensure the local-dev credential has the same
  permissions as the CI credential — same role, same scope tags,
  same backend-side ACL.  When this is true, the gap doesn't
  exist.
- **Data fixtures.**  A test that depends on rows existing in a
  database depends on the database's *content*, which lives in
  the data store, not in the data-profile configuration.  Replay
  can't reach into the data store and reproduce its state.  The
  discipline for this case is to ensure data fixtures are
  themselves typed (managed by a Specification or by a test-time
  fixture loader); the replay then re-runs the fixture loader to
  produce the same state locally.

For the eight common gaps, six are reproduced cleanly and two
require complementary discipline.  The discipline closes the
others.

## A worked CI-failure walk-through

A flaky property test:

```python
@given(integers=st.lists(st.integers(), min_size=1, max_size=100))
def test_sum_is_at_least_max(integers):
    """ The sum of a list of integers is at least the max. """
    verification.assert_true(sum(integers) >= max(integers))
    return
```

The test is incorrect — for lists of negatives, the sum can be
*less* than the max.  The bug is silent for most randomly-
generated input (lists with at least one positive element rarely
violate the property) but surfaces when the random sequence
produces an all-negative list.

### The CI failure

CI runs the test with `TESTBASE_MAX_EXAMPLES_CAP=10` and a
property-test seed of `1714492331`.  At case 7 of 10 the seed
produces `[-3, -1, -2]`; sum is `-6`, max is `-1`; `-6 >= -1` is
false; the test fails.

The CI logs:

```
FAILED tests/test_property.py::test_sum_is_at_least_max
Falsifying example: integers=[-3, -1, -2]
```

### The local "passes"

The developer pulls the branch and runs:

```bash
ccai testbase test run --profile local-dev tests/test_property.py
```

The local-dev runtime profile sets `TESTBASE_MAX_EXAMPLES_CAP=5`
(local-dev preference for fast feedback) and the property-test
seed is fresh (let's say `1714600000`, the current time in
seconds when the developer ran the test).  The five generated
cases all happen to have at least one positive integer; the
property holds; the test passes.

The developer is confused.  The CI log shows the failing
example explicitly: `[-3, -1, -2]`.  Why doesn't reproducing the
example locally fail too?

Without replay, the developer would manually paste the failing
example into a unit test, confirm it fails, and proceed to fix
the bug.  This is acceptable for one failure but expensive for
many — and many tests don't print their falsifying example as
clearly as Hypothesis does.

### The replay

The developer downloads the failure snapshot from the CI job's
artifacts and runs:

```bash
ccai testbase test run --replay-profile failure-snapshot.json tests/test_property.py
```

The replay:

- Sets `TESTBASE_MAX_EXAMPLES_CAP=10` (from the snapshot).
- Sets `TESTBASE_PROPERTY_SEED=1714492331` (from the snapshot).
- Loads the same staging-east landscape, topology, etc. (irrelevant
  for this test, but uniformly applied).
- Re-resolves credentials against local-dev.

The test runs.  Because the seed is identical and the
`max_examples` is identical, the property-test framework
generates the same sequence of cases — case 7 is `[-3, -1, -2]`,
the assertion fails, the test fails locally.

The developer can now set a breakpoint on the failing assertion,
run the test under the debugger, step through the implementation,
and find the bug.  The whole loop — capture, download, replay,
debug — takes ten minutes; without replay, the equivalent loop
might involve three CI re-runs and an hour of guessing.

## `TESTBASE_MAX_EXAMPLES_CAP` as the canonical small case

The `TESTBASE_MAX_EXAMPLES_CAP` knob, introduced in chapter 7 as
the smallest example of a typed runtime configuration that
switches behaviour, is the smallest worked example of replay.
A property test that fails at case 7 of CI's 10 will:

- **Pass locally without replay.**  Different cap (or no cap)
  plus different seed produces a different case sequence.
- **Fail locally with replay.**  Same cap, same seed reproduces
  the same case sequence and the same failure.

The chapter's wider point: the discipline of treating *every*
behaviour-controlling parameter as runtime configuration —
chapter 7's argument — is what makes replay work.  If
`TESTBASE_MAX_EXAMPLES_CAP` were a flag on the test runner
instead of a runtime configuration field, the snapshot wouldn't
capture it, and the replay wouldn't reproduce the behaviour.
Each piece of "this is just an env var I sometimes set" is a
piece that resists capture.  The discipline of typed runtime
configuration is what *closes* the gap; replay is the operational
mechanism that *exploits* the closure.

## Captured profile as audit artifact

Once captures exist, they are useful for more than debugging the
specific failure that produced them.  Three secondary uses worth
naming:

### Cross-environment diff

Capture the profile of a successful staging run; capture the
profile of a successful production run; diff them.  The diff
shows every configuration difference between staging and
production.  Differences that *should* exist (different
landscape labels, different credentials) appear; differences
that *shouldn't* (a runtime field that's set in one and unset in
the other) are the candidates for the next round of staging-vs-
production drift fixes.

### Historical replay

A test that passed six months ago and now fails — but on the
same code — is almost certainly a case of environment drift.  A
captured profile from the six-months-ago passing run lets the
developer replay against the historical configuration and
confirm whether the failure is configuration-driven or genuinely
a regression.  When the historical replay passes, the issue is
configuration drift; when it fails, the issue is in the code or
in something the configuration didn't capture (data fixture
drift, dependency upgrades).

### Compliance and audit

Some regulated environments require the team to be able to
demonstrate, after the fact, the exact configuration of a
production-affecting run.  Captured profiles satisfy this with
no additional machinery: every run captures, every capture is
archived, every archive is searchable by `captured_for` fields.

## What the replay primitive does *not* fix

Three boundaries to keep clear:

### Not a fix for code that depends on undeclared state

If a test reads `os.environ["FOO"]` directly (in violation of
the chapter-7 plumbing-vs-content principle), the snapshot will
not capture `FOO` because `FOO` is not a typed runtime variable.
The replay will not set `FOO`; the local replay will read
whatever `FOO` happens to be in the developer's environment, and
the gap remains.

The fix is the chapter-7 discipline: every behaviour-controlling
value becomes a typed configuration field.  Once it's typed, it's
captured; once it's captured, it's reproducible.

### Not a fix for non-deterministic tests

A test whose outcome depends on wall-clock time, random numbers
generated outside the property-test framework's seed, network
latency, or the moon phase will not reproduce under replay even
when the captured profile is identical.  Non-deterministic tests
are a separate engineering problem; the discipline can't fix them
without first making them deterministic.

The pattern that does work is to capture *the random seeds the
test uses internally* in the snapshot.  Test code that does
`random.seed(int(time.time()))` would migrate to
`random.seed(int(os.environ.get("TEST_SEED", time.time())))` and
have the runtime configuration include `test_seed`.  Once the
seed is captured, the test is deterministic for replay purposes.

### Not a fix for data drift

Replay doesn't reach into the database or the queue or the
filesystem.  When the test depends on a row existing or a file
being present, replay reproduces the *configuration* of the run
but not the *state of the world*.  The discipline:

- Use **data fixtures** (managed by a Specification or by a
  test-time fixture loader) so the data state is itself typed
  and reproducible.
- Where the data is genuinely external and beyond the
  orchestration's control (a third-party API's user database, a
  shared Kafka topic), document that the test depends on
  externalities and quarantine it from the deterministic test
  suite.

## Implementation cost

The capture and replay primitives together are roughly two
hundred lines of Python:

- `ccai profile capture`: ~70 lines (walk `AppVarSet.items()`,
  serialise the configuration maps, apply redaction, write JSON).
- `ccai testbase test run --replay-profile`: ~80 lines (parse
  the snapshot, override variables, register overlays, swap in
  local credentials, hand off to the existing test runner).
- The CI-side capture-on-failure wrapper: ~10 lines of bash per
  vendor.
- The redacting filter: already shipped (chapter 11).
- The variable iteration: already shipped (`AppVarSet`).
- The configuration snapshot: already shipped
  (`CONFIGURATION_MAPS` is the source of truth).

The discipline is built on top of what the portfolio already
provides.  The new tooling is small.

## Summary

- The "passes locally, fails in CI" gap has eight common sources
  — log levels, timeouts, parallelism, property-test sweeps,
  random seeds, landscape entries, credentials, data fixtures.
  Six of the eight are *configuration differences*; the
  discipline reproduces them faithfully via replay.
- The **capture primitive** (`ccai profile capture`) emits a
  JSON snapshot of every resolved configuration value at a moment
  in a run.  Includes resolved variables, configuration content
  per category, credential metadata with secrets redacted, and
  capture provenance (CI run, commit, vendor).
- The **replay primitive**
  (`ccai testbase test run --replay-profile <file>`) reads a
  snapshot, applies variable overrides, registers
  configuration overlays, re-resolves redacted credentials
  against the local backend roster, and runs the orchestration
  with the captured configuration.
- The `TESTBASE_MAX_EXAMPLES_CAP` + `TESTBASE_PROPERTY_SEED`
  pair makes property-test failures deterministically
  reproducible.  Same cap + same seed = same case sequence.
- The replay is *not* a fix for code that reads undeclared
  state, *not* a fix for genuinely non-deterministic tests, and
  *not* a fix for data drift.  Each is a separate engineering
  problem with its own discipline.
- Captured profiles double as audit artifacts: cross-environment
  drift detection, historical replay for "it used to work," and
  compliance evidence for regulated environments.
- The capture and replay primitives together are roughly two
  hundred lines on top of the shipped portfolio.  Every piece
  the discipline depends on already exists.

The next chapter is the last in Part IV: profile promotion.  How
a local-dev profile additively becomes staging via overlay; how
staging additively becomes prod via overlay; how additions in
one profile flow naturally into descendants without coordinated
edits across files.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/17-profile-promotion -->
# Chapter 17 — Profile Promotion

> *Your local-dev profile is not a draft of your staging profile.
> Your local-dev profile **is** your staging profile with two
> overlay files added.*

This is the last chapter of Part IV.  Chapters 13 through 16 built
the profile discipline: the four-environment ladder (chapter 13),
the profile mechanism (chapter 14), the local-dev migration
(chapter 15), the capture-and-replay primitive (chapter 16).
Each of those chapters treated the profiles as static — fixed
configurations that already exist, applied to a run.

This chapter is about the *dynamics*: how profiles change over
time, how additions propagate from one environment to another, and
how the discipline keeps four environments in sync without
coordinated rewrites.

## The promotion direction

A common misreading of the four-environment ladder is that
configurations *travel from local-dev up to production*.  That
reading is correct in spirit and slightly wrong in mechanics.
What actually travels is *changes*; the profile *files themselves*
sit in a fixed inheritance hierarchy.

The hierarchy from chapter 14, drawn explicitly:

```
                base
                 │
       ┌─────────┼──────────────────┬──────────────────┐
       ▼         ▼                  ▼                  ▼
   local-dev  integration-ci    staging-east       prod
                                       │
                                       ▼
                              staging-east-canary
```

`base` is the root.  Each environment profile extends `base` and
picks its own selections.  Some environments (`staging-east-canary`)
extend other environments rather than `base` directly.  The
hierarchy is acyclic, finite, and stable; profile files don't
move around.

What *moves* is changes:

- A new knob added to `base` becomes available in every profile
  that extends it.  This is **upward promotion** through the
  hierarchy.
- A new knob added to `local-dev` only appears in `local-dev`.
  Until and unless it is promoted to `base` (or to whichever
  shared ancestor the change should reach), it stays scoped.
  This is **non-promotion** — the change has been added to one
  profile but not migrated to its peers.
- An overlay added to `staging-east-canary` only appears in that
  one descendant profile.  The change is intentionally scoped
  narrow.  This is **scoped addition** — the change was promoted
  *to* a leaf profile, not *through* the hierarchy.

The discipline of this chapter is the conscious, reviewable
choice of which of these three moves to make every time a profile
changes.

## The promotion model

Three moves, each with a different level of impact and a different
review story.

### Promotion to `base`

The most impactful move.  A change to `base` reaches every
descendant profile — local-dev, integration-CI, staging-east,
staging-east-canary, prod, and any future profile.

Promotions to `base` are appropriate when the change represents a
*genuinely shared* concern that should apply everywhere:

- A new universal field on the runtime schema (e.g., adding a
  `tracing.sample_rate` field with a sane default).
- A new credential identifier the application code newly
  requires (e.g., adding `metrics-exporter` as a credential
  reference all environments must resolve).
- A new security-critical default (e.g., changing the `base`
  runtime to require TLS verification on by default).

When a change isn't *genuinely shared*, it doesn't belong in
`base`.  A new local-dev convenience knob is not a `base`
addition; it is a `local-dev` addition.

The review story for a `base` promotion is a single PR that
modifies one file (`profiles/base.yaml`) and that touches every
environment by reference.  The PR's reviewers know they are
making a multi-environment change; the diff is small and
focused; the impact is broad.

### Scoped addition

A change to one specific profile.  The most common move.

A new field on the local-dev runtime — say, lowering the timeout
for a particular slow test — affects only local-dev.  The PR
modifies one file (`config/runtime/runtime-local-dev.yaml`); no
other environment is affected.

Scoped additions are how the *day-to-day* of profile maintenance
works.  Most changes belong here.  When in doubt, a change is a
scoped addition; promote it later if it turns out to be more
broadly useful.

### Override

A change that *contradicts* an inherited value.  Inheritance via
`extends:` cascades values from ancestors down; a child profile
can shadow an inherited value with a more specific one.  Override
is how the descendant says *"this thing in my parent does not
apply here."*

Common overrides:

- `prod` overrides `base.runtime.logging.levels.console` from
  `INFO` to `WARN` to reduce log volume.
- `staging-east-canary` overrides `staging-east.runtime.parallelism.max_workers`
  from 8 to 2 because canary runs are deliberately slower.
- `prod-readonly` overrides `staging.runtime.timeouts.default_seconds`
  from 30 to 5 because production calls must fail fast.

Overrides are *additive* in the file structure (a new field in
the descendant) but *subtractive* in semantics (they negate an
inherited behaviour).  The review story is the same as a scoped
addition: one file changes, the diff is local, the impact is
local.

## The "automatically promoted" property

The discipline has an important compositional property: **a new
knob added to `base` is automatically available in every
descendant**, with the descendant's runtime configuration loader
picking up the new field on next process start.  No coordinated
edit of every environment profile is needed.

Two examples make this concrete.

### Example: adding a tracing field

The team decides to enable distributed tracing across the
platform.  They add a `tracing` section to the runtime schema.
The change goes into `base`:

```yaml
# config/runtime/runtime-base.yaml — modified
breakpoints: []
debugger: none
logging:
    levels:
        console: INFO
        logfile: DEBUG
parallelism:
    max_workers: 4
timeouts:
    default_seconds: 30

# NEW
tracing:
    enabled: true
    sample_rate: 0.1
    exporter: otlp
    exporter_endpoint: ${OTLP_ENDPOINT:-http://localhost:4318}
version: 1.0.0
```

After the merge, the next CI run loads `runtime-integration-ci`
which (via the chapter-14 `MergeMap` composition) inherits from
`runtime-base`.  The `tracing` section appears in the resolved
runtime configuration.  Tracing is on for CI.

The next staging run inherits the same way.  Tracing is on for
staging.

The next local-dev run inherits the same way.  Tracing is on
locally (with the local fallback OTLP endpoint).

No environment-specific edits.  No "did anyone update the
staging config?" question.  The promotion was a single file
change to `base`, and the cascade propagated automatically.

### Example: adding a per-environment override

Three months later, the team realises that local-dev should not
have tracing on by default — it adds noise to the logs and slows
down breakpoint debugging.  Local-dev's runtime profile gains a
single line:

```yaml
# config/runtime/runtime-local-dev.yaml — modified
# (inherits everything from base)
tracing:
    enabled: false      # override base; local-dev runs without
                        # tracing for inner-loop speed
```

The override is *additive in the file* (one new line); *negating
in semantics* (it disables the inherited tracing).  Other
environments are unchanged.  Local-dev now runs without tracing;
CI / staging / prod run with tracing.

The team did not have to:

- Remove the `tracing` field from `base`.
- Edit every environment's runtime to set `enabled: true`
  individually.
- Branch on environment in application code.

The two changes — turn tracing on globally, turn it off in
local-dev specifically — are reviewable PRs with focused diffs.
The cumulative effect is the desired behaviour.

## The audit story

A profile change has the same audit characteristics as any other
configuration change in the discipline:

- **Every profile file is in version control.**  Profile changes
  are git diffs.  The diff is reviewable in the same way every
  other code change is.
- **Every profile change is small.**  A `base` promotion changes
  one file.  A scoped addition changes one file.  An override
  changes one file.  None of the moves involve coordinated
  cross-file edits — which is the structural reason changes are
  reviewable in the first place.
- **Every active profile is captured at runtime.**  The chapter-16
  capture primitive emits a snapshot of the resolved profile,
  including which file each value came from.  When a question
  arises *months later* — "what was the staging-east timeout six
  months ago?" — the answer is in a captured snapshot, not in
  someone's memory.

The discipline turns cross-environment configuration into a
managed artifact.  Configuration drift becomes detectable
(captures from different environments diff; changes between
captures are visible).  Configuration regressions become
attributable (each value's origin file is traceable through the
inheritance chain).  Cross-environment surprises become rare
(the cascade is mechanical; surprises require explicit
overrides, which themselves are diffs in version control).

## Worked example: the promotion of a new field

Trace one change through the four environments.  The change is
small: add a per-orchestration `tracing.sample_rate` knob that
local-dev wants at `1.0` (sample everything for debugging) and
production wants at `0.01` (sample sparsely to control cost).

### Day 1 — propose

A senior engineer adds the `tracing` section to the runtime
schema, with a default for `sample_rate`:

```yaml
# config/runtime/runtime-base.yaml — PR #1
tracing:
    enabled: true
    sample_rate: 0.1            # default for non-prod
    exporter: otlp
    exporter_endpoint: ${OTLP_ENDPOINT:-http://localhost:4318}
```

PR #1 is reviewed by the SRE team (they own the tracing
infrastructure).  The PR description names which environments
inherit the default (every environment by default).  Reviewers
confirm the default is sane and the merge happens.

### Day 2 — local-dev preference

A developer wants tracing at `1.0` locally for an active
debugging session.  They add an override:

```yaml
# config/runtime/runtime-local-dev.yaml — PR #2 (developer's branch)
tracing:
    sample_rate: 1.0    # full sample for local debugging
```

PR #2 is reviewed by the developer's team.  Diff is one line.
Impact is local-dev only.  Merged.

### Day 3 — production preference

The SRE team learns that `0.1` sample rate is producing too much
trace volume in production.  They want production at `0.01`.
PR:

```yaml
# config/runtime/runtime-prod.yaml — PR #3
tracing:
    sample_rate: 0.01   # cost control in prod
```

PR #3 is reviewed by the SRE team.  Diff is one line.  Impact is
production only.  Merged.

### Day 4 — staging keeps the default

Staging inherits the `base` default of `0.1`.  No PR is required
for staging; the default applies automatically.  The staging
environment now runs at `sample_rate: 0.1` — *because that is
what the inheritance produced*, not because anyone explicitly set
it for staging.

### Day 100 — auditing the cascade

Three months later, an SRE wants to understand why staging
produces ten times the trace volume of production.  They run:

```bash
ccai profile capture --output staging-now.json
ccai profile capture --output prod-now.json
diff <(jq '.configuration_snapshots.runtime.tracing' staging-now.json) \
     <(jq '.configuration_snapshots.runtime.tracing' prod-now.json)
```

The diff shows `sample_rate: 0.1` in staging vs `sample_rate: 0.01`
in prod.  The SRE walks the inheritance: staging inherits from
`base`; `base` declares `0.1`; staging does not override.  Prod
overrides at `0.01`.  The cascade is documented in the capture
plus the original PRs.

The investigation took five minutes.  Without the discipline, the
same investigation would have involved finding which engineer
last touched the tracing config (none of them), reading three
different configuration sources (CI YAML, environment variables,
hardcoded defaults), and reconstructing the answer from
incomplete information.

## Anti-patterns: how the discipline gets broken

Three patterns produce the same kinds of drift the discipline is
supposed to prevent.  Each is recognisable; each is the result of
a small short-cut that compounds.

### Profile fork

A change is supposed to apply to multiple environments, but the
engineer copies the value into each profile rather than promoting
it to a shared ancestor.

Symptom: `runtime-staging-east.yaml`, `runtime-staging-west.yaml`,
and `runtime-prod.yaml` all have the same value for some field;
the value is different from `base`.

Why it happens: the engineer is making a quick fix and promoting
to `base` would require additional review.  Copying into three
files is "faster."

How to detect: a periodic script that walks the runtime files
across environments, finds fields with identical values across
multiple environments, and flags them as candidates for `base`
promotion.

How to fix: when the script flags a duplicate, *promote it to
`base`* (or to the nearest shared ancestor), and remove the
duplicates.  The drift is undone; the discipline is restored.

### Divergent rewrite

A profile is supposed to inherit from `base` but was authored as
a complete copy with `extends:` pointing at `base` for *form's
sake* and no actual inherited fields.

Symptom: a profile YAML lists every category selection
explicitly even when several of the selections match `base`'s
defaults.

Why it happens: the engineer writing the new profile used
another profile (`staging-east`) as a starting point and didn't
prune the inherited values when they happened to match.

How to detect: a `ccai profile diff <child> base` that shows
zero differences for a particular category means the child is
explicitly setting what it would inherit anyway.

How to fix: drop the redundant explicit values from the child.
The behaviour doesn't change; the file gets shorter; the
inheritance becomes truthful.

### Copy-paste promotion

A change is "promoted" by copying the modified file from one
environment to another, rather than by editing the source-of-
truth profile and letting inheritance cascade.

Symptom: a `git log` on `runtime-base.yaml` shows no recent
activity, but `git log` on `runtime-staging-east.yaml`,
`runtime-staging-west.yaml`, and `runtime-prod.yaml` shows the
same commit landing on each in quick succession.

Why it happens: the engineer doesn't trust inheritance to do the
right thing, so they apply the change manually to each file.

How to detect: a CI check that flags PRs touching multiple
environment files with the same diff and suggests promoting to
`base` instead.

How to fix: revert the per-environment changes; apply the change
to `base`; let inheritance carry it.  This is the discipline at
its most useful — the inheritance is *exactly* the mechanism the
copy-paste avoided.

## Where the promotion model doesn't apply

Three boundaries to keep clear.

### Categorical breaks

Some changes are not promotable.  When prod uses Snowflake for
its data warehouse but staging uses BigQuery, no amount of
inheritance can pretend they are the same.  The data-profile
files for each environment specify different categories
(`snowflake` in one, `bigquery` in the other), and the
application code has to handle both.

The discipline accommodates categorical breaks by keeping
*irreconcilable differences out of inheritance*.  Each
environment selects its own data profile; no shared ancestor
declares a category that doesn't apply universally.

### Fork-then-merge changes

Some changes start scoped and become broad.  A knob added to
local-dev as a debug helper turns out to be useful in CI.  The
promotion path:

1. Begin life in `local-dev` (scoped addition).
2. Confirm the change is useful in CI by setting it in
   `integration-ci` (scoped addition, second profile).
3. *Now* the change has two homes; promote to `base`; remove
   from both leaf profiles.

Step 3 is the key one.  A change that lives in two profiles is
a candidate for `base` promotion.  Skipping the promotion leaves
the team in the profile-fork anti-pattern.

### Permission-gated changes

Some changes affect production and require a different review
board.  The discipline accommodates this by *file ownership*: a
GitHub `CODEOWNERS` file (or its equivalent) restricts who can
approve changes to `runtime-prod.yaml` even when the change
landed in `base` and merely cascaded.

The reviewers of a `base` change are responsible for understanding
the cascade.  When the cascade reaches production with consequences
that need stricter review, the discipline asks the reviewer to
also enlist the production-config owners.  This is human process
on top of mechanical inheritance; the inheritance does not enforce
the process by itself.

## Summary — and end of Part IV

- A profile inheritance hierarchy is a stable structure where
  changes propagate predictably.  Three moves available: **promote
  to `base`** (cascade everywhere), **scoped addition** (one
  profile only), **override** (negate an inherited value in a
  descendant).
- A `base` promotion is the most impactful move and the rarest;
  scoped additions and overrides are the daily currency of profile
  maintenance.
- The "automatically promoted" property — a new field in `base`
  is available in every descendant on next run — eliminates
  coordinated cross-environment edits.  Adding a knob is one
  PR; the cascade is mechanical.
- Every profile change is in version control; every change is
  small; every active profile is captured at runtime.  Drift is
  detectable; regressions are attributable; surprises are rare.
- A worked example traced one tracing-config knob through four
  environments: PR to `base` adds the field, PR to local-dev
  overrides for full-sample debugging, PR to prod overrides for
  cost control, staging inherits the `base` default with no PR.
  Three months later the cascade is auditable from captured
  snapshots.
- Three anti-patterns — **profile fork** (duplicating across
  environments instead of promoting to base), **divergent rewrite**
  (copying instead of inheriting), **copy-paste promotion**
  (manual cascade instead of letting inheritance do it) — are the
  recurring ways the discipline gets broken.  Each has a
  detection signature and a mechanical fix.
- Three categories of change *don't* fit promotion: **categorical
  breaks** (different vendors per environment), **fork-then-merge
  changes** (knobs that start scoped and grow into broad), and
  **permission-gated changes** (where human review process layers
  on top of mechanical inheritance).

This is the last chapter of Part IV.  The volume has now built
the discipline (Parts I–III) and developed the environment-
spanning machinery (Part IV).  The next part — Part V — is the
implementation: `codecraftai-taskplus` as the workflow engine that
references the configurations the discipline has produced, by
name from a portable workflow YAML.  Where Parts II–IV asked
*what* the configurations should be and *how* they should
compose, Part V is *what runs them*.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/18-the-taskplus-task-model -->
# Chapter 18 — The taskplus Task Model

> *Part V is the implementation.  The four parts before this one
> asked what the configurations should be and how they should
> compose; this part asks what runs them.*

Welcome to Part V.  The volume's discipline half — Parts I, II,
III, IV — is finished.  Six configuration categories named, a
credential discipline established, an environment-spanning profile
system proposed.  All of that has built up to one question: *what
is the thing that takes these typed inputs and runs an
orchestration?*

The answer in the portfolio is `codecraftai-taskplus`.  Six chapters
of Part V cover it: the task model (this chapter), the built-in
task types (chapter 19), the workflow YAML grammar (chapter 20),
how information flows from orchestration to job (chapter 21), how
flow control is expressed as data (chapter 22), and how to extend
the task model with new types (chapter 23).

By the end of Part V, the reader has the complete implementation
substrate for the discipline.  Part VI then wires it to real CI
vendors with worked examples.

## Why a task model at all

A workflow is a sequence of steps.  At the smallest scale, "a
workflow" can be a shell script — one command after another, with
exits between them.  At the next scale up, the shell script grows
conditionals, loops, and error handlers; eventually it has more
control flow than commands.

A task model is the answer to the question *"what's the smallest
thing the orchestration engine needs to know about a step in
order to run it?"*  Three properties are essential:

1. **A way to identify the step.**  Logs, traces, results all
   need to refer back to a specific step.  Without identity, when
   a step fails you can't say *which* step.
2. **A way to execute the step.**  The engine has to call into
   the step's implementation; the step has to return success or
   failure.
3. **A way to express control-flow context.**  Where does this
   step sit relative to other steps?  What happens if it fails?
   What happens if it succeeds?

The taskplus task model captures these three properties in three
classes — `FlowNode`, `Task`, `TaskGroup` — that together form a
small inheritance hierarchy in
[`codecraftai-taskplus/source/packages/ccai/taskplus/model/`](https://github.com/code-craft-ai-company/codecraftai-taskplus/tree/main/source/packages/ccai/taskplus/model).
The hierarchy is small enough to read in one sitting and rich
enough to express every workflow shape Part V's chapters will
build on.

## `FlowNode` — the abstract base

The root of the task model is
[`FlowNode`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flownode.py),
an `abc.ABC` class with no abstract methods.  In its entirety:

```python
class FlowNode(abc.ABC):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any], logger: Logger):
        self._ordinal = ordinal
        self._label = label
        self._node_info = node_info
        self._logger = logger

        self._if_expr = node_info.get("if")
        return

    @property
    def if_expr(self) -> str:
        return self._if_expr

    @property
    def label(self) -> str:
        return self._label

    @property
    def node_info(self) -> dict:
        return self._node_info

    @property
    def ordinal(self) -> str:
        return self._ordinal
```

Five fields and four properties.  Every flow node — whether it's a
leaf task or a recursively-nested group — carries these.

### `ordinal` — the trace identity

The `ordinal` is a dotted string identifying the node's position
in the workflow.  Top-level nodes have ordinals `"1"`, `"2"`,
`"3"`; nested nodes have ordinals like `"2.3"` (the third item
inside the second top-level node) and `"2.3.1"` (the first item
inside that).

The ordinal is the trace key.  When a step fails, the log message
"step 2.3.1 failed" tells the engineer exactly where in the
workflow to look — even when the workflow has nested taskgroups
and parallel branches that defy a flat numbering.  The shipped
[`Workflow.parse`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
generates ordinals as it walks the YAML; tasks and taskgroups
inherit their ordinals at construction time.

The ordinal is a *string* rather than a number.  This is deliberate
— `"2.10"` and `"2.2"` would compare incorrectly as floats, and
the dotted-path semantics is more important than the numeric one.

### `label` — the human identity

Where the ordinal is for machine routing, the `label` is for
human readability.  A task labelled `"Run smoke tests"` shows up
in logs and result reports under that name.  The label has no
uniqueness constraint within the workflow; two `"Cleanup"`-labelled
tasks at different ordinals are fine.

The label is the YAML's `task:` or `taskgroup:` field — when a
workflow author writes:

```yaml
- task: Run smoke tests
  tasktype: my.module@SmokeTest
```

the string `"Run smoke tests"` becomes the task's `label`.  This
is the chapter-20 grammar's view of the same field.

### `node_info` — the raw configuration

The `node_info` is the YAML dictionary that defined this node.
Every field the workflow author wrote — including the ones the
framework doesn't know about — is preserved here.  The
implementation detail matters: by storing the whole node info, the
task model lets concrete subclasses access task-specific fields
without the model needing to enumerate them.

When `RunCommand` (chapter 19) wants its `command` field, it
reads `node_info["command"]`.  When `EmbeddedPython` wants its
`script`, it reads `node_info["script"]`.  The base class doesn't
know about these fields; the subclasses pull what they need.

### `logger` — the observability hook

Every node carries a Python `Logger` reference.  This is the
mechanism for log emission during execution; concrete tasks
typically emit `logger.info("STEP: %s - %s" % (label, ordinal))`
at the start of execution and `logger.info` or `logger.error` for
results.

The logger is *passed in* rather than fetched.  This keeps the
task model decoupled from the specific logging configuration —
the workflow loader (chapter 20) chooses which logger every node
gets, which is how runtime configurations (chapter 7's logging
levels) reach the tasks without the tasks knowing about the
runtime configuration system.

### `if_expr` — the conditional gate

The base class also surfaces an optional `if` expression from the
node info.  Workflow authors declare it like this:

```yaml
- task: Run staging cleanup
  tasktype: my.module@StagingCleanup
  if: "parameters['cleanup_after_run'] == True"
```

The expression is a Python string evaluated by the
[`FlowEngine`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flowengine.py)
at execution time.  When the expression is `None` (the field was
absent), the node always runs.  When the expression evaluates to
falsy, the node is skipped.

The `if_expr` is the chapter-22 flow control's gate; this chapter
just notes that it lives on every flow node and that the base
class exposes it.  Chapter 22 develops the evaluation semantics.

## `Task` — the leaf

A `Task` is a `FlowNode` that *does work*.  It has two
properties beyond the base — `onfailure` and `onsuccess` — and
two methods — `execute` and the abstract `perform_task`.

```python
class Task(FlowNode):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any], logger: Logger):
        super().__init__(ordinal, label, node_info, logger)

        self._onfailure = node_info.get("onfailure", None)
        self._onsuccess = node_info.get("onsuccess", None)

        return

    @property
    def onfailure(self) -> Optional[str]:
        return self._onfailure

    @property
    def onsuccess(self) -> Optional[str]:
        return self._onsuccess

    def execute(self, environment, parameters, flowstate) -> int:
        self._logger.info("STEP: %s - %s" % (self._label, self._ordinal))
        result = self.perform_task(environment, parameters, flowstate)
        return result

    @abc.abstractmethod
    def perform_task(self, environment, parameters, flowstate) -> int:
        raise AbstractMethodError("TaskBase->execute must be overloaded by derived classes.") from None
```

### `onfailure` and `onsuccess` — declarative jumps

Both fields are *optional*.  Each names another node's `label`
that the engine should jump to when this task ends in the
relevant outcome.

A task with `onfailure: cleanup` and `onsuccess: notify` means:
*"if I fail, jump to the task labelled cleanup; if I succeed,
jump to the task labelled notify."*  Tasks in between are
skipped.

This is the chapter-22 flow-control mechanism.  The chapter
develops the `JumpContext` machinery that drives the actual
jumps; the model simply records the pair of fields on every task.
Tasks with neither field set fall through to the next node in
sequence, which is the default behaviour.

### `execute` and `perform_task` — the two-method pattern

The `execute` method is concrete and not meant to be overridden.
It logs the step and delegates to `perform_task`.  The
`perform_task` method is abstract; concrete tasks (chapter 19)
override it with their actual work.

The split is deliberate.  The base class wants every task to
log its start and to be drivable through the same `execute(...)`
interface.  But the *contents* of the work — running a shell
command, executing a Python script, doing a Git checkout — are
task-specific.  The Template Method pattern keeps the
common-context concerns (logging, signature) in the base while
letting the concrete classes specify the work.

Three signature notes:

- The method takes three positional arguments: `environment`
  (typically the resolved runtime environment), `parameters` (the
  workflow-level parameters), and `flowstate` (a per-run mutable
  dictionary that tasks can read and write).  These are the
  three communication channels orchestration uses to talk to
  tasks; chapter 21 develops the contract.
- The method returns an `int`.  Zero is success; any non-zero
  value is failure.  The integer can be a process exit code (for
  shell-driven tasks), an enum (for application-defined error
  classifications), or just `0`/`1` for the simplest cases.
- The method is *not* allowed to raise to indicate failure.  Tasks
  that experience exceptions catch them and translate to a
  non-zero return.  This is partly a design choice (failure
  semantics are a single channel) and partly a pragmatic one (the
  `FlowEngine`'s sequencing relies on non-exception returns to
  drive `onfailure` jumps; an exception would skip the engine's
  flow logic).

The `AbstractMethodError` import on line 23 of `task.py`
([`ccai.errors`](https://github.com/code-craft-ai-company/codecraftai-foundations/blob/main/source/packages/ccai/errors/exceptions.py))
is the canonical error for "this base method must be overridden."
A concrete task that forgets to override `perform_task` raises
this at first call.

## `TaskGroup` — the recursive grouping

A `TaskGroup` is a `FlowNode` that *contains other flow nodes*.
It does no work of its own; instead, it sequences and groups
its children.

```python
class TaskGroup(FlowNode):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any],
                 items: List[FlowNode], logger: Logger):
        super().__init__(ordinal, label, node_info, logger)

        self._items = items
        return

    @property
    def items(self) -> List[FlowNode]:
        return self._items
```

One additional field beyond the base: `items`, a list of child
`FlowNode` instances.  Children may themselves be `Task` instances,
or further `TaskGroup` instances.  The recursion is unbounded —
deeply nested groupings are valid.

The taskgroup is the only structure in the model that produces
the dotted-ordinal pattern — a `TaskGroup` at ordinal `"2"` with
three children gives them ordinals `"2.1"`, `"2.2"`, `"2.3"`.
Without the grouping, every node is at the top level and every
ordinal is a single number.

### Why grouping matters

Three reasons to group rather than just listing.

#### Logical separation

A workflow that has a "build" phase, a "test" phase, and a
"deploy" phase reads better when each phase is a `TaskGroup`
than when they're flat siblings.  The phase becomes one node
in the workflow's top-level list; what's inside the phase is a
nested concern.

#### Conditional skipping

Because `TaskGroup` is itself a `FlowNode`, it carries the
`if_expr` field.  A whole phase can be conditionally skipped:

```yaml
- taskgroup: Deploy phase
  if: "parameters['deploy_after_test'] == True"
  items:
      - task: Build artifact
        tasktype: my.module@BuildArtifact
      - task: Push to registry
        tasktype: my.module@RegistryPush
      - task: Deploy
        tasktype: my.module@Deploy
```

When `deploy_after_test` is False, the entire group is skipped
— three child tasks at once, with one `if` decision.

#### Failure jumps that span groups

A task inside group A can declare `onfailure:` jumping to a
label inside group B.  The engine resolves the jump by walking
through groups; the cross-group jump is allowed.  This means a
deploy task can jump to a cleanup taskgroup on failure, without
the deploy task having to reach inside the cleanup group's
implementation.

The semantics here are nuanced — chapter 22 develops them in
detail — but the model's role is to make them *expressible*.  The
recursive `TaskGroup` is the structure the engine walks.

## A worked example of the hierarchy

The four-class structure is best understood through a concrete
workflow.  Here is a small workflow YAML and the in-memory tree
the loader produces:

```yaml
# Workflow YAML (preview of chapter 20's grammar)
name: nightly-integration
flow:
    - task: Setup
      tasktype: my.module@SetupTask

    - taskgroup: Test phase
      items:
          - task: Smoke
            tasktype: my.module@SmokeTest
          - task: Integration
            tasktype: my.module@IntegrationTest
            onfailure: Skip teardown

    - taskgroup: Teardown phase
      items:
          - task: Stop services
            tasktype: my.module@StopServices
          - task: Skip teardown
            tasktype: my.module@NoOp
```

The in-memory representation:

```
Workflow
├── flow[0]: Task("Setup", ordinal="1")
├── flow[1]: TaskGroup("Test phase", ordinal="2")
│              items[0]: Task("Smoke", ordinal="2.1")
│              items[1]: Task("Integration", ordinal="2.2",
│                             onfailure="Skip teardown")
└── flow[2]: TaskGroup("Teardown phase", ordinal="3")
               items[0]: Task("Stop services", ordinal="3.1")
               items[1]: Task("Skip teardown", ordinal="3.2")
```

Five `Task` nodes and two `TaskGroup` nodes, every node a
`FlowNode` carrying `(ordinal, label, node_info, logger)`.  The
`Integration` task at ordinal `"2.2"` has `onfailure="Skip
teardown"`; when it fails the engine looks up the label "Skip
teardown" across the whole tree, finds it at ordinal `"3.2"`, and
jumps the execution there.

The same shape recurses arbitrarily.  A workflow with a `Test
phase` taskgroup containing a `Per-host setup` taskgroup
containing five `Run hostprep` tasks would be a three-deep tree;
the model handles it with no special-case code.

## Universal field invariants

A few invariants worth naming, since the chapter has implicitly
relied on them.

- **`ordinal` is unique within a workflow.**  The
  `Workflow.parse` machinery enforces this by construction (the
  loader generates ordinals in walk order).
- **`label` is *not* required to be unique.**  Two tasks both
  labelled "Cleanup" at different ordinals can coexist.  The
  jump machinery in chapter 22 handles label collisions by
  matching the *first* task with the named label in the engine's
  walk order; consult that chapter for edge cases.
- **`node_info` is the workflow author's source of truth.**  Every
  field the author wrote is preserved; the model adds nothing.
  Concrete tasks pull what they need; unknown fields are ignored
  silently.  This is permissive on purpose — workflow YAML can
  carry application-specific metadata the framework doesn't
  understand.
- **`logger` is constructed once and shared.**  Every node in a
  given workflow carries the same logger instance.  Per-task
  logging differentiation is handled by the logger's filtering
  and routing, not by per-task logger creation.
- **The recursion is finite.**  A taskgroup whose items list
  contains itself would be a cycle; the loader's
  `_process_flow_info_items` recurses but does not detect cycles
  because the YAML format makes cycles impossible to express.

## What's *not* in the task model

Three things the task model deliberately omits.

### Concurrency

The model has no notion of *parallel* execution.  Tasks within a
group run sequentially in declaration order.  A task group does
*not* dispatch its items concurrently.

When parallel execution is needed, the workflow author either:

- Uses a different task implementation (a `RunCommand` task that
  itself spawns parallel subprocesses).
- Handles it at a higher layer (multiple workflows running in
  parallel from a CI vendor's matrix-build feature).
- Extends the task model with a new node type (chapter 23 covers
  the extension recipe).

The omission is deliberate — Part V's discipline is that the task
model is *small and predictable*.  Adding concurrency at the
model layer would require a scheduling abstraction the simple
sequential model doesn't need.

### Retries

The model has no notion of *retries*.  A task that returns
non-zero is a failed task; the engine's flow control (`onfailure`
jumps) takes over.  The model does not retry the task with
backoff or with reset state.

When retries are needed, they live *inside* a task's
`perform_task`.  A concrete `RetryingHttpTask` could implement its
own retry loop; the model would see one task that took five
attempts to return zero.  Chapter 23's extension recipe shows the
pattern.

This is consistent with the shipped portfolio's **Aspects pattern**
from chapter 5 of Volume 5: behaviour parameters belong on the
task, not on the engine.

### State

The model has no first-class state beyond what's in `flowstate`
(the per-run mutable dictionary passed through `perform_task`).
Tasks read from it and write to it freely; there's no schema, no
type checking, no scoping.

When richer state management is needed — typed state, scoped
visibility, persistence — the workflow author uses an external
state store (a database, a key-value cache, a configuration map)
and references it through the runtime configuration.  The
discipline is to keep `flowstate` small and ad-hoc; persistent
state belongs in proper data stores.

## Summary

- The taskplus task model is a small inheritance hierarchy in
  [`codecraftai-taskplus/source/packages/ccai/taskplus/model/`](https://github.com/code-craft-ai-company/codecraftai-taskplus/tree/main/source/packages/ccai/taskplus/model).
  Three classes: **`FlowNode`** (abstract base), **`Task`**
  (leaf with work), **`TaskGroup`** (recursive grouping).
- Every flow node carries five universal fields:
  - `ordinal` — dotted-path machine identity (e.g., `"2.3.1"`).
  - `label` — human-readable identity from the YAML's
    `task:`/`taskgroup:` field.
  - `node_info` — the raw YAML dictionary, preserved verbatim.
  - `logger` — observability hook injected by the loader.
  - `if_expr` — optional gate evaluated by the FlowEngine.
- `Task` adds `onfailure` and `onsuccess` (label-based jumps)
  plus the abstract `perform_task(environment, parameters,
  flowstate) -> int` method.  Zero is success; non-zero is
  failure; tasks do not raise to indicate failure.
- `TaskGroup` adds `items: List[FlowNode]` and contains other
  flow nodes recursively.  The recursion produces the
  dotted-ordinal pattern.
- The model is *deliberately small*: no concurrency primitives,
  no retry primitives, no first-class state.  Extensions live in
  task implementations (chapter 19, chapter 23), not in the
  model.
- The same model handles flat workflows, nested phases,
  cross-group failure jumps, and conditionally-skipped phases
  uniformly — without special-case code.

The next chapter introduces the five built-in task types
shipped in `codecraftai-taskplus/tasks/`: `RunCommand`,
`ShellScript`, `EmbeddedPython`, `GitCheckout`, and
`CreateEnvironment`.  Each is a concrete `Task` subclass; each
illustrates the model's `perform_task` pattern; the chapter
notes which are well-shipped and which (per the audit findings)
are transitional or stub.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/19-built-in-task-types -->
# Chapter 19 — Built-in Task Types

> *Five concrete `Task` subclasses ship in `codecraftai-taskplus`.
> Three are useful for green-field work, one is intentionally a
> stub (a reader exercise), and one is transitional — useful for
> migration but to be retired by green-field code.*

The previous chapter introduced the abstract task model.  This
chapter walks the five concrete `Task` subclasses shipped under
[`codecraftai-taskplus/source/packages/ccai/taskplus/tasks/`](https://github.com/code-craft-ai-company/codecraftai-taskplus/tree/main/source/packages/ccai/taskplus/tasks).
Each is a concrete implementation of `perform_task` — small in
LOC, big in cumulative usefulness.  The five together let workflow
authors build real orchestrations without writing a single line of
custom task code.

The chapter takes one task at a time, in the order most teams
will reach for them in practice.  For each task: the use case,
the YAML shape, the implementation, the audit findings the task
went through during the pre-book audit pass, and any
caveats the workflow author should know.

## RunCommand — single-shell execution

The simplest task type.  `RunCommand` runs a single shell command
and returns its exit code.

### Use case

A workflow step that boils down to "run this one command":

```yaml
- task: Lint
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: "ruff check ."
```

```yaml
- task: Pull image
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: "docker pull registry.example.com/my-image:latest"
```

The task is a thin wrapper around `subprocess.Popen` with a
shell.  When the workflow needs *one* command, this is the right
type.

### YAML shape

```yaml
- task: <human-readable label>
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: <shell-command-string>
  if: <optional Python expression>
  onfailure: <optional label>
  onsuccess: <optional label>
```

One required field beyond the universals:
[`command`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/tasks/runcommand.py) —
the shell command to execute.  The shell is the system's default
shell; the command is invoked with `shell=True`.

### Implementation

The task is roughly thirty lines.  `__init__` extracts `command`
from `task_info`; `perform_task` runs it:

```python
def perform_task(self, environment, parameters, flowstate) -> int:

    self._logger.info("Running Command: %s" % self._command)

    proc = subprocess.Popen(
        [self._command],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        shell=True,
    )
    stdout, stderr = proc.communicate()
    exit_code = proc.wait()

    stdout = stdout.decode()
    stderr = stderr.decode()

    log_msg_lines = [
        "RESULT CODE: %d" % exit_code,
        "STDOUT:",
        indent_lines(stdout, level=1),
        "STDERR:",
        indent_lines(stderr, level=1),
    ]

    log_msg = os.linesep.join(log_msg_lines)
    if exit_code == 0:
        self._logger.info(log_msg)
    else:
        self._logger.error(log_msg)

    return exit_code
```

Three things worth noting:

- **The exit code is the return value.**  The task's notion of
  success is the shell's notion of success.  No interpretation, no
  re-mapping.
- **Output is captured and logged**, not streamed.  For
  long-running commands this means the log waits until the
  command finishes; for the typical orchestration step (linters,
  short builds, docker pulls) this is fine.  When streaming is
  needed, `ShellScript` or a custom task implementation is the
  better choice.
- **STDOUT goes to `INFO` on success; both go to `ERROR` on
  failure.**  Failed-command output is highlighted at the right
  log level so it's findable in CI logs.

### Caveats

- **Shell-injection risk.**  `command` is interpreted by the
  shell with `shell=True`.  When the command is constructed from
  external input, ordinary shell-injection considerations apply.
  The discipline: workflow YAML is checked into the repository
  and code-reviewed; treat it as code, not data.
- **No environment-variable injection from `environment`.**  The
  task receives an `environment` argument but does not use it.
  Variables the command needs come from `os.environ` (set up by
  the engine before the workflow runs) or from the `command`
  string itself.

## ShellScript — multi-line scripts

When one command isn't enough.  `ShellScript` writes a temporary
file with the script content, marks it executable, runs it, and
returns the exit code.

### Use case

A workflow step that requires multiple commands, conditionals, or
intermediate variables — but doesn't justify pulling out a
standalone shell-script file:

```yaml
- task: Pre-test setup
  tasktype: ccai.taskplus.tasks.shellscript@ShellScript
  script:
    - "set -euo pipefail"
    - "rm -rf .output"
    - "mkdir -p .output/integration"
    - "if [ -d node_modules ]; then echo 'reusing node_modules'; else npm ci; fi"
    - "echo 'setup complete'"
```

The list-of-strings form is YAML-friendly and lets each command
sit on its own line for readability.

### YAML shape

```yaml
- task: <label>
  tasktype: ccai.taskplus.tasks.shellscript@ShellScript
  script:
    - <shell-line-1>
    - <shell-line-2>
    - ...
```

One required field:
[`script`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/tasks/shellscript.py) —
a list of strings.  Each string becomes one line of the temp
script.  The lines are joined with `os.linesep` before writing.

### Implementation

The task writes the script to a temp file (using
[`get_temporary_file`](https://github.com/code-craft-ai-company/codecraftai-runtime/blob/main/source/packages/ccai/runtime/paths.py)
from `codecraftai-runtime`), chmods it executable, runs it, captures
output:

```python
def perform_task(self, environment, parameters, flowstate) -> int:

    script_content = os.linesep.join(self._script)

    tempfile = get_temporary_file(prefix="step-%s" % self._ordinal, suffix=".sh")

    self._logger.info("Running Script: %s" % tempfile)

    with open(tempfile, 'w') as tf:
        tf.write(script_content)

    os.chmod(tempfile, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)

    proc = subprocess.Popen(
        [tempfile],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        shell=True,
    )
    stdout, stderr = proc.communicate()
    exit_code = proc.wait()

    # ... log captured output, same shape as RunCommand ...

    return exit_code
```

### Audit findings

The pre-book audit pass on `codecraftai-taskplus` found two real
bugs in this task that have since been fixed:

- **The temp-file prefix used `%d` against a string ordinal**, which
  raised `TypeError` at construction.  Fixed to `%s`.
- **The chmod set only `stat.S_IEXEC`** — owner-execute, no read
  bit.  On macOS the missing read bit caused `/bin/sh` to fail
  with exit code 126 on every script.  Fixed to `S_IREAD |
  S_IWRITE | S_IEXEC`.

These are exactly the kinds of bug that don't surface in unit
tests and only surface when the task is run for the first time.
The audit + 115-test addition pass made these tests permanent
regression guards.

### Caveats

- **The temp script's permissions are owner-only.**  Other users
  on the machine can't read or run the script.  This is intentional
  but means a subprocess running as a different user (sudo
  invocations, container processes) cannot directly invoke the
  generated file.
- **The script is one shell invocation.**  Variables set in line
  *N* are available to line *N+1*; this is the same as putting
  the lines in a single `bash -c "..."` invocation.  Workflow
  authors used to one-command-per-step CI YAML find this
  liberating; the script is its own scope.
- **Errors do not auto-fail the script.**  A line that fails will
  not stop the script unless `set -e` (or `set -euo pipefail`) is
  the first line.  The discipline is to put the error-handling
  preamble at the top of every script — explicitly visible rather
  than implicit.

## EmbeddedPython — inline Python execution

When the workflow step is a small piece of logic that doesn't
need a shell, `EmbeddedPython` runs Python source directly in the
orchestration's process.

### Use case

A workflow step that's one or two lines of Python — typically
parsing some output, computing a derived value, or reading a small
file:

```yaml
- task: Compute build version
  tasktype: ccai.taskplus.tasks.embeddedpython@EmbeddedPython
  script:
    - "import os"
    - "build_version = f'{os.environ[\"BRANCH\"]}-{os.environ[\"BUILD_ID\"]}'"
    - "os.environ['BUILD_VERSION'] = build_version"
    - "print(f'BUILD_VERSION={build_version}')"
```

The use case overlaps with `RunCommand` running a `python -c
"..."`; the inline form has the advantage of not spawning a
subprocess (faster, no IPC) at the cost of running in the
orchestration's interpreter rather than a fresh one.

### YAML shape

```yaml
- task: <label>
  tasktype: ccai.taskplus.tasks.embeddedpython@EmbeddedPython
  script:
    - <python-line-1>
    - <python-line-2>
    - ...
```

One required field:
[`script`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/tasks/embeddedpython.py) —
a list of Python source lines.  Joined with `os.linesep` before
`exec`.

### Implementation

```python
def perform_task(self, environment, parameters, flowstate) -> int:

    script_content = os.linesep.join(self._script)

    exit_status = 0

    try:
        # Execute the inline python script in the context of the current
        # globals and locals.
        exec(script_content, globals(), locals())
    except Exception:
        exit_status = 1

    return exit_status
```

### Audit findings

The pre-book audit found one significant bug: the script content
was being passed through `indent_lines(content, level=3)` before
`exec()`.  This prepended twelve spaces to every line, producing
an `IndentationError` at the first line of any non-empty script.
The bare-except swallowed the error and the task silently
returned `1` for everything.

Fixed in the same audit pass (the `indent_lines` call removed,
the bare `except:` narrowed to `except Exception`).

### Caveats

- **The script runs in the orchestration's process.**  Variables
  set in the script's globals persist for the rest of the
  orchestration's lifetime — a feature when used deliberately, a
  source of action-at-a-distance bugs when not.
- **Failure is collapsed to exit-code-1.**  Any exception in the
  script results in `return 1` with the exception message captured
  only in the orchestration's traceback (not the workflow's
  result code).  When richer error information is needed, the
  script should explicitly handle exceptions and emit clear log
  lines before returning failure.
- **The task is not sandboxed.**  Python is Python; the embedded
  script can do anything the orchestration process can do
  (read files, make network calls, exit the process).  Workflow
  YAML is code; treat it as code.

## GitCheckout — currently a stub

The fourth shipped task is intentionally a stub.

### Implementation

```python
class GitCheckout(Task):

    def __init__(self, ordinal: str, label: str, task_info: dict, logger):
        super(GitCheckout, self).__init__(ordinal, label, task_info, logger)
        return

    def perform_task(self, environment, parameters, flowstate) -> int:
        exit_code = 0
        return exit_code
```

The task accepts the universal `Task` constructor signature, does
nothing, and returns success.

### Why it ships as a stub

Two reasons.

First, **the right Git-checkout implementation is environment-
specific.**  CI environments typically have credentials configured
through the vendor's secret store; local-dev environments use
SSH agent or HTTPS-with-PAT; container environments may use a
mounted secret file.  A single shipped implementation would be
opinionated in a way that doesn't fit half the use cases.

Second, **the stub is a reader exercise.**  Chapter 23 develops
the recipe for adding a new task type.  `GitCheckout` is the
worked target — extending the stub into a real Git-checkout
implementation is the chapter's culminating exercise, with the
extension steps explicitly anchored on the existing stub.

### Caveats

A workflow that references `GitCheckout` as if it does work will
silently succeed without checking anything out.  Worth noting for
teams that may have pasted in a `GitCheckout` task by mistake.

For green-field code, the right answer until the reader implements
the extension is to use `RunCommand` or `ShellScript` with an
explicit `git clone` or `git fetch && git checkout` invocation.

## CreateEnvironment — transitional

The fifth shipped task computes environment variables from
shell-command output or from literal values, and exports them to
`os.environ` for use by subsequent steps.

### Use case (legacy)

The original use case: a workflow step that needs to set
environment variables based on dynamic values:

```yaml
- task: Compute env
  tasktype: ccai.taskplus.tasks.createenvironment@CreateEnvironment
  variables:
    - name: BUILD_VERSION
      command: "git describe --tags"
    - name: WORKSPACE_ROOT
      value: "$HOME/workspaces/${PROJECT_NAME}"
    - name: BRANCH_NAME
      command: "git rev-parse --abbrev-ref HEAD"
```

Each entry has a `name` and either a `command` (whose stdout
becomes the value) or a `value` (which is expanded with
`os.path.expandvars` and `os.path.expanduser`).  The result is
written to `os.environ`.

### Why this task is *transitional*

The whole content of the chapter-7 thesis applies to this task.
Computing environment variables at workflow-time and then
relying on them in subsequent steps:

- Couples downstream steps to the variable name (the brittle
  contract chapter 7 named).
- Hides configuration in `os.environ` (untyped, ephemeral, no
  schema).
- Encourages env-var-as-content patterns the volume's discipline
  is trying to retire.

For green-field code, the equivalent of the example above is:

- The build version becomes a *runtime configuration field* that
  downstream steps read via the runtime API, not from `os.environ`.
- The workspace root becomes a *path resolved by the runtime*,
  not a variable substituted into other commands.
- The branch name becomes a *parameter* to the orchestration,
  resolved at startup and propagated through `parameters` (chapter
  21).

When all three of those moves are made, `CreateEnvironment` is
not needed.

### Why it ships anyway

Two reasons it remains in the shipped set:

1. **Migration.**  Workflows that have used `CreateEnvironment`
   for years can keep using it during the migration to runtime
   profiles.  Removing the task before migration would force a
   coordinated cross-pipeline change.
2. **Genuine cases for environment-variable propagation.**  Some
   external tools the workflow runs only consume environment
   variables (legacy CLIs, third-party SDKs).  When a downstream
   process is a black-box that reads `os.environ`, computing the
   value at workflow-time and exporting it is the right answer
   even with the discipline.

The discipline's recommendation: when you reach for
`CreateEnvironment` for a workflow-internal value, ask whether
the value belongs in a runtime configuration instead.  When you
reach for it because an external tool requires an env var, the
task is the right answer.

### Implementation notes

The task supports a `VIRTUAL_ENV` mode that wraps each command
through a virtual-environment-activating shell script.  This is
useful when the orchestration runs inside a Python venv and the
commands the task spawns need to inherit it.  The task creates
the wrapper script lazily on first use.

The audit pass on this file found and fixed two issues:

- `stdout.strip()` produced `bytes` (not a string) which then
  couldn't be assigned to `os.environ`.  Fixed to
  `stdout.decode().strip()`.
- The `value` branch referenced an undefined `var_val` variable
  before assignment.  Fixed to `var_info["value"]`.

### Caveats

- **Order matters.**  Variables set by `CreateEnvironment` are
  visible only to *later* tasks in the workflow.  A task that runs
  *before* the `CreateEnvironment` task does not see the variables.
- **The variables persist beyond the workflow's lifetime in the
  process's `os.environ`** — the task does not unset them at end.
  When the orchestration is run inside a long-lived process (a
  daemon, a notebook), the `os.environ` accumulates
  `CreateEnvironment` outputs across runs.

## Choosing between the five

A small decision table for workflow authors:

| Use case | Task |
|---|---|
| One shell command | `RunCommand` |
| Several shell commands sharing variables | `ShellScript` |
| One or two lines of Python logic | `EmbeddedPython` |
| Git checkout (until you implement chapter 23) | `RunCommand` with `git` invocation; `GitCheckout` is currently a stub |
| Set an env var from a command output | `CreateEnvironment` (with the runtime-configuration alternative considered first) |
| Set an env var from a literal value | `CreateEnvironment` (with the runtime-configuration alternative considered first) |

Three rules of thumb beyond the table:

- **Reach for `RunCommand` first.**  When a step *can* be expressed
  as one command, it's clearer than a one-line `ShellScript` or a
  one-line `EmbeddedPython`.
- **Reach for `ShellScript` over `EmbeddedPython`** when the
  intent is shell-script-style logic (file manipulation, process
  invocation, conditional shell idioms).  The shell is built for
  this; Python is built for other things.
- **Reach for `EmbeddedPython` over `ShellScript`** when the
  intent is Python-style logic (parsing, computation, library
  calls).  Mismatching the language to the intent produces brittle
  code in either direction.

## Coverage and audit summary

Every line of every task above is covered by the 115-test
testbase suite added to `codecraftai-taskplus` in the pre-book audit
pass.  The tests cover:

- Construction with required fields present.
- Construction with required fields missing (raises
  `KeyError`).
- `perform_task` returning zero on success.
- `perform_task` returning non-zero on failure.
- Edge cases for each task type — empty scripts, expired
  credentials, command-not-found, virtual-env wrapping, value
  expansion.

The audit pass also surfaced bugs in every task except
`GitCheckout` (which is a stub and has no logic to be wrong).
Each bug fix is permanent in the test suite, so future
refactorings won't regress them.

The implication for the workflow author: these five tasks are
*proven*.  They've been read line-by-line, they've had their bugs
extracted, they've been tested against the same test suite that
guards the foundation tier.  The orchestration discipline that
Part V is building rests on tested code.

## Summary

- `codecraftai-taskplus` ships five concrete `Task` subclasses:
  `RunCommand`, `ShellScript`, `EmbeddedPython`, `GitCheckout`,
  `CreateEnvironment`.
- `RunCommand` runs one shell command; output is captured and
  logged.  The simplest task; the right default for one-command
  steps.
- `ShellScript` writes a list of shell lines to a temp file,
  chmods it executable, and runs it.  The right answer for
  multi-command steps that share state through shell variables.
- `EmbeddedPython` runs a list of Python lines via `exec` in the
  orchestration's process.  The right answer for small Python
  computations; *not* sandboxed and shares globals with the
  orchestration.
- `GitCheckout` is intentionally a stub — Git checkout
  implementation is environment-specific, and chapter 23 develops
  the extension recipe with `GitCheckout` as the worked target.
  Until then, use `RunCommand` for explicit `git clone` /
  `git fetch && git checkout`.
- `CreateEnvironment` is transitional.  For green-field code,
  prefer runtime configurations over workflow-time environment-
  variable computation.  For legacy migration and for genuine
  external-tool-needs-env-var cases, the task remains useful.
- All five tasks have been audited and tested in the pre-book
  audit pass.  Every fix is locked in by a test; the tasks are
  proven.

The next chapter introduces the workflow YAML grammar — the
file format that ties tasks and taskgroups into a complete
orchestration that the FlowEngine can execute.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/20-workflow-yaml-grammar -->
# Chapter 20 — Workflow YAML Grammar

> *The workflow YAML is the file the workflow author writes.  The
> in-memory tree is what the engine runs.  This chapter is the
> bridge between the two.*

The previous two chapters introduced the task model and the five
shipped task types.  This chapter introduces the *YAML format*
that workflow authors write to compose those types into a
runnable orchestration, plus the
[`Workflow.parse`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
classmethod that translates the YAML into the in-memory tree
chapter 18 described.

The grammar is small.  Six top-level fields, two node shapes
(`task` or `taskgroup`), four required-field validations, one
recursive walk that produces ordinals.  The smallness is the
point — a workflow YAML that fits in this grammar is one a
maintainer can read in five minutes.

## The top-level grammar

A workflow YAML is a single document with three required and
three optional top-level fields:

```yaml
# Required
name: <human-readable workflow name>
flow:
    - <flow node 1>
    - <flow node 2>
    - ...

# Optional
environment:
    <key>: <value>
    ...
parameters:
    <key>: <value>
    ...
```

The shipped
[`Workflow.parse`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
implementation enforces this structure.  Two ConfigurationError
checks at the top:

```python
if "name" not in workflow_info:
    errmsg = "A workflow document must have a 'name' element."
    raise ConfigurationError(errmsg)

if "flow" not in workflow_info:
    errmsg = "A workflow document must have a 'flow' element which contains the tasks to be performed."
    raise ConfigurationError(errmsg)
```

Two optional fields fall back to empty dicts:

```python
environment = {}
if "environment" in workflow_info:
    environment = workflow_info["environment"]

parameters = {}
if "parameters" in workflow_info:
    parameters = workflow_info["parameters"]
```

Walk the four fields in order.

### `name`

The workflow's identity.  Required.  Carries no special
machinery — it does not select files, does not appear in
ordinals, does not affect execution.  It is the human-readable
identity the workflow shows up under in logs, in result reports,
in time-travel debugging captures.

A typical workflow names itself after its purpose:

```yaml
name: nightly-integration-suite
```

Workflow file naming is independent of the `name` field.  A file
called `nightly.yaml` containing `name: nightly-integration-suite`
is valid; consistency between the two is a maintenance preference,
not a framework requirement.

### `flow`

The list of top-level flow nodes — required.  Each item is either
a `task` node or a `taskgroup` node.  The list defines the
workflow's top-level sequence; nesting happens inside taskgroups.

The `flow` field is what the loader walks recursively to build
the in-memory tree.  Empty `flow: []` is permitted (a workflow
with no work) and runs successfully — useful for syntax-check
testing.

### `environment` (optional)

A dictionary of environment-variable overrides the workflow
applies before its flow runs:

```yaml
environment:
    BUILD_ID: "12345"
    DEPLOY_TARGET: "staging-east"
```

The shipped
[`execute_workflow`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/execution.py)
function exports each entry into `os.environ` before the flow
executes.  This is the workflow's hook into the
plumbing-vs-content boundary discussed in chapter 7: the
environment block names the *fixed env-var interface* the
orchestration sets up for downstream processes.

The discipline from chapter 7 applies here.  The right
`environment:` block is small — typically the few `AM_*_NAMES`
selectors that name the configurations the workflow operates
under.  A workflow whose `environment:` block is fifteen lines
long is the chapter-2 env-var-accretion failure mode showing up
in workflow YAML.

### `parameters` (optional)

A dictionary of workflow-level parameters that flow to every
task's `perform_task` as the second argument:

```yaml
parameters:
    branch: main
    build: nightly-2026-04-30
    landscape: $HOME/ccai/config/default-landscape.yaml
```

Parameters are *workflow-time* values — known when the workflow
is invoked, used by tasks during execution.  They are distinct
from runtime configuration (chapter 7) which is *load-time*
content.  The boundary is fuzzy in small workflows; chapter 21
develops the contract in detail.

## The flow node shape

Each entry in the `flow:` list (and inside any `items:` list
inside a taskgroup) is a YAML mapping.  The mapping must have
*one* of two top-level keys: `task` or `taskgroup`.  The presence
of one key versus the other selects the node type.

The shipped parser enforces this with two ordered checks:

```python
if "task" in fnode_info:
    # ... task branch
elif "taskgroup" in fnode_info:
    # ... taskgroup branch
else:
    raise ConfigurationError("Unknown workflow node encountered. ...")
```

Three error cases are caught at parse time:

- **A `task` node that also has a `taskgroup` field** — caught
  with `raise ConfigurationError("A 'task' node should not have
  a 'taskgroup' field.")`.  A node should be one or the other;
  the YAML format does not let a single node be both.
- **A `taskgroup` node that lacks an `items` field** — caught
  with `raise ConfigurationError("A 'taskgroup' node must have
  an 'items' field.")`.
- **A node with neither `task` nor `taskgroup`** — caught with
  the unknown-node error, which dumps the offending node's
  content into the error message so the workflow author can find
  it.

These three checks are the entirety of structural validation in
the parser.  Beyond them, the grammar is permissive: a node may
carry any additional fields the framework doesn't recognise
without being rejected.

### Task node shape

```yaml
- task: <human-readable label>
  tasktype: <module>@<ClassName>
  if: <optional Python expression>
  onfailure: <optional label>
  onsuccess: <optional label>
  # ... task-specific fields ...
```

Two fields are required: `task` (the label) and `tasktype` (the
module-and-class reference).  The `if`, `onfailure`, and
`onsuccess` fields are optional and consumed by the
[`Task`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/task.py)
base class (chapter 18).  Task-specific fields — `command` for
`RunCommand`, `script` for `ShellScript`, `variables` for
`CreateEnvironment` — are read by the concrete task class
directly from `node_info` (chapter 19).

The `tasktype` field uses a `module@ClassName` syntax:

```yaml
tasktype: ccai.taskplus.tasks.runcommand@RunCommand
```

The parser splits on `@`, imports the module via
[`import_by_name`](https://github.com/code-craft-ai-company/codecraftai-xmodules/blob/main/source/packages/ccai/xmodules/ximport.py)
from `codecraftai-xmodules`, and looks up the class:

```python
task_type = fnode_info["tasktype"]
task_module_name, task_module_class = task_type.split("@")
task_module = import_by_name(task_module_name)

if hasattr(task_module, task_module_class):
    task_class = getattr(task_module, task_module_class)
    task_inst: Task = task_class(next_ordinal, task_label, fnode_info, logger)
    flow_items.append(task_inst)
else:
    error_msg = "The specified task module %r does not contain a class %r" % (
        task_module_name, task_module_class
    )
    raise ConfigurationError(error_msg) from None
```

Three observations on this dispatch:

- **Module imports happen at parse time.**  A workflow that
  references `my.module@MyTask` triggers an import of `my.module`
  during `Workflow.parse`.  Side effects of importing are paid
  during parsing, before any task runs.
- **The class lookup is `hasattr`-based.**  When the named class
  doesn't exist on the imported module, the error names *both*
  the module and the class — useful when the workflow author
  misspelled either one.
- **The reference is fully-qualified.**  There is no implicit
  module prefix, no shortform.  A `tasktype: RunCommand` would
  fail with a missing-`@` exception.  The fully-qualified
  requirement keeps the workflow's set of tasks explicit and
  greppable.

### Taskgroup node shape

```yaml
- taskgroup: <human-readable label>
  if: <optional Python expression>
  items:
      - <flow node>
      - <flow node>
      - ...
```

One required field beyond the universals: `items` (the list of
nested flow nodes).  Like the `flow` list at the top level, this
can contain `task` nodes, further `taskgroup` nodes, or a mix.

A taskgroup carries the `if` field but *not* the `onfailure` and
`onsuccess` fields — those are task-specific.  This is the
chapter-18 distinction: taskgroups are sequencing structures, not
units of work, so they don't have a notion of success or
failure of their own.  Failure handling for a taskgroup happens
through the failure of its constituent tasks.

## The recursive walk and ordinal generation

The parser's
[`_process_flow_info_items`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
method is the recursion that produces the in-memory tree.  It
takes a list of node info dictionaries and an `ordinal_prefix`
string, and returns a list of `FlowNode` instances:

```python
@classmethod
def _process_flow_info_items(cls, fnode_info_items: List[Dict[str, Any]],
                             ordinal_prefix: str = "") -> List[FlowNode]:

    flow_items: List[FlowNode] = []

    ordinal_position = 0

    for fnode_info in fnode_info_items:

        ordinal_position += 1

        next_ordinal = str(ordinal_position)
        if len(ordinal_prefix) > 0:
            next_ordinal = f"{ordinal_prefix}.{str(ordinal_position)}"

        # ... task or taskgroup branch ...

    return flow_items
```

Three things worth tracing.

### Position tracking

`ordinal_position` starts at zero and increments at the top of
each iteration.  After the increment, `next_ordinal` is `"1"` for
the first node, `"2"` for the second, and so on.  The list is
walked in declaration order, so ordinals match the YAML's
top-to-bottom order.

The audit pass on this file fixed a bug at this very line:
`ordinal_position` was previously *not* initialised before the
loop, raising `UnboundLocalError` on the first iteration.  The
shipped form initialises to zero (visible above).  The 115-test
suite added in the audit pass guards against regression.

### Ordinal prefixing

The `ordinal_prefix` parameter is empty for the top-level call
(producing ordinals `"1"`, `"2"`, `"3"`).  For recursive calls
into a taskgroup's items, the prefix is the taskgroup's own
ordinal (producing ordinals `"2.1"`, `"2.2"`, `"2.3"` inside the
taskgroup at ordinal `"2"`).

The recursive call:

```python
tg_titems = cls._process_flow_info_items(
    tg_items_info_list,
    ordinal_prefix=next_ordinal,
)
```

This is what produces the dotted-ordinal pattern from chapter 18.
Three-level nesting produces three-dot ordinals (`"2.3.1"`); the
recursion is unbounded, so any depth is valid.

### Taskgroup construction

When the loop encounters a taskgroup node, it recurses *first* to
build the children, then constructs the `TaskGroup`:

```python
tg_titems = cls._process_flow_info_items(
    tg_items_info_list,
    ordinal_prefix=next_ordinal,
)

tgroup_inst = TaskGroup(next_ordinal, tg_label, fnode_info, tg_titems, logger)
flow_items.append(tgroup_inst)
```

The post-audit form passes the `logger` argument explicitly.  The
audit found a missing-positional-arg bug here — pre-fix, the
`TaskGroup` constructor was called without the required `logger`
parameter, raising `TypeError` whenever a taskgroup appeared in a
workflow.  Fixed; covered by the test suite.

## Validation paths

The parser's validation is *structural* rather than *semantic*.
Three categories of error get caught:

### Missing required fields

- **`name`** missing from the document — `ConfigurationError`
  before any flow is processed.
- **`flow`** missing from the document — `ConfigurationError`
  before any flow is processed.
- **`tasktype`** missing from a task node — raises `KeyError`
  during the line `task_type = fnode_info["tasktype"]`.  The
  `KeyError` propagates; this is currently *not* a `ConfigurationError`
  but the symptom is the same (workflow fails to parse).
- **`items`** missing from a taskgroup node — `ConfigurationError`
  with the explicit message.

### Conflicting fields

- A `task` node also containing a `taskgroup` field — `ConfigurationError`
  rejecting the conflict.

### Unknown node shape

A node with neither `task` nor `taskgroup` — `ConfigurationError`
that includes the node's content in the message.  The post-audit
form uses `errmsg_lines.append(indent_lines(pformat(fnode_info,
indent=4, depth=2), level=1))` to format the offending node
nicely.  An audit finding fixed a previous bug where this used
`extend(...)` with a string (which iterates characters) instead
of `append(...)`.

### What's *not* validated

The parser does not validate:

- **Task-specific required fields.**  A `RunCommand` task without
  a `command` field will parse successfully and fail at first
  execution when `RunCommand.__init__` raises `KeyError("command")`.
  This is consistent with the chapter-18 design — the model
  doesn't know task-specific fields, and validation happens in
  the concrete task's constructor.
- **Cross-references.**  A task with `onfailure: cleanup` does
  not check whether a task labelled "cleanup" exists.  The check
  happens at execution time when the engine attempts the jump
  (chapter 22 develops this).
- **Schema for `environment` and `parameters`.**  The dictionaries
  are accepted as-is.  Type coercion and field validation are
  the workflow author's responsibility (or the tasks' responsibility
  to validate before they consume).

Three layers of validation, in summary: the parser validates
structure; concrete tasks validate their own fields at
construction; the engine validates cross-references at execution.
Each layer catches its appropriate class of error close to the
source.

## A complete worked example

Putting all the pieces together — a workflow YAML that covers
each construct:

```yaml
# nightly-integration.yaml
name: nightly-integration-suite

environment:
    AM_PROFILE: "integration-ci"
    AM_LANDSCAPE_NAMES: "staging-east"

parameters:
    branch: main
    build_id: nightly-2026-04-30
    cleanup_after_run: true

flow:
    # Top-level setup task
    - task: Setup
      tasktype: ccai.taskplus.tasks.runcommand@RunCommand
      command: "ccai provisioning apply --spec config/specs/spec-staging-east.yaml"

    # Test phase as a taskgroup
    - taskgroup: Test phase
      items:
          - task: Smoke tests
            tasktype: ccai.taskplus.tasks.runcommand@RunCommand
            command: "ccai testbase test run tests/smoke"

          - task: Integration tests
            tasktype: ccai.taskplus.tasks.runcommand@RunCommand
            command: "ccai testbase test run tests/integration"
            onfailure: Run cleanup

          - task: Benchmark
            tasktype: ccai.taskplus.tasks.runcommand@RunCommand
            command: "ccai testbase test run tests/benchmark"
            if: "parameters['build_id'].startswith('nightly')"

    # Cleanup phase as a taskgroup, only if cleanup_after_run
    - taskgroup: Cleanup phase
      if: "parameters['cleanup_after_run'] == True"
      items:
          - task: Run cleanup
            tasktype: ccai.taskplus.tasks.shellscript@ShellScript
            script:
                - "set -euo pipefail"
                - "ccai provisioning teardown --spec config/specs/spec-staging-east.yaml"
                - "rm -rf .output/staging-east-canary"

          - task: Notify
            tasktype: ccai.taskplus.tasks.embeddedpython@EmbeddedPython
            script:
                - "import os"
                - "import requests"
                - "requests.post(os.environ['SLACK_WEBHOOK_URL'], json={'text': 'Nightly run complete'})"
```

The in-memory tree the loader produces:

```
Workflow(name='nightly-integration-suite')
├── environment: {AM_PROFILE: integration-ci, AM_LANDSCAPE_NAMES: staging-east}
├── parameters: {branch: main, build_id: nightly-2026-04-30, cleanup_after_run: true}
└── flow:
    ├── [0] Task("Setup", ordinal="1")
    │            command: "ccai provisioning apply --spec ..."
    ├── [1] TaskGroup("Test phase", ordinal="2")
    │            items:
    │            ├── [0] Task("Smoke tests", ordinal="2.1")
    │            ├── [1] Task("Integration tests", ordinal="2.2",
    │            │            onfailure="Run cleanup")
    │            └── [2] Task("Benchmark", ordinal="2.3",
    │                         if_expr="parameters['build_id'].startswith('nightly')")
    └── [2] TaskGroup("Cleanup phase", ordinal="3",
                      if_expr="parameters['cleanup_after_run'] == True")
                 items:
                 ├── [0] Task("Run cleanup", ordinal="3.1")
                 └── [1] Task("Notify", ordinal="3.2")
```

Three things visible in the tree.

### Cross-group jump

The `Integration tests` task at ordinal `"2.2"` declares
`onfailure: Run cleanup`.  The engine resolves the label by
walking the whole tree (chapter 22) and finds the `Run cleanup`
task at ordinal `"3.1"`.  When the integration test fails, the
engine skips ahead — past `"2.3"` (Benchmark) and the entire
cleanup-phase taskgroup's gate evaluation — directly to the
labelled task.

### Conditional taskgroup

The `Cleanup phase` taskgroup carries `if_expr:
"parameters['cleanup_after_run'] == True"`.  When the flag is
set false, the engine skips the entire group.  Three children of
the group go silent in one decision.

### Conditional task within a non-conditional group

The `Benchmark` task within the `Test phase` taskgroup carries
its own `if_expr`.  The taskgroup runs unconditionally; the
benchmark child runs only when the build is a nightly.  The two
gates compose: the parent gate is checked first; if it allows
the group to run, each child's gate is then checked.

## What `Workflow.parse` does and doesn't do

The parser's job is structural.  Three things it *does*:

1. **Validate top-level shape** — name, flow, optionally
   environment and parameters.
2. **Recurse the flow tree** — produce ordinals, dispatch by
   `task`/`taskgroup`, instantiate the appropriate class.
3. **Resolve `tasktype` references** — import the named module,
   look up the named class, instantiate.

Three things it *doesn't*:

1. **Resolve cross-references.**  `onfailure: cleanup` is not
   validated against the rest of the tree; an unresolvable label
   surfaces only at execution time.
2. **Validate task-specific fields.**  `RunCommand` without a
   `command` field passes the parser; the missing field is caught
   when the task is instantiated.  Different tasks have different
   field requirements; the parser doesn't know them.
3. **Type-check `parameters` and `environment`.**  Both are
   accepted as plain dicts.  When tasks consume them, type
   coercion and validation happen at the consuming task's
   discretion.

The split is deliberate.  The parser is small; the concrete tasks
own their own field validation; the engine owns cross-reference
resolution.  Each layer's responsibility fits in a small piece
of code.

## Audit findings on `Workflow.parse`

The pre-book audit pass on this file found and fixed several
bugs:

- **`yaml.safe_load(workflow_stream, Loader=yaml.SafeLoader)`** —
  `safe_load` does not accept a `Loader` kwarg.  Fixed to plain
  `yaml.safe_load(workflow_stream)`.
- **`ordinal_position` used before initialisation** — caused
  `UnboundLocalError`.  Fixed to initialise at zero before the
  loop.
- **`tg_label = fnode_info["task"]`** — copy-paste typo where the
  taskgroup branch was reading the wrong key.  Fixed to
  `fnode_info["taskgroup"]`.
- **`TaskGroup(next_ordinal, tg_label, fnode_info, tg_titems)`** —
  missing the required `logger` positional arg.  Fixed.
- **`return` (no value)** at the end of `_process_flow_info_items`
  — the function should return `flow_items` but was returning
  `None`.  Fixed.
- **`errmsg_lines.extend(indent_lines(pformat(...)))`** — `extend`
  with a string iterates characters; should have been `append`.
  Fixed, with the call signature also corrected to include
  `level=1`.

Six bugs in one file.  The parser is now reliable; the 115-test
suite locks every fix in place.  This is the kind of "code that
clearly hadn't been runtime-tested" pattern the volume's audit
chapter (and the foundation document's audit-diary appendix
table) anchors on.

## Summary

- A workflow YAML is a single document with up to four
  top-level fields: **`name`** (required), **`flow`** (required),
  **`environment`** (optional dict of env-var exports),
  **`parameters`** (optional dict of workflow-level values).
- Each entry in `flow` (and inside any taskgroup's `items`) is
  either a **`task`** node or a **`taskgroup`** node.  A task
  node has a `task` (label) and a `tasktype` (module@Class
  reference); a taskgroup node has a `taskgroup` (label) and
  an `items` list.
- The `tasktype` reference uses `<module>@<ClassName>` syntax;
  the parser imports the module via `import_by_name` and
  instantiates the named class with the standard FlowNode
  constructor signature.
- The recursive walk produces ordinals automatically:
  top-level nodes get `"1"`, `"2"`, `"3"`; nested nodes inside
  a taskgroup at ordinal `"2"` get `"2.1"`, `"2.2"`, `"2.3"`;
  the recursion is unbounded.
- The parser validates *structural* shape (required fields,
  conflicting fields, unknown node shapes).  It does not
  validate cross-references (chapter 22), task-specific fields
  (concrete tasks do), or content schemas of `environment` and
  `parameters`.
- A complete worked example (a nightly-integration workflow with
  setup, a test phase, a conditional benchmark child, an
  `onfailure` jump, and a conditionally-skipped cleanup phase)
  shows every construct in one document.
- Six bugs in `Workflow.parse` were fixed during the pre-book
  audit pass.  The 115-test suite locks every fix in place; the
  parser is now reliable.

The next chapter develops the contract between an orchestration
and a job — how the workflow's `environment` and `parameters`
fields, plus the per-run `flowstate` dict, become the
information-flow channels between layers.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/21-passing-information-to-jobs -->
# Chapter 21 — Passing Information to Jobs

> *The orchestration knows which configurations are in scope.  The
> job knows how to use them.  The seam between the two is the
> chapter that ties Part V back to the discipline of Parts II–IV.*

The previous chapter walked the workflow YAML grammar.  This
chapter walks the *contract* between the orchestration that the
YAML describes and the *jobs* the orchestration invokes.  The
contract is small — four communication channels, each with a
specific purpose — and it is the chapter where chapter 7's
plumbing-vs-content thesis pays off most concretely.

The orchestration's job is *not* to package its content into the
job's environment.  The orchestration's job is to package the
*names* of configurations the job should load, hand those names
across the seam, and trust the job to resolve them through the
shipped configuration machinery.  The seam is small; the trust is
mechanical.

## The four channels

Four mechanisms move information from an orchestration to a job:

| Channel | Direction | Lifetime | Use |
|---|---|---|---|
| **1. Environment exports** | orchestration → job process | process lifetime | Configuration *selectors* (`AM_*_NAMES`); process identity (run ID, job ID) |
| **2. Workflow parameters** | orchestration → tasks within the orchestration | workflow lifetime | Flow-level values that vary per run (branch, build ID, feature flags) |
| **3. Flowstate** | task ↔ task within the orchestration | workflow lifetime | Inter-task ad-hoc state |
| **4. Task return codes + side effects** | task → orchestration | per task | Success/failure, logs, artifact paths |

The four channels are layered.  Channel 1 (environment exports)
crosses the *process boundary* — the orchestration's process to a
subprocess job's process.  Channels 2 and 3 (parameters and
flowstate) live *inside* the orchestration's process — they pass
between tasks within the same workflow.  Channel 4 is the tasks'
outbound channel — values they return to the engine, side
effects they produce on disk or over the network.

The discipline is to use the right channel for each kind of
information.  The volume's thesis is most concretely this
sentence: **environment exports carry only configuration
selectors and process identity; *content* travels through
parameters, flowstate, and resolved configurations.**

## Channel 1 — Environment exports

The first and narrowest channel.  The
[`Workflow`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
class's `environment:` field becomes a set of `os.environ`
exports applied before the flow runs:

```yaml
environment:
    AM_PROFILE: integration-ci
    AM_LANDSCAPE_NAMES: staging-east
    AM_RUNTIME_NAMES: integration-ci
    AM_CREDENTIAL_NAMES: integration-ci
    AM_DATAPROFILE_NAMES: integration-ci
    AM_TOPOLOGY_NAMES: staging-east-leader-follower
```

Six entries.  Each names a *configuration*.  Each has a name that
will not change (the `AM_*_NAMES` family is the fixed-interface
plumbing from chapter 7).  Each value identifies which named
configuration is in scope for this orchestration run.

When the orchestration spawns a subprocess job (a `RunCommand`
shell, a child Python process invoked through `EmbeddedPython`,
a containerised job started by a custom task), the subprocess
inherits the parent's `os.environ` — so the subprocess sees the
same six entries.  At its own startup the subprocess uses
[`codecraftai-configuration`](https://github.com/code-craft-ai-company/codecraftai-configuration/blob/main/source/packages/ccai/configuration/configurationmaps.py)
to resolve each `AM_*_NAMES` value into the corresponding loaded
configuration.

The result: the orchestration and the job share *configuration
identity*.  When the orchestration is running with
`landscape: staging-east`, every job the orchestration spawns
runs against the same staging-east landscape — without the
orchestration having to pass the landscape's content through any
other channel.

### What does *not* go in `environment:`

Three categories of value that *do not* belong in the workflow's
`environment:` block:

- **Configuration content.**  A workflow that exports
  `LOG_LEVEL=DEBUG` is putting content in plumbing.  The right
  answer is to put `LOG_LEVEL` in a runtime configuration
  (chapter 7); the workflow's `environment:` exports
  `AM_RUNTIME_NAMES=debug-runtime` to select that runtime, not
  the value itself.
- **Secrets.**  Credentials never travel as `environment:`
  values.  The discipline from chapter 12: the workflow exports
  the *credential identifier* (or, more precisely, exports the
  `AM_CREDENTIAL_NAMES` selector that names the credential set);
  the credential resolver materialises the secret value at the
  job's startup using the local backend.
- **Per-task variation.**  When task A needs to know about task
  B's outcome, the right channel is flowstate (channel 3), not an
  environment-variable export.  Setting an env var to communicate
  between tasks is the chapter-2 conditional-thicket failure
  mode wearing yet another costume.

The discipline of channel 1 is fixed and small: configuration
selectors plus process identity.  Anything else lives in another
channel.

## Channel 2 — Workflow parameters

The workflow's `parameters:` block is a typed dict that flows to
*every* task's `perform_task` as the second argument:

```yaml
parameters:
    branch: main
    build_id: nightly-2026-04-30
    feature_flags:
        enable_new_caching: true
        enable_metrics_v2: false
    cleanup_after_run: true
```

Tasks consume parameters by reading the dict in `perform_task`:

```python
def perform_task(self, environment, parameters, flowstate):
    branch = parameters.get("branch", "main")
    build_id = parameters["build_id"]
    flags = parameters.get("feature_flags", {})

    # ... do work ...

    return 0
```

Three properties matter:

### Flow-scope, not process-scope

Parameters are visible to every task in the workflow, but they
do *not* automatically reach subprocess jobs.  When a task spawns
a subprocess (via `RunCommand`, for example), the subprocess
inherits `os.environ` but does *not* inherit `parameters` —
because parameters live in the orchestration's Python memory,
not in the process environment.

When a subprocess job needs a parameter value, the parent task
explicitly passes it.  Most commonly this happens by *name* —
the parameter holds a configuration identifier that the
subprocess's startup machinery resolves.  Sometimes it happens
by *value* — the parent task interpolates the parameter into the
subprocess's command line:

```yaml
- task: Tag deployment
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: "kubectl set image deployment/api api=registry/api:${BUILD_ID}"
```

The shipped `RunCommand` does *not* substitute `${BUILD_ID}`
itself — that interpolation happens in the shell.  For parameter
substitution the workflow author can either:

- Write a custom task that does the substitution explicitly
  (chapter 23 develops the extension recipe).
- Use `EmbeddedPython` to construct the command string from
  `parameters` and write it back into `flowstate` for a
  subsequent `RunCommand` to pick up.
- Use shell-level variable expansion through a `ShellScript`
  whose first lines establish the variables.

### Read-only by convention

Tasks should *read* parameters, not modify them.  The shipped
`Task.perform_task` does not enforce this — Python doesn't have
a "frozen dict" passed by value — but the discipline is uniform:
parameters are workflow-level values set at orchestration start
and unchanged for the duration.

When a task needs to *produce* a value for downstream tasks,
the right channel is flowstate (channel 3).  Mutating
parameters defeats the workflow-author's mental model that the
parameters are stable.

### Type-as-data

Unlike environment-variable exports, parameters can carry rich
data — nested dicts, lists, booleans, integers.  YAML's type
fidelity is preserved through the parser:

```yaml
parameters:
    feature_flags:
        enable_new_caching: true        # bool, not the string "true"
    retry_attempts: 3                   # int, not the string "3"
    target_hosts:                       # list, not a comma-separated string
        - api-1.staging.internal
        - api-2.staging.internal
        - api-3.staging.internal
```

Tasks consume the typed values directly, no parsing required.
This is one of the most concrete payoffs of putting variation in
content rather than plumbing — environment-variable values are
always strings, parameter values are whatever YAML says they are.

## Channel 3 — Flowstate

The third argument to `perform_task` is `flowstate`, a per-run
mutable dictionary that tasks read *and write*:

```python
def perform_task(self, environment, parameters, flowstate):
    # Read state produced by an earlier task
    artifact_path = flowstate.get("build_artifact_path")

    # ... do work ...

    # Write state for a later task to consume
    flowstate["smoke_test_passed"] = True

    return 0
```

Flowstate is the *inter-task communication channel*.  Use cases:

- A build task produces an artifact at a path; a deploy task
  later in the workflow needs the path.  The build task writes
  it to `flowstate["build_artifact_path"]`; the deploy task
  reads it.
- A smoke-test task writes summary metrics; a notification task
  later reads them and posts them to Slack.
- A cleanup-decision task computes whether teardown should
  happen; the teardown task gates on it.

Flowstate is *ad-hoc*.  No schema, no type checking, no
contractual ownership of keys.  The discipline:

- **Use namespaced keys.**  Prefer `build.artifact_path` over
  `artifact_path`; prefer `smoke.passed` over `passed`.  The
  namespace makes ownership obvious and reduces collision when
  the workflow grows.
- **Document key contracts close to the producing task.**  When
  a custom task writes a flowstate key, the task's class
  docstring should list the keys it produces.  When a task reads
  a flowstate key, the docstring should name which task is
  expected to produce it.
- **Keep the dict small and ad-hoc.**  Flowstate is for
  workflow-internal coordination, not for replacing data stores.
  When the value is large (gigabytes of build output, thousands
  of test results), the right pattern is to write to disk and
  put the *path* in flowstate.

Three things flowstate is *not*:

- **Not persistent.**  When the workflow ends, flowstate is
  gone.  Subsequent runs start with an empty flowstate.
- **Not visible across orchestrations.**  Two parallel workflows
  running on the same machine each have their own flowstate.
- **Not visible to subprocess jobs by default.**  When a task
  spawns a subprocess, the subprocess does not inherit
  flowstate.  Cross-process state requires explicit passing
  (typically by writing to a file the subprocess reads).

## Channel 4 — Task return codes and side effects

Tasks communicate *outward* — to the engine, to subsequent
tasks, to observability infrastructure — through a small set of
well-known channels.

### Return codes

`perform_task` returns an integer.  Zero is success; non-zero is
failure.  The engine reads the return code and uses it to drive
flow control (chapter 22):

- A non-zero return triggers the task's `onfailure` jump, if
  declared.
- A zero return triggers the task's `onsuccess` jump, if
  declared.
- Otherwise execution proceeds to the next node in sequence.

The integer can carry semantic information beyond
zero/non-zero — a `RunCommand` task returns the wrapped shell's
exit code, so a failure with code 42 distinguishes from a
failure with code 1.  The engine's flow control treats both as
"failure" but the engineer reading the log sees the specific
code.

### Logger output

Every task is constructed with a logger reference (chapter 18).
Tasks emit log lines through the logger; the engine routes the
output to the workflow's configured destinations (console,
logfile, structured-log sinks).

The discipline from chapter 11 applies: tasks never log
credentials, never log resolved secrets, never log values that
could leak through the audit boundary.  The
`__repr__` redaction and the logging filter are the defences.

### Filesystem side effects

Tasks routinely produce files — build artifacts, test reports,
captured profiles, logs.  The convention in
[`codecraftai-taskplus`](https://github.com/code-craft-ai-company/codecraftai-taskplus) is that every
task that produces files writes them under the configured
output directory (the runtime configuration's `paths.output`
field).  The output directory is per-orchestration-run; tasks
write into it freely; subsequent tasks (or post-orchestration
tooling) read from it.

Filesystem side effects are *not* part of the four-channel
contract directly — they're a side channel.  But they're the
typical answer to "how does task A pass a 5-megabyte artifact
to task B?": task A writes to a file, task A puts the path in
flowstate, task B reads the path from flowstate and opens the
file.  The pattern works without any framework support beyond
the shipped channels.

### Custom side channels

For richer cross-task communication — pub/sub between tasks,
shared streaming buffers, cross-process state — custom tasks
can establish their own side channels.  Common patterns:

- A task that spawns a long-running subprocess writes a PID
  file, and a later task reads the PID file to attach.
- A task that produces telemetry writes structured events to a
  named pipe, and a metrics-collection task reads from the
  pipe.
- A task that reserves a resource (a port, a slot in a queue)
  writes the reservation to a lock file, and the cleanup task
  releases it.

These patterns are *outside* the four-channel framework
deliberately.  The framework gives you the channels you need
for normal orchestration; specialised needs are the workflow
author's responsibility to design.

## The bootstrap-name → resolve-config flow

The chapter's central engineering claim: an orchestration
spawning a subprocess job hands across only *names*; the job
*resolves* the names into structured configurations using the
shipped machinery.

Walk the flow end-to-end.

### Step 1 — Orchestration startup

The orchestration's process starts.  It reads the workflow YAML.
The workflow's `environment:` block exports the `AM_*_NAMES`
family into `os.environ`.  At this point the orchestration
itself has *not* loaded any configurations — it has only
established which configurations are in scope.

```python
# At orchestration startup, conceptually:
for name, value in workflow.environment.items():
    os.environ[name] = value

# os.environ now contains:
#   AM_PROFILE=integration-ci
#   AM_LANDSCAPE_NAMES=staging-east
#   AM_RUNTIME_NAMES=integration-ci
#   AM_CREDENTIAL_NAMES=integration-ci
#   AM_DATAPROFILE_NAMES=integration-ci
#   AM_TOPOLOGY_NAMES=staging-east-leader-follower
```

### Step 2 — Orchestration loads configurations

The orchestration's runtime calls `establish_configuration_settings()`
and `resolve_configuration_variables()` from
`codecraftai-configuration`.  These functions:

- Read the `AM_*_NAMES` env vars set in step 1.
- Locate the named configuration files on disk.
- Load and parse them.
- Insert the resulting `MergeMap`s into the application context
  at the well-known `ContextPaths`.

After step 2, the orchestration's process holds typed
configurations.  The orchestration's tasks consume them through
the runtime API (chapter 8's `DataProfileManagerSingleton`,
chapter 9's `CredentialManagerSingleton`, etc.).

### Step 2.5 — The verification step (the load-bearing precondition)

There is a step between "configurations loaded" and "the first
task runs" that the framework treats as a *precondition*, not
a feature: **verification of the resolved Landscape and
Topology against the actual environment.**

- **Landscape verification.**  The framework walks every
  resource declared in the resolved Landscape and confirms it
  is reachable: every host is contactable at its declared
  address with its declared protocol, every named credential
  resolves, every named service in the `services` section is
  alive at the address claimed.  Failure here means the CI
  system handed the orchestration a Landscape that doesn't
  match reality — wrong before the first test runs.
- **Topology verification.**  The framework walks the resolved
  Topology and confirms the resources are *configured as the
  Topology declares*.  If the Topology says "Nodes A, B, C are
  cluster members and Node D is a spare," verification confirms
  the cluster membership and the spare assignment match.  If
  the Topology says a sound system has three players in
  master/slave order, verification confirms the wiring.  The
  canonical `clusters` shape (chapter 6) is verified by the
  framework directly; user-defined Topology sections are
  verified by the user's `Topology` subclass overriding
  `validate_topology()`.

If either verification fails, the orchestration refuses to
proceed.  This is **the** load-bearing contract that makes
distributed automation deterministic.  Volume 2 chapter 2's
single-actor determinism model — one driver, observers
attached in tandem — depends on the *environment* matching
the *expectation* before the actor starts acting.  An
orchestration that ran tests against an unverified Landscape
would be running against an environment whose state is not
what the tests assume; a test that says "modify cluster member
A" against an environment where A is actually a spare would
produce non-deterministic outcomes.  Verification removes that
class of failure before it can happen.

The verification contract also explains why the
`InteropLibraryTestJob` from Volume 2 chapter 4 fires
`fire_attach_to_environment` *before* the first test — that
hook is where the test job runs the verification on the
landscape it was handed.  This volume's orchestration layer
hands the job the *names*; the job realises the names into
typed configurations *and verifies* them; only then does the
test sequencer start traversing tests.

Step 2 produced typed configurations; step 2.5 made them
*verified* typed configurations.  Step 3 (below) is the first
moment the orchestration begins doing work in the realised
environment.

### Step 3 — Task spawns a subprocess

A `RunCommand` task spawns a subprocess.  The subprocess inherits
the parent's `os.environ` automatically — so the subprocess sees
the same `AM_*_NAMES` values the orchestration set in step 1.

The subprocess does *not* inherit:

- The orchestration's loaded configurations (those live in
  Python memory, not the environment).
- The orchestration's parameters dict (Python memory).
- The orchestration's flowstate dict (Python memory).
- The orchestration's resolved credentials (Python memory; and
  by design, since secret material should not cross the process
  boundary as values).

The subprocess *does* inherit:

- The orchestration's `os.environ` (configuration selectors,
  process identity, plus whatever else the parent had set).
- The orchestration's working directory.
- The orchestration's stdin/stdout/stderr (or whatever the
  parent connected).

### Step 4 — Subprocess loads configurations

The subprocess starts an `codecraftai-configuration`-aware
application.  At its own startup the subprocess reads the
`AM_*_NAMES` env vars (the same ones the parent had), loads the
named configuration files, and produces its own typed
configurations.

The result: the subprocess has the *same configurations* the
parent had — but loaded *independently*, in the subprocess's
own memory.  No serialisation, no IPC, no shared memory.  The
configurations are reproduced through the same machinery on both
sides.

### Step 5 — Subprocess runs

The subprocess's code reads typed configurations through the
runtime API (the same calls the orchestration made in step 2).
When it needs the staging-api endpoint, it asks the data-profile
manager.  When it needs a credential, it asks the credential
manager.  When it needs a runtime knob, it asks the runtime.

The code is identical to what runs in the orchestration's own
process, in local-dev, in CI, in staging.  The environment
provides the *names*; the configuration machinery provides the
*content*.  This is the chapter-13 four-environment ladder
applied at the orchestration-to-job seam.

### Which test job the orchestration spawns

When the subprocess the orchestration spawns is a *test job*,
there are now two shapes it can take — and the `AM_*_NAMES`
selector contract is identical for both.

- **Plain `testbase` `TestJob`.**  The near-standalone job from
  Volume 1.  It runs *without activation*: no presence folder, no
  `RuntimeVariables`, no dependency on `codecraftai-this-app`.  Its
  default recorder is `StdoutResultRecorder` — it prints the run
  summary to stdout and (when an output directory is supplied)
  reuses the JSOS/JSON serialisation so `testrun_results_stream.jsos`
  and `testrun_summary.json` are still written for the `testbase
  agents` CLI to consume, but it *skips* the HTML render.  This is
  the right shape for a unit-style job invoked by a `RunCommand`
  that simply needs a pass/fail and a stdout transcript.  It is
  launched through testbase's standalone
  [`generic_test_entrypoint`](https://github.com/code-craft-ai-company/codecraftai-testbase/blob/main/source/packages/ccai/testbase/entrypoints.py).
- **`IntegratedTestJob`.**  The integration-tier job from
  [`codecraftai-integration-runtime`](https://github.com/code-craft-ai-company/codecraftai-integration-runtime/blob/main/source/packages/ccai/integrationruntime/integratedtestjob.py).
  It *extends* `TestJob` and overrides exactly three provider
  hooks: `_create_output_context()` returns a
  `RuntimeOutputContext` (results land under the activated app's
  well-known presence folder — activation-gated; it raises
  `SemanticError` unless the process was activated as a job),
  `_create_run_metadata()` returns a `RuntimeRunMetadata`
  (build / pipeline / job sourced from `RuntimeVariables` instead
  of the standalone `(unknown)` placeholders), and
  `_create_recorder()` returns a `JsonResultRecorder` (JSOS
  streaming *plus* the rendered HTML summary).  It is launched
  through
  [`integrated_test_entrypoint`](https://github.com/code-craft-ai-company/codecraftai-integration-runtime/blob/main/source/packages/ccai/integrationruntime/entrypoints.py),
  which first performs this-app activation (establish app persona,
  `resolve_runtime_variables`, `activate_profile_job(JobType.TestRun)`)
  and *then* delegates to the same standalone
  `generic_test_entrypoint`.

The two jobs differ only in *result recording and run
metadata* — the presence-folder results-directory layout,
JSOS-to-disk-plus-HTML, and CI/build metadata are the
responsibility of `IntegratedTestJob`, not of plain `testbase`.
The configuration-selector seam this chapter is about is
unchanged: both jobs read the same inherited `AM_*_NAMES` values
and resolve the same configurations independently.

`IntegratedTestJob` does not have to be named explicitly.  When
`codecraftai-integration-runtime` is installed, its
`IntegrationRuntimeDefaultTypeFactory`
([`integrationruntimedefaulttypefactory.py`](https://github.com/code-craft-ai-company/codecraftai-integration-runtime/blob/main/source/packages/ccai/integrationruntime/integrationruntimedefaulttypefactory.py))
registers through `codecraftai-extension`'s factory protocol so the
super-factory search path resolves `DefaultIntegratedTestJob` as
the integration-tier default job type — sitting above testbase's
`DefaultTestJob` and below testplus's distributed default.  An
orchestration that activates the integration runtime therefore
gets the integration recording behaviour by default, without the
workflow author hand-wiring the job class.

## What the job sees vs what the orchestration knew

A subprocess job does not have access to the orchestration's
parameters or flowstate.  The discipline acknowledges this by
recommending three patterns:

### Make values configurations, not parameters

When a value is genuinely workflow-time-stable but needs to
reach a subprocess, the right home is in a runtime configuration
field:

```yaml
# config/runtime/runtime-integration-ci.yaml
build:
    target_branch: main
    feature_flags:
        enable_new_caching: true
```

The subprocess reads `runtime["build"]["target_branch"]` through
the runtime API.  The same field is the same field in every
process — orchestration and subprocess alike.

### Pass values through command-line arguments

When a value is genuinely per-task and the subprocess accepts
arguments, the parent task interpolates the value into the
command line:

```python
# A custom task that knows about parameters
def perform_task(self, environment, parameters, flowstate):
    build_id = parameters["build_id"]
    cmd = f"./scripts/tag-deployment.sh {build_id}"
    return run_subprocess(cmd)
```

The subprocess reads `argv[1]`; the parent has packaged the
value at the call site.  Explicit, traceable, and consistent
with how non-orchestration command-line tools work.

### Write to a file and pass the path

When a value is large or structured, the parent writes it to a
file and passes the path:

```python
def perform_task(self, environment, parameters, flowstate):
    build_metadata = {
        "build_id": parameters["build_id"],
        "feature_flags": parameters["feature_flags"],
        "smoke_test_results": flowstate.get("smoke_test_results", {}),
    }
    metadata_path = os.path.join(output_dir, "build-metadata.json")
    with open(metadata_path, "w") as fh:
        json.dump(build_metadata, fh)

    return run_subprocess(f"./scripts/deploy.sh --metadata {metadata_path}")
```

The subprocess reads the file.  The pattern handles arbitrarily
large values without polluting `os.environ`.

The three patterns combined cover every common
orchestration-to-subprocess information-flow case.  When all
three are insufficient, a custom protocol is the answer — but
the threshold for needing one is high.

## Distributed orchestrations

Volume 2 develops the distributed-orchestration story in detail.
This chapter notes that the four channels generalise cleanly:

- **Channel 1** generalises to the
  `InteropLibraryTestJob`
  hooks: when the coordinator activates a remote worker, the
  hook propagates the `AM_*_NAMES` env vars to the worker's
  process.  Same names, same configurations on both sides.
- **Channel 2** (parameters) generalises through
  serialisable-context propagation.  The coordinator's parameters
  dict is JSON-serialised and shipped to the worker; the worker
  reconstructs the dict at startup.
- **Channel 3** (flowstate) is *not* generalised.  Cross-machine
  flowstate would be a distributed-state problem; the discipline
  in distributed orchestration is to use external coordination
  primitives (a shared key-value store, a message broker)
  rather than treating flowstate as cross-machine.
- **Channel 4** (return codes + side effects) generalises through
  the JSOS streaming results pipeline (Volume 1's territory):
  worker-side return codes and logged events stream back to the
  coordinator over the same channels the single-host orchestration
  uses.

The discipline in this chapter holds end-to-end across the
distributed case.  Volume 2 develops the specifics; Part V's
foundation is what Volume 2 builds on.

## What this is *not*

Three boundaries to keep clear.

### Not RPC

The four-channel contract is *unidirectional* per channel and
*ad-hoc* in semantics.  When tasks need true RPC — a
request/response pattern with a service contract — the right
answer is to use a real RPC library, not to bend flowstate or
the engine into a poor imitation.

### Not a message queue

The contract has no notion of *deferred* delivery, no fanout,
no durable queue, no consumer groups.  When the orchestration
needs message-queue semantics — a producer task and several
parallel consumer tasks, with at-least-once semantics — that's
a message queue, and the workflow author should bring one in.

### Not shared state

Flowstate is per-orchestration.  When two parallel orchestrations
need to coordinate, the right answer is an external coordination
primitive (Redis, etcd, a shared filesystem with lock files).
The flowstate dict is *not* a substitute.

The chapter's discipline applies *within* one orchestration's
process and to the subprocesses it spawns.  Cross-orchestration
coordination is out of scope; the discipline does not pretend
otherwise.

## Summary

- The orchestration-to-job contract has **four channels**:
  environment exports (process-boundary-crossing, narrow,
  configuration-selectors-only), workflow parameters
  (orchestration-internal, flow-scope, typed), flowstate
  (orchestration-internal, ad-hoc inter-task state), and task
  return codes plus side effects (the outbound channel).
- The discipline of channel 1: **only configuration selectors
  and process identity travel through environment exports**.
  Configuration *content* travels through resolved configurations
  the subprocess loads independently using `codecraftai-configuration`
  and the inherited `AM_*_NAMES` selectors.
- The discipline of channel 2: parameters are flow-scope,
  read-only by convention, and carry typed YAML data (not
  string-encoded content).  Parameters do not automatically
  reach subprocesses; explicit passing is required.
- The discipline of channel 3: flowstate is ad-hoc inter-task
  communication.  Use namespaced keys; document contracts close
  to the producing task; keep the dict small.
- The discipline of channel 4: return codes carry binary
  outcome (zero / non-zero) plus optional semantic information
  (specific error codes); logger output goes through the shared
  logger with chapter-11 redaction; filesystem side effects use
  the configured output directory.
- The bootstrap-name → resolve-config flow is the central
  engineering claim: orchestration sets `AM_*_NAMES`, subprocess
  inherits them, subprocess loads the same configurations
  independently.  Same code, same configurations, no
  serialisation across the seam.
- Three patterns cover orchestration-to-subprocess information
  flow when channels 1 and 2 don't reach: make the value a
  runtime configuration; pass the value as a command-line
  argument; write the value to a file and pass the path.
- The four-channel contract generalises to distributed
  orchestrations (Volume 2's territory) for channels 1, 2, and
  4; channel 3 (flowstate) is intentionally local and not
  generalised.
- The contract is not RPC, not a message queue, not shared
  state.  Each is a separate engineering problem with its own
  primitive.

The next chapter turns to flow control: how `if_expr`,
`onfailure`, and `onsuccess` become the engine's actual
sequencing decisions.  The `JumpContext` and the
`evaluate_expression` machinery from
[`flowengine.py`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flowengine.py)
get walked end-to-end with the same code-anchored fidelity that
chapters 18–20 used.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/22-flow-control-as-data -->
# Chapter 22 — Flow Control as Data

> *Sequencing a workflow is a property of the workflow file, not
> of the engine.  The engine reads gates and jumps from data; the
> data lives in the YAML the author wrote.*

The previous chapters introduced the model (chapter 18), the
shipped tasks (chapter 19), the YAML grammar (chapter 20), and
the four communication channels (chapter 21).  This chapter walks
the engine that *runs* the workflow — specifically, the small set
of mechanisms by which flow control becomes data in the YAML and
behaviour at runtime.

The
[`FlowEngine`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flowengine.py)
class shipped in `codecraftai-taskplus` is small — under a hundred
lines.  It implements three flow-control mechanisms:

1. **The `if_expr` gate** — conditional inclusion of a node.
2. **The `onfailure` / `onsuccess` jump** — label-based jumps
   driven by task return codes.
3. **The recursion into TaskGroups** — sequencing of nested
   structure.

Each mechanism is *data-driven*: the workflow author declares the
behaviour in the YAML, the engine reads the declaration and
acts on it.  No imperative orchestration code; the workflow file
is the source of truth for how the workflow behaves.

## The engine's outer loop

The shipped
[`FlowEngine.execute`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flowengine.py)
method is the outermost layer.  It runs once per workflow
invocation:

```python
def execute(self, workflow: Workflow) -> int:

    result_code = 0

    environment = workflow.environment
    parameters = workflow.parameters

    flowitems = workflow.flow

    flowstate = {}
    errors = []
    jump_context = JumpContext()

    self.process_flowitems(environment, parameters, flowstate, errors, flowitems, jump_context)

    if len(errors) > 0:
        result_code = 1

    return result_code
```

Six lines of visible behaviour:

1. **Pull `environment` and `parameters` from the workflow.**
   These are the chapter-21 channels 1 and 2.  The engine threads
   them through every task as the first two arguments to
   `perform_task`.
2. **Initialise an empty `flowstate` dict.**  This is channel 3 —
   the per-run mutable inter-task communication channel.
3. **Initialise an empty `errors` list.**  Tasks (or the engine
   itself) can append error records during the run; the final
   non-empty errors list translates to a non-zero workflow result.
4. **Initialise a `JumpContext`.**  The label-jump state for the
   whole workflow; the engine threads this through every level of
   recursion.
5. **Call `process_flowitems` on the top-level flow list.**  This
   is the recursive walk; every flow node visited or skipped flows
   through this method.
6. **Translate `errors` into a final result code.**  Empty errors
   list → success; non-empty → failure.

The engine is *single-pass*.  No retries at the engine level (the
chapter-18 design choice — retries belong inside tasks, not in
the engine), no parallelism, no backtracking.  One walk through
the flow tree, in order, with skip-and-jump primitives applied.

## `process_flowitems` — the recursive walk

The recursion is where every flow control decision is made.  In
its entirety:

```python
def process_flowitems(self, environment, parameters, flowstate, errors,
                     flowitems: List[FlowNode], jump_context: JumpContext):

    for fitem in flowitems:

        node_label = fitem.label

        # If we are seeking a specific node, skip any nodes that are not that node
        if jump_context.seeking_node is not None:
            if jump_context.seeking_node != node_label:
                continue
            else:
                jump_context.seeking_node = None

        include_node = True
        if fitem.if_expr is not None:
            include_node = self.evaluate_expression(fitem.if_expr, environment, parameters)

        if include_node:
            if isinstance(fitem, Task):

                task_result = fitem.execute(environment, parameters, flowstate)
                if task_result != 0:
                    if fitem.onfailure is not None:
                        jump_context.seeking_node = fitem.onfailure
                elif fitem.onsuccess is not None:
                    jump_context.seeking_node = fitem.onsuccess

            elif isinstance(fitem, TaskGroup):

                group_items = fitem.items

                self.process_flowitems(environment, parameters, flowstate, errors, group_items,
                                       jump_context)

    return
```

Twenty-five lines of method body.  Walk what each block does.

### The jump check (lines 67–72)

The first thing the loop does for each node is check whether a
jump is in progress.  When `jump_context.seeking_node` is set,
the engine is in *seek mode* — looking for a specific labelled
node, skipping everything until it finds it.

```python
if jump_context.seeking_node is not None:
    if jump_context.seeking_node != node_label:
        continue
    else:
        jump_context.seeking_node = None
```

Three behaviours:

- **Not seeking** (`seeking_node is None`): proceed normally.
- **Seeking and current node doesn't match** (`seeking_node !=
  node_label`): `continue` — skip this node entirely.  Do not
  evaluate its gate; do not execute it; do not recurse into it
  if it's a taskgroup.
- **Seeking and current node matches** (`seeking_node ==
  node_label`): clear the seeking state and fall through to
  process this node normally.

The seek-and-skip pattern is the engine's *only* flow-control
primitive beyond sequential traversal.  All cross-node jumps
reduce to setting `seeking_node` and letting the loop's next
iterations skip until they reach the named target.

### The gate evaluation (lines 74–76)

When the node is *not* being skipped by a jump, the engine checks
the node's `if_expr`:

```python
include_node = True
if fitem.if_expr is not None:
    include_node = self.evaluate_expression(fitem.if_expr, environment, parameters)
```

Two behaviours:

- **No `if_expr`** (`fitem.if_expr is None`): `include_node`
  stays True; the node will run.
- **`if_expr` set**: evaluate the expression with the workflow's
  environment and parameters as context.  When the expression
  returns truthy, `include_node` becomes True; when it returns
  falsy, `include_node` becomes False and the node is skipped.

The gate runs *after* the seek check.  This means: a node can be
skipped by either being on the wrong side of a jump or by having
its gate evaluate false.  Both produce the same outcome (the
node is bypassed) through different mechanisms.

### The execution branch (lines 78–93)

When the node is included, the engine dispatches by node type:

#### Task path

```python
if isinstance(fitem, Task):

    task_result = fitem.execute(environment, parameters, flowstate)
    if task_result != 0:
        if fitem.onfailure is not None:
            jump_context.seeking_node = fitem.onfailure
    elif fitem.onsuccess is not None:
        jump_context.seeking_node = fitem.onsuccess
```

The task's `execute` method runs (which delegates to
`perform_task` per chapter 18's Template Method pattern) and
returns an integer.  Then:

- **Task failed** (`task_result != 0`) and **`onfailure` is
  declared**: set `seeking_node` to the `onfailure` label.  The
  loop's next iteration will start skipping until the named
  task is found.
- **Task failed** and **`onfailure` is *not* declared**:
  do nothing — the task failure is silently absorbed and
  execution continues to the next node.  (Whether this is the
  right semantic is discussed below.)
- **Task succeeded** (`task_result == 0`) and **`onsuccess` is
  declared**: set `seeking_node` to the `onsuccess` label.
- **Task succeeded** and **`onsuccess` is *not* declared**:
  fall through to the next node normally.

#### TaskGroup path

```python
elif isinstance(fitem, TaskGroup):

    group_items = fitem.items

    self.process_flowitems(environment, parameters, flowstate, errors, group_items,
                           jump_context)
```

The engine recurses into the taskgroup's items, threading the
*same* `jump_context` into the recursion.  This is what allows
**cross-group jumps**: when a task inside group A sets
`seeking_node` to a label inside group B, the recursion bubbles
back up to the outer loop, which keeps skipping until the
recursion descends into group B and finds the labelled node.

The recursion is what makes the engine's flow control compose.
The same `process_flowitems` method handles both top-level
sequences and nested taskgroup items; every node is at *some*
depth, but the depth doesn't change how flow control works.

## `evaluate_expression` — the gate evaluator

The gate evaluator is two non-trivial lines:

```python
def evaluate_expression(self, if_expr: str, environment: Dict[str, str],
                        parameters: Dict[str, Any]) -> bool:

    flowstate = self._flowstate

    eval_result = eval(if_expr)

    return eval_result
```

The implementation uses Python's built-in `eval` against a string
expression, with three names available in the evaluation scope:
`environment`, `parameters`, and `flowstate`.

Three observations.

### The expression is Python

The `if_expr` field in the YAML is a *Python expression string*.
A workflow's gate looks like this:

```yaml
- task: Run benchmark
  tasktype: my.module@Benchmark
  if: "parameters['build_id'].startswith('nightly')"

- task: Tear down staging
  tasktype: my.module@Teardown
  if: "parameters['cleanup_after_run'] == True and not flowstate.get('teardown_skipped', False)"
```

The YAML author writes Python.  This is more powerful than (say)
a custom expression DSL — comparison operators, string methods,
boolean logic, dict subscript, and method calls are all
available.

It is also more dangerous than a custom DSL.  `eval` on
attacker-controlled input is well-known as a security
vulnerability; the discipline relies on workflow YAML being
*authored by trusted developers* and being checked into the
repository.  When a workflow's `if_expr` would consume external
input, the workflow author should prefer to compute the
condition in a task and write a boolean to flowstate, then gate
on the simpler `flowstate['xyz']` expression.

### `flowstate` is the engine's, not the workflow's

The evaluator pulls `flowstate` from `self._flowstate` —
specifically, the engine's instance variable.  The workflow's
flowstate dict (the per-run dict initialised in `execute` and
threaded through `process_flowitems`) is *different* from
`self._flowstate`.  This is a subtle implementation detail of
the shipped engine; gates that try to read flowstate may not see
what tasks have written.

This is a known limitation.  Workflow authors who want gates to
read task-produced state should use `parameters` (read-only,
visible everywhere) for stable values, or work around the
limitation by writing a custom task that combines computation
and conditional behaviour into one step.

### Errors propagate

When `eval` raises (a syntax error in the expression, a
`KeyError` from missing parameters, a `NameError` from referring
to an undefined name), the exception propagates through
`evaluate_expression` and through `process_flowitems` up to the
caller.  The engine does *not* catch the exception; the workflow
fails.

This is consistent with the volume's "errors should never pass
silently" discipline (chapter 0, foundation tier).  A malformed
gate is a workflow bug; failing loudly is better than treating
the gate as falsy and silently skipping the node.

## A worked example of jump propagation

To make the jump mechanics concrete, walk through what happens
when a task fails and triggers a cross-group jump.

The workflow:

```yaml
name: cross-group-jump-example
flow:
    - task: Setup
      tasktype: my.module@Setup

    - taskgroup: Test phase
      items:
          - task: Smoke
            tasktype: my.module@Smoke
          - task: Integration
            tasktype: my.module@Integration
            onfailure: Run cleanup
          - task: Benchmark
            tasktype: my.module@Benchmark

    - taskgroup: Cleanup phase
      items:
          - task: Stop services
            tasktype: my.module@StopServices
          - task: Run cleanup
            tasktype: my.module@RunCleanup
```

The `Integration` task at ordinal `"2.2"` has `onfailure: Run
cleanup`, naming the task at ordinal `"3.2"`.  Trace what the
engine does when `Integration` fails.

### Time T₀: Engine begins

```
seeking_node = None
flowitems = [Setup, TestPhase, CleanupPhase]
```

The outer loop starts walking the top-level flow.

### Time T₁: Setup runs

`Setup` is processed normally.  `seeking_node` was None;
`if_expr` was None; the engine executes the task.  Suppose it
succeeds; no `onsuccess` declared; fall through.

```
seeking_node = None
just-processed = Setup (ordinal 1)
```

### Time T₂: Engine recurses into Test phase

`TestPhase` is a `TaskGroup`; the engine calls
`process_flowitems` recursively with `flowitems =
[Smoke, Integration, Benchmark]`.

```
seeking_node = None
recursion-depth = 1
inner-flowitems = [Smoke, Integration, Benchmark]
```

### Time T₃: Smoke runs

The inner loop processes `Smoke`.  Succeeds; no `onsuccess`;
fall through.

```
seeking_node = None
just-processed = Smoke (ordinal 2.1)
```

### Time T₄: Integration runs and fails

The inner loop processes `Integration`.  The task's
`perform_task` returns non-zero.  The engine sees the failure
and the `onfailure` label:

```python
if task_result != 0:
    if fitem.onfailure is not None:
        jump_context.seeking_node = fitem.onfailure
```

`seeking_node` becomes `"Run cleanup"`.  The inner loop's next
iteration begins.

```
seeking_node = "Run cleanup"
just-processed = Integration (ordinal 2.2, FAILED)
```

### Time T₅: Benchmark is skipped

The inner loop reaches `Benchmark`.  `seeking_node` is set to
`"Run cleanup"`; `Benchmark`'s label is `"Benchmark"`; mismatch.
The engine `continue`s.  No gate evaluation, no execution.

```
seeking_node = "Run cleanup"
just-skipped = Benchmark (ordinal 2.3)
```

### Time T₆: Inner loop ends; recursion returns

The inner loop's iteration is complete.  `process_flowitems`
returns to its caller (the outer loop).  Critically, the
`seeking_node = "Run cleanup"` state survives the return — both
the outer and inner loops share the same `jump_context`
reference.

```
seeking_node = "Run cleanup"
recursion-depth = 0 (returned)
```

### Time T₇: Outer loop resumes; CleanupPhase reached

The outer loop's next iteration is `CleanupPhase`.  The engine
checks: `seeking_node = "Run cleanup"`; CleanupPhase's label is
`"Cleanup phase"`; mismatch.

But this is a TaskGroup, not a Task.  Does the seek-and-skip
treatment of TaskGroups skip the *whole group* or just the
group node itself?

Looking at the loop structure:

```python
for fitem in flowitems:
    node_label = fitem.label

    if jump_context.seeking_node is not None:
        if jump_context.seeking_node != node_label:
            continue       # <-- skips this iteration entirely
```

The `continue` skips the entire iteration — including the
recursion into the TaskGroup's items.  So when the outer loop
encounters `CleanupPhase` while seeking `"Run cleanup"` and the
group's *own* label doesn't match, the *whole group is
skipped without descending into its items*.

This is a subtle behaviour worth naming: **the engine's
seek-and-skip applies at every level of the tree, including
taskgroup boundaries**.  When a jump's target is *inside* a
taskgroup, the engine has to first enter the taskgroup —
which means matching the taskgroup's *own* label is the only
way for seek mode to descend into the items.

Look back at the workflow: `CleanupPhase` is labelled "Cleanup
phase".  The jump target is "Run cleanup".  Mismatch.  The
group is skipped.  *The cross-group jump doesn't reach its
target.*

This is a real subtlety in the shipped engine.  Workflow
authors who want cross-group jumps to work as intuited (jumping
from inside one group to a label inside another) should
declare the *outer* group's label to match the seek target — or
restructure so that the jump targets a top-level task rather
than one inside a different taskgroup.

The volume's foundation-document outline notes this kind of
subtlety as a place where the discipline of *expressing flow
control as data* exposes its limits: jumps are powerful in
theory, brittle in nested practice.  Chapter 26's case study
revisits the pattern and shows the workaround.

## What flow control as data buys

Despite the subtlety above, the discipline of flow control as
data has clear payoffs over imperative orchestration.

### Reviewability

A workflow's behaviour is visible in the YAML.  When a workflow
fails in production, the engineer reading the YAML can see
which gates and jumps applied to that run.  An imperative
orchestration with branches, loops, and exception handlers
distributed across a Python script does not have this property
— understanding what *would* have happened requires running the
code in your head.

### Captured-profile compatibility

Chapter 16's capture-and-replay primitive serialises the
*resolved configuration* but not the *engine's runtime
behaviour*.  This works because the engine's behaviour is
*derivable* from the workflow YAML plus the captured
configurations.  An imperative orchestration's behaviour would
also depend on the engine's internal state, the order of
exception handlers, and other runtime details that wouldn't
serialise cleanly.

### Audit story

A configuration change to the workflow's flow is a git diff,
visible at code review time.  An imperative-orchestration
change spread across multiple Python files is harder to review
as a unit.  The discipline keeps the changes small and
reviewable, the same way every other chapter of the volume has
argued for.

### Predictability

The engine's behaviour is bounded.  The shipped
`process_flowitems` is twenty-five lines; the worst-case
behaviour for a given input is computable.  The engine does
not retry, does not back off, does not introduce
non-determinism beyond what's in the workflow's `if_expr`
strings.  When a workflow misbehaves, the cause is in the YAML
or in a task's implementation, not in the engine's hidden
state.

## What flow control as data does *not* buy

Three honest scoping notes.

### Not arbitrary control flow

The engine has *gates* and *jumps*.  It does not have *loops*,
*parallelism*, *try/except*, or *coroutines*.  When the
orchestration needs richer control flow — running three tasks
concurrently, retrying with exponential backoff, branching on
runtime conditions richer than a single boolean — the right
answer is either:

- A custom `Task` implementation that does the richer behaviour
  internally (chapter 23 develops the extension recipe).
- A higher-level orchestration tool entirely (Airflow, Argo,
  Step Functions) that the workflow author chooses *because* it
  has the primitives this engine doesn't.

The chapter-27 ("where this approach is *not* the right fit")
honest-scoping discussion includes "heavy parallel fan-out" and
"dynamic workflow generation" precisely because the engine's
small set of primitives doesn't fit those use cases.

### Not transactional

The engine has no notion of *atomic* multi-task operations.
When task A succeeds and task B fails halfway through, there's
no built-in mechanism to "undo" task A.  Tasks that need
transactional semantics implement them themselves (most commonly
through chapter-19's `onfailure` jumps to a cleanup task).

The limitation is acceptable for most orchestration use cases
— pipeline steps are typically idempotent or have explicit
cleanup paths — but workflow authors should be aware that the
engine is not a transaction manager.

### Not parallelism

A `TaskGroup` does not run its items in parallel.  Items run
sequentially in declaration order.  When parallelism is
needed, the workflow author either:

- Spawns parallel work from inside a custom Task
  implementation.
- Uses the CI vendor's native parallelism (matrix builds,
  parallel jobs).
- Restructures the workflow into multiple workflows that the CI
  vendor runs in parallel.

The shipped engine is a sequential one-pass walker.  This is
deliberate (chapter 18's "what's not in the model" discussion);
parallelism at the engine level would require a scheduling
abstraction the simple model doesn't need.

## Audit findings on `flowengine.py`

The pre-book audit pass on this file found and fixed several
bugs.  All are now covered by the 115-test suite that came out
of the audit:

- **`evaluate_expression` had a `topology` parameter** in its
  signature that was never used and never passed.  Calls into
  the function from `process_flowitems` only passed
  `(if_expr, environment, parameters)`; the extra parameter was
  a vestigial leftover from an earlier API.  Fixed by removing
  the parameter.
- **`process_flowitems` had `topology` in its signature** with
  the same vestigial-parameter problem.  `execute` was passing
  it; the inner uses didn't reference it.  Fixed by removing.
- **`execute` had `result_code = 0`** before
  `process_flowitems` was called, then *never updated* unless
  errors were appended.  Tasks failing without `onfailure`
  jumps were silently absorbed; the workflow result code
  reflected only errors that someone had explicitly added to the
  errors list.  This is *still* the shipped behaviour — failing
  tasks without `onfailure` declarations don't increment the
  errors list — and the audit chose to preserve the behaviour as
  documented rather than change it; chapter-27's scoping
  discussion notes this as a known limitation.

The errors-list-as-result-channel pattern is the engine's
weakest design point.  A future iteration could replace it with
a strict-by-default mode where any unhandled task failure marks
the workflow as failed; the volume documents the current
behaviour rather than papering over it.

## Summary

- The
  [`FlowEngine`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/flowengine.py)
  is a small (~100 LOC) sequential engine implementing three
  flow-control mechanisms: the `if_expr` gate, the
  `onfailure`/`onsuccess` label jump, and the recursive
  TaskGroup walk.
- The engine's outer loop (`execute`) sets up environment,
  parameters, flowstate, errors, and a `JumpContext`, then
  delegates to the recursive `process_flowitems`.
- Flow control is *seek-and-skip*: when a jump fires,
  `jump_context.seeking_node` is set; subsequent loop iterations
  skip every node whose label doesn't match, including taskgroup
  boundaries.
- The `if_expr` gate is a Python expression evaluated with
  `eval` against an environment containing `environment`,
  `parameters`, and `flowstate` (the engine's `_flowstate`, with
  the limitation that this is *not* the same as the workflow's
  per-run flowstate dict).
- Cross-group jumps work *if* the engine descends into the right
  taskgroup before encountering the target.  Jumps targeting
  labels inside a different taskgroup will skip the wrong group
  unless the workflow author structures the labels carefully —
  a real subtlety chapter 26 will revisit.
- The discipline of "flow control as data" buys reviewability,
  captured-profile compatibility, an audit story, and bounded
  predictability — at the cost of giving up arbitrary control
  flow, transactionality, and engine-level parallelism.
- The audit pass on `flowengine.py` removed two vestigial
  parameters (`topology`) and noted the errors-list-as-result-
  channel pattern as the engine's weakest design point.

The next chapter is the last in Part V: extending the task
model.  How to add a new task type when the five shipped types
don't cover the use case, with the chapter-19 `GitCheckout`
stub as the worked target.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/23-extending-the-task-model -->
# Chapter 23 — Extending the Task Model

> *Five shipped task types are enough for a starter set, never
> enough for a real codebase.  This chapter is the recipe for
> adding the sixth, seventh, and eighth.*

The previous five chapters of Part V established the task model
(chapter 18), the shipped task types (chapter 19), the YAML
grammar (chapter 20), the orchestration-to-job contract (chapter
21), and the engine that runs the result (chapter 22).  This
chapter closes Part V with the *extension recipe* — how to add a
new task type when the shipped five don't cover the use case.

The chapter uses a worked target: turning the shipped
[`GitCheckout`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/tasks/gitcheckout.py)
stub from chapter 19 into a real Git checkout implementation.
The same recipe applies to any other extension; `GitCheckout` is
chosen because it's the smallest visible gap in the shipped set
and because it's the chapter-19 reader exercise.

The recipe is short.  Six steps; roughly thirty lines of code per
step; one new entry in the YAML's `tasktype:` namespace.

## When to write a new task type

The shipped five — `RunCommand`, `ShellScript`,
`EmbeddedPython`, `GitCheckout` (stub), `CreateEnvironment` —
cover most common needs.  Before reaching for a new task class,
check whether the existing types fit:

| Use case | Reach for |
|---|---|
| Run a single shell command | `RunCommand` |
| Run a multi-line shell script | `ShellScript` |
| Run a small piece of Python | `EmbeddedPython` |
| Set environment variables (legacy) | `CreateEnvironment` (with chapter-7 caveat) |

The five cover the vast majority of orchestration needs.  When
you find yourself writing a `RunCommand` that wraps a Python
script that wraps a custom CLI to do *one specific thing*, you
have a candidate for a new task type.

Three signals that a new task type is the right answer:

### Signal 1: the same wrapper appears in multiple workflows

When three workflows all have a step like this:

```yaml
- task: Pull image
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: "docker pull registry.example.com/${IMAGE_NAME}:${IMAGE_TAG}"
```

with `IMAGE_NAME` and `IMAGE_TAG` varying — the workflows are
duplicating wrapper logic.  A `DockerPull` task with `image:`
and `tag:` fields would replace the duplication with a typed
construct.

### Signal 2: the shell command is structurally complex

When the workflow needs to run a command with structured arguments
(a JSON config blob, a multi-step retry loop, an output parser),
expressing it as `command:` strings becomes brittle.  A custom
task that takes typed YAML fields and constructs the
underlying call internally is more maintainable.

### Signal 3: the task needs to interact with the orchestration

When the task wants to write to flowstate, log structured events,
read from a runtime configuration, or spawn related child
processes — all things the shipped tasks do *minimally* — a
custom task with explicit access to those mechanisms is the right
answer.

## The recipe in six steps

Worked target: turn `GitCheckout` from a stub into a real
implementation.

### Step 1 — Decide the YAML shape

What fields will the workflow author write?  For Git checkout:

```yaml
- task: Check out app
  tasktype: ccai.taskplus.tasks.gitcheckout@GitCheckout
  repository_url: "git@github.com:example/app.git"
  branch: main                              # optional; default 'main'
  destination: "${WORKSPACE}/app"
  credential: ci-bot-github                  # credential identifier
  shallow: true                              # optional; default false
```

Five fields beyond the universals: `repository_url`, `branch`,
`destination`, `credential`, `shallow`.  Two are required
(`repository_url`, `destination`); three are optional with sane
defaults.

The `credential:` field references a credential identifier
(chapter 9).  The actual credential object will be looked up
through `CredentialManagerSingleton` at task execution.  No
secret material lives in the workflow YAML.

### Step 2 — Write the constructor

The task class extends `Task` and reads its required fields from
`task_info` (the chapter-18 universal):

```python
"""
.. module:: gitcheckout
    :platform: Darwin, Linux, Unix, Windows
    :synopsis: Real GitCheckout task — replaces the chapter-19 stub.

.. moduleauthor:: CodeCraftX <automationx@code-craft.ai>
"""

from typing import Any, Dict, Optional

import os
import subprocess

from ccai.errors.exceptions import ConfigurationError

from ccai.taskplus.model.task import Task


class GitCheckout(Task):
    """
        Checks out a Git repository at a specific branch into a
        destination directory.  Authenticates using a credential
        identifier resolved through CredentialManagerSingleton.
    """

    def __init__(self, ordinal: str, label: str, task_info: dict, logger):
        super(GitCheckout, self).__init__(ordinal, label, task_info, logger)

        if "repository_url" not in task_info:
            errmsg = "GitCheckout requires a 'repository_url' field."
            raise ConfigurationError(errmsg)
        if "destination" not in task_info:
            errmsg = "GitCheckout requires a 'destination' field."
            raise ConfigurationError(errmsg)

        self._repository_url = task_info["repository_url"]
        self._destination = task_info["destination"]
        self._branch = task_info.get("branch", "main")
        self._credential_id = task_info.get("credential", None)
        self._shallow = task_info.get("shallow", False)

        return
```

Two design decisions visible above:

- **Required fields are validated at construction time.**  A
  workflow that lacks `repository_url` fails to *load*, not to
  execute.  This matches the chapter-19 pattern — `RunCommand`
  fails fast on missing `command`, `ShellScript` on missing
  `script`.  The workflow author finds out about the mistake
  before the orchestration starts.
- **Optional fields have explicit defaults.**  The constructor
  uses `task_info.get(field, default)` with the default visible
  in code.  When the YAML omits `branch:`, the task uses
  `"main"`; when it omits `shallow:`, the task uses `False`.
  Defaults are part of the task's contract and live near the
  field reads.

### Step 3 — Write `perform_task`

The implementation does the actual work.  Three concerns:
resolve the credential, build the Git command, run it.

```python
def perform_task(self, environment, parameters, flowstate) -> int:

    self._logger.info(
        "GitCheckout: %s @ %s -> %s" %
        (self._repository_url, self._branch, self._destination)
    )

    # Step 3a: resolve credential
    git_env = os.environ.copy()
    if self._credential_id is not None:
        git_env = self._apply_credential_to_env(git_env)

    # Step 3b: build the Git command
    cmd = ["git", "clone", "--branch", self._branch]
    if self._shallow:
        cmd.extend(["--depth", "1"])
    cmd.append(self._repository_url)
    cmd.append(self._destination)

    # Step 3c: run it
    proc = subprocess.Popen(
        cmd,
        env=git_env,
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    )
    stdout, stderr = proc.communicate()
    exit_code = proc.wait()

    stdout_text = stdout.decode()
    stderr_text = stderr.decode()

    if exit_code == 0:
        self._logger.info("Git clone succeeded.")
        flowstate["gitcheckout.last_destination"] = self._destination
    else:
        self._logger.error(
            "Git clone failed (exit %d): %s" %
            (exit_code, stderr_text.rstrip())
        )

    return exit_code
```

Four implementation notes:

#### Credential resolution

The credential is fetched through the
[`CredentialManagerSingleton`](https://github.com/code-craft-ai-company/codecraftai-credentials/blob/main/source/packages/ccai/credentials/wellknown.py)
(chapter 9) via a helper:

```python
def _apply_credential_to_env(self, env: Dict[str, str]) -> Dict[str, str]:
    """
        Resolve the credential identifier and apply Git auth to env.
    """
    from ccai.credentials.wellknown import CredentialManagerSingleton

    cred = CredentialManagerSingleton().lookup_credential(self._credential_id)

    # Different credential types apply differently.  Two common cases:
    if hasattr(cred, "token"):
        # GithubTokenCredential or PersonalApiTokenCredential
        env["GIT_ASKPASS"] = "/bin/echo"   # never prompt
        env["GIT_USERNAME"] = getattr(cred, "username", "git")
        env["GIT_PASSWORD"] = cred.token
        env["GIT_HTTP_EXTRAHEADER"] = f"Authorization: Bearer {cred.token}"
    elif hasattr(cred, "keyfile"):
        # SshCredential
        env["GIT_SSH_COMMAND"] = (
            f"ssh -i {cred.keyfile} -o StrictHostKeyChecking=no "
            f"-o IdentitiesOnly=yes"
        )

    return env
```

The credential's *type* dispatches the auth strategy.  An SSH
credential becomes `GIT_SSH_COMMAND`; a token credential becomes
HTTP basic-auth headers.  The dispatch is *the task's*
responsibility, not the credential's.  This is consistent with
chapter-9's vocabulary-versus-resolution boundary: the credential
is data; the consumer decides how to apply it.

The credential lookup is a programmatic call — chapter 11's
"never log a credential" rule applies.  Note the absence of any
`logger.info(cred)` or `f"{cred}"` patterns; the task reads
fields and applies them directly without intermediate
serialisation.

#### Subprocess invocation

The task uses `subprocess.Popen` with `env=git_env` rather than
`shell=True`.  Two reasons:

- The credential-bearing env vars need to reach the Git
  subprocess deterministically.  `env=git_env` is explicit;
  `shell=True` introduces a shell that could mangle the env vars
  through expansion or quoting.
- Avoiding shell injection.  When the task's fields come from
  workflow YAML (which is checked in and reviewed) the risk is
  modest, but the explicit `cmd` list-of-args form is uniformly
  safer.

#### Flowstate write

On success, the task writes `flowstate["gitcheckout.last_destination"]`
so subsequent tasks can read where the checkout landed:

```yaml
- task: Check out app
  tasktype: ccai.taskplus.tasks.gitcheckout@GitCheckout
  repository_url: "git@github.com:example/app.git"
  destination: "${WORKSPACE}/app"

- task: Build app
  tasktype: ccai.taskplus.tasks.runcommand@RunCommand
  command: "cd $(ccai flowstate get gitcheckout.last_destination) && make build"
```

The `gitcheckout.` prefix is the chapter-21 namespacing
convention.  Multiple `GitCheckout` tasks in one workflow could
trip over each other on this key — when the workflow has
multiple checkouts, the task should write a per-task key
(`gitcheckout.<label>.last_destination`).  The discipline is the
workflow author's; the task gives a sensible default.

#### Error reporting

On failure, the task logs the *stderr* output at error level.
This is the chapter-19 pattern carried forward — failed-command
output goes to the right log level so it's findable in CI logs.
The task does *not* log stdout on failure; Git's stdout on
failure is typically empty.

### Step 4 — Register the tasktype reference

The task is now usable in workflows by its module-and-class
reference:

```yaml
- task: Check out app
  tasktype: ccai.taskplus.tasks.gitcheckout@GitCheckout
  ...
```

The
[`Workflow.parse`](https://github.com/code-craft-ai-company/codecraftai-taskplus/blob/main/source/packages/ccai/taskplus/model/workflow.py)
loader (chapter 20) resolves the reference at parse time:
imports the module, looks up the class, instantiates with the
chapter-18 constructor signature.

Two observations on registration:

- **No central registry.**  The task class doesn't need to be
  registered anywhere; the YAML's `module@Class` reference is
  the registration.  This is why custom tasks can live anywhere
  in the importable namespace — the workflow author writes the
  fully-qualified reference and `import_by_name` resolves it.
- **The module must be importable from the orchestration's
  context.**  When the task lives in a separate package, the
  package must be installed (or on `PYTHONPATH`) before the
  orchestration runs.  This is straightforward when the task
  ships in `codecraftai-taskplus` itself; for tasks shipped in a
  consumer package (e.g., a project's own `myproject.taskplus`
  module), the package needs to be installed alongside.

### Step 5 — Write tests

Every shipped task in `codecraftai-taskplus` has a corresponding
test file in
[`source/testroots/ccai/tests/singlehost/taskplus/`](https://github.com/code-craft-ai-company/codecraftai-taskplus/tree/main/source/testroots/ccai/tests/singlehost/taskplus).
A new task type adds a new test file with the same shape.

A minimal test suite for `GitCheckout`:

```python
"""Tests for the :class:`GitCheckout` task."""

import logging
import os
import shutil

from ccai.testbase import verification

from ccai.errors.exceptions import ConfigurationError
from ccai.taskplus.tasks.gitcheckout import GitCheckout


def _make_logger() -> logging.Logger:
    return logging.getLogger("test.gitcheckout")


def test_construct_requires_repository_url() -> None:
    """Construction without 'repository_url' raises ConfigurationError."""

    raised = False
    try:
        GitCheckout(
            ordinal="1",
            label="checkout",
            task_info={"destination": "/tmp/x"},
            logger=_make_logger(),
        )
    except ConfigurationError:
        raised = True

    verification.assert_equal(raised, True)
    return


def test_construct_requires_destination() -> None:
    """Construction without 'destination' raises ConfigurationError."""

    raised = False
    try:
        GitCheckout(
            ordinal="1",
            label="checkout",
            task_info={"repository_url": "https://example/r.git"},
            logger=_make_logger(),
        )
    except ConfigurationError:
        raised = True

    verification.assert_equal(raised, True)
    return


def test_construct_stores_required_fields() -> None:
    """Required fields are stored as private attributes."""

    task = GitCheckout(
        ordinal="1",
        label="checkout",
        task_info={
            "repository_url": "https://example/r.git",
            "destination": "/tmp/x",
        },
        logger=_make_logger(),
    )

    verification.assert_equal(task._repository_url, "https://example/r.git")
    verification.assert_equal(task._destination, "/tmp/x")
    verification.assert_equal(task._branch, "main")    # default
    verification.assert_equal(task._shallow, False)    # default
    verification.assert_equal(task._credential_id, None)  # default

    return


def test_construct_uses_explicit_branch_and_shallow() -> None:
    """Optional fields are honoured when present."""

    task = GitCheckout(
        ordinal="1",
        label="checkout",
        task_info={
            "repository_url": "https://example/r.git",
            "destination": "/tmp/x",
            "branch": "develop",
            "shallow": True,
        },
        logger=_make_logger(),
    )

    verification.assert_equal(task._branch, "develop")
    verification.assert_equal(task._shallow, True)
    return


def test_perform_task_with_invalid_url_returns_nonzero() -> None:
    """A clone against an unreachable URL returns non-zero."""

    target = "/tmp/codecraftai-gitcheckout-test"
    if os.path.exists(target):
        shutil.rmtree(target)

    task = GitCheckout(
        ordinal="1",
        label="checkout",
        task_info={
            "repository_url": "/dev/null/this-does-not-exist.git",
            "destination": target,
        },
        logger=_make_logger(),
    )

    result = task.perform_task(environment={}, parameters={}, flowstate={})

    verification.assert_true(result != 0)
    return
```

Five tests covering: two constructor validation cases, the
required-fields-stored case, the optional-fields-honoured case,
and the perform-task-fails-loudly case.  Real Git operations
against real repositories are typically left out of unit tests
(integration tests run them); the suite focuses on the parts of
the task that don't need a live network.

### Step 6 — Document the task type

The task class's docstring is the workflow author's API
reference.  At minimum, the docstring names the YAML fields and
their semantics:

```python
class GitCheckout(Task):
    """
        Checks out a Git repository.

        Required YAML fields:

        :repository_url: Git repository URL (HTTPS or SSH).
        :destination: Local filesystem path the repository is cloned to.

        Optional YAML fields:

        :branch: Branch to check out.  Default 'main'.
        :credential: Credential identifier resolved at execution time
                      via CredentialManagerSingleton.  Default None
                      (use system Git auth — SSH agent, gitconfig, etc.).
        :shallow: When True, performs --depth=1 clone.  Default False.

        Flowstate writes on success:

        :gitcheckout.last_destination: The destination path of the
                                        successful clone.

        Failure modes:

        * Returns the Git CLI's exit code on failure.
        * Logs stderr at ERROR level on non-zero exit.
    """
```

The docstring is what the workflow author reads to know how to
use the task.  Per the volume's chapter-0-anchored coding-standards
discipline, the docstring uses Sphinx field syntax (`:param:`,
`:returns:`, etc.) and is parseable by the documentation
generator.

## A more substantial extension: a `Retry` wrapper

The recipe above produces a *leaf* task — a class that inherits
directly from `Task` and does work.  A more substantial extension
is a *wrapper* task that *modifies* another task's behaviour.
Worked target: a task that retries a wrapped task on failure.

```yaml
- task: Flaky API call (with retry)
  tasktype: ccai.taskplus.tasks.retry@Retry
  attempts: 3
  delay_seconds: 5
  inner:
      tasktype: ccai.taskplus.tasks.runcommand@RunCommand
      command: "curl -f https://flaky-api.example.com/healthcheck"
```

The `Retry` task takes two retry-control fields plus an `inner:`
field whose value is itself a task description (a sub-YAML).  The
implementation:

```python
import time

from ccai.errors.exceptions import ConfigurationError
from ccai.xmodules.ximport import import_by_name

from ccai.taskplus.model.task import Task


class Retry(Task):
    """
        Wraps another task and retries it on failure.

        :attempts: Maximum number of attempts (including the first).
        :delay_seconds: Seconds to sleep between attempts.
        :inner: A sub-task description with 'tasktype:' and any other fields
                 the inner task type requires.
    """

    def __init__(self, ordinal: str, label: str, task_info: dict, logger):
        super().__init__(ordinal, label, task_info, logger)

        if "inner" not in task_info:
            errmsg = "Retry requires an 'inner' field."
            raise ConfigurationError(errmsg)

        self._attempts = task_info.get("attempts", 3)
        self._delay_seconds = task_info.get("delay_seconds", 1)

        # Construct the inner task.
        inner_info = task_info["inner"]
        inner_tasktype = inner_info["tasktype"]
        inner_module_name, inner_class_name = inner_tasktype.split("@")
        inner_module = import_by_name(inner_module_name)
        inner_class = getattr(inner_module, inner_class_name)

        # Inner task gets a derived ordinal so its log lines are
        # distinguishable from the outer wrapper's.
        inner_ordinal = f"{ordinal}.inner"
        inner_label = f"{label} (inner)"
        self._inner = inner_class(inner_ordinal, inner_label, inner_info, logger)

        return

    def perform_task(self, environment, parameters, flowstate) -> int:
        last_result = 1

        for attempt in range(1, self._attempts + 1):
            self._logger.info(
                "Retry attempt %d of %d for %s" %
                (attempt, self._attempts, self._label)
            )
            last_result = self._inner.perform_task(
                environment, parameters, flowstate
            )
            if last_result == 0:
                return 0

            if attempt < self._attempts:
                self._logger.info(
                    "Inner task failed; sleeping %ds before retry" %
                    self._delay_seconds
                )
                time.sleep(self._delay_seconds)

        return last_result
```

Three things worth naming:

### The wrapper instantiates its inner task

The constructor uses the same `import_by_name` machinery the
workflow loader uses (chapter 20).  This is what lets the
`Retry` task wrap *any* other `Task` — `RunCommand`, `ShellScript`,
a custom task, or even another `Retry`.  The model's
`Task → perform_task` contract is the only thing the wrapper
needs to know about its inner.

### The wrapper preserves the model's contract

The wrapper's `perform_task` returns an int (0 = success,
non-zero = last failure).  The `FlowEngine` (chapter 22) sees
the wrapped task as a single task — the retry loop is internal.
This is the **Aspects pattern** from Volume 5: behaviour
parameters (retry policy) are packaged into a wrapping
construct rather than being added to the engine.  The chapter-18
"what's not in the model" discussion specifically called out
that retries belong inside tasks; this is the canonical example.

### The pattern composes

A workflow author can stack wrappers — a `Retry` wrapping a
`Timeout` wrapping a `RunCommand` is a perfectly sensible
composition:

```yaml
- task: Bounded retry
  tasktype: my.module@Retry
  attempts: 3
  delay_seconds: 5
  inner:
      tasktype: my.module@Timeout
      timeout_seconds: 30
      inner:
          tasktype: ccai.taskplus.tasks.runcommand@RunCommand
          command: "./run-flaky.sh"
```

Each wrapper handles one concern (retry, timeout, the actual
work).  The model's small-and-uniform interface is what makes
the composition clean.

## Anti-patterns to avoid

Three patterns that *look* like extensions but aren't.

### Don't subclass an existing concrete task

A `RunCommandWithRetry` that subclasses `RunCommand` to add retry
logic is the wrong shape.  Inheritance couples retry to the
specific command-running implementation; the same retry logic
would have to be duplicated for `ShellScriptWithRetry` and
`EmbeddedPythonWithRetry`.  The wrapper-task pattern from the
previous section is the right answer.

### Don't put framework concerns in tasks

A task that mutates the engine's `JumpContext`, that re-orders
the workflow's flow list, or that conditionally re-defines the
`if_expr` semantics is reaching past the model's interface into
the engine.  When a use case requires this, the right response
is *not* a custom task — it's either a custom engine (which the
volume's chapter-27 honest-scoping discussion would direct
elsewhere) or a workflow restructuring that expresses the
intent through declarative gates and jumps.

### Don't create tasks that wrap arbitrary Python code

A `RunPythonFunction` task that takes a `module:` and `function:`
field and calls the function is nominally extensible, but it's
also a *sub-engine* — the function it calls becomes a
mini-orchestration the framework can't see into.  The recipe is
to *make the work a task itself*, not to write a task that runs
arbitrary work.  The chapter-18 task model is small precisely so
that every step is *visible to the engine*.

## Sharing custom tasks

A team's custom tasks typically live in a separate package —
something like `myproject-taskplus`.  Three patterns for
sharing:

### Per-project `taskplus` package

The project's `pyproject.toml` declares a `myproject.taskplus`
namespace; tasks live there:

```
myproject-taskplus/
└── source/packages/myproject/taskplus/tasks/
    ├── gitcheckout.py            # the recipe target above
    ├── docker_pull.py
    ├── kubectl_apply.py
    └── retry.py                  # the wrapper above
```

Workflows reference them by full module path:

```yaml
- task: Pull image
  tasktype: myproject.taskplus.tasks.docker_pull@DockerPull
  ...
```

The package is installed alongside `codecraftai-taskplus` in the
orchestration's environment.  No registration step required.

### Cross-team task library

When multiple teams in an organisation share custom tasks, the
shared package becomes its own thing —
`codecraftai-taskplus-extras` or similar.  The pattern is the
same; the maintenance overhead grows with the number of teams,
which the foundation document's "cross-team profile sharing"
note also covers (chapter 14).

### Contributing back

Tasks that prove broadly useful can be contributed back to
`codecraftai-taskplus` itself.  The criterion: the task is generic
(not project-specific), well-tested, and fits the
"environment-neutral" discipline of the shipped set.  The
`GitCheckout` recipe target is itself a good candidate —
filling in the stub is exactly the contribution path, and the
recipe above is the implementation.

## Summary

- **The five shipped task types** cover most orchestration
  needs.  When the same wrapper appears in multiple workflows,
  when the shell command is structurally complex, or when the
  task needs to interact with the orchestration, a custom task
  is the right answer.
- **The recipe is six steps**: decide the YAML shape; write the
  constructor with required-field validation; write `perform_task`
  with credential resolution + work + error reporting + flowstate
  writes; reference by `module@Class` in the workflow YAML;
  write tests; document.  The total surface is roughly
  150 lines for a non-trivial task.
- **Wrapper tasks** like `Retry` and `Timeout` are the pattern
  for adding behaviour parameters (the chapter-5 Aspects
  pattern in Volume 5).  Wrappers preserve the model's small
  interface; concerns compose by stacking wrappers.
- **Three anti-patterns to avoid**: inheriting from concrete
  tasks (use wrappers instead), reaching into engine internals
  (the engine is bounded), wrapping arbitrary Python (make the
  work a task itself).
- **Custom tasks live in a separate package** (or directly in
  the project's namespace).  No central registration; the
  YAML's `module@Class` reference is the registration.  Tasks
  that prove broadly useful can be contributed back to
  `codecraftai-taskplus`.

This is the last chapter of Part V.  The volume now has the
complete implementation surface: the task model (ch 18), the
shipped types (ch 19), the YAML grammar (ch 20), the
orchestration-to-job contract (ch 21), the engine (ch 22), and
the extension recipe (ch 23).  Every piece anchored on
audited code; every code citation backed by 115 tests in the
test suite the audit pass produced.

Part VI is what remains: wiring the implementation to real CI
vendors, bridging to the other volumes (distributed jobs from
Volume 2, sysman from Volume 4, provisioning from Volume 6),
walking the case-study port end-to-end, and naming honestly
where this discipline is *not* the right fit.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/24-wiring-to-a-real-ci-vendor -->
# Chapter 24 — Wiring to a Real CI Vendor

> *The runner is dumb.  The workflow is portable.  When the CI
> vendor's YAML is small enough to fit on a screen, you have done
> the wiring correctly.*

Welcome to Part VI.  The volume's discipline (Parts I–IV) and
implementation (Part V) are now complete.  This part is the
*operational* layer — how the typed configurations and the
taskplus engine actually meet a CI vendor's pipeline runner; how
the discipline composes with the rest of the ccai portfolio
across volume boundaries; the case-study capstone that walks one
real port end-to-end; and the honest discussion of where the
discipline is *not* the right fit.

Four chapters in Part VI.  This one is the wiring.  The thesis is
narrow and uniform: **the CI vendor's YAML has exactly two jobs —
trigger the workflow and provide a runner image — and everything
else is the taskplus workflow's responsibility.**  When the
discipline holds, the vendor YAML shrinks to ten lines; when it
doesn't, you can see the discipline failing in the size of the
file.

The chapter walks three vendors.  GitLab CI, GitHub Actions, and
Jenkins.  Each gets a complete shim — the full vendor YAML from
trigger through runner — followed by the same handful of
operational notes (artifact upload, secret pass-through, parallel
matrix builds, retries) that recur across all three.

## The taskplus invocation

Every CI vendor's shim eventually contains the same one-line
invocation:

```bash
AM_PROFILE=integration-ci ccai testbase test run \
    --workflow config/workflows/integration.yaml
```

(Or, if the workflow is invoked directly by the taskplus runner
rather than wrapped through testbase:)

```bash
AM_PROFILE=integration-ci ccai taskplus workflow run \
    config/workflows/integration.yaml
```

That's it.  One environment-variable export (`AM_PROFILE`) plus
one command.  The vendor YAML's job is to set up the environment
in which this command runs and to capture its output afterward.

The volume's chapter-7 thesis applies most concretely here: the
plumbing layer is *small and stable*.  The configuration content
— landscapes, topologies, runtimes, credentials, data profiles —
all live in the typed YAML files the workflow loads.  The vendor
YAML never needs to know about any of them.

## GitLab CI shim

The full `.gitlab-ci.yml` for a project that runs an integration
suite on every merge request, plus a nightly full-sweep on
schedule:

```yaml
# .gitlab-ci.yml
default:
  image: registry.example.com/codecraftai-runner:latest
  before_script:
    - poetry install --no-interaction

stages:
  - test

integration-mr:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    AM_PROFILE: "integration-ci"
  script:
    - ccai testbase test run --workflow config/workflows/integration.yaml ||
      (ccai profile capture --output failure-snapshot.json && exit 1)
  artifacts:
    when: on_failure
    paths:
      - failure-snapshot.json
      - .output/
    expire_in: 1 month

nightly-full-sweep:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  variables:
    AM_PROFILE: "nightly-full-sweep"
  script:
    - ccai testbase test run --workflow config/workflows/nightly.yaml
  artifacts:
    when: always
    paths:
      - .output/
    expire_in: 1 month
```

Twenty-eight lines.  Walk what's there.

### The `default:` block

```yaml
default:
  image: registry.example.com/codecraftai-runner:latest
  before_script:
    - poetry install --no-interaction
```

Two responsibilities.  The `image:` provides the runner — a
container with the toolchain and the `codecraftai-*` packages
installed.  The `before_script:` runs the project's dependency
installation step.  These are the *one-time setup* steps for the
job; everything after is the workflow.

The chapter-15 local-dev migration suggested that toolchain
installation is a legitimate machine-setup imperative.  The same
applies in CI: the runner needs the toolchain.  The CI vendor's
`image:` field is the right place to provide it.

### The `integration-mr` job

```yaml
integration-mr:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    AM_PROFILE: "integration-ci"
  script:
    - ccai testbase test run --workflow config/workflows/integration.yaml ||
      (ccai profile capture --output failure-snapshot.json && exit 1)
  artifacts:
    when: on_failure
    paths:
      - failure-snapshot.json
      - .output/
    expire_in: 1 month
```

Six fields.  Two are the per-job concerns: `stage:` (where in the
pipeline this job runs) and `rules:` (when this job runs).  The
other four are the workflow's *narrow* contract with the vendor:

- `variables: AM_PROFILE: "integration-ci"` — the chapter-14
  profile selector.  One env var; the rest of the configuration
  selection cascades from the profile.
- `script:` — one line of `ccai testbase test run` plus the
  chapter-16 capture-on-failure pattern.
- `artifacts:` — the chapter-16 capture artifact plus the test
  output directory.

Notice what's *not* in the vendor YAML:

- **No env-var soup.**  The chapter-2 fail mode of fifteen
  `LOG_LEVEL=...`, `RETRY_LIMIT=...`, `STAGING_HOST=...`
  exports does not exist here.  The runtime profile carries the
  log level; the runtime profile carries the retry limit; the
  landscape carries the staging host.
- **No imperative setup.**  No `kubectl apply`, no `aws ec2
  create-instance`, no `docker pull`.  When provisioning is
  needed, it's a step *inside* the taskplus workflow (chapter
  4's Specification consumed by `ccai provisioning apply`).
- **No conditional branching by environment.**  No `if [ "$CI"
  = "true" ]; then ...; fi`.  The profile selector handles every
  environment-specific behaviour at the configuration layer.

### The `nightly-full-sweep` job

```yaml
nightly-full-sweep:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  variables:
    AM_PROFILE: "nightly-full-sweep"
  script:
    - ccai testbase test run --workflow config/workflows/nightly.yaml
  artifacts:
    when: always
    paths:
      - .output/
    expire_in: 1 month
```

The same shape as `integration-mr` — different profile, different
workflow file, different artifact policy.  Adding a new job to
the pipeline is a copy of the structure with the per-job fields
filled in.  No coordination across the pipeline; each job stands
alone.

The two jobs together demonstrate the chapter-13 ladder: the
same code runs in two CI scenarios, and what differs is the
profile selection plus the workflow YAML.

### Vendor secrets

The shim above doesn't show secret pass-through because the
chapter-12 discipline keeps secrets *out* of the vendor YAML
entirely.  The runner image has the credentials it needs to
fetch from Vault (or AWS Secrets Manager, or whichever backend);
the workflow's resolver fetches credentials at startup.  The
GitLab project's protected-variable settings hold the
*Vault-bootstrap* credentials (the AWS IAM role assumption, the
Vault token), not the application's runtime secrets.

A typical GitLab project's variable list is short:

| Variable | Purpose |
|---|---|
| `VAULT_ADDR` | Address of the Vault instance |
| `VAULT_ROLE_ID` | App-role ID for the runner to authenticate with Vault |
| `VAULT_SECRET_ID` | App-role secret ID, masked |

Three variables.  The runner image's startup uses them to
authenticate with Vault; once authenticated, the resolver fetches
every other credential from there.  None of the application's
runtime secrets ever appear in the GitLab project's variable
settings.

## GitHub Actions shim

The same project's `.github/workflows/integration.yml`:

```yaml
# .github/workflows/integration.yml
name: integration

on:
  pull_request:
  schedule:
    - cron: "0 4 * * *"   # nightly at 04:00 UTC

jobs:
  integration-pr:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    container: ghcr.io/example/codecraftai-runner:latest
    env:
      AM_PROFILE: integration-ci
      VAULT_ADDR: ${{ vars.VAULT_ADDR }}
      VAULT_ROLE_ID: ${{ vars.VAULT_ROLE_ID }}
      VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }}
    steps:
      - uses: actions/checkout@v4
      - run: poetry install --no-interaction
      - run: |
          ccai testbase test run --workflow config/workflows/integration.yaml \
            || (ccai profile capture --output failure-snapshot.json && exit 1)
      - name: Upload failure artifact
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: failure-snapshot
          path: |
            failure-snapshot.json
            .output/

  nightly-full-sweep:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    container: ghcr.io/example/codecraftai-runner:latest
    env:
      AM_PROFILE: nightly-full-sweep
      VAULT_ADDR: ${{ vars.VAULT_ADDR }}
      VAULT_ROLE_ID: ${{ vars.VAULT_ROLE_ID }}
      VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }}
    steps:
      - uses: actions/checkout@v4
      - run: poetry install --no-interaction
      - run: ccai testbase test run --workflow config/workflows/nightly.yaml
      - name: Upload artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: nightly-output
          path: .output/
```

Forty-three lines.  More verbose than the GitLab shim because of
GitHub Actions' explicit `actions/checkout`, `actions/upload-artifact`,
and per-step structure.  But the *structure* is identical:

- The vendor declares two jobs (`integration-pr`, `nightly-full-sweep`).
- Each job picks a runner (`runs-on: ubuntu-latest`, the runner
  image).
- Each job sets exactly four env vars: `AM_PROFILE` plus the
  three Vault-bootstrap secrets.
- Each job runs `ccai testbase test run`.
- Each job uploads the appropriate artifact.

The same workflow YAML — `config/workflows/integration.yaml` —
runs in both vendors.  The same profiles, same configurations,
same credentials apply.  The vendor YAML is *only* the wiring;
the *behaviour* is in the portable workflow.

### What changes between GitLab CI and GitHub Actions

Three categories of change when switching:

- **Trigger conditions.**  GitLab uses `rules: - if:
  $CI_PIPELINE_SOURCE == ...`; GitHub uses
  `if: github.event_name == '...'` plus the top-level `on:` block.
  Both express the same intent (run on PR; run on schedule) in
  vendor-specific shape.
- **Variable interpolation.**  GitLab uses `$VAR` directly in
  YAML; GitHub uses `${{ vars.VAR }}` and `${{ secrets.VAR }}`.
- **Artifact upload.**  GitLab has a top-level `artifacts:`
  field; GitHub uses an explicit `actions/upload-artifact@v4`
  step.

The translation is mechanical.  When a team migrates from one
vendor to the other, the shim files change; the workflow YAML,
the configuration files, the credential file — none of them
change.  This is the chapter-7 fixed-interface principle paying
off operationally.

## Jenkins shim

A Jenkins `Jenkinsfile` for the same project:

```groovy
// Jenkinsfile
pipeline {
    agent {
        docker {
            image 'registry.example.com/codecraftai-runner:latest'
            args '-v ${WORKSPACE}:/workspace -w /workspace'
        }
    }

    environment {
        AM_PROFILE = 'integration-ci'
        VAULT_ADDR = credentials('vault-addr')
        VAULT_ROLE_ID = credentials('vault-role-id')
        VAULT_SECRET_ID = credentials('vault-secret-id')
    }

    stages {
        stage('Setup') {
            steps {
                sh 'poetry install --no-interaction'
            }
        }
        stage('Test') {
            steps {
                sh '''
                    ccai testbase test run --workflow config/workflows/integration.yaml \
                        || (ccai profile capture --output failure-snapshot.json && exit 1)
                '''
            }
        }
    }

    post {
        failure {
            archiveArtifacts artifacts: 'failure-snapshot.json,.output/**', allowEmptyArchive: true
        }
        always {
            archiveArtifacts artifacts: '.output/**', allowEmptyArchive: true
        }
    }
}
```

Thirty-three lines.  Jenkins's syntax differs more from GitLab and
GitHub than the two differ from each other (Groovy DSL versus
declarative YAML), but the structure is the same:

- Pick a runner (`agent { docker { image '...' } }`).
- Set the env vars (`environment { AM_PROFILE = 'integration-ci'
  ... }`).
- Run the toolchain install (`stage('Setup') { steps { sh ... } }`).
- Run the workflow (`stage('Test') { steps { sh '...' } }`).
- Archive artifacts (`post { failure { archiveArtifacts ... } }`).

A team running Jenkins for one project and GitHub Actions for
another can keep the *workflow YAML* and *configuration files*
identical between them; only the per-vendor shim differs.

## What stays in vendor YAML

The discipline asks the vendor YAML to do *exactly two things*:

### 1. Trigger

When does this pipeline run?  Per-vendor mechanism:

- GitLab: `rules:` with `$CI_PIPELINE_SOURCE` checks.
- GitHub Actions: top-level `on:` with `pull_request`,
  `schedule`, `push`, etc.
- Jenkins: triggers on the job's General page (or the `triggers
  { ... }` block in Jenkinsfile).

The trigger logic is genuinely vendor-specific — different
vendors expose different events with different conditional
syntax.  The discipline accepts this and confines it to the shim.

### 2. Runner image plus toolchain bootstrap

Which container, with which Python version and which ccai
packages, runs the job?  Per-vendor mechanism:

- GitLab: `default: image:` plus `before_script:`.
- GitHub Actions: `container:` plus a `run:` step.
- Jenkins: `agent { docker { image '...' } }` plus a setup stage.

The runner image typically pre-installs the toolchain
(`asdf`-managed Python, `poetry`, the `codecraftai-*` packages).
The shim's setup step is then a one-liner (`poetry install`) that
fetches the project's specific dependencies.

## What does *not* stay in vendor YAML

Five categories of content the discipline keeps *out* of the
vendor YAML:

- **Configuration content.**  Log levels, timeouts, retry counts,
  parallelism, hostnames, ports, database names — all live in
  the typed configuration files.  The vendor YAML mentions none
  of them.
- **Imperative provisioning.**  `aws ec2 create-instance`,
  `kubectl apply`, `terraform apply` — all become tasks inside
  the taskplus workflow when needed, consumed by chapter-4
  Specifications.
- **Step ordering of test phases.**  The order of "build → test
  → deploy" is in the taskplus workflow's `flow:` field, not in
  the vendor's `stages:` field.  The vendor YAML has *one* job
  per pipeline trigger; that job runs the workflow; the workflow
  handles its own internal ordering.
- **Cross-environment branching.**  No `if [ "$CI_COMMIT_BRANCH"
  == "main" ]; then ...; fi` in the vendor YAML.  The branch
  determines which profile to load (per chapter 13's ladder);
  the profile selector chooses the right configurations.
- **Secrets values.**  Only the *Vault-bootstrap* secrets live
  in the vendor's secret store.  The application's runtime
  secrets are fetched by the resolver from Vault.

## Multi-vendor support: same workflow, different shims

A team supporting both GitLab CI and GitHub Actions has two shim
files in the repository:

```
project-root/
├── .gitlab-ci.yml
├── .github/
│   └── workflows/
│       └── integration.yml
├── Jenkinsfile               # if also using Jenkins
├── config/
│   ├── workflows/
│   │   ├── integration.yaml          ← portable
│   │   └── nightly.yaml              ← portable
│   ├── profiles/
│   │   ├── integration-ci.yaml       ← portable
│   │   └── nightly-full-sweep.yaml   ← portable
│   └── ...                            ← all portable
└── ...
```

The vendor-specific files are short.  The portable files (under
`config/`) are long and information-dense.  The ratio is the
discipline's most visible operational property.

When the team migrates entirely off GitLab to GitHub Actions, the
`.gitlab-ci.yml` is deleted; everything else is unchanged.

## Operational concerns common to all vendors

Five concerns recur across vendors.  Each has a per-vendor
mechanism, but the *discipline* is the same.

### Artifact handling

The taskplus workflow writes outputs to `.output/` (the
chapter-7 runtime configuration's `paths.output` field).  The
vendor's job uploads the directory as an artifact when the job
ends.  Per-vendor:

- GitLab: `artifacts: paths: [.output/]`.
- GitHub Actions: `actions/upload-artifact@v4` with `path:
  .output/`.
- Jenkins: `archiveArtifacts artifacts: '.output/**'`.

When the test fails, the chapter-16 capture artifact
(`failure-snapshot.json`) is included alongside `.output/`.  The
developer downloads the failure snapshot and replays it
locally per chapter 16.

### Parallel matrix builds

When the workflow needs to run against multiple landscapes (e.g.,
staging-east *and* staging-west), the vendor's matrix-build
feature handles the fan-out.  The matrix variable becomes the
profile name:

```yaml
# GitLab CI
integration-multi:
  parallel:
    matrix:
      - PROFILE: [staging-east-ci, staging-west-ci]
  variables:
    AM_PROFILE: $PROFILE
  script:
    - ccai testbase test run --workflow config/workflows/integration.yaml
```

The same workflow runs twice in parallel, once with
`AM_PROFILE=staging-east-ci` and once with
`AM_PROFILE=staging-west-ci`.  No code change in the workflow;
two profile files declare the per-region differences (per chapter
14 and chapter 17).

GitHub Actions has `strategy.matrix:`; Jenkins has `parallel { ... }`
or `matrix { ... }` blocks.  The shape is consistent.

### Retries

When a CI vendor's *job* needs to be retryable (because of
infrastructure flakiness, not test failures), each vendor exposes
a retry mechanism:

- GitLab: `retry: max: 2 when: [runner_system_failure]`.
- GitHub Actions: there isn't one built in; teams use a
  third-party action like `nick-fields/retry`.
- Jenkins: `retry(2) { ... }` block.

The taskplus workflow's *internal* retry concerns are handled by
the chapter-23 `Retry` wrapper task — the Aspects pattern again,
applied to per-task flakiness rather than per-job infrastructure
flakiness.

### Caching

CI vendors offer caching for the dependency-install step.  The
runner image's pre-installed toolchain is the *primary* cache
(it doesn't change between runs); the project's poetry venv is
the *secondary* cache.

- GitLab: `cache: paths: [.venv/]`.
- GitHub Actions: `actions/cache@v4` with the venv path.
- Jenkins: `stash`/`unstash` or external cache plugins.

Caching is a vendor-specific optimisation.  The workflow's
behaviour is unchanged with or without caching; caching only
affects job latency.

### Notifications

Slack/email notifications on pipeline failure are a vendor-side
concern.  Each vendor has its own integration; the workflow
itself does not need to know.

When the workflow *itself* needs to send notifications during
execution (a "deploy started" message, a "smoke tests passed"
message), that's a *task* in the workflow — typically a
`RunCommand` calling a notification CLI or an `EmbeddedPython`
calling a webhook.  Chapter 23's extension recipe applies.

## Vendor migration: a worked example

A team currently on Jenkins decides to migrate to GitHub Actions.
The discipline makes the migration concrete and bounded.

### Day 0 — current state

Repository contents:

```
project-root/
├── Jenkinsfile               (33 lines, the shim)
├── config/
│   └── ... (all the typed configurations)
└── ...
```

Jenkins runs the integration suite on every PR via a webhook.

### Day 1 — add the GitHub Actions shim

A developer adds `.github/workflows/integration.yml` with the
GitHub Actions shape from earlier in the chapter.  The Jenkins
file remains unchanged.

```
project-root/
├── Jenkinsfile               (33 lines, unchanged)
├── .github/
│   └── workflows/
│       └── integration.yml   (43 lines, NEW)
├── config/
│   └── ... (unchanged)
└── ...
```

The first PR after this commit runs *both* CI systems against the
change.  The team uses this period to validate that GitHub
Actions produces the same results Jenkins does.

### Day 14 — disable Jenkins

The team disables the Jenkins webhook.  The `Jenkinsfile` remains
in the repository for now, in case rollback is needed.

### Day 30 — delete the Jenkins shim

After thirty days of clean GitHub Actions runs, the team deletes
`Jenkinsfile`.  The repository is now GitHub-only.

```
project-root/
├── .github/
│   └── workflows/
│       └── integration.yml   (43 lines, the only shim)
├── config/
│   └── ... (still unchanged)
└── ...
```

The `config/` directory was never touched during the migration.
Every typed configuration file, every workflow YAML, every
profile, every credential reference — none changed.  The
migration was confined to the vendor-specific shim.

This is the operational payoff the volume has been promising.
Vendor migration as a thirty-day project rather than a multi-
quarter rewrite is what the discipline delivers.

## Summary

- The CI vendor's YAML has **exactly two responsibilities**:
  trigger the workflow and provide a runner image.  Everything
  else is the taskplus workflow's responsibility.
- The taskplus invocation in every vendor's shim is one line:
  `ccai testbase test run --workflow <file>`, with
  `AM_PROFILE` set in the environment.
- **Three full vendor shims** (GitLab CI, GitHub Actions,
  Jenkins) are shown end-to-end; each is 28–43 lines.  The
  *structure* is identical across vendors; only the syntax
  differs.
- **What stays in vendor YAML**: trigger logic, runner image,
  toolchain bootstrap, vendor-specific Vault-bootstrap secrets.
  **What does not**: configuration content, imperative
  provisioning, step ordering of test phases, cross-environment
  branching, application secret values.
- **Operational concerns** — artifact handling, parallel
  matrix builds, retries, caching, notifications — each have
  per-vendor mechanisms with consistent shapes.  The discipline
  is the same; the syntax is per-vendor.
- **Multi-vendor support** is straightforward: one shim per
  vendor; the portable workflow + configurations under
  `config/` are unchanged across all vendors.
- **Vendor migration** is a 30-day project rather than a
  multi-quarter rewrite when the discipline holds.  The shims
  change; everything in `config/` does not.

The next chapter develops the bridges to other layers of the
ccai portfolio — orchestration → distributed test job (Volume
2), orchestration → sysman feature (Volume 4), orchestration →
provisioning (Volume 6).  Where this chapter showed the
*outward* wiring (CI vendor), the next chapter shows the
*downward* wiring (the layers below the orchestration that the
workflow's tasks invoke).


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/25-bridges-to-other-layers -->
# Chapter 25 — Bridges to Other Layers

> *Where chapter 24 showed the outward wiring (CI vendor), this
> chapter shows the downward wiring — the taskplus tasks that
> invoke the layers below the orchestration.*

The previous chapter wired the orchestration to the CI vendor.
This chapter wires the orchestration *downward* — to the layers
of the ccai portfolio that the workflow's tasks invoke.
Three bridges in particular:

1. **Orchestration → distributed test job** — the
   `DistributedTestJob` from Volume 2's `codecraftai-testplus`.
2. **Orchestration → sysman feature** — the cross-OS feature
   execution machinery from Volume 4's `codecraftai-sysman-*`
   packages.
3. **Orchestration → provisioning** — the Specification
   application from Volume 6's `codecraftai-provisioning` (which
   chapter 4 introduced).

Each bridge is a *task* — a concrete `Task` subclass that wraps
the relevant downstream API.  The pattern is uniform: the task
reads its required configuration from the active typed
configurations (per chapter 21's information-passing contract),
calls into the downstream library, and returns a result code.

This chapter is shorter than most because the pattern is small.
The chapter's job is to make the *pattern* visible across three
worked examples and to draw out the discipline that ties them
together.

## The composition pattern

The three bridges share a structure.  Each is a `Task` subclass
that:

1. **Reads its required configuration from the runtime API** —
   not from `os.environ`, not from the workflow's `parameters`,
   not from constructor-passed values that the workflow had to
   know about.  The active landscape, the active credentials,
   the active runtime are all already loaded by the time the
   task's `perform_task` runs.

   *A nuance worth pinning down:* "already loaded" means
   different things for different bridges.  For
   `DistributedTestJobTask`, the downstream `DistributedTestJob`
   reads Landscape, Topology, Runtime, and Credentials *itself*
   through the singleton managers (Volume 2 chapter 6's landscape
   activation hooks are where this happens) — the bridge task
   does not pre-load them.  For `ProvisioningTask`, the
   downstream `ProvisioningEngine` consumes Specifications
   *directly* (chapter 4) rather than through the configuration
   loader — credentials travel via the Specification's
   `CloudVenue` references, not via the orchestration's
   `AM_CREDENTIAL_NAMES` resolution.  The bridge task never
   duplicates the downstream API's loading; it relies on it.
   The orchestration's job is to hand off the *names*; the
   downstream library's job is to *resolve* them.

2. **Calls into the downstream library through its public API.**
   The downstream library — testplus, sysman-X, provisioning —
   exposes a small surface that the bridge task uses.  No
   reaching past the API into internals; no mutating internal
   state directly.

3. **Returns a single integer result.**  The downstream library
   may have rich result types (test result objects, sysman
   feature outcomes, provisioning state diffs); the bridge task
   collapses the result to the chapter-18 success/failure
   contract and writes anything richer to flowstate or to disk.

The discipline keeps the bridges *thin*.  A bridge task is
typically forty to eighty lines of Python — it's mostly glue,
not logic.

## Bridge 1: orchestration → distributed test job

A taskplus workflow that runs a distributed test job invokes a
`DistributedTestJob` from Volume 2's
[`codecraftai-testplus`](https://github.com/code-craft-ai-company/codecraftai-testplus/tree/main/source/packages/ccai/testplus).
The chain of classes — `TestJob` (in testbase) →
`InteropLibraryTestJob` (in interop) → `DistributedTestJob` (in
testplus) — is Volume 2's territory; this chapter just shows the
bridge from a workflow into the chain.

### YAML shape

```yaml
- task: Run integration suite
  tasktype: ccai.taskplus.tasks.distributedtest@DistributedTestJobTask
  test_root: source/testroots/ccai
  includes:
      - ccai.tests.integration
  excludes: []
  job_seed: ${parameters[job_seed]:-${RANDOM}}
```

Four task-specific fields:

- `test_root` — the testbase test-root directory.
- `includes` — list of test paths the job should include.
- `excludes` — list of test paths the job should exclude.
- `job_seed` — the chapter-16 reproducibility seed, optionally
  pulled from workflow parameters.

The active landscape, topology, runtime, credentials, and data
profiles are *already loaded* — the bridge doesn't need to read
them from the YAML.

### Task implementation

The bridge is roughly sixty lines:

```python
"""
.. module:: distributedtest
    :synopsis: Bridge task that runs a DistributedTestJob from within
               a taskplus workflow.
"""

from typing import Any, Dict

from ccai.errors.exceptions import ConfigurationError
from ccai.taskplus.model.task import Task


class DistributedTestJobTask(Task):
    """
        Runs a DistributedTestJob (Volume 2) from within a taskplus
        workflow.  The active landscape, topology, credentials, and
        runtime configurations are picked up automatically through
        the runtime API.
    """

    def __init__(self, ordinal: str, label: str, task_info: dict, logger):
        super().__init__(ordinal, label, task_info, logger)

        if "test_root" not in task_info:
            errmsg = "DistributedTestJobTask requires a 'test_root' field."
            raise ConfigurationError(errmsg)

        self._test_root = task_info["test_root"]
        self._includes = task_info.get("includes", [])
        self._excludes = task_info.get("excludes", [])
        self._job_seed = task_info.get("job_seed", None)

        return

    def perform_task(self, environment, parameters, flowstate) -> int:

        # Late import to avoid coupling taskplus to testplus at module load.
        from ccai.testplus.distributedtestjob import DistributedTestJob

        # The active landscape comes from the resolved configuration —
        # the chapter-21 bootstrap-name flow already loaded it.
        job = DistributedTestJob(
            test_root=self._test_root,
            includes=self._includes,
            excludes=self._excludes,
            job_seed=self._job_seed,
        )

        self._logger.info(
            "Distributed test job: includes=%s excludes=%s seed=%s" %
            (self._includes, self._excludes, self._job_seed)
        )

        result = job.run()

        flowstate["distributedtest.last_results_path"] = result.results_path
        flowstate["distributedtest.passed_count"] = result.passed_count
        flowstate["distributedtest.failed_count"] = result.failed_count

        if result.failed_count > 0:
            self._logger.error(
                "Distributed test job: %d tests failed (results: %s)" %
                (result.failed_count, result.results_path)
            )
            return 1

        return 0
```

Three things worth naming.

#### Late import

The `from ccai.testplus.distributedtestjob import
DistributedTestJob` is inside `perform_task`, not at module top.
This is deliberate: a workflow that uses taskplus but *not*
testplus shouldn't pay the cost of importing testplus.  The late
import means the dependency is *optional* — `codecraftai-taskplus`
can be installed without `codecraftai-testplus` and the bridge
class is only constructible when the dependency is present.

The technique generalises.  Bridges that wrap optional dependencies
(testplus, sysman-X, gitlab, etc.) use late imports so the
bridge is loadable at module-import time even when the
dependency isn't installed.

#### Configuration is implicit

The bridge does *not* take `landscape:`, `topology:`,
`credentials:`, `runtime:` fields.  Those configurations are
*already loaded* — the chapter-21 bootstrap-name flow set
`AM_LANDSCAPE_NAMES`, etc., in the orchestration's environment;
the orchestration's startup loaded the named files.  The
distributed-test-job machinery reads them through the runtime
API (`LandscapeSingleton()`, `CredentialManagerSingleton()`,
etc.).

This is the chapter's structural point: the bridge wraps an API
call.  The API has its own contract for finding configurations
(through the runtime API).  The bridge does not duplicate the
contract; it relies on it.

#### Flowstate writes

The bridge writes three flowstate keys with the
`distributedtest.` namespace prefix (chapter 21's convention):

- `distributedtest.last_results_path` — where the JSOS results
  stream lives on disk.
- `distributedtest.passed_count` — count of passed tests.
- `distributedtest.failed_count` — count of failed tests.

A subsequent task in the workflow — say, a notification task
that posts pass/fail counts to Slack — reads these flowstate
keys and uses them.  The bridge does *not* attempt to do the
notification itself; that's a separate task with its own
concerns.

### Cross-references to Volume 2

The full distributed-test-job machinery — the `TestJob` →
`InteropLibraryTestJob` → `DistributedTestJob` inheritance
chain, the landscape-activation hooks, the
`DistributedTestSequencer`, the result aggregation — is the
subject of Volume 2.  This chapter's bridge task is the
*entrance* to that machinery from a taskplus workflow.

A reader who wants more detail than the bridge provides — how
does the distributed sequencer work?  what are the
`fire_*` lifecycle hooks?  how does landscape activation
propagate to remote workers? — should read Volume 2 chapter 4
(landscape activation) and chapter 9 (result aggregation).

## Bridge 2: orchestration → sysman feature

A workflow that runs a sysman feature against a fleet — for
example, "set the firewall ruleset on every host in the active
landscape" — invokes one of the
[`codecraftai-sysman-*`](https://github.com/code-craft-ai-company/codecraftai-sysman-common) packages
through a small bridge task.

### YAML shape

```yaml
- task: Configure firewall on staging
  tasktype: ccai.taskplus.tasks.sysmanfeature@SysmanFeatureTask
  feature: networking.firewall
  action: configure
  parameters:
      ruleset_name: ci-restricted
      allow_inbound:
          - tcp/22
          - tcp/443
      default_deny: true
```

Three task-specific fields:

- `feature` — the cross-OS contract identifier (chapter 4 of
  Volume 4 catalogues these).
- `action` — the verb (`configure`, `enable`, `query`, `apply`)
  the feature exposes.
- `parameters` — feature-specific arguments.

### Task implementation

```python
class SysmanFeatureTask(Task):
    """
        Runs a sysman feature (Volume 4) against the apod members
        of the active landscape.  The feature is dispatched to the
        per-OS implementation automatically.
    """

    def __init__(self, ordinal, label, task_info, logger):
        super().__init__(ordinal, label, task_info, logger)

        for field in ("feature", "action"):
            if field not in task_info:
                errmsg = "SysmanFeatureTask requires '%s' field." % field
                raise ConfigurationError(errmsg)

        self._feature = task_info["feature"]
        self._action = task_info["action"]
        self._parameters = task_info.get("parameters", {})

        return

    def perform_task(self, environment, parameters, flowstate) -> int:

        from ccai.sysman.common.featurebase import (
            run_feature_action_on_landscape,
        )

        self._logger.info(
            "Sysman feature: %s.%s on landscape members" %
            (self._feature, self._action)
        )

        results = run_feature_action_on_landscape(
            feature=self._feature,
            action=self._action,
            parameters=self._parameters,
        )

        flowstate["sysmanfeature.last_results"] = results

        failed_hosts = [
            host for host, result in results.items()
            if result.exit_code != 0
        ]

        if len(failed_hosts) > 0:
            self._logger.error(
                "Sysman feature %s.%s failed on %d hosts: %s" %
                (self._feature, self._action, len(failed_hosts), failed_hosts)
            )
            return 1

        return 0
```

Three observations:

#### The feature runs against the landscape

The bridge does not take a list of hosts.  The
`run_feature_action_on_landscape` function (a stand-in for
Volume 4's actual API) iterates the *active landscape*'s apod
members and dispatches the feature to each.  The same task
running against `staging-east` runs against three runners;
running against `local-dev` runs against one local machine; the
task's YAML doesn't change.

This is the same configuration-implicit pattern as Bridge 1.
The discipline is uniform: the bridge wraps an API; the API
already knows how to find the landscape; the bridge passes
through.

#### The cross-OS contract is the boundary

A workflow that says `feature: networking.firewall` on a
landscape with one Linux runner and two macOS runners gets
*three different per-OS implementations* dispatched
automatically.  The cross-OS contract from Volume 4 chapter 2
(the 52 contracts catalogued there) is the typed surface; the
per-OS implementations are the dispatch targets.

The workflow author writing `feature: networking.firewall` does
not need to know that the Linux implementation calls `ufw` and
the macOS implementation calls `pfctl` and the Windows
implementation calls PowerShell `NetSecurity`.  The contract
is the abstraction; the dispatch happens at the sysman library
level.

#### Per-host results

Sysman features run against multiple hosts; the result is a
dict keyed by host name.  The bridge writes the full dict to
flowstate (`sysmanfeature.last_results`) and computes a single
pass/fail decision for the engine — succeed if all hosts
succeeded; fail if any host failed.

A more sophisticated workflow might want partial success
semantics — "fail only if more than 50% of hosts failed" — and
would use a custom bridge task or a chapter-23 wrapper.  The
shipped bridge is uniform; teams that need richer semantics
extend.

### Cross-references to Volume 4

The full sysman story — the 52 cross-OS contracts, the
`featurebase` machinery, the per-OS implementations for RHEL,
Ubuntu, macOS, Windows, UEFI — is Volume 4.  The bridge above
is the *entrance* from a workflow.  Readers who want more than
the bridge provides should read Volume 4 chapters 2
(contracts), 3 (`featurebase`), and the per-OS chapters
relevant to their fleet.

## Bridge 3: orchestration → provisioning

A workflow that needs to provision (or tear down) cloud
resources before (or after) the rest of its work invokes
`ProvisioningEngine` from
[`codecraftai-provisioning`](https://github.com/code-craft-ai-company/codecraftai-provisioning/tree/main/source/packages/ccai/provisioning),
introduced in chapter 4.

### YAML shape

```yaml
- task: Provision staging-east
  tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
  specification: config/specs/spec-staging-east.yaml
  action: apply           # apply | teardown | query
```

Two task-specific fields:

- `specification` — path to the chapter-4 Specification YAML.
- `action` — `apply` (default), `teardown`, or `query`.

### Task implementation

```python
class ProvisioningTask(Task):
    """
        Applies, tears down, or queries an codecraftai-provisioning
        specification (chapter 4) from within a taskplus workflow.
    """

    VALID_ACTIONS = frozenset({"apply", "teardown", "query"})

    def __init__(self, ordinal, label, task_info, logger):
        super().__init__(ordinal, label, task_info, logger)

        if "specification" not in task_info:
            errmsg = "ProvisioningTask requires a 'specification' field."
            raise ConfigurationError(errmsg)

        self._specification = task_info["specification"]
        self._action = task_info.get("action", "apply")

        if self._action not in self.VALID_ACTIONS:
            errmsg = (
                "ProvisioningTask action must be one of %s; got %r" %
                (sorted(self.VALID_ACTIONS), self._action)
            )
            raise ConfigurationError(errmsg)

        return

    def perform_task(self, environment, parameters, flowstate) -> int:

        from ccai.provisioning.provisioningengine import (
            ProvisioningEngine,
        )

        self._logger.info(
            "Provisioning %s: %s" % (self._action, self._specification)
        )

        engine = ProvisioningEngine.create_provisioning_engine(
            specification=self._specification
        )

        if self._action == "apply":
            result = engine.apply()
        elif self._action == "teardown":
            result = engine.teardown()
        elif self._action == "query":
            result = engine.query()

        flowstate["provisioning.last_action"] = self._action
        flowstate["provisioning.last_specification"] = self._specification
        flowstate["provisioning.last_result"] = result

        if result.errors:
            for err in result.errors:
                self._logger.error("Provisioning error: %s" % err)
            return 1

        return 0
```

Three observations:

#### Action enumeration

The task validates `action` against a fixed set of allowed
values at construction time.  An unknown action fails *to load*,
not *to execute* — the workflow author finds out about the typo
before the workflow starts.  The pattern is the same as the
chapter-19 task validation pattern; both use the same fail-fast
discipline.

#### The Specification is path-based

The `specification:` field is a path, not a name.  This is a
deliberate departure from the chapter-3 lookup-by-name pattern
the other categories use.  Specifications live outside the
configuration loader (chapter 4 explained why); the workflow
references them by path.

When a team wants the lookup-by-name behaviour for
specifications, the workflow author can build a thin adapter —
construct the path from a chapter-7 runtime configuration's
`paths.specifications_dir` plus the spec name.  The discipline
is the workflow author's; the shipped bridge is intentionally
narrow.

#### Apply, teardown, query as separate calls

The three actions are *separate methods* on the engine.  This is
deliberate: each has different semantics, different failure
modes, different audit requirements.  The bridge task chooses
the right one and lets the engine do the work.

A workflow that provisions before the test phase and tears down
after looks like this:

```yaml
flow:
    - task: Provision
      tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
      specification: config/specs/spec-staging-east.yaml
      action: apply

    - taskgroup: Test phase
      items:
          # ... test tasks ...

    - task: Teardown
      tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
      specification: config/specs/spec-staging-east.yaml
      action: teardown
      if: "parameters['cleanup_after_run'] == True"
```

The same Specification is applied at the start and torn down at
the end.  The Specification's idempotent-by-design property
(chapter 4) makes both safe.

### Cross-references to Volume 6

The full provisioning story — vendor-neutral resource models,
AWS / Azure / GCP backends, the four-layer abstraction (Venue,
Group, Profile, Device) — is Volume 6's territory.  The bridge
above is the *entrance* from a workflow.  Volume 6 chapter 4
(the cloud provisioning model) develops the implementation in
depth.

## A workflow combining all three bridges

To make the composition concrete, here is a workflow that uses
all three bridges in one pipeline:

```yaml
name: nightly-integration-with-provisioning
parameters:
    cleanup_after_run: true

flow:
    # Bridge 3: provision the test environment
    - task: Provision staging-east
      tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
      specification: config/specs/spec-staging-east.yaml
      action: apply

    # Bridge 2: configure firewalls on the provisioned hosts
    - task: Configure firewall
      tasktype: ccai.taskplus.tasks.sysmanfeature@SysmanFeatureTask
      feature: networking.firewall
      action: configure
      parameters:
          ruleset_name: ci-restricted
          allow_inbound:
              - tcp/22
              - tcp/443
          default_deny: true

    # Bridge 1: run the distributed integration suite
    - task: Run integration suite
      tasktype: ccai.taskplus.tasks.distributedtest@DistributedTestJobTask
      test_root: source/testroots/ccai
      includes:
          - ccai.tests.integration
      job_seed: ${parameters[job_seed]:-${RANDOM}}

    # Bridge 3 again: tear down the test environment
    - task: Teardown
      tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
      specification: config/specs/spec-staging-east.yaml
      action: teardown
      if: "parameters['cleanup_after_run'] == True"
```

Four tasks; four downstream-library invocations.  The workflow
itself is twenty-five lines.  The downstream libraries —
provisioning, sysman, testplus, plus the testbase substrate —
together represent thousands of lines of substantial
implementation, all hidden behind the small typed surface this
chapter has shown.

This is the chapter's central claim: **the orchestration
expresses *what* should happen as a sequence of typed
invocations into other layers**.  The *how* of each invocation
is the responsibility of the layer.  When the workflow is
right-sized (two dozen lines), the downstream layers do the
substantial work; when the workflow is wrong-sized (hundreds of
lines), the workflow has absorbed responsibilities that
belong elsewhere.

## Anti-patterns

Three patterns that look like bridges but aren't.

### Reaching past the API

A bridge task that imports `ccai.testplus.internal._sequencer_state`
and mutates it directly is reaching past the public API.  When
the downstream library refactors, the bridge breaks; when the
breaking change reaches the workflow, the workflow author is
the one debugging it.

The discipline is: the bridge calls *only* the downstream
library's public API.  When the public API doesn't expose what
the workflow needs, the right answer is to *propose the API*
(submit a PR to the downstream library), not to reach past it.

### Doing the layer's work in the bridge

A bridge task that *re-implements* part of the downstream
library's logic — loading credentials manually instead of
letting the testplus job load them, dispatching per-OS code
manually instead of using the sysman library's dispatch — has
absorbed responsibilities that belong elsewhere.

The bridge is *thin* by design.  When the bridge gets fat —
more than a hundred lines for a single downstream invocation —
something has gone wrong.  Either the downstream API is
inadequate (propose a PR to it) or the bridge is doing work it
shouldn't (extract the work back into the downstream library).

### Bridges that compose multiple layers

A bridge task that "runs distributed tests against a freshly
provisioned environment" — combining Volume 2 and Volume 6 in
one task — is doing the workflow's job inside a task.  The
right shape is two tasks (one for provisioning, one for
distributed testing) sequenced in the workflow.

The discipline is: each bridge wraps *one* downstream library.
Composition happens at the workflow layer, not inside any
single task.  When the workflow needs the composition shape
"provision, then test, then tear down," the workflow expresses
it as three tasks; the engine sequences them.

## Summary

- Three bridges connect the taskplus orchestration layer to the
  rest of the ccai portfolio: **DistributedTestJob** (Volume
  2), **sysman feature** (Volume 4), **provisioning** (Volume
  6).  Each is a thin `Task` subclass — typically 50–80 lines
  — that wraps a single downstream API call.
- The composition pattern is uniform: read configuration through
  the runtime API (not from `os.environ` or workflow YAML
  values), call into the downstream library through its public
  surface, return a single integer result, write rich state to
  flowstate.
- Bridges use **late imports** so the dependency on the
  downstream library is optional — `codecraftai-taskplus` can
  install without `codecraftai-testplus` and the testplus bridge
  class is only constructible when the dependency is present.
- The downstream APIs read configuration through the runtime
  API automatically.  The bridge does *not* take landscape,
  topology, credentials, or runtime fields in its YAML — those
  are already loaded by chapter-21's bootstrap-name flow.
- A worked example combines all three bridges in one workflow:
  provision the environment, configure firewalls, run the
  distributed test suite, tear down.  Twenty-five lines of YAML;
  thousands of lines of underlying implementation.
- Three anti-patterns: reaching past the downstream library's
  API into internals, doing the layer's work in the bridge,
  composing multiple layers in a single bridge.  The discipline
  is uniform — bridges are thin, composition is at the
  workflow layer.

The next chapter is the case-study capstone of the volume — a
worked port of a real CI pipeline (a 200-line GitLab YAML with
47 environment variables) into the discipline.  Where this
chapter showed three abstract bridges, the next chapter shows
the discipline as an end-to-end migration of a system that
actually runs in production.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/26-case-study -->
# Chapter 26 — Case Study: Porting a Real Pipeline

> *Twenty-five chapters of discipline applied to one file.  The
> file goes from 200 lines and 47 environment variables to 30
> lines and zero application-content variables.  This is the
> chapter that shows the discipline pays off when applied to
> something with the proportions of a real problem.*

The case study below is composite — drawn from patterns that
recur across real CI pipelines without being any one team's
specific code.  The shape is realistic: a pipeline for a Python
web service with a Postgres backing store, deployed across two
regions of staging plus production-readonly debugging.  The
pipeline has accumulated cruft over three years; the engineers
who originally wrote each section have rotated off the team; the
README's "you might also need to..." section runs to fifteen
lines.

The chapter walks the migration in commit-sized steps.  Each step
produces a reviewable diff; each step is independently revertable;
each step makes the pipeline shorter and more maintainable.  By
the end of the chapter, the pipeline's surface area is a third
of what it was, every value has a typed home, and a vendor
migration would take thirty days rather than a quarter.

## Day 0 — The before state

The current `.gitlab-ci.yml`:

```yaml
# .gitlab-ci.yml — before (200 lines, abridged)
stages:
  - build
  - test
  - deploy
  - cleanup

variables:
  PYTHON_VERSION: "3.11"
  POETRY_VERSION: "1.7.1"
  AWS_DEFAULT_REGION: "us-east-1"
  AWS_ACCOUNT_ID: "123456789012"
  STAGING_DB_HOST: "db-staging-east-1.internal"
  STAGING_DB_PORT: "5432"
  STAGING_DB_NAME: "app"
  STAGING_DB_USER: "app_user"
  STAGING_API_HOST: "api-staging-east.internal"
  STAGING_API_PORT: "8443"
  REDIS_HOST: "redis-staging-east-1.internal"
  REDIS_PORT: "6379"
  PROD_DB_HOST: "db-prod-east-1.internal"
  PROD_DB_PORT: "5432"
  PROD_API_HOST: "api-prod-east.internal"
  PROD_API_PORT: "443"
  WEST_DB_HOST: "db-staging-west-1.internal"
  WEST_REDIS_HOST: "redis-staging-west-1.internal"
  LOG_LEVEL: "INFO"
  LOG_FILE_LEVEL: "DEBUG"
  RETRY_LIMIT: "3"
  RETRY_BACKOFF_MS: "500"
  PARALLELISM: "8"
  TIMEOUT_DEFAULT: "30"
  TIMEOUT_DB: "60"
  TIMEOUT_API: "10"
  OUTPUT_DIR: "/var/log/integration"
  DEBUGGER: "none"
  BREAKPOINT_AT: "none"
  ARTIFACT_BUCKET: "company-ci-artifacts"
  CACHE_BUCKET: "company-ci-cache"
  IMAGE_REGISTRY: "registry.example.com"
  RUNNER_IMAGE: "registry.example.com/runner:latest"
  TEST_DB_SEED_FILE: "/tmp/seed.sql"
  TEST_TIMEOUT_MULTIPLIER: "1"
  ENABLE_NEW_CACHING: "false"
  ENABLE_METRICS_V2: "false"
  ENABLE_TRACING: "true"
  TRACING_SAMPLE_RATE: "0.1"
  CLEANUP_AFTER_RUN: "true"
  NOTIFY_ON_FAILURE: "true"
  SLACK_WEBHOOK_BOT: "ci-bot-staging-east"
  PAGERDUTY_SERVICE: "staging-east-ci"
  GITHUB_TOKEN: ""              # populated from CI variables (masked)
  AWS_ACCESS_KEY_ID: ""         # populated (masked)
  AWS_SECRET_ACCESS_KEY: ""     # populated (masked)
  STAGING_DB_PASSWORD: ""       # populated (masked)
  PROD_DB_RO_PASSWORD: ""       # populated (masked)
  REDIS_PASSWORD: ""            # populated (masked)

default:
  image: $RUNNER_IMAGE
  before_script:
    - poetry install --no-interaction
    - if [ "$CI_PIPELINE_SOURCE" == "schedule" ]; then export TEST_TIMEOUT_MULTIPLIER=3; fi
    - if [ "$CI_COMMIT_REF_NAME" == "main" ]; then export DEPLOY_ENABLED=true; else export DEPLOY_ENABLED=false; fi

build-image:
  stage: build
  script:
    - docker build -t $IMAGE_REGISTRY/app:$CI_COMMIT_SHORT_SHA .
    - echo "$DOCKER_PASSWORD" | docker login $IMAGE_REGISTRY -u $DOCKER_USER --password-stdin
    - docker push $IMAGE_REGISTRY/app:$CI_COMMIT_SHORT_SHA
    - if [ "$CI_COMMIT_REF_NAME" == "main" ]; then docker tag $IMAGE_REGISTRY/app:$CI_COMMIT_SHORT_SHA $IMAGE_REGISTRY/app:latest; docker push $IMAGE_REGISTRY/app:latest; fi
  artifacts:
    when: on_success
    paths:
      - .build/
    expire_in: 1 day

provision-staging:
  stage: build
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - aws ec2 describe-instances --filters "Name=tag:Project,Values=staging-east" --query 'Reservations[*].Instances[*].[InstanceId,State.Name]' --output text | grep running || aws ec2 run-instances --image-id ami-0abcd1234 --count 3 --instance-type t3.medium --tag-specifications 'ResourceType=instance,Tags=[{Key=Project,Value=staging-east},{Key=Lifecycle,Value=ephemeral}]'
    - aws ec2 wait instance-running --filters "Name=tag:Project,Values=staging-east"
    - sleep 30   # let userdata complete
    - ./scripts/configure-firewall.sh staging-east
    - ./scripts/seed-database.sh $STAGING_DB_HOST $STAGING_DB_USER $STAGING_DB_PASSWORD < $TEST_DB_SEED_FILE

smoke-tests:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - export TEST_LOG_LEVEL=$LOG_LEVEL
    - export TEST_PARALLELISM=$PARALLELISM
    - export TEST_TIMEOUT=$(($TIMEOUT_DEFAULT * $TEST_TIMEOUT_MULTIPLIER))
    - if [ "$CI_PIPELINE_SOURCE" == "schedule" ]; then export TEST_PROFILE=full; else export TEST_PROFILE=fast; fi
    - export DATABASE_URL="postgresql://$STAGING_DB_USER:$STAGING_DB_PASSWORD@$STAGING_DB_HOST:$STAGING_DB_PORT/$STAGING_DB_NAME?sslmode=require"
    - export REDIS_URL="redis://default:$REDIS_PASSWORD@$REDIS_HOST:$REDIS_PORT"
    - export API_BASE_URL="https://$STAGING_API_HOST:$STAGING_API_PORT"
    - poetry run pytest tests/smoke -v --output-dir=$OUTPUT_DIR/smoke
    - if [ "$NOTIFY_ON_FAILURE" == "true" ] && [ $? -ne 0 ]; then curl -X POST -H 'Content-type: application/json' --data "{\"text\":\"Smoke tests failed in $CI_JOB_URL\"}" $SLACK_WEBHOOK_URL; fi
  artifacts:
    when: always
    paths:
      - $OUTPUT_DIR/smoke
    expire_in: 7 days

integration-tests:
  stage: test
  needs: [smoke-tests]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    # ... 35 more lines, structurally identical to smoke-tests but with
    # different test paths, slightly different DATABASE_URL, retries
    # configured by hand, conditional logic for nightly vs PR runs ...
  artifacts:
    when: always
    paths:
      - $OUTPUT_DIR/integration
    expire_in: 7 days

deploy-staging:
  stage: deploy
  needs: [integration-tests]
  rules:
    - if: $CI_COMMIT_REF_NAME == "main" && $CI_PIPELINE_SOURCE == "push"
  script:
    - kubectl set image deployment/app app=$IMAGE_REGISTRY/app:$CI_COMMIT_SHORT_SHA -n staging-east
    - kubectl rollout status deployment/app -n staging-east --timeout=5m
    # ... 20 lines of post-deploy smoke verification with hardcoded URLs ...

cleanup:
  stage: cleanup
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: always
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: always
  script:
    - if [ "$CLEANUP_AFTER_RUN" == "true" ]; then
        aws ec2 terminate-instances --instance-ids $(aws ec2 describe-instances --filters "Name=tag:Project,Values=staging-east" --query 'Reservations[*].Instances[*].InstanceId' --output text);
      fi
```

Two hundred lines.  Forty-seven environment variables (six masked
secrets plus forty-one application-content vars).  Five stages.
Inline AWS commands.  Hardcoded hostnames in five places.  Three
shell-conditional branches keyed off `CI_PIPELINE_SOURCE`.  Two
URL-construction lines that concatenate hostnames, ports,
credentials, database names, and TLS flags.  One
notification-on-failure block embedded in the test script.

Walk through what's wrong using the chapter-2 failure-mode
catalogue:

| Failure mode | Where it appears |
|---|---|
| 1. Env-var accretion | The whole `variables:` block — 47 entries |
| 2. Conditional thicket | The `before_script:` lines that set `TEST_TIMEOUT_MULTIPLIER` and `DEPLOY_ENABLED` based on multiple env-var checks |
| 3. Inlined recipe | `provision-staging`'s `aws ec2 run-instances` invocation |
| 4. Credential leak through env | `STAGING_DB_PASSWORD`, `REDIS_PASSWORD`, etc. exposed as environment variables |
| 5. Host name in YAML | `STAGING_DB_HOST`, `WEST_DB_HOST`, `PROD_DB_HOST`, all hardcoded |
| 6. Topology buried in step ordering | `needs:` declarations encode the test-then-deploy graph; cleanup runs `when: always` regardless of which earlier steps fired |

All six failure modes present in one file.  This is the proof
that the chapter-2 failure modes are not abstract — they
actually compound, and they actually accumulate in real
pipelines.

## Day 1 — Inventory and triage

The first migration step is *no code change*.  The team
inventories the file and groups every value by category.  An
afternoon of work; produces a spreadsheet (or for a smaller
project, a markdown table):

| Value | Category | Notes |
|---|---|---|
| `STAGING_DB_HOST` / `PORT` / `NAME` | Landscape (infrastructure service) | Will become a `web/database` infrastructure entry |
| `STAGING_API_HOST` / `PORT` | Landscape (infrastructure service) | Will become a `web/api` infrastructure entry |
| `REDIS_HOST` / `PORT` | Landscape (infrastructure service) | Will become a `cache/redis` infrastructure entry |
| `WEST_DB_HOST` / `WEST_REDIS_HOST` | Landscape (different env) | Will be in `landscape-staging-west.yaml` |
| `PROD_DB_HOST` / `PROD_API_HOST` | Landscape (different env, prod-readonly only) | Will be in `landscape-prod-readonly.yaml` |
| `LOG_LEVEL`, `LOG_FILE_LEVEL` | Runtime | Goes into `runtime-staging-east.yaml` |
| `RETRY_LIMIT`, `RETRY_BACKOFF_MS` | Runtime | Same |
| `PARALLELISM` | Runtime | Same |
| `TIMEOUT_DEFAULT` / `_DB` / `_API` | Runtime | Same |
| `OUTPUT_DIR` | Runtime | Same |
| `DEBUGGER`, `BREAKPOINT_AT` | Runtime | Same |
| `TEST_TIMEOUT_MULTIPLIER` | Runtime (different per profile) | Encodes the nightly-vs-PR difference; goes in profile-keyed runtimes |
| `ENABLE_NEW_CACHING`, `ENABLE_METRICS_V2`, `ENABLE_TRACING`, `TRACING_SAMPLE_RATE` | Runtime (feature flags) | Same |
| `STAGING_DB_PASSWORD` | Credential | Becomes `staging-db-rw` credential reference |
| `PROD_DB_RO_PASSWORD` | Credential | Becomes `prod-db-readonly` |
| `REDIS_PASSWORD` | Credential | Becomes `staging-redis-default` |
| `GITHUB_TOKEN` | Credential | Becomes `ci-bot-github` |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Credential | Becomes `ci-runner-aws` |
| `DATABASE_URL` (constructed) | Data Profile | The string isn't stored; it's built from the data-profile + credential at runtime |
| `REDIS_URL` (constructed) | Data Profile | Same |
| `API_BASE_URL` (constructed) | Data Profile | Same (a `web/api` profile) |
| `PYTHON_VERSION`, `POETRY_VERSION` | Toolchain manifest | Stays in `.tool-versions` |
| `AWS_DEFAULT_REGION`, `AWS_ACCOUNT_ID` | Specification (provisioning venue) | Goes in `spec-staging-east.yaml` |
| `aws ec2 run-instances ...` recipe | Specification (provisioning device) | Same |
| `ARTIFACT_BUCKET`, `CACHE_BUCKET`, `IMAGE_REGISTRY` | Runtime (infrastructure addresses for CI) | Could go in runtime; arguable |
| `CLEANUP_AFTER_RUN`, `NOTIFY_ON_FAILURE` | Workflow parameters | Per-run flags; go in workflow YAML's `parameters:` |
| `SLACK_WEBHOOK_BOT`, `PAGERDUTY_SERVICE` | Landscape (infrastructure service) | These name external services |
| `RUNNER_IMAGE` | Vendor YAML | Stays in `.gitlab-ci.yml`'s `image:` |

Forty-seven values become entries in six categories (plus a
short fixed-vendor-shim list).  No code changes yet — the
inventory is the *plan*.  The implementation that follows is
mechanical.

## Day 5 — Author the typed configurations

The team authors five configuration files plus a profile set
plus a Specification file, in that order.  Each file lands as
its own commit; the team reviews each separately.

### Commit 1: `config/landscape/landscape-staging-east.yaml`

```yaml
environment:
    label: staging-east

apod:
    clients:
        - deviceType: network/client-linux
          name: ci-runner-1
          host: <set by spec-staging-east>
          role: runner
          credentials: [ci-runner-ssh]
          features: {parallelism_max: 8}
          skip: false

infrastructure:
    services:
        - serviceType: database/postgres
          name: staging-db
          host: db-staging-east-1.internal
          port: 5432
          database: app
          credentials: [staging-db-rw]

        - serviceType: cache/redis
          name: staging-redis
          host: redis-staging-east-1.internal
          port: 6379
          credentials: [staging-redis-default]

        - serviceType: web/api
          name: staging-api
          endpoint: https://api-staging-east.internal:8443
          credentials: [staging-api-token]

        - serviceType: web/webhook
          name: slack-notifications
          endpoint: ${VAULT:/secret/staging-east/slack-webhook}
          credentials: [slack-webhook]

        - serviceType: pagerduty/service
          name: ci-pager
          service_id: "staging-east-ci"
          credentials: [pagerduty-token]
```

Diff: +28 lines added.  No removals from `.gitlab-ci.yml` yet.
The commit is *additive* — the team adds the file to the
repository without breaking anything.

### Commit 2: similar landscape files for staging-west and prod-readonly

```
config/landscape/
├── landscape-staging-east.yaml         (commit 1)
├── landscape-staging-west.yaml         (commit 2)
├── landscape-prod-readonly.yaml        (commit 2)
└── landscape-local-dev.yaml            (commit 2)
```

Three more landscape files for the other environments.  Each
follows the same shape; only the hostnames and credential
identifiers differ.

### Commit 3: data profiles file

```yaml
# config/dataprofiles/dataprofiles.yaml
dataprofiles:
    - identifier: staging-db-rw
      category: basic-tcp
      host: db-staging-east-1.internal
      port: 5432
      dbtype: postgres
      dbname: app
      credential: staging-db-rw

    - identifier: staging-redis
      category: basic-tcp
      host: redis-staging-east-1.internal
      port: 6379
      dbtype: redis
      credential: staging-redis-default

    # ... staging-west variants, prod-readonly variants
```

The data profiles are dispatch entries for the per-environment
landscape variants.  Each profile carries its `host` field
(self-contained per chapter 8); the credential is referenced by
identifier.

### Commit 4: credentials file

```yaml
# config/credentials/credentials.yaml
credentials:
    - identifier: ci-runner-ssh
      categories: [ssh, environment:integration-ci]
      username: runner
      keyfile: ${FILE:/run/secrets/ssh/ci-runner-rsa}

    - identifier: staging-db-rw
      categories: [basic, environment:staging-east]
      username: app_user
      password: ${VAULT:/secret/staging-east/db-rw#password}

    - identifier: staging-redis-default
      categories: [basic, environment:staging-east]
      username: default
      password: ${VAULT:/secret/staging-east/redis#password}

    - identifier: ci-bot-github
      categories: [github-token, environment:integration-ci]
      username: ci-bot
      token: ${VAULT:/secret/ci/github-pat#token}

    - identifier: ci-runner-aws
      categories: [aws-access-key, environment:integration-ci, ttl:900]
      keyid: ${VAULT:/secret/ci/aws/runner#keyid}
      secret: ${VAULT:/secret/ci/aws/runner#secret}

    - identifier: prod-db-readonly
      categories: [basic, environment:prod, role:readonly, audit:required, ttl:300]
      username: ${VAULT:/secret/prod/db-readonly#username}
      password: ${VAULT:/secret/prod/db-readonly#password}

    # ... and so on
```

Every secret value is a `${VAULT:...}` reference (per chapter
10).  Every credential carries an `environment:<name>` scope tag
(per chapter 12).  The file is committable to the repository
without leaking any secret material.

### Commit 5: runtime profiles

```yaml
# config/runtime/runtime-base.yaml
breakpoints: []
debugger: none
logging:
    levels:
        console: INFO
        logfile: DEBUG
parallelism:
    max_workers: 8
retry:
    limit: 3
    backoff_ms: 500
timeouts:
    default_seconds: 30
    db_seconds: 60
    api_seconds: 10
paths:
    output: ./.output
feature_flags:
    enable_new_caching: false
    enable_metrics_v2: false
tracing:
    enabled: true
    sample_rate: 0.1
version: 1.0.0
```

```yaml
# config/runtime/runtime-integration-ci.yaml
# (inherits from base via profile composition)
property_test_caps:
    max_examples: 10           # fast-CI cap
```

```yaml
# config/runtime/runtime-nightly-full-sweep.yaml
property_test_caps:
    max_examples: null         # full sweep
timeouts:
    default_seconds: 90        # 3x multiplier from the original
                               # TEST_TIMEOUT_MULTIPLIER=3 logic
```

```yaml
# config/runtime/runtime-local-dev.yaml
logging:
    levels:
        console: DEBUG
timeouts:
    default_seconds: 300       # generous for breakpoints
property_test_caps:
    max_examples: 5
```

Four runtimes; each layers on top of `base`.  The chapter-7
runtime-vs-env-var thesis applied: every behaviour knob lives
in a runtime YAML field; the workflow does not export any of
them.

### Commit 6: provisioning specification

```yaml
# config/specs/spec-staging-east.yaml
environment:
    venues:
        - name: aws-us-east-1
          provider: aws
          management_url: https://ec2.us-east-1.amazonaws.com
          subscription: "123456789012"
          region: us-east-1
          credential: ci-runner-aws
    groups:
        - name: staging-east
          description: "Staging east runner pool"
          tags:
              - {key: Project, value: staging-east}
              - {key: Lifecycle, value: ephemeral}

profiles:
    - name: runner-medium
      description: "Standard medium-sized integration runner"
      credential: ci-runner-aws
      parameters:
          instance_type: t3.medium
          image: ami-0abcd1234
      tags: [{key: role, value: runner}]

apod:
    devices:
        - identifier: ci-runner-1
          category: ec2-instance
          group: staging-east
          profile: runner-medium
          role: runner
        - identifier: ci-runner-2
          category: ec2-instance
          group: staging-east
          profile: runner-medium
          role: runner
        - identifier: ci-runner-3
          category: ec2-instance
          group: staging-east
          profile: runner-medium
          role: runner
```

The chapter-4 worked example specialised to this project.  The
40-line `aws ec2 run-instances` shell from the original is now
20 lines of declarative YAML; the chapter-4 idempotent-on-re-run
property means the cleanup-and-recreate dance the original did
manually is unnecessary.

### Commit 7: profile files

```yaml
# config/profiles/base.yaml
profile:
    name: base
    select:
        landscape: staging-east
        runtime: base
        credentials: base
        dataprofiles: dataprofiles
```

```yaml
# config/profiles/integration-ci.yaml
profile:
    extends: base
    name: integration-ci
    select:
        runtime: integration-ci
```

```yaml
# config/profiles/nightly-full-sweep.yaml
profile:
    extends: base
    name: nightly-full-sweep
    select:
        runtime: nightly-full-sweep
```

```yaml
# config/profiles/local-dev.yaml
profile:
    extends: base
    name: local-dev
    select:
        landscape: local-dev
        runtime: local-dev
```

Four profiles; each chooses a small set of overrides on top of
`base`.  The discipline from chapter 17's profile promotion in
action: each profile is a delta from the base, additive.

## Day 12 — Author the workflow

The taskplus workflow file replaces the *flow* of the original
GitLab YAML — the build, provision, test, deploy, cleanup
sequence.  But not the *configuration* (already in the typed
files committed days 5–7) and not the *vendor wiring* (will be
handled in commit 9).

### Commit 8: `config/workflows/integration.yaml`

```yaml
# config/workflows/integration.yaml
name: integration

parameters:
    cleanup_after_run: true
    notify_on_failure: true

flow:
    # Build phase
    - taskgroup: Build
      items:
          - task: Build app image
            tasktype: my.taskplus.tasks.docker_build@DockerBuild
            image_name: app
            tag: ${parameters[commit_sha]}

          - task: Push app image
            tasktype: my.taskplus.tasks.docker_push@DockerPush
            image: ${parameters[commit_sha]}

    # Provision phase (only on MR / scheduled runs)
    - task: Provision
      tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
      specification: config/specs/spec-staging-east.yaml
      action: apply
      if: "parameters['source'] in ('merge_request_event', 'schedule')"

    # Test phase
    - taskgroup: Test
      items:
          - task: Smoke
            tasktype: ccai.taskplus.tasks.distributedtest@DistributedTestJobTask
            test_root: source/testroots/ccai
            includes: [ccai.tests.smoke]

          - task: Integration
            tasktype: ccai.taskplus.tasks.distributedtest@DistributedTestJobTask
            test_root: source/testroots/ccai
            includes: [ccai.tests.integration]
            onfailure: NotifyAndCleanup

    # Deploy phase (only on push-to-main)
    - taskgroup: Deploy
      if: "parameters['source'] == 'push' and parameters['ref'] == 'main'"
      items:
          - task: Deploy to staging
            tasktype: my.taskplus.tasks.kubectl_set_image@KubectlSetImage
            deployment: app
            namespace: staging-east
            image: ${parameters[commit_sha]}

    # Cleanup phase (always runs)
    - taskgroup: NotifyAndCleanup
      items:
          - task: Notify failure
            tasktype: my.taskplus.tasks.slack_notify@SlackNotify
            webhook_service: slack-notifications
            template: "Pipeline failed in {pipeline_url}"
            if: "parameters['notify_on_failure'] == True"

          - task: Teardown
            tasktype: ccai.taskplus.tasks.provisioning@ProvisioningTask
            specification: config/specs/spec-staging-east.yaml
            action: teardown
            if: "parameters['cleanup_after_run'] == True"
```

About fifty lines.  Walk what changed:

- **No env-var soup.**  No `LOG_LEVEL`, no `RETRY_LIMIT`, no
  `STAGING_DB_HOST`.  All of those are configuration content
  loaded by the workflow's startup.
- **No inline AWS commands.**  Provisioning is a chapter-25
  bridge task consuming the chapter-4 Specification.
- **No URL construction.**  The smoke and integration tests
  invoke the chapter-25 `DistributedTestJobTask`, which finds
  `staging-db`, `staging-redis`, and `staging-api` through the
  active landscape.
- **No conditional shell.**  The `if:` fields on tasks and
  taskgroups are typed Python expressions reading from
  `parameters`.

### Revisiting the chapter-22 cross-group jump subtlety

The workflow above has the `Integration` task at ordinal `"3.2"`
(inside the `Test` taskgroup) declaring `onfailure:
NotifyAndCleanup`.  This is the chapter-22 cross-group jump
case.

Chapter 22 documented a subtlety: when seek mode is active and
the outer loop encounters a TaskGroup whose own label doesn't
match, the entire group is skipped.  In the workflow above:

- `Integration` fails; `seeking_node` becomes `"NotifyAndCleanup"`.
- The loop reaches the `Deploy` taskgroup.  Its label is
  `"Deploy"`; mismatch; the group is skipped (correct).
- The loop reaches the `NotifyAndCleanup` taskgroup.  Its label
  is `"NotifyAndCleanup"`; *match*; the seek state clears; the
  group runs.

The key here is naming the *taskgroup* with the jump target's
label, not naming an interior task.  When the workflow author
follows this convention — *the failure-handler taskgroup's
own label is the seek target* — cross-group jumps work as
intuited.

The alternative — declaring `onfailure: Teardown` (a label
inside the `NotifyAndCleanup` taskgroup) — would *not* work,
because the engine would skip past the `NotifyAndCleanup` group
without descending.  The workflow above avoids the trap by
naming the group itself as the target.

The workflow's `NotifyAndCleanup` group runs *both* the
notification task and the teardown task on failure, which
matches the original GitLab YAML's intent (cleanup runs
`when: always`).

## Day 14 — The vendor shim

### Commit 9: shrink `.gitlab-ci.yml`

```yaml
# .gitlab-ci.yml — after (30 lines)
default:
  image: registry.example.com/runner:latest
  before_script:
    - poetry install --no-interaction

stages:
  - run

integration-mr:
  stage: run
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    AM_PROFILE: integration-ci
  script:
    - ccai testbase test run --workflow config/workflows/integration.yaml ||
      (ccai profile capture --output failure-snapshot.json && exit 1)
  artifacts:
    when: on_failure
    paths:
      - failure-snapshot.json
      - .output/
    expire_in: 1 month

nightly-full-sweep:
  stage: run
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  variables:
    AM_PROFILE: nightly-full-sweep
  script:
    - ccai testbase test run --workflow config/workflows/nightly.yaml
  artifacts:
    when: always
    paths: [.output/]
    expire_in: 1 month
```

Thirty lines.  The chapter-24 pattern applied.  The diff in
the GitLab YAML is enormous: 200 lines down to 30, with the
deleted lines distributed across removed `variables:` (47 →
just `AM_PROFILE`), removed shell commands (the `aws ec2`, the
URL constructions, the conditional setups), and removed staging
declarations (the workflow handles its own internal sequencing).

## Day 30 — Migration complete

The repository's structure post-migration:

```
project-root/
├── .gitlab-ci.yml                      30 lines (was 200)
├── .tool-versions                      4 lines  (kept)
├── config/
│   ├── credentials/credentials.yaml    50 lines (NEW)
│   ├── dataprofiles/dataprofiles.yaml  40 lines (NEW)
│   ├── landscape/
│   │   ├── landscape-staging-east.yaml 30 lines (NEW)
│   │   ├── landscape-staging-west.yaml 30 lines (NEW)
│   │   ├── landscape-prod-readonly.yaml 25 lines (NEW)
│   │   └── landscape-local-dev.yaml    25 lines (NEW)
│   ├── runtime/
│   │   ├── runtime-base.yaml           20 lines (NEW)
│   │   ├── runtime-integration-ci.yaml 3 lines  (NEW)
│   │   ├── runtime-nightly-full-sweep.yaml 5 lines (NEW)
│   │   └── runtime-local-dev.yaml      8 lines  (NEW)
│   ├── specs/
│   │   └── spec-staging-east.yaml      45 lines (NEW)
│   ├── profiles/
│   │   ├── base.yaml                   8 lines  (NEW)
│   │   ├── integration-ci.yaml         5 lines  (NEW)
│   │   ├── nightly-full-sweep.yaml     5 lines  (NEW)
│   │   └── local-dev.yaml              7 lines  (NEW)
│   └── workflows/
│       ├── integration.yaml            50 lines (NEW)
│       └── nightly.yaml                30 lines (NEW)
└── README.md                           (now 5 lines, was 80)
```

Total lines of CI-related configuration:
- Before: 200 (one file, one repository).
- After: ~390 lines spread across 17 files.

The total grew.  That's a feature.  Before, two-hundred lines
in one file; the file had nineteen orthogonal concerns smeared
together.  After, three-hundred-ninety lines in seventeen files;
each file owns one concern.  The total is bigger; the per-file
diff size is much smaller.  Code review at the per-change level
gets sharper, not noisier.

## The operational payoff in numbers

Six concrete metrics worth measuring:

| Metric | Before | After |
|---|---|---|
| `.gitlab-ci.yml` line count | 200 | 30 |
| Application-content env vars in vendor YAML | 41 | 0 |
| Files touched to add a new test phase | 1 (the YAML) | 1 (the workflow) |
| Files touched to switch staging-east → staging-west | 1 (the YAML, 8 places) | 1 (set `AM_LANDSCAPE_NAMES`) |
| Time to reproduce a CI failure locally | ~30 min (read logs, set up env) | <10 min (download capture, replay) |
| README's "you might also need to..." length | 80 lines | 5 lines |

The metrics are not the only payoff.  Two qualitative changes
matter as much:

- **Configuration drift across environments became visible.**
  Before the migration, "what's different between staging-east
  and staging-west?" was a question requiring archaeology.
  After, it's a `ccai profile diff` invocation that prints a
  side-by-side diff in two seconds.
- **The team's mental model of the pipeline got smaller.**
  Before, debugging a pipeline failure required holding two
  hundred lines and forty-seven env vars in your head.  After,
  debugging starts with the workflow's flow tree (fifty lines),
  drills into the relevant configuration file (twenty to fifty
  lines), and stops there.

## What the migration didn't fix

Honesty about what didn't change.

### The runner image is still a black box

The `registry.example.com/runner:latest` image contains the
toolchain (Python, Poetry, the ccai packages, AWS CLI,
kubectl, docker).  The image's *contents* are still managed
through a separate Dockerfile and image-build pipeline; the
discipline in this volume doesn't apply to that layer.  When the
image breaks (a Poetry upgrade introduces a regression, an OS
package's ABI changes), debugging still requires going into the
Dockerfile and the image-build pipeline.

A team applying the discipline more broadly might bring the
image-build pipeline under the same typed-configuration
treatment — Specifications for the image, profiles for image
variants — but that's a follow-on project rather than part of
the migration above.

### Some accumulated weirdness survived

The original pipeline had a fifteen-line workaround in
`provision-staging` for a known issue where `aws ec2 wait
instance-running` doesn't actually wait for userdata to
complete.  The workaround (`sleep 30`) survives the migration —
it's now an explicit Specification field
(`post_create_delay_seconds: 30`), still ugly, but at least named.

The discipline doesn't *eliminate* workarounds; it makes them
*nameable*.  A workaround with a name is one a future engineer
can find, understand, and eventually replace.  A workaround
buried in a 200-line shell script tends to survive longer
because no one notices it.

### Cross-pipeline coordination

The team also had a pipeline for the deployment of the same app
to *production* (separate from the staging-east one above).  The
migration didn't unify the two pipelines — each kept its own
`.gitlab-ci.yml`, even though both now reference the same
typed configurations.  This is a deliberate scoping choice; the
two pipelines have different triggers, different on-call
escalation paths, different operational concerns.  Sharing
configuration without sharing the *pipeline structure* is the
right boundary.

## Summary

- A 200-line GitLab YAML with 47 environment variables migrated
  to a 30-line vendor shim plus 17 typed configuration files.
- The migration ran in nine commits over thirty days.  Each
  commit was independently reviewable; each commit was
  independently revertable; the migration made progress every
  day without breaking the pipeline.
- The chapter-2 failure modes (env-var accretion, conditional
  thicket, inlined recipe, credential leak through env, host
  name in YAML, topology buried in step ordering) all appeared
  in the original; all were closed in the migration.
- The chapter-22 cross-group jump subtlety was navigated by
  naming the *taskgroup* with the jump target's label, rather
  than naming an interior task.  The convention is small but
  important.
- The total lines of configuration *grew* (from 200 to ~390),
  spread across 17 files instead of 1.  The per-file diff size
  shrunk dramatically; code review got sharper.
- Operational payoffs: zero application-content env vars,
  single-file changes for previously cross-cutting edits, sub-
  ten-minute CI failure reproduction, a five-line README
  section in place of an 80-line one.
- Honest residual: the runner image is still a black box; some
  workarounds survive (now nameable rather than buried);
  cross-pipeline structural unification is out of scope.

The volume's last chapter — chapter 27 — is the inverse of
this one's claim: where this chapter showed what the discipline
buys when applied, chapter 27 names where the discipline does
*not* fit and what to use instead.


<!-- https://code-craft.ai/references/codecraftai/03-integration-and-orchestration/27-where-not-to-fit -->
# Chapter 27 — Where This Approach Is *Not* the Right Fit

> *Every opinionated discipline accumulates a list of cases where
> the opinion is wrong.  The chapters before this one made the
> case for the discipline; this one is honest about its limits.*

The volume has spent twenty-six chapters arguing for a specific
discipline: typed configurations as the contract between
orchestrations and jobs; the plumbing-vs-content boundary kept
small and fixed; a four-environment ladder where profile
selection replaces conditional branching.  The case study in
chapter 26 showed the discipline pay off on a real pipeline.

This chapter is the inverse.  Six categories of work where the
discipline does *not* fit, named honestly, with the alternative
tools that fit each better.  A short chapter — eight categories
of mismatch is enough — but an important one.  A reader who
finishes the volume and tries to apply the discipline to a
case it doesn't fit will produce something worse than what
they started with.  Knowing where to *not* apply the discipline
is part of knowing the discipline.

## The diagnostic question

Before any of the categories below, a single diagnostic question
catches most of them:

> *Is the orchestration's primary concern the **shape of the
> work** (which steps, in which order, with which configurations)?*

When the answer is yes, the discipline fits.  Pipelines, integration
suites, deployment workflows, scheduled batch jobs — all of
these are *shape-of-the-work* problems.  The chapter-26 case
study fits the discipline because the pipeline's value is in the
declarative description of what it does.

When the answer is no — when the orchestration's primary concern
is something *other* than shape (latency, parallelism, state,
event-reactivity, real-time response) — the discipline either
fits poorly or doesn't fit at all.  The categories below name
the most common no-cases.

## Category 1 — Heavy parallel fan-out

The taskplus engine sequences items in declaration order; a
TaskGroup with five children runs them one at a time.  When the
work *requires* parallelism — running a thousand subtests
concurrently against a partitioned dataset, fanning out a single
deploy across a hundred shards — the engine's sequential walk is
the wrong shape.

A workflow that wants 100-way parallelism could express it as
chapter-23 wrapper tasks that internally spawn parallel work,
but at that scale the wrapper task is doing the orchestration
job and the engine is just a launcher.  This is the
chapter-23 anti-pattern of "tasks that wrap arbitrary Python" —
the engine can't see the work being done.

### What fits better

For *static* heavy parallelism (the same task across N shards),
CI vendors' matrix-build features are usually the right answer.
GitLab's `parallel: matrix:`, GitHub Actions' `strategy.matrix:`,
Jenkins's `parallel { ... }`.  The chapter-24 vendor shim grows
to define the matrix; the per-shard taskplus workflow stays
small.

For *dynamic* heavy parallelism (variable shard count determined
at runtime, work-stealing schedulers, complex scheduling
constraints), bring in a real workflow engine: Argo Workflows,
Apache Airflow, Prefect, Temporal.  These have first-class
parallelism, dependency-aware scheduling, and dynamic-graph
support.  The discipline of typed configurations from Parts
II–IV still applies — the parallel engine consumes the same
landscape, runtime, credentials, and data profiles — but the
*orchestration* layer is the parallel engine, not taskplus.

## Category 2 — Dynamic workflow generation from streaming events

The taskplus YAML is parsed once at workflow start.  The flow
tree is fixed for the duration of the run.  When the workflow
needs to *change shape* based on events that arrive during
execution — adding tasks because new shards arrived, removing
tasks because conditions changed, restructuring the graph
mid-run — the static YAML is the wrong representation.

The discipline's `if_expr` gates can hide some dynamism (a
chapter-22 gate keyed on `flowstate` can include or exclude a
node based on what an earlier task wrote), but the *graph
structure* is fixed.  When the workflow needs new *nodes* that
didn't exist in the YAML, taskplus can't express it.

### What fits better

Event-driven orchestration tools: Apache NiFi, Apache
StreamPipes, AWS Step Functions with dynamic states, or a
custom Kubernetes Operator pattern.  Each models the workflow
as a *graph that mutates in response to events* — the engine's
core abstraction is exactly what the static-graph engine
doesn't have.

A common hybrid pattern: use taskplus for the *static*
backbone of an orchestration (the shape that's known at
design time), and have a taskplus task invoke an event-driven
sub-orchestrator for the dynamic portion.  The sub-orchestrator
runs to completion, returns a result; the taskplus workflow
sequences around it.  The discipline keeps the static parts
typed; the dynamic parts live in their proper engine.

## Category 3 — Sub-second event-driven flows

The taskplus engine has measurable startup latency.  Loading
the workflow YAML, resolving configurations through
`codecraftai-configuration`, instantiating credential and data-profile
managers, walking the recursive flow tree — all takes hundreds
of milliseconds at minimum, often a second or two on
configuration-heavy workflows.

For orchestration steps that take seconds-to-minutes (the typical
CI / integration / deployment use case), the startup cost is
amortised away.  For orchestration steps that take *milliseconds*
— a per-request fan-out in a serving pipeline, a real-time
feature-flag-based experiment routing, an event-handler chain —
the startup latency dominates.

### What fits better

In-process event handlers (the application's own code, dispatched
through whatever event-routing primitive fits — `asyncio` event
loops, `rxpy`, message-queue consumers).  For inter-service
event flows, dedicated stream processors: Apache Kafka Streams,
Apache Flink, AWS Kinesis Data Analytics.

The discipline of typed configurations *does* still apply at the
service-startup layer: the service that handles events should
read its own configuration through the same chapter-3 typed
mechanisms.  But the per-event orchestration is in the service's
hot path, and that hot path needs a different engine.

## Category 4 — Multi-day stateful workflows with checkpoint/resume

The taskplus engine has no persistent state.  When the
orchestration's process exits — for whatever reason: scheduled
job ends, machine reboots, container is preempted — the run is
gone.  The next run starts from scratch.

This is fine for runs that complete in hours.  For runs that
span days or weeks — a long-running data migration, a multi-stage
ML training pipeline with human-in-the-loop checkpoints, a
phased deployment with manual approval gates — the lack of
persistent state is disqualifying.  The orchestration cannot be
*paused* and *resumed*; it cannot survive the underlying
machine's lifecycle.

### What fits better

Workflow engines with built-in durable state: Temporal, Cadence,
AWS Step Functions, Azure Durable Functions, Apache Airflow's
operator model.  These persist orchestration state to a
backing store (a database, a queue, a state machine) and resume
from where they left off after restart.

For the simpler "long-running but not multi-day" case — a
six-hour nightly run — taskplus is fine; the chapter-16
capture-and-replay primitive provides a different kind of
resumability (recover by replaying captured configuration, not
by resuming mid-run state).

## Category 5 — ML training pipelines

ML training has its own orchestration vocabulary: experiment
tracking, hyperparameter sweeps, model versioning, dataset
versioning, GPU scheduling, checkpoint management.  These are
specialised concerns that map poorly onto the chapter-3
configuration categories.  A landscape doesn't capture "the
GPU model you need"; a runtime doesn't capture "the experiment
tracking server"; the typed-credential vocabulary doesn't
capture "an ML platform's experiment session token."

The discipline could be *extended* to cover these — chapter-23's
extension recipe applies — but the cost-benefit changes.  The
extensions would replicate functionality that exists in
purpose-built ML tooling.  When a team's ML stack already
includes MLflow, Weights & Biases, or Kubeflow, the
orchestration layer should be those tools, not a custom
taskplus extension.

### What fits better

Purpose-built ML orchestration tools: Kubeflow Pipelines,
Metaflow, ZenML, Argo Workflows with the ML extensions, AWS
SageMaker Pipelines.  Each models ML-specific concerns as
first-class citizens.

The discipline of typed configurations still applies *around*
the ML pipeline — the data profiles for the training data, the
credentials for the model registry, the runtime knobs for the
training-cluster — but the orchestration layer is the ML tool.

## Category 6 — Real-time / hard-deadline systems

Real-time orchestration — a workflow that *must* respond within
a guaranteed time bound, where missing the deadline is a system
failure — is a different engineering problem.  The taskplus
engine is not real-time; the OS schedulers it runs on are not
real-time; the configuration-loading machinery has no timing
guarantees.

For orchestration in *soft* real-time (best-effort latency
targets, occasional misses tolerated), other tools are
better-fit but the gap is smaller.  For *hard* real-time
(industrial control systems, automotive ECUs, medical devices),
the entire toolchain is different — RTOS, time-triggered
schedulers, formal verification.

### What fits better

For hard real-time, RTOS-based architectures: VxWorks, QNX,
RT-Linux's PREEMPT_RT, or specialised industrial control
frameworks like CODESYS.  The volume's discipline does not
extend to these environments and the volume's audience is not
typically working in them.

## Category 7 — One-off scripts and small projects

The discipline has a fixed cost.  Authoring six configuration
files, a profile set, and a workflow YAML for a project that
runs *one* CI job and is going to be archived in three months
is overkill.  The cost of the discipline exceeds the benefit
when the surface area is small enough that the chapter-2
failure modes can't really accumulate.

A useful rule of thumb: when the project's CI YAML is *under
fifty lines* and is *unlikely to grow*, the discipline is more
ceremony than payoff.  Use the vendor's YAML directly; keep
the secrets in the vendor's secret store; defer the discipline
until the project's CI grows past a hundred lines or branches
across multiple environments.

### What fits better

Vendor-native YAML, kept simple.  When the file gets big
enough to start showing chapter-2 failure modes, the discipline
is worth adopting.  Until then, the cost is too high.

## Category 8 — Highly opinionated platforms with their own orchestration

Some platforms come with their own deeply integrated
orchestration: Salesforce's Apex/Flow, ServiceNow workflows,
Workday Studio, Pega.  These platforms expect orchestration
to be expressed in their native vocabulary; integrating a
taskplus workflow with them is fighting the platform.

When a team is building *on top of* such a platform, the
discipline of this volume applies *to the parts of the work
outside the platform* (the build pipeline, the integration
testing of the platform-side code, the deployment of supporting
services), not to the platform's own orchestration.

### What fits better

Use the platform's native orchestration for platform-side work;
apply the discipline of this volume to whatever is *not* the
platform.  The boundary is wherever the platform's
orchestration capabilities end.

## The build-vs-buy question

Behind every category above is the same question: when should
a team *adopt* the discipline of this volume versus *bring in
a different tool*?

A small framework for the decision:

### Adopt the discipline when

- The orchestration is *primarily* about the shape of the
  work — sequencing of build, test, deploy, cleanup phases
  with dependencies and conditional inclusion.
- The orchestration runs on a manageable cadence — minutes to
  hours — and complete-restart-on-failure is acceptable.
- Multiple environments (local-dev / CI / staging / prod) are
  in scope and the team values configuration-driven differences
  over code-driven ones.
- The team has the capacity to maintain typed configuration
  files and the discipline that comes with them.
- The tool ecosystem the team is already using
  (`codecraftai-*`, or any portable typed-config toolkit) is
  available and stable.

### Bring in a different tool when

- The orchestration is primarily about something other than
  shape — parallelism, state, event-reactivity, real-time
  response.
- A purpose-built tool already exists for the specific problem
  (ML pipelines, durable workflows, event-stream processing).
- The team is small enough that adopting the discipline's
  ceremony costs more than it saves.
- The orchestration's lifecycle requirements exceed what
  taskplus provides (multi-day, hard-deadline,
  sub-second-latency).

The volume has nothing against any of the alternative tools.
Each fits its niche.  The discipline is *opinionated*, not
*universal*; opinionated is the only way to be useful at all.

## A note on hybrid systems

Real-world automation rarely lives in one orchestration tool.
A team might have:

- **Argo Workflows** for ML training (durable, parallel).
- **Temporal** for multi-day customer-onboarding workflows
  (durable, stateful).
- **Apache Kafka Streams** for real-time event processing
  (low-latency, event-driven).
- **`codecraftai-taskplus`** for CI / integration / deployment
  pipelines (typed, portable, environment-spanning).

The discipline of this volume applies to the *taskplus
portion* of the team's automation portfolio.  The other tools
have their own disciplines; the four can coexist without
conflicting.

What *is* shared across all four is the **typed configuration
discipline** of Parts II–IV.  Every tool above benefits from
having its credentials in a typed credential vocabulary, its
landscapes in typed inventory files, its runtime knobs in
typed runtime profiles.  The configuration discipline is more
universal than the orchestration discipline.  A team that
adopts the configuration discipline gets the inter-tool
coherence even when the orchestration tools differ.

This is the volume's final claim: **the configuration
discipline survives independently of the orchestration tool.**
A team that walks away from this volume preferring Argo or
Temporal over taskplus has not failed to adopt the discipline
— they have adopted the *transferable* part.  The taskplus
implementation is the volume's worked example, not the
discipline itself.

## Closing the volume

The volume's central thesis, restated for the last time:

> *Configuration files allow arbitrary parameterisation of
> runtime behaviour without modifying the automation plumbing
> code.*

Twenty-seven chapters made the case.  The six categories of
typed configuration (Specifications, Landscapes, Topologies,
Runtimes, Credentials, Data Profiles).  The credential
discipline that earns its own four chapters.  The four-environment
ladder where profile selection replaces codebase divergence.
The taskplus implementation that ties the configurations to
real workflow execution.  The vendor wiring that confines the
CI YAML to ten lines of plumbing.  The case study that ports a
200-line monstrosity to 30 lines of vendor shim plus 17 typed
configuration files in nine commits over thirty days.

The discipline is opinionated.  It is not universal.  This
final chapter has been honest about where it doesn't fit.

What the discipline *does* offer — across every place it does
fit — is the same payoff in different shapes.  Pipelines that
are readable.  Pipelines that are portable.  Pipelines that are
debuggable.  Pipelines that survive vendor migrations as
thirty-day projects rather than multi-quarter rewrites.
Configuration drift that is detectable; regressions that are
attributable; cross-environment surprises that are rare.

The discipline is, ultimately, a small set of decisions:

- Treat configuration content as data, not as plumbing.
- Reference configurations by stable identifiers; resolve them
  through machinery, not through application code.
- Compose environments through profiles, not through codebases.
- Express flow as data; let the engine read it.
- Keep the plumbing layer small enough to fit on a screen.

Five decisions.  Twenty-seven chapters of consequences.  The
team that makes the decisions ships pipelines that scale; the
team that doesn't ships the chapter-26 200-line YAML and grows
into the chapter-2 failure modes.

The choice is the reader's.  This volume has done what books can
do — name the discipline, show the implementation, walk the
worked example, scope the limits.  The application is the
reader's project, with the reader's team, against the reader's
constraints.

The next volume in the series — Volume 4 — develops the
cross-OS system-management discipline introduced briefly through
the chapter-25 sysman bridge.  Where this volume has been about
*how the work is orchestrated*, the next volume is about *what
the work is*: the 52 cross-OS contracts that let a single
sysman feature run on RHEL, Ubuntu, macOS, Windows, and UEFI
without per-OS branches in the calling code.  The discipline
is the same; the surface area is different.

For now: the volume is finished.  The tools it has named are
real; the audits it has cited produced real bugs and real test
suites; the configurations it has shown have been read,
audited, and run.  Apply the discipline where it fits.  Skip it
where it doesn't.  The next pipeline you build will be easier
than the last one if you do.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman -->
# Volume 4 — Cross-Platform System Management

*52 Cross-OS Contracts and 5 Reference Implementations*

## About this volume

Volume 4 is the **"do it this way"** book of the series.  The
sysman tier is the most disciplined product line in the
portfolio: 52 cross-OS contracts in
`codecraftai-sysman-common/specs/contracts/` plus five reference
implementations (RHEL, Ubuntu, macOS, Windows, UEFI), each
tested green to thousands of testbase tests.

Where Volume 5 covered *how to talk to remote systems*
(protocols, the wire), Volume 4 covers *what to ask remote
systems to do* — install a package, start a service,
configure the firewall, manage boot order.  The tier sits
*above* the wire and *above* the landscape, in the
"actuator" position: it's the layer test code reaches for
when it needs to *change the system under test*.

## Who it is for

Platform / SRE engineers who need cross-OS automation that
actually works.  System-management-tier maintainers who want
to extend the contract or add a new OS implementation.
Anyone whose project's automation must reach across RHEL,
Ubuntu, macOS, Windows, or UEFI consistently.

## Source material

- `codecraftai-sysman-common` — the 52 contracts, the
  `featurebase` substrate, and the two feature bases (the
  abstract VPN base and the concrete python-environment base).
- `codecraftai-sysman-linux-redhat` — RHEL implementation.
- `codecraftai-sysman-linux-ubuntu` — Ubuntu / Debian
  implementation.
- `codecraftai-sysman-macos` — macOS implementation.
- `codecraftai-sysman-windows` — Windows implementation.
- `codecraftai-sysman-uefi` — UEFI firmware implementation.

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — The Contract Layer | 1, 2, 3 | Why contracts; the 52 contracts; `featurebase` substrate |
| II — Five Reference Implementations | 4, 5, 6, 7, 8 | RHEL, Ubuntu, macOS, Windows, UEFI |
| III — The Discipline | 9, 10 | 11 Implementation Conventions; cross-OS test parity |

- [Chapter 1 — Why a Contract Layer](chapters/01-why-a-contract-layer.md)
- [Chapter 2 — The 52 Cross-OS Contracts](chapters/02-the-50-contracts.md)
- [Chapter 3 — `featurebase` and the Per-OS Implementation Pattern](chapters/03-featurebase.md)
- [Chapter 4 — RHEL Implementation](chapters/04-rhel-implementation.md)
- [Chapter 5 — Ubuntu / Debian Implementation](chapters/05-ubuntu-implementation.md)
- [Chapter 6 — macOS Implementation](chapters/06-macos-implementation.md)
- [Chapter 7 — Windows Implementation](chapters/07-windows-implementation.md)
- [Chapter 8 — UEFI Firmware Implementation](chapters/08-uefi-implementation.md)
- [Chapter 9 — Spec-Driven Development for System Administration](chapters/09-spec-driven-development.md)
- [Chapter 10 — Building Cross-OS Test Parity](chapters/10-cross-os-test-parity.md)

## How it connects to the other volumes

- **Volume 0** — error vocabulary, configuration loading,
  application identity (the foundation tier).
- **Volume 1 / 2** — testbase / tandem testing as the
  framework that runs sysman feature tests.
- **Volume 3** — orchestration that invokes sysman feature
  execution; ch.25's bridge tasks.
- **Volume 5** — protocols (SSH, REST) that sysman features
  use to reach remote systems.
- **Volume 6** — landscape (which devices to reach) and
  topology (the configured state).

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/01-why-a-contract-layer -->
# Chapter 1 — Why a Contract Layer

> *Cross-OS automation has two shapes that fail.  One is
> "OS-by-OS feature flags" — `if platform.system() == "Linux":`
> branches scattered through every test.  The other is "least
> common denominator" — strip away the platform-specific
> concepts until the API is too anaemic to be useful.  The
> sysman tier rejects both with a *contract layer*: 52 cross-OS
> contracts that lock the *semantics* of each feature without
> committing to OS-specific *mechanism*.*

This is the volume's foundational chapter.  By the end, a
reader should know *why* a separately-versioned contract layer
exists and what it commits to.  Chapter 2 walks the contracts
themselves; chapter 3 walks the substrate (`featurebase`)
that turns the contracts into runnable code.

## The two failure modes

Most automation suites that span multiple OSes choose between
two unworkable patterns.

### Pattern A — OS-by-OS feature flags

```python
def install_package(name: str) -> bool:
    if platform.system() == "Linux":
        if shutil.which("dnf"):
            return _run_dnf_install(name)
        elif shutil.which("apt-get"):
            return _run_apt_install(name)
        else:
            raise RuntimeError("unknown Linux package manager")
    elif platform.system() == "Darwin":
        return _run_brew_install(name)
    elif platform.system() == "Windows":
        return _run_winget_install(name)
    else:
        raise NotImplementedError(platform.system())
```

The pattern looks reasonable in one place.  It's a disaster
across thousands of test cases.  The `if platform.system()`
ladders proliferate.  The error-handling diverges.  The
return shapes diverge — `_run_dnf_install` returns a tuple,
`_run_brew_install` returns a status enum, `_run_winget_install`
returns a parsed dict.  Test code that calls
`install_package` has no idea what to expect; either every
caller learns every OS's quirks, or the function lies by
forcing a least-common-denominator return.

The cost shows up as drift.  Six months in:

- One OS's branch tests for a different return type than
  another's because the developer who wrote that branch had
  different assumptions.
- One OS's branch silently swallows errors that another's
  bubbles, and a test passes on macOS while failing on
  Linux for the same scenario.
- One OS's branch handles the "package already installed"
  case as success; another raises.  Test code rewrites the
  semantics every time.

### Pattern B — Least common denominator

```python
def install_package(name: str) -> bool:
    """
        Returns True if the package install command exited cleanly.
    """
    cmd = _platform_install_command(name)
    return subprocess.run(cmd).returncode == 0
```

The pattern looks clean.  It's also *useless*.  "Did the
install command exit cleanly" is not the question the test
is asking.  The test wants to know:

- Was the package already installed (skipped) or was it
  freshly installed (succeeded)?
- If a version was specified, did the right version land?
- If installation failed, was it a transient network blip
  or a permanent error like "package doesn't exist in any
  configured repository"?
- Was elevation required, and was it granted?
- Did the install have side effects that need to be undone
  if the test fails partway through?

A least-common-denominator API answers none of these.  Every
call site has to *re-derive* the answer from raw stdout /
stderr / exit codes — and the call site doesn't know the
OS-specific output formats.

## The third option — a contract layer

The sysman tier picks neither pattern.  It picks a *contract
layer*: a set of cross-OS specifications that define the
*semantics* of each feature without committing to OS-specific
*mechanism*.

Concretely:

- Every feature has a markdown contract at
  `codecraftai-sysman-common/specs/contracts/<feature>/contract.md`.
- The contract specifies *verb signatures* (what methods
  exist, what arguments they take, what they return).
- The contract specifies *invariants* (what must be true
  before / during / after the call).
- The contract specifies *result vocabulary* (what
  `ActionStatus` values are returned, when each applies).
- The contract specifies *elevation rules* (which verbs
  elevate, which don't).
- The contract leaves *mechanism* to per-OS implementations.

A test calling `package_manager.install("nginx")` knows it
will get back a result with a status of `SUCCESS`, `SKIPPED`,
`FAILED`, or `DRY_RUN` — and knows what each means.  The
test code is *the same* across OSes.  The implementations
underneath differ wildly (dnf invocations vs. apt-get vs.
brew), but the test sees a uniform surface.

## The 52-contract substrate

The portfolio ships **52 contracts** — one per feature area.
The full list lives at
`codecraftai-sysman-common/specs/contracts/README.md:385–510`,
catalogued in three layers:

- **Layer 1 (Infrastructure)** — 1 contract: `command-execution`.
  The substrate for everything above.
- **Layer 2 (Data)** — 1 contract: `maintenance-reporting`.
  Cross-feature reporting.
- **Layer 3 (Business Logic)** — 50 contracts: firewall,
  service, package, network, encryption, certificate,
  user/group, kernel parameters, virtualization, container,
  ACLs, audit, authentication, backup, DNS server, mail
  transport, NFS, repository, samba, security compliance,
  storage, etc.

The chapter doesn't list all 52 by name (the catalogue does
that).  What matters is the *shape* of the layering.  A
Layer-3 contract like `package-management` calls into Layer
1's `command-execution` contract for actually running the
underlying command.  Layer 2's `maintenance-reporting`
sits alongside, capturing the cross-feature concerns of
"what changed and when."

## The five reference implementations

Five OSes are implemented to the contract:

1. **`codecraftai-sysman-linux-redhat`** — RHEL family (RHEL,
   CentOS Stream, Fedora).  46 features.  53 test files.
2. **`codecraftai-sysman-linux-ubuntu`** — Ubuntu / Debian
   family.  40 features.  41 test files.
3. **`codecraftai-sysman-macos`** — macOS (Apple Silicon and
   Intel).  49 features.  45 test files.
4. **`codecraftai-sysman-windows`** — Windows 10/11 / Server.
   49 features.  45 test files.
5. **`codecraftai-sysman-uefi`** — UEFI firmware.  Intentionally
   smaller — 5 features only.

Why five?  Because these five OSes cover the bulk of
production automation targets.  The contract layer doesn't
*forbid* implementations for other OSes — adding FreeBSD,
Solaris, or AIX would follow the same pattern — but the
maintenance burden of each implementation is real and the
five shipped are what the portfolio actively maintains.

A note on the test counts.  The volume's outline mentions
much higher numbers (e.g., "814 tests" for Ubuntu, "917 tests"
for Windows).  Those are *test invocation* counts (individual
`test_*` functions) rather than test *file* counts.  A single
test file typically holds a dozen or more test functions.
The chapter uses file counts in its summaries because they're
verifiable; readers should expect actual test invocations to
be 10–20× the file count.

## Why these five are not the same

A reader might naively expect that ~50 contracts × 5 OSes
should produce ~250 features × 5 implementations.  The
reality is messier:

- **RHEL has features Ubuntu doesn't** — `selinux-management`
  (RHEL has SELinux; Ubuntu uses AppArmor),
  `subscription-management` (Red Hat Subscription Manager),
  `cockpit-management`, `insights-management`,
  `tuned-management`.
- **Ubuntu has features RHEL doesn't** — `bootloader-management`
  (covers EFI variants beyond RHEL's simpler boot story).
- **macOS doesn't ship some features** — Linux-specific
  concepts (`selinux-management`, `firewalld`) have no macOS
  analogue.
- **Windows has unique concepts** — Active Directory
  integration, Windows-specific service control, DSC.

The contract layer accommodates this through **Convention 5**
(chapter 9 walks the eleven conventions): when a feature has
no scriptable implementation on a given OS, the manager
returns `*ActionResult` with `status=FAILED` and a stable
detail token like `unsupported_on_host_os_darwin`.  This
*is* the contract — a feature that's intentionally
unsupported on an OS still has a manager that *responds
predictably*.

## What the contract layer commits to

A reader should understand what the contract layer locks
down and what it leaves loose.

### Locked

- **Verb names.**  Every OS's `PackageManager` exposes
  `install`, `remove`, `query`, `list_installed`, etc. with
  exactly the names the contract specifies.
- **Verb signatures.**  Argument order, types, defaults.
- **Result types.**  Every `*ActionResult` subclass has
  `status`, `detail`, `exit_code`, `stdout`, `stderr`,
  `command` fields (per Convention 2).
- **Status vocabulary.**  `SUCCESS`, `FAILED`, `SKIPPED`,
  `DRY_RUN` — same four values across every feature on
  every OS.
- **Elevation rules.**  Reads never elevate; writes elevate
  per the manager's config (with the Homebrew / Convention
  11 inversion as the well-known exception).

### Loose

- **Mechanism.**  How a manager actually invokes the
  underlying tool.  RHEL's `PackageManager.install` runs
  `dnf install -y`; Ubuntu's runs `apt-get install -y`;
  macOS's runs `brew install` (with `sudo -u`).
- **Output parsing.**  Every OS's tool produces different
  text; per-OS code parses its own.
- **Per-OS quirks.**  Apt's `DEBIAN_FRONTEND=noninteractive`,
  Homebrew's root refusal, Windows' winget table-format
  fragility — all per-OS implementation concerns.

The split is what makes the layer work.  A test author
writing against the contract sees a uniform surface.  An
implementor handling a single OS focuses on that OS's
specifics.  Neither has to know about the other's concerns.

## What the contract layer is *not*

Three misconceptions worth clearing up.

### It is not a shared abstraction layer

The contract layer is not "an abstract base class everyone
inherits from."  It is *prose* — markdown contracts that
specify the API in English plus tables.  The shared base
class (`BaseFeatureManager` from chapter 3) implements *some*
of the contract uniformly, but the contract itself is text.

There is one deliberate exception that proves the rule.  The
`python-environment-management` contract is implemented
*once*, as a concrete base class in `codecraftai-sysman-common`,
because the tools it drives (`python -m venv`, `python -m pip`)
do not vary by OS at all.  Chapter 3 walks that case; the
point here is that the exception was made only after the
tool-level analysis showed there was nothing per-OS left to
write except paths and directory probes.

The reason: per-OS implementations vary so much in *how*
they implement the contract that a code-level abstraction
would either be too thin (forcing duplication) or too
opinionated (forcing per-OS quirks into a shape that doesn't
fit).  Prose contracts let the contract specify *what* and
let each OS's code decide *how*.

### It is not generated

There is *no code generator* for the per-OS implementations.
Every per-OS file is hand-written to follow the contract.
The substrate (`featurebase` — chapter 3) provides shared
helpers, but the manager classes themselves are
hand-authored.

The reason: the OS-specific quirks (Homebrew root refusal,
winget output parsing, launchd domain semantics) are
genuinely irreducible.  A code generator that tried to
produce them would either (a) not produce them and force
hand-edits, defeating the generator, or (b) become a
prose-to-code translator that's harder to read than the
direct hand-written code.

The chapter is honest: hand-writing ~50 contracts × 5
implementations is *expensive*.  The portfolio justifies the
cost by treating sysman as a *foundational* tier — once it's
correct, every higher-tier test relies on it.  The cost is
amortised across the rest of the series.

### It is not bidirectional

The contracts describe *what the framework provides to test
code*, not *what test code provides to the framework*.  A
test that wants to install a package calls
`PackageManager.install(...)`; the contract specifies that
call.  The reverse — "what does test code have to do for the
framework to call its hooks?" — is a different concern
covered by the test framework (Volume 1).

## How the discipline holds up

Three mechanisms keep the per-OS implementations aligned with
the contracts:

### Shared base classes

Every per-OS manager inherits from `BaseFeatureManager` (or
its `featurebase` family).  The base class implements the
common scaffolding (`__init__`, `_dispatch`,
`_wrap_result`) so that *anything that uses these helpers*
follows the contract by construction.  Chapter 3 walks the
base.

### The 11 Implementation Conventions

Documented at
`codecraftai-sysman-common/specs/contracts/README.md:75–364`,
the eleven conventions cover the cross-OS patterns that
emerged from the Phase-3 implementation work.  Things like:

- **Convention 1**: `ICommandRunner` is the file-I/O seam.
  No separate `IFileOperator`.
- **Convention 4**: OS-prefixed concrete type names
  (`UbuntuPackageManager`, etc.).
- **Convention 11**: Elevation-model inversion when the
  underlying tool refuses root (Homebrew).

Chapter 9 walks all eleven in detail.

### Per-OS test coverage

Every contract's "Tests" section prescribes minimum coverage
(command-construction tests per write verb, idempotency
tests, dry-run tests, elevation tests).  Per-OS packages
implement these tests.  When a per-OS implementation drifts
from the contract, the tests catch it.

The discipline isn't enforced by automated specification
tooling.  It's enforced by the combination of shared bases,
documented conventions, and thorough tests.  An engineer
trying to skip the discipline runs into one of the three.

## What this volume covers

The remaining nine chapters walk the layer in detail:

- **Chapter 2** — the 52 contracts themselves.  How they're
  organised, the EARS-inspired structure, three worked
  examples (package-management, service-management,
  network-configuration).
- **Chapter 3** — `featurebase` and the per-OS implementation
  pattern.  `BaseFeatureManager`, `FeatureConfig`,
  `FeatureActionResult`, the `_dispatch` helper.
- **Chapters 4–8** — the five reference implementations,
  one per OS.  RHEL, Ubuntu, macOS, Windows, UEFI.  Each
  chapter walks the package's structure, key features, and
  OS-specific quirks.
- **Chapter 9** — the 11 Implementation Conventions and the
  spec-driven development discipline.
- **Chapter 10** — building cross-OS test parity.  How the
  tests compose into a green-across-OSes campaign.

By the end of the volume, a reader should be able to:

- Read any contract and predict the per-OS implementation
  shape.
- Read any per-OS feature and identify which contract it
  implements.
- Add a new feature to an existing OS by writing the
  contract first and the implementation second.
- Add a new OS to the suite by implementing the existing
  contracts following the conventions.

The discipline is the volume's value.  Five OSes × 50
features is a maintenance bill many automation suites would
walk away from.  The portfolio pays the bill because the
contract layer makes the maintenance *predictable* — every
test against the sysman tier behaves the same way regardless
of OS.

## What's next

Chapter 2 walks the 52 contracts: their structure, what
they specify, and three worked examples that show the
contract format end-to-end.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/02-the-50-contracts -->
# Chapter 2 — The 52 Cross-OS Contracts

> *The contracts are markdown.  Fifty-two directories under
> `codecraftai-sysman-common/specs/contracts/`, each containing
> one `contract.md` file with a fixed structure: Scope, Verbs,
> Invariants, Data Models, Channel/Source Mapping, Elevation,
> Execution Model, Naming, Aspect Composition, Tests, Out of
> Scope, Implementation Drift.  This chapter walks the
> structure and four worked examples.*

The previous chapter argued that a contract layer is the
*right shape* for cross-OS automation.  This chapter walks
the contracts themselves.  By the end, a reader should be
able to open any of the 52 contracts and find what they need
within the structure.

## Where the contracts live

```
codecraftai-sysman-common/
└── specs/
    └── contracts/
        ├── README.md                          # 525-line master index
        ├── package-management/contract.md
        ├── service-management/contract.md
        ├── network-configuration/contract.md
        ├── firewall-management/contract.md
        ├── encryption-management/contract.md
        ├── vpn-client-management/contract.md
        ├── python-environment-management/contract.md
        ├── ... (45 more)
```

52 directories.  Each directory is a feature area.  Each
holds exactly one `contract.md` file.  No feature has its
contract spread across multiple files; no directory has
contracts for multiple features.

The README at the directory root is the master index.  Its
catalogue (from line 392) lists every contract with:

- **Feature name** — e.g. `package-management`.
- **Contract path** — relative path to `contract.md`.
- **Scope parameter** — what the contract covers in one
  sentence.

A reader looking for a specific feature opens the README,
finds the row, follows the path.

## The three layers

Per chapter 1, the 52 contracts split into three layers:

### Layer 1 — Infrastructure (1 contract)

- **`command-execution`** — the `ICommandRunner` contract
  that every other feature depends on.  Specifies how
  commands are dispatched, how results are returned, how
  elevation is requested, how dry-run is signalled.

Every feature manager runs commands through an
`ICommandRunner` (per Convention 1: file I/O is also routed
through this same seam).  The `command-execution` contract
locks the `ICommandRunner` interface so all higher contracts
can rely on it.

### Layer 2 — Data (1 contract)

- **`maintenance-reporting`** — cross-feature reporting
  vocabulary.  When a manager performs a write that's worth
  recording in an audit log (a service start, a package
  install, a firewall rule add), the report follows this
  contract's shape.

The contract exists separately from the feature contracts
because *every* feature reports through the same shape.
Centralising the report model keeps consumers (audit log
writers, observability dashboards, time-travel debuggers
from Volume 8) consistent.

### Layer 3 — Business logic (50 contracts)

The bulk of the suite.  Each contract is one feature area:

```
acl-management              kdumpmanagement
audit-management            kernel-parameters
authentication-management   localization-management
backup-restore              log-management
boot-management             mail-transport-management
bootloader-management       maintenance-reporting (also Layer 2)
certificate-issuance        network-configuration
certificate-trust-management nfs-server-management
cockpit-management          package-management
configuration-management    precision-time-management
container-management        process-management
dns-server-management       remote-log-forwarding
encryption-management       repository-management
environment-configuration   resource-control-management
filesystem-management       samba-server-management
filesystem-quotas           scheduled-tasks
firewall-management         security-compliance
hardware-inventory          selinux-management
identity-management         service-management
insights-management         ssh-server-configuration
                            storage-management
                            subscription-management
                            tuned-management
                            user-group-management
                            virtualization-management
```

Some Layer-3 contracts are RHEL-specific
(`cockpit-management`, `insights-management`,
`selinux-management`, `subscription-management`,
`tuned-management`).  Others are universal across most OSes
(`package-management`, `service-management`,
`network-configuration`).  Per chapter 1's Convention 5
discussion: a feature without a per-OS implementation
returns `FAILED` with a stable token; the contract still
exists.

## The contract structure

Every `contract.md` follows the same EARS-inspired structure
with named sections.  A reader who's seen one contract knows
where to find each piece in any other.

### `# <Feature> Contract` and the boilerplate

Every contract opens identically:

```markdown
# Package-Management Contract

This is the cross-OS abstract contract for the
**package-management** feature.  Every `codecraftai-sysman-*`
package that ships a package manager MUST honor this contract.
```

The opening identifies the feature and reminds the reader
that *every* implementation must honour the contract.  The
*MUST* is RFC 2119 capitalisation — non-negotiable.

### `## Scope`

The Scope section is the contract's *abstract*.  A bulleted
list of *what the feature does*, written abstractly so it
applies to every OS:

```markdown
## Scope

The package-management feature provides idempotent,
auditable control over installed software packages.  It
covers:

- Installing a package at the latest version or at a pinned
  version.
- Removing an installed package.
- Upgrading a package to its latest version.
- Upgrading all installed packages.
- Querying a package's installed state.
- Listing all installed packages.
- Listing packages with available upgrades.
- Refreshing package-manager metadata.
- Installing a package from a local file.
- Dry-run-aware writes.
- Elevation for writes; reads never elevate.

The feature is executed exclusively through an `ICommandRunner`.
```

The section is two things:

- A *checklist* of what an implementation must support.
- A *boundary* — anything not in this list is not part of
  the contract (deferred to "Out of Scope" later).

The closing line — *"executed exclusively through an
`ICommandRunner`"* — is Convention 1 lock-in: file I/O,
read I/O, write I/O all routed through the runner seam.

### `## Verbs`

The Verbs section is a *table*.  Every verb (method) the
implementation must expose, with its signature and *kind*
(Read or Write):

```markdown
## Verbs

Every OS implementation MUST expose a `PackageManager` class
(the OS-prefixed variant — see §Naming) whose public surface
is the following set of verbs.

| Verb | Signature | Kind |
|------|-----------|------|
| `install_package` | `(name: str, version: str \| None) → PackageActionResult` | Write |
| `remove_package` | `(name: str) → PackageActionResult` | Write |
| `upgrade_package` | `(name: str) → PackageActionResult` | Write |
| `upgrade_all` | `() → PackageActionResult` | Write |
| `is_installed` | `(name: str) → bool` | Read |
| `get_package_info` | `(name: str) → PackageInfo \| None` | Read |
| `list_installed` | `() → list[PackageInfo]` | Read |
| `list_upgradable` | `() → list[PackageInfo]` | Read |
| `refresh_metadata` | `() → PackageActionResult` | Write |
| `install_local` | `(path: str) → PackageActionResult` | Write |
```

Three things matter in the table:

- **The verb name is exact.**  The implementation's method
  must have this name.  Renaming or aliasing breaks the
  contract.
- **The signature is exact.**  Argument types, argument
  order, return type — all locked.  An OS implementation that
  needs an extra argument adds it as a *config* on the
  manager (per Convention 8), not as a verb argument.
- **The Kind is exact.**  Reads never elevate; writes
  elevate per the manager's config.  A "Read" verb must not
  modify state; a "Write" verb returns a `*ActionResult`.

### `## Method-level invariants (writes and reads)`

The invariants section says *what's true before, during, and
after each verb runs*:

```markdown
## Method-level invariants

### Writes

- Every write MUST honour `dry_run`.  When `dry_run=True`,
  the manager MUST NOT invoke the runner; it MUST return
  `*ActionResult(status=DRY_RUN, command=[...the would-have-
  been-argv...])`.
- Every write MUST honour `elevated`.  When `elevated=True`,
  the manager MUST pass `elevated=True` to the runner.
- Every write MUST be idempotent.  When the post-state
  matches the pre-state, the manager MUST return
  `*ActionResult(status=SKIPPED, ...)` without invoking the
  runner.
- Every write MUST record the command argv on the result
  (per Convention 2).

### Reads

- Reads MUST NOT elevate.  The runner is invoked with
  `elevated=False` regardless of `config.elevated`.
- Reads MUST NOT modify state.
- Reads MAY return `None` / empty / synthetic record on
  resource-not-found (per Convention 7); the per-OS spec
  pins down which.
```

The *MUST* / *MUST NOT* / *MAY* keywords are RFC 2119
again.  The contract is precise about which behaviours are
required and which are permitted to vary per OS.

### `## Data Models`

Enums and dataclasses the verbs return:

```markdown
## Data Models

### `PackageActionStatus` (Enum)

| Value | Meaning |
|-------|---------|
| `SUCCESS` | The package was installed / removed / upgraded as requested. |
| `FAILED`  | The operation could not be completed. |
| `SKIPPED` | The post-state already matched (idempotent skip). |
| `DRY_RUN` | `dry_run=True`; the runner was not invoked. |

### `PackageActionResult` (Dataclass)

| Field | Type | Description |
|-------|------|-------------|
| `status` | `PackageActionStatus` | The outcome status. |
| `package_name` | `str` | The package name acted on. |
| `version` | `str \| None` | The version acted on (or installed). |
| `command` | `list[str] \| None` | The argv that was (or would have been) invoked. |
| ... | ... | (inherited fields from `FeatureActionResult`) |

### `PackageInfo` (Dataclass — query result)

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Package name. |
| `version` | `str` | Installed version. |
| `architecture` | `str \| None` | Architecture (when known). |
| ... | ... | (per-OS extensions allowed) |
```

The status enum names are *the same across every contract*
— `SUCCESS`, `FAILED`, `SKIPPED`, `DRY_RUN`.  The
dataclass names are *per-feature* (`PackageActionResult`,
`ServiceActionResult`, etc.) but they all subclass
`FeatureActionResult` so the standard fields are uniform.

### `## Channel/Source Mapping`

Where channels (e.g., command-execution channels) and
sources (configuration files) come from for each verb:

```markdown
## Channel/Source Mapping

| Verb | Source | Channel |
|------|--------|---------|
| `install_package` | (none) | runner |
| `list_installed` | (none) | runner |
| `install_local` | `path` argument | runner |
| ... | ... | ... |
```

Useful for understanding what each verb reads / writes at
the system level.

### `## Elevation`

The contract restates the elevation rules per verb:

```markdown
## Elevation

Reads NEVER elevate.  Writes elevate when `config.elevated=True`,
EXCEPT:

- Implementations of `install_package` on macOS via Homebrew
  MUST pass `elevated=False` to the runner and wrap the
  argv with `sudo -u <brew_user>` instead.  See Convention 11.
```

The "EXCEPT" carve-outs are the contract's *acknowledgement*
of OS-specific quirks that affect the contract's surface.
Convention 11 (Homebrew root refusal) is the canonical
example.

### `## Execution Model`

Whether verbs are synchronous or asynchronous, in-process
or via subprocess:

```markdown
## Execution Model

Every verb is **synchronous** from the caller's perspective.
The verb returns when the underlying operation has
completed (or failed, or hit a timeout).  Implementations
MAY use threads internally for parallelism but MUST NOT
return before the operation is observable on the system.

Every verb runs through `ICommandRunner` for the actual
command execution.  Implementations MUST NOT call
`subprocess.run` or equivalent directly.
```

The synchronous-from-the-caller commitment matches the
single-actor determinism model from Volume 2 chapter 2.

### `## Naming`

The OS-prefixed type-name convention (Convention 4):

```markdown
## Naming

Every OS implementation MUST expose its types with an
OS-prefixed name:

- Ubuntu: `UbuntuPackageManager`, `UbuntuPackageActionResult`,
  `UbuntuPackageActionStatus`, `UbuntuPackageInfo`.
- macOS: `MacosPackageManager`, ...
- Windows: `WindowsPackageManager`, ...
- UEFI: `UefiPackageManager` (when applicable).

RHEL retains unprefixed names (`PackageManager`,
`PackageActionResult`) for historical reasons; this is the
documented exception.
```

The historical exception for RHEL is honest — when the
suite was first developed, RHEL was the reference and the
prefixing convention came later.  Renaming RHEL's classes
across the test suite is a maintenance cost the project
hasn't paid; the contract acknowledges the inconsistency
rather than pretending it isn't there.

### `## Aspect Composition`

How the verb interacts with Volume 5's `Aspects` pattern:

```markdown
## Aspect Composition

Every write verb that runs through the runner MAY accept
an optional `aspects: AspectsCmd` parameter.  When provided,
the runner uses the supplied aspects; when omitted, the
runner uses its default aspects.

A verb that takes longer-than-default to complete (e.g.,
`upgrade_all` on a system with hundreds of packages) SHOULD
document the appropriate aspects shape in its per-OS spec.
```

This is the contract acknowledging Volume 5's Measured
Interoperability discipline.  The package-management verbs
go through the runner; the runner takes aspects; therefore
package-management implicitly inherits the persistence
behaviours from chapter 5.

### `## Tests`

What every implementation must test (per chapter 1's
discipline mechanisms):

```markdown
## Tests

Each OS implementation MUST include at minimum:

1. One command-construction test per write verb.
2. One idempotency test per write proving `SKIPPED` on
   matching state.
3. One `is_installed` / `get_package_info` test for an
   installed and a missing package.
4. One `list_installed` parser test covering representative
   output.
5. One `list_upgradable` parser test covering representative
   output, including the OS's "updates available" exit-code
   idioms (e.g., `dnf check-update` returning 100).
6. One `install_local` test proving `PackageFileNotFoundError`
   is raised for a missing path and the runner is not invoked.
7. One dry-run test per write verb proving the runner is
   not invoked.
8. One elevation test proving writes pass `elevated=True`
   and reads do not, when configured.
```

The test list is a *minimum bar*.  Per-OS implementations
add more — output-format-specific parser tests, edge cases
that arise from the OS's tool quirks, integration tests
against the real tool.

### `## Out of Scope`

What the contract *deliberately doesn't* cover:

```markdown
## Out of Scope

The package-management contract does NOT cover:

- Repository configuration (covered by `repository-management`).
- Package signing / verification (covered by
  `certificate-trust-management`).
- Service activation after install (covered by
  `service-management`).
- Automatic upgrade scheduling (covered by `scheduled-tasks`).
```

The "Out of Scope" section is what keeps the contract
focused.  Tests that need to verify "the package was
installed AND its service is running" use *both*
`PackageManager` and `ServiceManager`, calling each one's
verbs.  The contracts compose; no contract tries to do
everything.

### `## Implementation Drift`

Acknowledged places where per-OS implementations *can't*
honour the contract literally:

```markdown
## Implementation Drift

- macOS Homebrew has no concept of "system-wide install"
  — packages install per-user.  Implementations MUST
  document this and the manager's `brew_user` config field.
  Tests SHOULD use `brew_user` to verify the user-context
  inversion (Convention 11).

- Windows winget produces text output rather than a stable
  machine-readable format.  Per-OS spec MUST document the
  parsing strategy (column-position from header dashes; see
  `codecraftai-sysman-windows/.../packagemanagement.py`).

- Some Linux distributions ship `apt` and `dnf`
  simultaneously (e.g., Fedora's `dnf` plus optional
  `apt`).  Implementations target one tool only; the
  package's name (`codecraftai-sysman-linux-redhat` vs
  `-ubuntu`) determines which.
```

The "Implementation Drift" section is the contract's
*honesty*.  Real OSes don't all support every concept the
contract describes; the section calls out where the
implementations have to diverge and what tests should look
for.

## Three worked examples

To make the structure concrete, the chapter walks three
contracts end-to-end.

### Example 1 — `package-management`

The example used throughout the chapter so far.  Highlights:

- 10 verbs (4 reads, 6 writes).
- Three data models: `PackageActionStatus`,
  `PackageActionResult`, `PackageInfo`.
- Implementation Drift acknowledges Homebrew's per-user
  install model and winget's text fragility.
- 8 minimum tests per implementation.

### Example 2 — `service-management`

The contract is at
`codecraftai-sysman-common/specs/contracts/service-management/contract.md`.

Distinctive features:

- 11 verbs covering service lifecycle (start, stop, restart,
  reload, enable, disable, mask, unmask, query, list,
  reload_daemon).
- A `ServiceScope` enum (`SYSTEM` vs `USER`) — matters on
  Linux (systemctl --user) and macOS (launchctl `gui/<uid>`).
- "Reload" semantics differ across OSes: Linux systemd has
  `systemctl reload`; Windows has no reload concept and
  returns `FAILED` per Convention 5.
- Implementation Drift notes:
  - Windows has no separate "masking" — `mask_service` is
    aliased to `disable_service`.
  - macOS launchd uses domains (`system`, `gui/<uid>`)
    rather than systemd's `--user` flag.

### Example 3 — `network-configuration`

The contract is at
`codecraftai-sysman-common/specs/contracts/network-configuration/contract.md`.

Distinctive features:

- 11 verbs covering interfaces, addressing, routes, DNS,
  hostname.
- `InterfaceInfo` carries fields like `name`, `type`,
  `mac_address`, `addresses`, `mtu`, `state`.
- Implementation Drift is *substantial*: Linux uses netplan
  YAML (Ubuntu) or NetworkManager KeyFile (RHEL); macOS
  uses `networksetup` writing through System Configuration;
  Windows uses PowerShell with `Get-NetAdapter`.
- Per-OS configurations differ in *where persistence
  lives*: Linux files under `/etc/`, macOS in
  `configd`/`scutil` plists, Windows in WMI / registry.
- The contract acknowledges that "persistent route
  management" is out of scope on macOS (BSD `route` is
  runtime-only).

### Example 4 — `python-environment-management`

The contract is at
`codecraftai-sysman-common/specs/contracts/python-environment-management/contract.md`.

This one is worth reading because it is the *inverted*
case.  The first three examples describe verbs that every OS
implements separately, because `dnf` is not `apt-get` is not
`brew`.  Python virtual environments are different: the
tools are CPython's own `venv` module and `pip`, and they
behave identically on every OS.  So the contract's
Implementation Drift section opens with "shared
implementation, not just shared shapes" — every verb lives
once in `PythonEnvironmentManagementBase` in
`codecraftai-sysman-common`, and the per-OS packages add
about 200 lines each.

Distinctive features:

- 8 write verbs (create, remove, install packages, install
  requirements, uninstall, upgrade pip, `deploy`,
  `ensure_prerequisites`) and 8 read verbs.
- A **Layout Mapping** section in place of the usual
  Channel/Source Mapping.  The only OS-varying concerns are
  where the interpreter lives inside the environment
  (`bin/python` vs `Scripts\python.exe`), the default
  launcher (`python3` vs `py -3`), and the directory probe
  and removal commands (`test -d` / `rm -rf` vs PowerShell
  `Test-Path` / `Remove-Item`).  Those are isolated behind a
  `PythonEnvironmentLayout` strategy.
- A **Requirement Probe** table.  `install_packages` is
  idempotent, but deciding "already satisfied" for a PEP 508
  specifier without a third-party version library is
  hazardous.  The contract pins a conservative table: exact
  pins compare strings, ordering operators compare only
  purely numeric versions, and anything else (URLs, editable
  installs, pre-releases) is "not satisfied" so pip decides.
- `deploy(spec)` is a composite verb with a specified step
  order and a first-failure short-circuit — the contract
  spells out both.
- `ensure_prerequisites` is the *only* per-OS verb.  It
  delegates to the OS's existing `package-management`
  manager (apt `python3-venv`, dnf `python3-pip`, brew
  `python`, winget `Python.Python.3.12`) rather than issuing
  package commands itself — Rule 10 of the AGENTS.md
  ("prioritise reuse") applied at the contract level.
- Credential handling lands in Convention 12's fourth
  channel: pip index URLs carry tokens in the URL userinfo,
  so `redact_command` scrubs `user:password@` from every
  recorded argv.
- Naming: the RHEL package prefixes here
  (`RedHatPythonEnvironmentManager`), breaking its historical
  unprefixed convention on purpose, because the types are
  aliases of shared cross-OS types.

## What the contracts give you

A reader who's read this chapter should now:

- Know where the 52 contracts live and how they're
  organised.
- Recognise the standard sections in any contract (Scope,
  Verbs, Invariants, Data Models, Channel/Source Mapping,
  Elevation, Execution Model, Naming, Aspect Composition,
  Tests, Out of Scope, Implementation Drift).
- Be able to open any contract and find specific
  information quickly.
- See how Convention 5 (FAILED with stable token) and
  Convention 11 (Homebrew elevation inversion) fit into
  the contracts as Implementation Drift.
- Recognise the one contract whose verbs are implemented
  once rather than per OS, and why the tool-level analysis
  justified it.

The contracts are the *documentation* of cross-OS
automation.  Chapter 3 walks the *substrate* that turns the
contracts into runnable code.

## What's next

Chapter 3 walks `featurebase` — `BaseFeatureManager`,
`FeatureConfig`, `FeatureActionResult`, and the `_dispatch`
helper that enforces the contract invariants (dry-run,
elevation, idempotency).  The substrate is what every per-OS
implementation builds on top of.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/03-featurebase -->
# Chapter 3 — `featurebase` and the Per-OS Implementation Pattern

> *The contracts are markdown.  The implementations are
> Python.  The bridge is `featurebase` — a 366-line module in
> `codecraftai-sysman-common` that ships `BaseFeatureManager`,
> `FeatureConfig`, `FeatureActionResult`, and the `_dispatch`
> helper that enforces the contract invariants (dry-run,
> elevation, idempotency, result wrapping).*

The previous chapter walked the contracts.  This chapter
walks the *substrate* — the shared base classes every per-OS
implementation extends.  By the end, a reader should be able
to read any per-OS feature manager and know which behaviours
come from `featurebase` and which are implementation
specifics.

## Why a shared substrate

A reader who's read chapter 1 knows the discipline mechanism
is "shared base classes plus 11 conventions plus per-OS
tests."  This chapter is about the shared base classes.

If `featurebase` didn't exist, every per-OS manager would
re-implement:

- Dry-run handling — checking `config.dry_run` before
  invoking the runner.
- Elevation — passing `elevated=True` to the runner when
  `config.elevated` is set, except for reads.
- Result wrapping — building the `*ActionResult` instance
  with the standard fields (`status`, `command`,
  `exit_code`, `stdout`, `stderr`).
- Status mapping — translating the runner's `RunResult`
  into the contract's status enum.

Multiplied across ~50 features × 5 OSes = ~250 places where
that boilerplate could go subtly wrong.  Centralising the
boilerplate in a single helper means the boilerplate is
correct in ~250 places at once.  When a Phase-3 audit changes
how dry-run is reported, the change happens in
`_dispatch`; every implementation inherits the fix.

## The four canonical types

**File:**
`codecraftai-sysman-common/source/packages/ccai/sysman/common/featurebase.py:113–151`

```python
class ActionStatus(Enum):
    """
        Canonical outcome-status enum for every feature manager in
        this package.
    """
    SUCCESS = "success"
    FAILED  = "failed"
    SKIPPED = "skipped"
    DRY_RUN = "dry_run"


@dataclass
class FeatureConfig:
    """
        Canonical configuration base for every feature manager.
    """
    dry_run: bool = False
    elevated: bool = False


@dataclass
class FeatureActionResult:
    """
        Canonical per-operation result shape.
    """
    status: ActionStatus
    detail: Optional[str] = None
    exit_code: Optional[int] = None
    stdout: Optional[str] = None
    stderr: Optional[str] = None
    command: Optional[list[str]] = None
```

Three types, all canonical.  Per-OS managers subclass each:

- `MacosPackageActionStatus(ActionStatus)` — same four
  values, OS-prefixed name (Convention 4).
- `MacosPackageManagerConfig(FeatureConfig)` — adds
  `brew_user`, `cask_default`, etc.
- `MacosPackageActionResult(FeatureActionResult)` — adds
  `package_name`, `version`, `formula`.

The base types lock down the *common shape*; subclasses add
feature-specific fields.

### `ActionStatus`

Four members.  Every manager in every package returns one
of these four:

- `SUCCESS` — the operation completed as requested.
- `FAILED` — the operation could not be completed.
- `SKIPPED` — the post-state already matched (idempotent
  skip; the runner was not invoked).
- `DRY_RUN` — `dry_run=True`; the runner was not invoked,
  but the would-have-been argv was recorded.

Why these four?  Because they cover every meaningful
outcome of an operation that *might* mutate state:

- The operation *did* mutate state → `SUCCESS`.
- The operation *didn't* mutate state because of an error
  → `FAILED`.
- The operation *didn't* mutate state because it didn't
  need to → `SKIPPED`.
- The operation *didn't* mutate state because the caller
  asked it not to → `DRY_RUN`.

Test code reading the result can dispatch on the status
without parsing exit codes or stderr.  Tests that want to
be tolerant of "it was already configured this way" check
for `status in (ActionStatus.SUCCESS, ActionStatus.SKIPPED)`.
Tests that want strict success-or-fail check
`status == ActionStatus.SUCCESS` only.

### `FeatureConfig`

Two fields by default:

- `dry_run` — when True, the manager records the would-have-
  been command but never invokes the runner.
- `elevated` — when True, write verbs pass `elevated=True`
  to the runner.  Reads ignore this and pass `elevated=False`
  unconditionally.

Per-OS managers subclass to add fields.  The
`MacosPackageManagerConfig` adds `brew_user`.  The
`UbuntuPackageManagerConfig` adds `apt_options` and
`noninteractive_env`.  The `WindowsServiceManagerConfig`
adds nothing — Windows services use the canonical fields
directly.

The two-field minimum is *the* contract config.  Anything
else is per-OS extension.

### `FeatureActionResult`

Six fields:

- `status` — the `ActionStatus` value.
- `detail` — optional human-readable extra context.
- `exit_code` — the runner's exit code (None for SKIPPED
  / DRY_RUN where the runner wasn't invoked).
- `stdout` — captured stdout (or None).
- `stderr` — captured stderr (or None).
- `command` — the argv that was (or would have been)
  invoked.  Per Convention 2: every result records this.

Per-OS subclasses add fields.  `PackageActionResult`
(unprefixed in RHEL) adds `package_name` and `version`.
`UbuntuFirewallActionResult` adds `zone`.  Each addition is
documented in the per-OS spec.

The `command` field is the framework's audit trail.
Diagnostic captures (Volume 2 chapter 6) include action
results in their reports; an engineer reading a failure
record sees the exact argv that was run.

## `BaseFeatureManager`

**File:** `featurebase.py:154–205`

```python
class BaseFeatureManager:
    """
        Shared base for every feature manager.

        Subclasses override the class variables ``result_class`` and
        (rarely) ``status_class`` to plug in feature-specific result
        and status types.  Methods that need to populate extra result
        fields either pass them through as ``**extra`` to
        :meth:`_dispatch` / :meth:`_wrap_result`, or override
        :meth:`_wrap_result` to inject them explicitly.
    """

    # Feature-specific result dataclass.  MUST subclass
    # FeatureActionResult so the standard fields are accepted as
    # keyword arguments.
    result_class: type = FeatureActionResult

    # Feature-specific status enum.  Defaults to ActionStatus;
    # subclasses override only if they extend the base enum.
    status_class: type = ActionStatus

    # Feature name used for future auto-reporting.  Subclasses set
    # this to the canonical spec-folder name.
    feature_name: Optional[str] = None

    def __init__(
        self,
        runner: ICommandRunner,
        config: Optional[FeatureConfig] = None,
    ) -> None:
        """
            :param runner: The command runner used by every mutating
                           operation.
            :param config: Manager configuration.  Defaults to the
                           base FeatureConfig when omitted.
        """
        if config is None:
            config = FeatureConfig()

        self._runner: ICommandRunner = runner
        self._config: FeatureConfig = config

        override_attr = getattr(config, "override_host_os", None)
        normalised_override = normalise_override_host_os(override=override_attr)
        if normalised_override is not None:
            self._host_os: str = normalised_override
        else:
            self._host_os = detect_host_os()
        return
```

Three things to notice:

### Three class variables for subclass override

- **`result_class`** — the subclass-specific result type.
  Default `FeatureActionResult`; per-OS managers override
  with `MacosPackageActionResult`, etc.
- **`status_class`** — the subclass-specific status enum.
  Default `ActionStatus`; OS-prefixed variants set this
  per Convention 4.
- **`feature_name`** — the spec-folder name (e.g.
  `"package-management"`).  Used by the (forthcoming) auto-
  reporting hook from Layer 2's `maintenance-reporting`
  contract.

Subclasses set these as class attributes:

```python
class UbuntuPackageManager(BaseFeatureManager):
    result_class = UbuntuPackageActionResult
    status_class = UbuntuPackageActionStatus
    feature_name = "package-management"

    def install(self, name: str, version: Optional[str] = None) -> UbuntuPackageActionResult:
        # ... implementation ...
```

The subclass doesn't override `__init__`; it inherits the
runner / config / host-OS handling from the base.

### `runner` and `config` parameters

Every manager takes:

- A *command runner* (an `ICommandRunner` instance).  This
  is the seam from Convention 1: every command — and every
  file I/O operation — flows through this runner.
  Test code injects a mock runner; production code injects
  a real one (typically `LocalCommandAgent` from Volume 5
  chapter 3 for local execution, or `SshAgent` for remote).
- A *config* (a subclass of `FeatureConfig`).  Carries
  `dry_run`, `elevated`, and per-OS extensions.

The runner is the *mechanism*; the config is the
*behaviour parameter*.  Manager code calls `self._runner.run(
command, elevated=...)` for the underlying execution.

### Host-OS detection

```python
override_attr = getattr(config, "override_host_os", None)
normalised_override = normalise_override_host_os(override=override_attr)
if normalised_override is not None:
    self._host_os: str = normalised_override
else:
    self._host_os = detect_host_os()
```

The base supports an `override_host_os` config field
(Convention 10).  When the config has this attribute set
(typical for UEFI managers, optional for others), the
manager treats the host as if it were the named OS.  When
absent, the manager calls `detect_host_os()` (which calls
`platform.system()` under the hood).

The override is *testability infrastructure*.  A UEFI
test running on a developer's macOS laptop sets
`override_host_os="linux"` to drive the Linux dispatch
paths through the manager — the manager runs the same code
paths it would on a Linux host, against mock command output.
Volume 4 chapter 8 walks UEFI's use of this in detail.

## The `_dispatch` helper

**File:** `featurebase.py:229–300`

```python
def _dispatch(
    self,
    *,
    command: list[str],
    detail: Optional[str] = None,
    stdin: Optional[str] = None,
    operation: Optional[str] = None,
    elevated_override: Optional[bool] = None,
    **extra,
) -> FeatureActionResult:
    """
        Dispatch a mutating command through the runner, honouring
        dry-run, and map the result to the feature's result type.
    """
    command_copy = list(command)

    # Step 1 — dry-run short-circuit.
    if self._config.dry_run:
        dry_result = self._wrap_result(
            status=self.status_class.DRY_RUN,
            detail=detail,
            command=command_copy,
            **extra,
        )
        return dry_result

    # Step 2 — resolve elevation.
    if elevated_override is None:
        run_elevated = self._config.elevated
    else:
        run_elevated = elevated_override

    # Step 3 — invoke the runner.
    run_result: RunResult = self._runner.run(
        command_copy,
        elevated=run_elevated,
        stdin=stdin,
    )

    # Step 4 — map runner result to manager status.
    if run_result.exit_code == 0:
        mapped_status = self.status_class.SUCCESS
    else:
        mapped_status = self.status_class.FAILED

    # Step 5 — wrap result.
    final_result = self._wrap_result(
        status=mapped_status,
        detail=detail,
        exit_code=run_result.exit_code,
        stdout=run_result.stdout,
        stderr=run_result.stderr,
        command=command_copy,
        **extra,
    )
    return final_result
```

Five steps that *every write verb* delegates to.  The
discipline is locked into one place:

### Step 1 — Dry-run short-circuit

`if self._config.dry_run: return _wrap_result(status=DRY_RUN,
command=command_copy, ...)`.  The runner is *not invoked*.
The would-have-been argv is recorded on the result.  The
caller sees `status == DRY_RUN`.

A test that wants to verify a write verb constructs the
right argv without actually running it sets
`config = FeatureConfig(dry_run=True)`, calls the verb,
and asserts on `result.command`.

### Step 2 — Resolve elevation

The base resolves elevation in priority order:

1. If the caller passed `elevated_override=True` or
   `elevated_override=False`, use that.
2. Otherwise, use `self._config.elevated`.

Reads typically pass `elevated_override=False` to lock
elevation off regardless of config.  Writes typically don't
override and let the config decide.  The Homebrew elevation
inversion (Convention 11) passes `elevated_override=False`
explicitly, regardless of `config.elevated`, because Homebrew
refuses root.

### Step 3 — Invoke the runner

```python
run_result: RunResult = self._runner.run(
    command_copy,
    elevated=run_elevated,
    stdin=stdin,
)
```

Single call to the runner.  The runner is responsible for
the actual subprocess / SSH / WinRM / serial dispatch; the
manager is responsible for choosing the argv and elevation
flag.

The `stdin` parameter is for credential delivery.  Per
Convention 11's credential-delivery rules, secrets *should*
flow via stdin (the runner's `RunResult` doesn't carry the
stdin data, so secrets stay out of the audit trail).  For
example, `luksFormat` routes the passphrase via stdin; the
recorded `command` argv has no secret material.

### Step 4 — Map runner result to manager status

(Since Phase 4 this step also checks the raw `RunResult` for a
truthy `elevation_blocked` attribute — the Windows runners'
Convention 6 extension — and, when present, replaces the
caller's `detail` with the stable token
`DETAIL_ELEVATION_BLOCKED`.  POSIX runners never set the
attribute, so the branch is inert on Linux and macOS.)

```python
if run_result.exit_code == 0:
    mapped_status = self.status_class.SUCCESS
else:
    mapped_status = self.status_class.FAILED
```

The default mapping is *exit-code-driven*: zero means
success, non-zero means failure.

A few features need a different mapping.  For example,
`dnf check-update` returns 100 to signal "updates are
available."  In that case, the per-OS manager *doesn't*
delegate to `_dispatch` for the affected verb; it calls
the runner directly and applies the feature-specific status
mapping.  The base helper covers the common case; the
exceptional case is per-OS.

### Step 5 — Wrap result

```python
final_result = self._wrap_result(
    status=mapped_status,
    detail=detail,
    exit_code=run_result.exit_code,
    stdout=run_result.stdout,
    stderr=run_result.stderr,
    command=command_copy,
    **extra,
)
return final_result
```

`_wrap_result` constructs the `*ActionResult` instance with
the standard fields plus any feature-specific extras
passed via `**extra`.

The per-OS manager calls `_dispatch(command=[...], detail="nginx",
package_name="nginx", version="1.21")` and the
`package_name` / `version` extras flow through to the
result.  No subclass needs to override `_wrap_result` for
the common case; only features with unusual result shapes
override.

## Idempotency — the manager's job

A point worth being explicit about: `_dispatch` does NOT
implement idempotency on its own.  Per the contracts, every
write must be idempotent — but the *check* for idempotency
is per-feature.

A typical write-verb implementation:

```python
def install(self, name: str, version: Optional[str] = None) -> PackageActionResult:
    # Step 1 — check idempotency.
    existing = self.query(name)
    if existing is not None:
        if version is None or existing.version == version:
            return PackageActionResult(
                status=PackageActionStatus.SKIPPED,
                package_name=name,
                version=existing.version,
            )

    # Step 2 — construct the command.
    target = name
    if version is not None:
        target = "{}-{}".format(name, version)
    command = [_DNF, "install", "-y", target]

    # Step 3 — delegate to _dispatch for execution.
    return self._dispatch(
        command=command,
        detail=name,
        operation="install",
        package_name=name,
        version=version,
    )
```

Three steps:

1. **Idempotency check** — the manager queries the current
   state via `self.query(name)` (a Read verb).  If the
   target state already matches, return `SKIPPED` without
   calling `_dispatch`.
2. **Command construction** — the manager builds the
   OS-specific argv.  Per-OS variation lives here.
3. **`_dispatch` delegation** — pass the argv to the helper
   for actual execution and result wrapping.

Step 1's logic is per-feature.  Some features have trivial
idempotency (a service that's already running is `SKIPPED`).
Others have nuanced idempotency (a firewall rule is a
member of a zone; checking membership is OS-specific).  The
contract specifies *what* idempotency means; the manager's
code implements *how* to check it for the OS.

## What `_dispatch` *doesn't* do

A short list of things `_dispatch` deliberately leaves to
the manager:

### Custom status mapping

Verbs that need exit-code mappings other than zero=SUCCESS
(`dnf check-update` returning 100, etc.) must implement
their own status logic.  `_dispatch` covers the simple case;
custom cases bypass it.

### Output parsing

`_dispatch` captures stdout and stderr but doesn't parse
them.  The manager parses (e.g.,
`UbuntuPackageManager.list_installed` parses `dpkg-query`
output into `PackageInfo` records).

### Per-OS pre-call setup

E.g., Ubuntu's package manager sets
`DEBIAN_FRONTEND=noninteractive` in the env before calling
apt-get.  That setup happens in the manager's verb code
before `_dispatch`; `_dispatch` doesn't know about it.

### Multi-step operations

A verb that needs multiple commands in sequence (e.g.,
"create LVM physical volume, then volume group, then logical
volume") makes multiple `_dispatch` calls and aggregates the
results into one `*ActionResult`.

## When the substrate becomes the implementation

`featurebase` is deliberately thin: it locks the invariants
and leaves every verb to the per-OS manager.  Two feature
bases in the same package sit one level up from it, and they
answer opposite questions about how much can be shared.

**File:**
`codecraftai-sysman-common/source/packages/ccai/sysman/common/vpnclientmanagementbase.py`

`VPNClientManagementBase` is *abstract*.  It declares the
seven `vpn-client-management` verbs as `@abstractmethod`s and
ships the helpers every OS needs (`_validate_provider`,
`_short_circuit_dry_run`, `_should_elevate`,
`_dispatch_write`).  The verbs stay per-OS because installing
Tailscale means an apt repository on Ubuntu, a dnf
repository on RHEL, a Homebrew cask on macOS, and an MSI on
Windows.  The base fixes the *shape*; the OS supplies the
*behaviour*.

**File:**
`codecraftai-sysman-common/source/packages/ccai/sysman/common/pythonenvironmentmanagementbase.py`

`PythonEnvironmentManagementBase` is *concrete*.  Every
`python-environment-management` verb — `create_environment`,
`install_packages`, `deploy`, the read surface — is
implemented in the base, because `python -m venv` and
`python -m pip` are the same program on every OS.  The one
abstract method is `_build_layout()`, which returns a
`PythonEnvironmentLayout` from the sibling module
`pythonenvironmentlayout.py`:

```python
class PythonEnvironmentLayout(ABC):
    def default_interpreter_argv(self) -> list[str]: ...   # ["python3"] / ["py", "-3"]
    def venv_python_path(self, path: str) -> str: ...      # bin/python / Scripts\python.exe
    def build_directory_exists_argv(self, path) -> list[str]: ...   # test -d / Test-Path
    def build_remove_tree_argv(self, path) -> list[str]: ...        # rm -rf / Remove-Item
```

A per-OS subclass is then about 200 lines: pick the layout,
alias the shared types with the OS prefix, and add
`ensure_prerequisites`, which delegates to that OS's existing
package manager.

Two details of the concrete base are worth noticing because
they show where `_dispatch` stops being enough:

- Its `_dispatch_write` is a thin wrapper over
  `BaseFeatureManager._dispatch`.  It did not start that way:
  the first version called the runner directly because it
  needed the raw `RunResult` to read the Windows
  `elevation_blocked` extension (Convention 6) and surface it
  as `detail="elevation_blocked"`, and `_dispatch` discarded
  the raw result after mapping status.  That was the "custom
  status mapping" escape hatch from the previous section,
  used for a cross-OS reason rather than a per-OS one — which
  is exactly the signal that the behaviour belonged in the
  substrate.  The pass-through was upstreamed into
  `_dispatch`, and every manager that routes writes through
  it now conforms to Convention 6 for free.
- Its idempotency probe for `install_packages` is a
  conservative PEP 508 evaluator (`_requirement_satisfied`)
  that refuses to guess: pre-release versions, URL and
  editable requirements, and non-numeric ordering comparisons
  all answer "not satisfied" and let pip decide.  A false
  "not satisfied" costs one redundant pip call; a false
  "satisfied" would silently skip a dependency.

The rule the two bases illustrate: share the *shape* by
default, and share the *behaviour* only when the underlying
tool is provably identical across OSes.  The VPN base is the
default; the python-environment base is the justified
exception.

## What this chapter set up

A reader who's read this chapter should:

- Recognise `BaseFeatureManager`, `FeatureConfig`,
  `FeatureActionResult`, `ActionStatus` as the framework's
  canonical types for the sysman tier.
- Be able to read any per-OS manager and identify which
  behaviours come from the base (dispatch, dry-run,
  elevation, result wrapping) and which are
  implementation-specific (idempotency check, command
  construction, output parsing).
- Understand `override_host_os` as testability
  infrastructure (Convention 10).
- See the `_dispatch` helper as the *concrete locking
  mechanism* for the contract invariants — every write
  verb that uses it follows the contract by construction.
- Tell an abstract feature base (`VPNClientManagementBase`)
  from a concrete one (`PythonEnvironmentManagementBase`) and
  know which question — "is the tool the same on every OS?"
  — decides between them.

The next five chapters walk the per-OS implementations, one
per OS.  Each chapter shows what the OS contributes
specifically — RHEL's 47-feature registry, Ubuntu's
shared-base pattern, macOS's quirks, Windows' PowerShell
JSON serialisation, UEFI's single-tier dispatch.

## What's next

Chapter 4 walks the RHEL implementation
(`codecraftai-sysman-linux-redhat`) — the historical reference
that established the patterns.  RHEL retains unprefixed
class names (the documented Convention 4 exception) and
ships a 47-feature registry that's the closest thing to a
"complete" sysman implementation in the portfolio.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/04-rhel-implementation -->
# Chapter 4 — RHEL Implementation

> *RHEL is the historical reference implementation.  47 features
> in a registry, 53 test files, the unprefixed type names that
> predate Convention 4.  This chapter walks the package's
> structure, three representative features (`dnf`,
> `systemctl`, `firewalld`), and the layered spec architecture
> that distinguishes RHEL from Ubuntu's shared-base pattern.*

`codecraftai-sysman-linux-redhat` is the first sysman package
shipped in the portfolio.  Many of the patterns the other
four reference implementations follow were *invented* here —
the registry, the layered spec architecture, the
contract-driven development discipline.  Reading this
chapter is reading the *origin* of the sysman tier's
discipline.

## Layout

```
codecraftai-sysman-linux-redhat/source/packages/ccai/sysman/linux/redhat/
├── __init__.py
├── featurebase.py            # local extension to the common base
├── featureregistry.py        # the 47-feature registry
├── packagemanagement.py
├── servicemanagement.py
├── firewallmanagement.py
├── networkconfiguration.py
├── selinuxmanagement.py
├── subscriptionmanagement.py
├── cockpitmanagement.py
├── insightsmanagement.py
├── tunedmanagement.py
├── ... (37 more feature modules)
└── tests/                    # 53 test files in source/testroots/...
```

47 Python files in the implementation directory (excluding
`__init__.py`).  Each represents either a feature manager or
shared infrastructure (`featurebase`, `featureregistry`).
The structure is *flat* — no deep nesting.  Every feature is
a single file at the top level of the package.

A reader looking for "the RHEL package manager" finds it at
`packagemanagement.py`.  A reader looking for "the RHEL
SELinux feature" finds it at `selinuxmanagement.py`.  The
naming is uniform; the files map 1:1 to the feature names in
the contract layer.

## The 47-feature registry

**File:** `featureregistry.py:146–476`

The RHEL package's feature registry is a comprehensive
catalogue of every feature the package implements.  It binds
canonical feature names to their implementing classes.

```python
# Excerpt — featureregistry.py uses lazy imports to avoid
# circular dependency issues.

FEATURE_DESCRIPTORS = [
    FeatureDescriptor(
        name="command-execution",
        layer=1,
        module="ccai.sysman.linux.redhat.commandexecution",
        class_name="LocalCommandRunner",
        summary="Run single commands, ordered sequences, and shell scripts.",
    ),
    FeatureDescriptor(
        name="package-management",
        layer=3,
        module="ccai.sysman.linux.redhat.packagemanagement",
        class_name="PackageManager",
        summary="RPM install/remove/update/query via dnf + rpm.",
    ),
    FeatureDescriptor(
        name="service-management",
        layer=3,
        module="ccai.sysman.linux.redhat.servicemanagement",
        class_name="ServiceManager",
        summary="systemd service lifecycle + journal queries.",
    ),
    FeatureDescriptor(
        name="firewall-management",
        layer=3,
        module="ccai.sysman.linux.redhat.firewallmanagement",
        class_name="FirewallManager",
        summary="firewalld zones, services, ports, rich rules.",
    ),
    # ... 42 more entries ...
]
```

A representative selection of the 47 features:

- **Layer 1 (1)**: `command-execution` (`LocalCommandRunner`).
- **Layer 2 (1)**: `command-runner-architecture`.
- **Layer 3 (44)**: `acl-management`, `audit-management`,
  `authentication-management`, `backup-restore`,
  `boot-management`, `certificate-issuance`,
  `certificate-trust-management`, `cockpit-management`,
  `configuration-management`, `container-management`,
  `dns-server-management`, `encryption-management`,
  `environment-configuration`, `filesystem-management`,
  `filesystem-quotas`, `firewall-management`,
  `hardware-inventory`, `identity-management`,
  `insights-management`, `kdump-management`,
  `kernel-parameters`, `localization-management`,
  `log-management`, `mail-transport-management`,
  `network-configuration`, `nfs-server-management`,
  `package-management`, `precision-time-management`,
  `process-management`, `remote-log-forwarding`,
  `repository-management`, `resource-control-management`,
  `samba-server-management`, `scheduled-tasks`,
  `security-compliance`, `selinux-management`,
  `service-management`, `ssh-server-configuration`,
  `storage-management`, `subscription-management`,
  `tuned-management`, `user-group-management`,
  `virtualization-management`.

A few of these are RHEL-specific — `cockpit-management`
(RHEL's web admin console), `insights-management` (Red Hat
Insights client), `selinux-management` (RHEL has SELinux;
Ubuntu uses AppArmor), `subscription-management` (Red Hat
Subscription Manager), `tuned-management` (the tuned
performance daemon).

The registry uses **lazy imports** — each `FeatureDescriptor`
records the module path and class name as strings; the
actual class is resolved on `descriptor.resolve()` rather
than at registry import time.  This avoids circular
imports (a feature whose implementation imports from another
feature) and reduces the cost of importing the registry
when only a handful of features are needed.

## The unprefixed naming exception

A reader following Volume 4 chapter 2's Convention 4 will
notice something: the RHEL classes are *not OS-prefixed*.

```python
# RHEL — unprefixed (the documented exception)
class PackageManager(BaseFeatureManager):
    ...

# Ubuntu — prefixed
class UbuntuPackageManager(BaseFeatureManager):
    ...

# macOS — prefixed
class MacosPackageManager(BaseFeatureManager):
    ...
```

The contract documentation (Convention 4 from chapter 9)
acknowledges this exception:

> *RHEL retains unprefixed names (`PackageManager`,
> `PackageActionResult`) for historical reasons; this is the
> documented exception.*

The reason is *project history*.  RHEL was the first
implementation; the suite's prefixing convention emerged
later when Ubuntu / macOS / Windows were added and the team
needed to disambiguate.  Renaming RHEL's classes across the
test suite would touch hundreds of test files for an
aesthetic improvement.  The project chose to acknowledge
the inconsistency rather than pay the rename cost.

A reader importing from RHEL writes:

```python
from ccai.sysman.linux.redhat.packagemanagement import (
    PackageManager,
    PackageActionResult,
)
```

A reader importing from Ubuntu writes:

```python
from ccai.sysman.linux.ubuntu.packagemanagement import (
    UbuntuPackageManager,
    UbuntuPackageActionResult,
)
```

The module paths disambiguate at the import level even
though the class names don't.  Tests that use both packages
(rare) typically alias one to avoid a class-name collision:

```python
from ccai.sysman.linux.redhat.packagemanagement import (
    PackageManager as RhelPackageManager,
)
from ccai.sysman.linux.ubuntu.packagemanagement import (
    UbuntuPackageManager,
)
```

## Three worked features

### Package management with `dnf`

**File:** `packagemanagement.py:143–179`

```python
class PackageManager(BaseFeatureManager):
    """
        High-level facade for Red Hat package management.  Keeps its
        own :class:`PackageActionResult` shape (``package_name``
        required) so does not use the base class's ``_dispatch``;
        inherits only the standard ``__init__`` / ``config`` /
        ``runner`` surface.
    """

    feature_name = "package-management"

    def install(self, name: str, version: Optional[str] = None) -> PackageActionResult:
        """
            Install a package, optionally at a specified version.
        """
        existing = self.query(name)
        if existing is not None:
            if version is None or existing.version == version:
                already_installed = PackageActionResult(
                    status=PackageActionStatus.SKIPPED,
                    package_name=name,
                    version=existing.version,
                )
                return already_installed

        target = name
        if version is not None:
            target = "{}-{}".format(name, version)
        command = [_DNF, "install", "-y", target]

        # ... call self._runner.run(command, elevated=...) directly ...
        # ... map exit code to status ...
        # ... return PackageActionResult ...
```

Two notes:

- **The class doesn't use `_dispatch`.**  The docstring
  explicitly says: *"does not use the base class's
  `_dispatch`."*  This is one of the cases chapter 3
  mentioned: features whose status mapping isn't simple
  exit-code-zero-vs-non-zero (dnf has `check-update`
  returning 100 for "updates available") implement their
  own dispatch.  RHEL's package manager is one of those.
- **Idempotency check first, then construct command.**  The
  `query` call (a Read verb) returns `None` if the package
  isn't installed, or a `PackageInfo` with the installed
  version.  If installed and version matches (or version
  is unspecified), return `SKIPPED` without invoking dnf.
  If not, build the argv and proceed.

The `target = "{}-{}".format(name, version)` line is the
RHEL-specific quirk.  dnf supports the `package-version`
syntax to pin a specific version.  Ubuntu's apt uses
`package=version` instead.  Per-OS code choice; visible at
this layer only.

### Service management with `systemctl`

**File:** `servicemanagement.py:100–178`

```python
class ServiceManager(BaseFeatureManager):

    def start(self, name: str) -> ServiceActionResult:
        """
            Start a service unit.  Skipped when the unit is already active.
        """
        info = self.query(name)
        if info.active_state == "active":
            return ServiceActionResult(
                status=ServiceActionStatus.SKIPPED,
                service_name=name,
            )
        return self._dispatch(
            command=[_SYSTEMCTL, "start", name],
            detail=name,
            operation="start",
            service_name=name,
        )

    def stop(self, name: str) -> ServiceActionResult:
        """
            Stop a service unit.  Skipped when already inactive.
        """
        info = self.query(name)
        if info.active_state == "inactive":
            return ServiceActionResult(
                status=ServiceActionStatus.SKIPPED,
                service_name=name,
            )
        return self._dispatch(
            command=[_SYSTEMCTL, "stop", name],
            detail=name,
            operation="stop",
            service_name=name,
        )
```

In contrast to `PackageManager`, the service manager *does*
use `_dispatch`.  systemctl's exit codes are
exit-code-zero-vs-non-zero clean — zero on success, non-zero
on failure — so the base helper's mapping suffices.

The pattern is uniform across the verbs:

1. Query the current state.
2. If it matches the target state, return `SKIPPED`.
3. Otherwise call `_dispatch` with the systemctl argv.
4. The dispatcher invokes the runner, maps the result, and
   wraps in `ServiceActionResult`.

### Firewall management with `firewalld`

**File:** `firewallmanagement.py:63–80`

```python
class FirewallManager(BaseFeatureManager):
    """
        Facade for firewalld management through the command runner.
    """

    result_class = FirewallActionResult
    feature_name = "firewall-management"

    def add_service(self, service: str, zone: str) -> FirewallActionResult:
        """
            Add ``service`` to ``zone`` (permanent configuration).
        """
        info = self.query_zone(zone)
        if service in info.services:
            return FirewallActionResult(
                status=FirewallActionStatus.SKIPPED,
                zone=zone, detail=service,
            )
        # ... build firewall-cmd argv, dispatch ...
```

Three notes:

- **`result_class = FirewallActionResult`** — overrides the
  default `FeatureActionResult` so `_wrap_result` builds the
  firewall-specific class with `zone` field.
- **Zone vocabulary** — firewalld's *zones* (public,
  internal, trusted, etc.) are first-class concepts.  RHEL's
  manager exposes them directly.  Ubuntu's ufw has no zones
  (chapter 5 walks the divergence); the contract abstracts
  the concept and Ubuntu collapses it.
- **`info.services`** — the query result includes a list of
  enabled services in the zone.  The idempotency check is "is
  this service already in this zone's services list?"

The `firewall-cmd` argv typically uses the `--permanent`
flag to make changes survive reload — a firewalld-specific
detail visible only at this layer.

## The layered spec architecture

`featureregistry.py:146–476` defines the three layers
explicitly:

```python
# Layer 1 — Infrastructure
FeatureDescriptor(name="command-execution", layer=1, ...),
FeatureDescriptor(name="command-runner-architecture", layer=1, ...),

# Layer 2 — Data
FeatureDescriptor(name="maintenance-reporting", layer=2, ...),

# Layer 3 — Domain features (44 entries)
FeatureDescriptor(name="acl-management", layer=3, ...),
FeatureDescriptor(name="audit-management", layer=3, ...),
# ... etc.
```

The layers are not just metadata.  They imply a *dependency
direction*:

- Layer 1 features have no dependencies on the rest of the
  registry.  `command-execution`'s `LocalCommandRunner` is
  the substrate everything else builds on.
- Layer 2 features depend on Layer 1.
- Layer 3 features depend on Layers 1 and 2.

A test that wants to use a Layer-3 feature (e.g.,
`PackageManager`) constructs the manager with a Layer-1
runner.  The framework's typical setup:

```python
from ccai.sysman.linux.redhat.commandexecution import LocalCommandRunner
from ccai.sysman.linux.redhat.packagemanagement import PackageManager
from ccai.sysman.linux.redhat.featurebase import FeatureConfig

runner = LocalCommandRunner()
manager = PackageManager(runner=runner, config=FeatureConfig(elevated=True))
result = manager.install("nginx")
```

The dependency injection is explicit — the test code passes
the runner to the manager.  The layered architecture is
*expressed* in the construction pattern.

The spec files themselves follow the same layering.  Under
`codecraftai-sysman-linux-redhat/specs/`, each layer has its
own subdirectory (`L1/`, `L2/`, `L3/`), and within each
layer, each feature has its own subdirectory with
`design.md`, `requirements.md`, and `tasks.md`.  The spec
files are the per-OS counterpart to the contracts — where
the contract specifies the abstract verb signatures, the
spec specifies the OS-specific tool mappings, output
formats, and edge cases.

## Tests

53 test files at
`source/testroots/testbase/ccai/tests/sysman/linux/redhat/`.

The test structure mirrors the source structure:

```
tests/
├── test_packagemanagement.py
├── test_servicemanagement.py
├── test_firewallmanagement.py
├── test_featureregistry.py
└── ... (49 more)
```

Each test file targets one feature manager.  Tests typically
include:

- Command-construction tests (verify the argv is right
  for each verb).
- Idempotency tests (verify SKIPPED on matching state).
- Dry-run tests (verify the runner isn't invoked).
- Elevation tests (writes elevate; reads don't).
- Output-parsing tests (verify query results are parsed
  correctly).
- Error-handling tests (FAILED on non-zero exit).

The tests run against a *mock runner* — they don't actually
invoke dnf or systemctl.  This makes the tests fast and
hermetic; they verify the manager's *logic* (what argv it
builds, what status it returns for which inputs) without
depending on a live RHEL system.

A separate integration test suite (run periodically against
a real RHEL host) validates that the constructed argvs
actually do what the contract promises.  But the unit tests
in `source/testroots/...` are the bulk of the test count.

## What's next

Chapter 5 walks the Ubuntu implementation
(`codecraftai-sysman-linux-ubuntu`) — 40 features, 41 test
files, the *prefixed* class names from Convention 4, and
the shared-base pattern that imports `featurebase` from
`codecraftai-sysman-common` rather than defining it locally.
The Ubuntu package shows what the sysman discipline looks
like *after* the patterns RHEL established.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/05-ubuntu-implementation -->
# Chapter 5 — Ubuntu / Debian Implementation

> *Ubuntu came after RHEL.  It established the patterns RHEL
> grandfathered: the OS-prefixed class names (Convention 4),
> the shared `featurebase` imported from
> `codecraftai-sysman-common` (rather than locally defined), and
> the per-feature config extensions for tool quirks
> (`DEBIAN_FRONTEND=noninteractive`, the single-zone ufw
> abstraction).*

`codecraftai-sysman-linux-ubuntu` is the second sysman package.
Its existence is what *forced* the prefixing convention —
two packages with `PackageManager` classes was the
collision that prompted Convention 4.  Reading this chapter
is reading the *generalisation* of the patterns RHEL
established.

## Layout

```
codecraftai-sysman-linux-ubuntu/source/packages/ccai/sysman/linux/ubuntu/
├── __init__.py
├── packagemanagement.py
├── servicemanagement.py
├── firewallmanagement.py
├── networkconfiguration.py
├── bootloadermanagement.py    # covers EFI variants RHEL doesn't
├── ... (35 more feature modules)
└── (no local featurebase — imports from codecraftai-sysman-common)
```

40 Python files.  Notably *no local `featurebase.py`* — the
Ubuntu package imports `BaseFeatureManager`, `FeatureConfig`,
`FeatureActionResult`, and `ActionStatus` directly from
`ccai.sysman.common.featurebase`.

This is the **shared-base pattern** — the second-mover
advantage.  RHEL was first and has its own `featurebase` for
historical reasons (the common package didn't exist yet).
Ubuntu was second and uses the common package directly.
Every per-OS package added after Ubuntu (macOS, Windows,
UEFI) follows the shared-base pattern.

## OS-prefixed class names

Every public type in the Ubuntu package is prefixed with
`Ubuntu`:

```python
class UbuntuPackageManager(BaseFeatureManager): ...
class UbuntuPackageManagerConfig(FeatureConfig): ...
class UbuntuPackageActionResult(FeatureActionResult): ...
class UbuntuPackageActionStatus(Enum): ...
class UbuntuPackageInfo: ...

class UbuntuServiceManager(BaseFeatureManager): ...
class UbuntuServiceManagerConfig(FeatureConfig): ...
class UbuntuServiceActionResult(FeatureActionResult): ...
class UbuntuServiceScope(Enum): ...
```

The prefix is **mandatory** per Convention 4 — no
exceptions.  A reader importing from Ubuntu sees the prefix
in every name, which is what makes cross-package usage
unambiguous:

```python
from ccai.sysman.linux.redhat.packagemanagement import PackageManager
from ccai.sysman.linux.ubuntu.packagemanagement import UbuntuPackageManager

# Distinct names; no aliasing needed.
rhel_mgr = PackageManager(runner=rhel_runner, config=...)
ubuntu_mgr = UbuntuPackageManager(runner=ubuntu_runner, config=...)
```

The historical exception (RHEL retains unprefixed names) is
the *only* exception.  Every other package follows.

## Three worked features

### Package management with `apt-get`

**File:**
`packagemanagement.py:1–79`

```python
from ccai.sysman.common.featurebase import (
    ActionStatus,
    BaseFeatureManager,
    FeatureActionResult,
    FeatureConfig,
)


@dataclass
class UbuntuPackageManagerConfig(FeatureConfig):
    """
        Configuration for :class:`UbuntuPackageManager`.  Inherits
        ``dry_run`` and ``elevated`` from :class:`FeatureConfig`.

        :param apt_options: Optional list of additional flags appended to
                            every ``apt-get`` write argv.  Defaults to
                            ``None``.
        :param noninteractive_env: When True (the default), the runner's
                                   env for write verbs includes
                                   ``DEBIAN_FRONTEND=noninteractive``.
    """
    apt_options: Optional[list[str]] = None
    noninteractive_env: bool = True


class UbuntuPackageManager(BaseFeatureManager):
    """
        Ubuntu apt/dpkg package manager.  Every verb is dispatched through
        the injected :class:`ICommandRunner`.
    """

    result_class = UbuntuPackageActionResult
    status_class = UbuntuPackageActionStatus
    feature_name = "package-management"
```

Two Ubuntu-specific config extensions:

- **`apt_options`** — extra flags appended to every apt-get
  argv.  A test that wants to pass `--no-install-recommends`
  for stricter installs sets
  `config = UbuntuPackageManagerConfig(apt_options=["--no-install-recommends"])`.
- **`noninteractive_env`** — when True (the default),
  every write verb runs with the
  `DEBIAN_FRONTEND=noninteractive` env var set.  This
  prevents apt from prompting for user input during
  unattended runs (e.g., when a package's postinst script
  asks "do you want to keep your existing config file?").

The `noninteractive_env` flag is the *Ubuntu-specific
quirk* the contract leaves to the implementation.  RHEL's
dnf doesn't have this concept (dnf is non-interactive by
default with the `-y` flag).  The contract specifies the
verb's behaviour ("idempotent install at the requested
version"); the implementation handles the OS-specific noise
that gets in the way.

### Firewall management with `ufw`

**File:**
`firewallmanagement.py:167–176`

```python
class UbuntuFirewallManager(BaseFeatureManager):
    """
        Ubuntu ufw firewall manager.  Every verb is dispatched through
        the injected :class:`ICommandRunner`.
    """

    result_class = UbuntuFirewallActionResult
    status_class = UbuntuFirewallActionStatus
    feature_name = "firewall-management"
```

The contract's `firewall-management` interface includes
zone-based methods (`add_service(service, zone)`, etc.).
ufw, however, *has no zone concept*.

The Ubuntu manager handles this via Convention 5 (FAILED
with stable detail token):

```python
def add_service(self, service: str, zone: str) -> UbuntuFirewallActionResult:
    if zone != "default":
        return UbuntuFirewallActionResult(
            status=UbuntuFirewallActionStatus.FAILED,
            zone=zone,
            detail="ufw_no_zones",  # stable token
        )
    # ... ufw-specific implementation for the default zone ...
```

A caller passing any value other than `"default"` gets
`FAILED` with the stable token `ufw_no_zones`.  Tests
written against the contract know to accept this token on
Ubuntu; cross-OS tests that target the firewall feature on
both RHEL and Ubuntu either:

- Use `zone="default"` (works on both — RHEL's "default"
  is `public`).
- Have separate paths for the two OSes when zone-specific
  behaviour is needed.

Convention 5 is the framework's mechanism for handling this
class of divergence cleanly.

### Network configuration with `netplan`

**File:**
`networkconfiguration.py:1–80`

```python
"""
    Ubuntu network-configuration feature.

    Implements the cross-OS network-configuration contract for Ubuntu using
    the iproute2 family (``ip``), netplan (``netplan apply`` plus a
    declarative YAML drop-in under ``/etc/netplan/``), ``resolvectl`` for
    DNS, and ``hostnamectl`` for the static hostname.
"""


@dataclass
class UbuntuNetworkConfigurationManagerConfig(FeatureConfig):
    """
        :param netplan_dir: Directory holding netplan YAML drop-ins.
                            Defaults to ``/etc/netplan``.
        :param netplan_basename: Filename of the netplan drop-in
                                 maintained by this manager.  Defaults to
                                 ``99-ccai.yaml``.
    """
    netplan_dir: str = "/etc/netplan"
    netplan_basename: str = "99-ccai.yaml"
```

Persistent IPv4 addressing on Ubuntu happens through
**netplan YAML drop-in files**.  The Ubuntu manager:

1. Reads the existing drop-in file (if it exists).
2. Computes the desired post-state as a YAML dict.
3. If post-state matches existing, return SKIPPED.
4. Otherwise, render the YAML and write the file via
   `tee` (per Convention 1's file-I/O-through-runner rule).
5. Run `netplan apply` to make the change live.

The `netplan_dir` and `netplan_basename` config fields let
tests target a custom drop-in path (useful for testing in
CI environments where `/etc/netplan/` should be left
alone).

Compare to RHEL's network-configuration: RHEL uses
NetworkManager's KeyFile format under
`/etc/NetworkManager/system-connections/`.  Same contract;
different tool; different file layout; same idempotency
discipline.

The `resolvectl` and `hostnamectl` tools handle DNS and
hostname respectively — also different from RHEL's
NetworkManager-based DNS handling.  Each manager does what
the OS's idiom requires.

## What's NOT in Ubuntu

A reader comparing the Ubuntu and RHEL packages will notice:

### RHEL-only features absent from Ubuntu

- **`selinux-management`** — Ubuntu uses AppArmor, not
  SELinux.  The Ubuntu package doesn't ship an AppArmor
  manager (no contract for AppArmor exists yet).
- **`subscription-management`** — RHEL-specific (Red Hat
  Subscription Manager).
- **`cockpit-management`** — RHEL-specific.
- **`insights-management`** — RHEL-specific.
- **`tuned-management`** — RHEL-specific.
- **`command-execution`** and **`command-runner-architecture`**
  — these are in RHEL's registry as Layer-1 features, but
  Ubuntu inherits the runner from `codecraftai-sysman-common`
  and doesn't re-register.

### Ubuntu-specific feature: `bootloader-management`

The Ubuntu package ships `bootloader-management` (covering
GRUB2 with EFI variants).  RHEL's package has a simpler
`boot-management` feature; the Ubuntu version is more
sophisticated because Ubuntu's bootloader story (Secure
Boot, EFI, BIOS modes) varies more than RHEL's typical
deployment.

The asymmetry is *not* a contract violation.  The contracts
are a *menu*; not every OS implements every feature.  What
the contracts ensure is that *when* an OS implements a
feature, the implementation honours the contract.

## The shared-base benefit visible

A worth-pointing-out detail: when the
`codecraftai-sysman-common` package's `featurebase` is
updated (a Phase-3 audit fix, a new convention added), the
Ubuntu package picks up the change *automatically* on its
next dependency resolution.  The RHEL package's local
`featurebase` is a copy that has to be updated *manually*
(or the package has to migrate to the shared base, which is
a separate maintenance task).

This is the trade-off that prompted the shared-base pattern.
The cost: Ubuntu can't customise its base.  The benefit:
common improvements arrive uniformly.  Ubuntu's choice
(shared base) is a vote in favour of consistency over
flexibility.

## Tests

41 test files at
`source/testroots/testbase/ccai/tests/sysman/linux/ubuntu/`.

Slightly fewer than RHEL's 53, reflecting:

- Fewer features (40 vs 46).
- Some features have simpler test surfaces (ufw's
  single-zone reduces the test matrix for firewall vs
  firewalld's multiple zones).

The test patterns are the same as RHEL's: command-construction,
idempotency, dry-run, elevation, output-parsing,
error-handling.  The tests run against mock runners in unit
mode; integration tests against real Ubuntu hosts run
periodically.

## Cross-OS feature surface — shared and divergent

A summary of where Ubuntu and RHEL converge and diverge on
features they *both* implement:

| Feature | RHEL tool | Ubuntu tool | Convergence |
|---|---|---|---|
| package-management | dnf + rpm | apt-get + dpkg | Same contract; different tools; different version-pinning syntax |
| service-management | systemctl | systemctl | Both systemd; Ubuntu adds `UbuntuServiceScope` enum for SYSTEM/USER distinction |
| firewall-management | firewalld (zones) | ufw (single "default" zone) | Same contract; Ubuntu uses Convention 5 for non-default zones |
| network-configuration | NetworkManager KeyFile | netplan YAML | Both write persistent files, run a "reload" command |
| storage-management | parted, mkfs, LVM tools | parted, mkfs, LVM tools | Largely identical (Linux-wide tools) |
| user-group-management | useradd, groupadd, usermod | useradd, groupadd, usermod | Identical |
| repository-management | yum/dnf .repo files | apt sources.list.d | Same concept; very different file formats |
| ssh-server-configuration | sshd_config edit + restart | sshd_config edit + restart | Identical |

The convergence pattern: when the *concept* is shared
(package install, service start, user add), the contract
abstracts over both OSes cleanly.  When the *concept*
diverges (firewall zones, network config files), the
contract specifies the abstract verb and Convention 5
handles the cases where one OS can't honour the abstraction.

## What's next

Chapter 6 walks the macOS implementation
(`codecraftai-sysman-macos`) — 49 features, 45 test files,
and the most distinctive set of OS-specific quirks: Homebrew
elevation inversion (Convention 11), launchd's `gui/<uid>`
domain mapping for user services, pf's per-anchor file
layout, and the macOS hostname triple
(`HostName` / `ComputerName` / `LocalHostName`).


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/06-macos-implementation -->
# Chapter 6 — macOS Implementation

> *macOS is the most quirky OS in the suite.  Homebrew
> refuses root.  launchd's "user services" run in
> `gui/<uid>` domains, not bare-user domains.  pf has no
> first-class zones — they're emulated via per-anchor files.
> The macOS hostname is a triple, not a string.  This chapter
> walks `codecraftai-sysman-macos` with each quirk explicit.*

`codecraftai-sysman-macos` ships 49 features and 45 test files.
The package follows the patterns Ubuntu established — shared
`featurebase`, OS-prefixed class names — but adds more
implementation-specific peculiarities than any other OS in
the suite.  Convention 11 (elevation-model inversion) was
*invented* for macOS.

## Layout

```
codecraftai-sysman-macos/source/packages/ccai/sysman/macos/
├── __init__.py
├── packagemanagement.py        # Homebrew (Convention 11)
├── servicemanagement.py        # launchctl + gui/<uid> domains
├── firewallmanagement.py       # pf with anchor-file emulation
├── networkconfiguration.py     # networksetup + scutil hostname triple
├── configurationmanagement.py  # plist via defaults
├── ... (44 more features)
```

49 Python files in the implementation directory.  Like
Ubuntu, no local `featurebase.py` — imports from
`ccai.sysman.common`.  All public types are
`Macos`-prefixed.

## Homebrew elevation inversion (Convention 11)

The most-discussed macOS quirk.  Homebrew **refuses to run
as root**.  If a Homebrew command is invoked under root, it
errors out:

```
Error: Running Homebrew as root is extremely dangerous and no
longer supported.
```

This breaks the standard sysman elevation model.  The
contract says "writes elevate when `config.elevated=True`."
Homebrew's writes can't elevate — they have to *de-elevate*.

The solution (Convention 11): pass `elevated=False`
unconditionally to the runner, and wrap the argv with
`sudo -u <brew_user>` when the calling process is root.

**File:**
`packagemanagement.py:159–246`

```python
class MacosPackageManager(BaseFeatureManager):
    """
        macOS Homebrew package manager.  Every verb is dispatched
        through the injected :class:`ICommandRunner`.

        **Homebrew never runs as root.**  Every argv assembled by
        this manager is dispatched with ``elevated=False`` via
        :meth:`_dispatch_brew`, regardless of ``config.elevated``.
        When the calling process is root the caller must populate
        :attr:`MacosPackageManagerConfig.brew_user` so the manager
        can wrap every argv with ``sudo -u <brew_user>`` and
        execute ``brew`` under a non-root identity.
    """

    result_class = MacosPackageActionResult
    status_class = MacosPackageActionStatus
    feature_name = "package-management"
```

The `brew_user` config field is the operator's *non-root
identity* on the host.  When the test process is running as
root (typical on CI hosts that need elevation for other
features), the manager:

1. Detects it's running as root.
2. Looks at `config.brew_user`.
3. Prepends `["sudo", "-u", brew_user]` to the brew argv.
4. Calls `self._runner.run(prefixed_argv, elevated=False)`.

The runner's `elevated=False` means the `sudo -u` is the
*only* privilege transition — root drops to `brew_user`,
runs brew as that user, returns.

When the test process is *not* running as root, the manager
just calls brew directly with `elevated=False`.

The discipline is documented and the manager validates:
running as root *without* `brew_user` set raises
`ConfigurationError` at construction time.  An operator who
forgets to configure the brew_user gets a clear error rather
than a confusing brew-refuses-root failure mid-run.

This is the canonical example of the **elevation-model
inversion** Convention 11 names.

## launchd domains for service management

**File:**
`servicemanagement.py:1–75`

```python
"""
    macOS service-management feature.

    Implements the cross-OS service-management contract for macOS
    using ``launchctl``.  Every verb is dispatched through an
    :class:`ICommandRunner` so the manager works identically under
    local and SSH execution.

    Scope mapping:
        ``MacosServiceScope.SYSTEM`` -> domain ``"system"``
        ``MacosServiceScope.USER``   -> domain ``f"gui/{config.target_uid}"``

    The ``user/<uid>`` background-user domain is also a valid
    macOS launchd domain target.  This pilot uses ``gui/<uid>``
    as the primary user-scope domain because GUI-session agents
    are by far the most common user-scoped target on macOS
    workstations.
"""
```

macOS's launchd uses *domains* to scope services:

- `system` — system-wide services (LaunchDaemons).
- `gui/<uid>` — services in a user's GUI session
  (LaunchAgents).
- `user/<uid>` — services in a user's background session.

Linux's systemd has a simpler model (`--user` flag).  The
contract specifies a `ServiceScope` enum (`SYSTEM` vs
`USER`); macOS implements it via launchctl's domain
targeting.

The Ubuntu manager maps `ServiceScope.USER` to systemctl's
`--user` flag.  The macOS manager maps it to
`gui/<uid>` because user-scoped services on macOS are
overwhelmingly GUI-session agents (menubar apps, login
items).  The `user/<uid>` background domain is supported by
launchd but rarely the right target; the macOS manager
chooses `gui/<uid>` as the default for `USER` scope and
documents the choice.

A test that wants the background domain explicitly can pass
a config with the alternative; the manager honours it.  The
default tracks the common case.

## pf firewall via anchor files

**File:**
`firewallmanagement.py:1–60`

```
Zone mapping: macOS pf has no first-class zone concept, but it
does have anchor namespaces.  This manager maps each contract
``zone`` to a pf anchor named ``ccai.<zone>``; the anchor's
rules live in ``/etc/pf.anchors/ccai.<zone>``.

Surface choices:
    * Each ``add_*`` / ``remove_*`` verb reads the current anchor
      file via ``cat``, computes the desired post-state, and
      either rewrites the file via ``tee`` (followed by a
      per-anchor ``pfctl -a`` reload) or returns ``SKIPPED``
      when the post-state already matches.
```

macOS's pf (Packet Filter) doesn't have zones the way
firewalld does.  pf has *anchors* — named rule sub-tables
that can be loaded, listed, flushed independently.

The macOS manager maps the contract's `zone` argument to
pf anchor names with a fixed prefix:

- Contract zone `"public"` → pf anchor `"ccai.public"`.
- Contract zone `"internal"` → pf anchor `"ccai.internal"`.

Anchor rules live in `/etc/pf.anchors/ccai.<zone>`.
The manager reads-modify-writes the file:

1. `cat /etc/pf.anchors/ccai.public` → current rules.
2. Compute the desired post-state.
3. If matches, return SKIPPED.
4. Otherwise `tee /etc/pf.anchors/ccai.public` →
   write the new rules.
5. `pfctl -a ccai.public -f /etc/pf.anchors/ccai.public`
   → reload that anchor.

The reload-per-anchor scoping prevents the manager's
changes from disturbing rules in *other* pf anchors that
non-ccai tools maintain.

A separate concern: pf needs `/etc/pf.conf` to *include*
the ccai.<zone> anchors; otherwise pf doesn't load
them.  The manager doesn't manage `/etc/pf.conf` — that's
the operator's setup task.  The chapter is honest that the
sysman tier can't substitute for initial system
configuration; it can only manage *changes* to the firewall
rules, given an already-configured pf.conf that includes
the anchors.

## networksetup + scutil for network configuration

**File:**
`networkconfiguration.py:1–45`

```python
"""
    Implements the cross-OS network-configuration contract for
    macOS using ``networksetup`` (the canonical persistent
    surface, which writes through System Configuration /
    ``configd``), ``ifconfig`` for runtime interface detail,
    BSD ``route`` for routes, and ``scutil`` for the macOS
    hostname triple (``HostName`` / ``ComputerName`` /
    ``LocalHostName``).
"""
```

macOS's network configuration is split across multiple
tools, each with a specific role:

- **`networksetup`** — the canonical *persistent*
  configuration tool.  Writes through the System
  Configuration framework (`configd`); changes survive
  reboot and are visible to the GUI Network Preferences
  pane.
- **`ifconfig`** — runtime interface state.  Read-mostly;
  changes don't persist.
- **BSD `route`** — runtime route table.  Persistent route
  management is *out of scope* on macOS; runtime routes
  are what the manager handles.
- **`scutil`** — the macOS *hostname triple*.

### The hostname triple

macOS has *three* hostnames:

- **`HostName`** — the BSD hostname (returned by
  `hostname` command).
- **`ComputerName`** — the friendly name shown in System
  Preferences.
- **`LocalHostName`** — the Bonjour mDNS name (e.g.,
  `MyMac.local`).

The contract has one `set_hostname(name)` verb.  The
macOS manager sets *all three* using `scutil`:

```python
def set_hostname(self, name: str) -> MacosNetworkActionResult:
    # ... query existing values ...
    # ... idempotency check ...

    cmds = [
        ["scutil", "--set", "HostName", name],
        ["scutil", "--set", "ComputerName", name],
        ["scutil", "--set", "LocalHostName", name],
    ]
    # ... dispatch each in sequence ...
```

The three are set together because they typically *should*
match.  An operator who needs them to differ (rare —
typically only on Bonjour-conscious lab gear) bypasses the
contract and uses `scutil` directly.

The *contract specifies one verb*; the *implementation*
maps it to three commands; the *result* aggregates the
outcomes.  This is the "manager handles multi-step
operations" pattern from chapter 3.

## Configuration management — plist routing

**File:**
`configurationmanagement.py:1–80`

```python
"""
    Implements the cross-OS configuration-management contract for
    macOS by routing each call by the source path:

    * Apple plist files (path ends in ``.plist`` OR lives under
      ``/Library/Preferences/`` or ``~/Library/Preferences/``)
      are manipulated with ``defaults``.
    * Every other path is treated as a plain shell-style
      ``KEY=VALUE`` configuration file and is manipulated with
      ``cat`` / ``tee``.

    Plist-type inference (write):
        ``"true"``/``"false"`` -> ``-bool``; all-digit strings ->
        ``-int``; everything else -> ``-string``.  Floats, arrays,
        and nested dicts must be written via
        :meth:`reload_configuration` + an externally rendered
        plist; the limitation is documented in
        ``specs/3-configuration-management/tasks.md``.
"""
```

macOS configuration files come in two shapes:

- **plist files** (Property List XML or binary) under
  `/Library/Preferences/` and similar.  Manipulated by
  `defaults` command.
- **shell-style `KEY=VALUE` files** for shell environments,
  cron tables, etc.  Manipulated by `cat` / `tee`.

The macOS configuration manager *routes by path*: ends in
`.plist` or lives in a Preferences directory → `defaults`;
otherwise → plain text.  A test setting a configuration
key writes the same `set_value(path, key, value)` call;
the manager picks the right tool.

**Plist type inference** is the macOS-specific quirk.
`defaults write` requires a type flag (`-string`, `-bool`,
`-int`, `-float`, etc.).  The manager infers from the value:

- `"true"` / `"false"` → `-bool true` / `-bool false`.
- All-digits → `-int`.
- Otherwise → `-string`.

Floats, arrays, and nested dicts are *out of scope* for the
macOS configuration manager.  The implementation drift
section of the contract (chapter 2) documents this; tests
that need rich plist values use `reload_configuration`
with an externally rendered plist file.

The boundary is honest.  `defaults` works well for simple
key-value plist edits.  Complex plist structures need a
different tool (`PlistBuddy`, custom XML rendering); the
contract acknowledges the limit rather than pretending the
manager handles every case.

## Other macOS-specific features

A short tour of the other distinctive macOS features:

### `launchctl` user-domain reload

`reload_daemon` on Linux means `systemctl daemon-reload`.
On macOS, it means `launchctl bootstrap`/`bootout` cycles
to refresh the launchd domain's known unit list.  The
implementation differs because launchd's reload semantics
are different — same contract verb, different mechanics.

### `csrutil` for system integrity protection

The macOS `security-compliance` feature exposes SIP
(System Integrity Protection) state via `csrutil status`.
SIP is a recovery-mode-only setting (you can't disable it
from a running OS), so the manager's
`get_security_state()` reads but `set_security_state()`
returns FAILED with a stable token —
`unsupported_runtime_change_macos`.

### `softwareupdate` for system updates

The macOS `package-management` feature is *Homebrew*.  But
*system* updates (macOS itself, security patches) go
through `softwareupdate`.  The macOS package adds a
separate manager for this — `MacosSystemUpdateManager` —
that's not in the cross-OS contract surface (no
`software-update` contract exists; system updates vary too
much across OSes to abstract).

This is honest scoping.  Some OS-specific concerns don't
generalise; they ship as OS-specific features without
contract participation.

## Tests

45 test files at `source/testroots/testbase/`.

The test count is comparable to Ubuntu's 41 and Windows's
45.  The macOS tests cover the same patterns
(command-construction, idempotency, dry-run, elevation,
output-parsing) plus *more* per-feature edge cases because
of the OS-specific quirks:

- Homebrew root-detection tests verify the
  `brew_user` requirement and the `sudo -u` wrapping.
- launchctl domain-mapping tests verify
  `MacosServiceScope.USER` produces the correct
  `gui/<uid>` argv.
- pf anchor-file tests verify the read-modify-write
  flow against mock file contents.
- scutil hostname-triple tests verify all three names get
  set in the correct order.

The mock-runner pattern from chapter 4 still applies; tests
don't actually invoke brew / launchctl / pfctl / scutil.
They verify the manager constructs the right argvs.

## What's next

Chapter 7 walks the Windows implementation
(`codecraftai-sysman-windows`) — 49 features, 45 test files,
the PowerShell-driven mechanism throughout (cmdlets +
`ConvertTo-Json`), winget's locale-fragile output parsing,
the elevation-blocked detection (Convention 6), and the
profile-based firewall model that maps the contract's
`zone` to Windows Firewall profiles (Domain / Private /
Public / Any).


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/07-windows-implementation -->
# Chapter 7 — Windows Implementation

> *Windows automation is PowerShell.  Every feature manager
> wraps PowerShell cmdlets, parses output via
> `ConvertTo-Json -Compress -Depth 5` for cross-version
> stability, and handles the elevation-blocked condition
> (Convention 6) when the calling process isn't elevated.
> winget's text-format fragility, profile-based firewall
> scoping, no-masking aliasing — Windows brings its own set
> of quirks the contract layer absorbs.*

`codecraftai-sysman-windows` ships 49 features and 45 test
files.  Like macOS, the package follows the shared-base
pattern with `Windows`-prefixed types throughout.  Unlike
macOS, the Windows package is *uniformly* PowerShell-driven
— almost every feature manager builds a PowerShell
expression as its argv.

## Layout

```
codecraftai-sysman-windows/source/packages/ccai/sysman/windows/
├── __init__.py
├── packagemanagement.py        # winget
├── servicemanagement.py        # Get/Start/Stop/Set-Service
├── firewallmanagement.py       # NetSecurity module
├── networkconfiguration.py     # Get-NetAdapter / Get-NetIPConfiguration
├── ... (45 more)
```

49 Python files.  Same shared-base pattern as macOS.

A note up front: many Windows feature managers use the same
underlying mechanism (PowerShell + `ConvertTo-Json`).  The
volume of cross-feature mechanism duplication is higher
than on Linux or macOS.  This is consistent with PowerShell
being the canonical Windows automation surface.

## PowerShell + `ConvertTo-Json` — the read pattern

**File:**
`servicemanagement.py:1–100`

```python
"""
    Windows service-management feature.

    Implements the cross-OS service-management contract for
    Windows using the PowerShell ``ServiceController`` cmdlets
    (``Start-Service``, ``Stop-Service``, ``Restart-Service``,
    ``Set-Service``, ``Get-Service``).

    Reads use ``Get-Service ... | ConvertTo-Json -Compress -Depth 5``
        for a single, robust serialization that round-trips
        across PowerShell 5.1 and PowerShell 7+.
    - ``Status`` and ``StartType`` come back as integers
        because ``ConvertTo-Json`` serializes the underlying
        .NET enum values as their numeric encoding.  See
        :func:`_decode_status_int` and
        :func:`_decode_start_type_int` for the mapping.
    - ``mask_service`` / ``unmask_service`` are aliased to
        ``disable`` / ``enable`` because the Windows Service
        Control Manager has no separate masking concept.
    - ``reload_service`` always returns ``FAILED`` because
        Windows services have no native reload signal;
        ``Restart-Service`` is the closest equivalent if a
        reconfigure-with-restart is acceptable.
    - ``reload_daemon`` is a SUCCESS no-op: the Windows
        Service Control Manager applies service definitions
        immediately.
"""
```

Three things define the Windows service manager (and
generalise to most Windows feature managers):

### `ConvertTo-Json -Compress -Depth 5` for reads

PowerShell objects don't serialise consistently across
versions.  Plain `Get-Service | Out-String` produces
human-readable text whose format differs between PowerShell
5.1 (Windows 10's default) and PowerShell 7+ (modern
Windows / Linux PowerShell).

`ConvertTo-Json` produces *machine-readable JSON* that's
stable.  The `-Compress` flag avoids whitespace; `-Depth 5`
ensures nested structures serialise (the default depth is
2, which truncates many objects).

The read pattern across the Windows package:

```powershell
Get-Service -Name nginx | ConvertTo-Json -Compress -Depth 5
```

The Python manager invokes this via
`self._runner.run(["powershell", "-NoProfile", "-Command", "..."])`,
captures stdout, and `json.loads` it.  Cross-version stable.

### Status / StartType as integers

PowerShell's `ConvertTo-Json` serialises .NET enums as
*integers* — the underlying numeric value rather than the
string name.

```python
# Status values for a service:
# 1 = Stopped
# 2 = StartPending
# 3 = StopPending
# 4 = Running
# 5 = ContinuePending
# 6 = PausePending
# 7 = Paused

def _decode_status_int(status_int: int) -> str:
    return {
        1: "stopped", 4: "running", 7: "paused",
        # ... transitional states ...
    }.get(status_int, "unknown")
```

The manager has decoder functions for every cmdlet that
returns enum-typed fields.  An engineer reading the manager
sees the decoder before the verb implementation; the
discipline keeps the integer semantics out of the verb
logic.

### Aliasing where the OS doesn't support a concept

The Windows Service Control Manager has no separate
"masking" concept the way Linux systemd does.  systemd's
`mask` is a stronger form of `disable` — a masked service
can't be started even by transient activation.  Windows
SCM doesn't distinguish.

The Windows manager handles this via Convention 5
(supported here as a *naming alias* rather than a FAILED
return):

```python
def mask_service(self, name: str, scope: ServiceScope) -> ServiceActionResult:
    """
        Aliased to disable_service on Windows; SCM has no separate
        masking primitive.
    """
    return self.disable_service(name, scope)


def unmask_service(self, name: str, scope: ServiceScope) -> ServiceActionResult:
    """
        Aliased to enable_service on Windows.
    """
    return self.enable_service(name, scope)
```

A test that calls `mask_service` on Windows gets the same
behaviour as `disable_service`.  The contract verb is
honoured (a result is returned with `status=SUCCESS`); the
underlying OS just doesn't have the granularity systemd
does.

`reload_service` on Windows returns `FAILED` with stable
detail token `unsupported_no_reload_signal`.  Per Convention
5; the contract verb is reachable; the OS doesn't support
it.

## winget's locale-fragile parsing

**File:**
`packagemanagement.py:1–50`

```python
"""
    Windows package-management feature.

    Output-parsing fragility:
        ``winget`` produces text output for ``list`` /
        ``show`` / ``upgrade`` rather than a stable
        machine-readable form.  The column boundaries and
        dash-separator rows differ across ``winget`` versions
        and Windows locales (the column headers and the dash
        row are localised).  This module's parsing strategy is:

            1. Locate the dash-separator row (``-`` runs
               interleaved with whitespace) which appears once
               after the header row and never inside the data
               rows.
            2. Use the column starts inferred from the header
               row to slice each subsequent data row by
               character position; fall back to whitespace
               splitting when a row is shorter than expected.
            3. Skip lines that are obvious progress / footer noise.
"""
```

winget is the Windows Package Manager.  Unlike `Get-Service`
(which produces JSON-friendly objects), winget produces
*text* output for `list` and `show` commands.  Worse, the
text format is *localised* — column headers appear in the
user's language; the dash-separator row varies in width.

The Windows package manager's parsing strategy:

1. Find the dash row.  The first row of all-dashes (with
   spaces between dash runs marking column boundaries) is
   the column-position anchor.
2. From the header row above the dashes, compute each
   column's start position.
3. Slice each data row by character position.
4. Fall back to whitespace splitting when a row is shorter
   than the header would suggest.

The code is a small parser explicitly designed for
fragility tolerance.  Tests cover representative output
from multiple winget versions and locales.

A note in the chapter's honesty section: *future winget
versions are expected to add a JSON output mode*.  When
that ships, the manager will switch.  Until then, the
column-position parser is the pragmatic answer.

## NetSecurity module for firewall

**File:**
`firewallmanagement.py:1–90`

```python
"""
    Windows firewall-management feature.

    Implements the cross-OS firewall-management contract for
    Windows using the PowerShell ``NetSecurity`` module
    (``New-NetFirewallRule``, ``Remove-NetFirewallRule``,
    ``Get-NetFirewallRule``, ``Set-NetFirewallProfile``,
    ``Get-NetFirewallProfile``, ``Get-NetConnectionProfile``).

    Zone mapping:
        The contract's ``zone`` argument maps directly onto a
        Windows Firewall profile name: one of ``"Domain"``,
        ``"Private"``, ``"Public"``, or ``"Any"``.  Any other
        value yields a ``FAILED`` result with
        ``message="invalid_zone"`` BEFORE the runner is
        invoked.

    Verb behaviour highlights:
        * ``set_default_zone`` is a SKIPPED no-op when the
          zone is valid.  Windows has no "default profile"
          concept -- profiles activate based on the network
          category that the OS assigns to the current
          connection.
        * ``query_state`` reports ``running=True`` iff every
          profile's ``Enabled`` field is True; ``default_zone``
          is taken from ``Get-NetConnectionProfile``'s
          ``NetworkCategory`` for the active connection,
          which maps directly onto a profile name.
"""
```

Windows Firewall has *profiles* (Domain, Private, Public,
Any) rather than zones.  The contract's `zone` argument
maps directly to a Windows profile name.

A few interesting differences from firewalld and ufw:

### No default zone

Windows doesn't have a "default profile."  Which profile is
active depends on the *network category* the OS assigns to
the current connection — automatically, based on the
network's properties.  An office network gets `Domain`;
home Wi-Fi gets `Private`; coffee shop Wi-Fi gets `Public`.

The contract's `set_default_zone` verb is therefore a
no-op on Windows — there's nothing to set.  The manager
returns `SKIPPED` to acknowledge the call without
modifying state.

### Rule prefixing for round-trip queries

```python
# When add_service is called for service "ssh" in zone "Private":
ps_cmd = """
New-NetFirewallRule
    -DisplayName 'codecraftai-svc-ssh'
    -Profile Private
    -LocalPort 22 -Protocol TCP
    -Action Allow
"""
```

Rules created by the manager are prefixed:

- `codecraftai-svc-<service>` for service-based rules.
- `codecraftai-port-<port>-<protocol>` for explicit port
  rules.

The prefix lets the manager:

- Query "is this service enabled?" by listing rules whose
  DisplayName starts with `codecraftai-svc-`.
- Avoid conflicts with rules created by Group Policy or
  the user via the GUI.

A bundled service-name table maps service names to ports:
ssh (22/tcp), http (80/tcp), https (443/tcp), smtp
(25/tcp), dns (53/udp), smb (445/tcp), rdp (3389/tcp).

## Convention 6 — the elevation-blocked check

**File:** referenced from
`codecraftai-sysman-common/specs/contracts/README.md:185–200`

Convention 6: *the Windows `LocalCommandRunner` pre-detects
elevation status*.  When a manager passes `elevated=True`
but the calling process isn't elevated, the runner returns
a `RunResult` with:

- `status = FAILED`.
- An additional `elevation_blocked = True` field.
- A clear stderr message explaining the cause.

The runner *doesn't* attempt to elevate (Windows UAC isn't
something the runner can drive non-interactively).  It
*signals* the manager that the elevation request can't be
honoured.

The manager catches the `elevation_blocked` flag and:

- Returns the standard `FAILED` result to the caller.
- Adds `elevation_blocked=True` to the manager's
  `*ActionResult` so the test can detect the cause.

A test running unelevated Windows can:

```python
result = service_mgr.start("nginx")
if result.elevation_blocked:
    pytest.skip("This test requires elevated PowerShell.")
```

The check is what makes Windows tests run cleanly under
both elevated and unelevated processes — the test discovers
its own constraints.

## Other Windows quirks

A few more distinctive Windows features:

### Hostname rename requires reboot (Convention 3)

The Windows hostname-management feature renames the
computer using `Rename-Computer`.  The change *doesn't take
effect until reboot*.  The manager returns
`status=SUCCESS, requires_reboot=True` per Convention 3;
test code can decide whether to schedule a reboot or treat
the change as pending.

### Registry access for some features

Some Windows features that don't have first-class PowerShell
cmdlets reach into the registry:

```python
# environment-configuration writes user/system env vars
# via PowerShell's [Environment] static class:
ps_cmd = (
    "[Environment]::SetEnvironmentVariable("
    "    'MY_VAR', 'value', 'Machine')"
)
```

The manager builds the `[Environment]` invocation rather
than poking the registry directly.  PowerShell's class
abstracts the underlying registry mechanics.

### winget's unattended flags

Every write verb's argv includes:

```
--silent --accept-package-agreements --accept-source-agreements
```

These flags suppress winget's interactive prompts (license
acceptance, source addition).  Without them, an unattended
test run hangs waiting for the operator to press a key.
The manager always applies them; tests don't have to
remember.

## Tests

45 test files at `source/testroots/testbase/`.

Test patterns mirror RHEL / Ubuntu / macOS:
command-construction, idempotency, dry-run, elevation
(including elevation_blocked), output parsing.  Windows
tests have a few additional categories:

- **JSON serialisation tests** — verify that `_decode_*_int`
  decoders handle all enum values.
- **winget output-parsing tests** — sample outputs from
  multiple versions / locales.
- **Profile-based firewall tests** — verify each of
  Domain / Private / Public / Any maps correctly.

Mock-runner pattern again — tests don't actually invoke
PowerShell; they construct the expected argv and verify
the manager produces it.

## What's next

Chapter 8 walks the UEFI implementation
(`codecraftai-sysman-uefi`) — 5 features intentionally minimal,
single-tier dispatch where each manager detects the host OS
once and routes verbs through host-specific private methods,
the `override_host_os` config field as testability
infrastructure (Convention 10), and the firmware-tier scope
decision that explains why UEFI is so much smaller than the
per-OS packages.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/08-uefi-implementation -->
# Chapter 8 — UEFI Firmware Implementation

> *UEFI is the smallest sysman package — 5 features, one per
> firmware concern.  But it's structurally distinct: each
> manager detects the running host OS at construction and
> routes every verb through host-specific private methods.
> Single-tier dispatch where the per-OS packages are
> multi-tier.  This chapter walks the structure and the five
> features.*

`codecraftai-sysman-uefi` is *intentionally* the smallest
sysman package.  The entire package is 5 feature managers:
boot management, secure boot, EFI variables, capsule
updates, and ESP management.

The smallness is *the design*.  UEFI is a firmware-tier
concern that exists across all hosts (any modern x86 / ARM
machine has UEFI firmware).  Rather than ship five copies
of each feature (one per host OS), the package ships one
implementation per feature with *internal dispatch* on the
host OS.

## Layout

```
codecraftai-sysman-uefi/source/packages/ccai/sysman/uefi/
├── __init__.py
├── bootmanagement.py        # firmware boot entries + boot order
├── secureboot.py            # Secure Boot state + MOK (Linux)
├── efivariables.py          # raw NVRAM variable read/write
├── capsuleupdate.py         # firmware capsule application
└── espmanagement.py         # EFI System Partition management
```

5 modules.  No `featurebase.py` (imports from
`codecraftai-sysman-common`).  No registry.  Every type prefixed
with `Uefi`.

## The five features

### 1. Boot management

Firmware boot entries (the list of bootable installations
the firmware knows about) and the persistent boot order
(which entry boots by default).

Host-OS dispatch:

- **Linux**: `efibootmgr` (the canonical Linux UEFI tool).
- **macOS**: `bless` + `nvram` — Apple's UEFI doesn't have
  `efibootmgr`; the equivalent is `bless` for setting the
  system's startup disk plus `nvram` for raw EFI variable
  edits.
- **Windows**: `bcdedit` — the Boot Configuration Data
  editor.  Windows hides the firmware-level boot entries
  behind BCD; `bcdedit` is what edits them.

Each verb dispatches based on `_host_os`:

```python
class UefiBootManager(BaseFeatureManager):

    feature_name = "uefi-boot-management"

    def list_boot_entries(self) -> list[UefiBootEntry]:
        if self._host_os == "linux":
            return self._dispatch_linux_list_boot_entries()
        elif self._host_os == "darwin":
            return self._dispatch_darwin_list_boot_entries()
        elif self._host_os == "windows":
            return self._dispatch_windows_list_boot_entries()
        else:
            raise ConfigurationError(...)

    def _dispatch_linux_list_boot_entries(self) -> list[UefiBootEntry]:
        # ... efibootmgr -v ... parse output ...

    def _dispatch_darwin_list_boot_entries(self) -> list[UefiBootEntry]:
        # ... nvram -p ... parse output ...

    def _dispatch_windows_list_boot_entries(self) -> list[UefiBootEntry]:
        # ... bcdedit /enum firmware ... parse output ...
```

Three private methods, one per host OS.  The public verb
selects which to call.  The private methods do the
host-specific argv construction and output parsing.

### 2. Secure Boot

Secure Boot state inspection (is it on?  what mode?) and,
on Linux, MOK (Machine Owner Key) management for kernel
module signing.

Host-OS dispatch:

- **Linux**: `mokutil` + `efivar` — MOK enrolment and EFI
  variable inspection.
- **macOS**: `csrutil` + `bputil` — `csrutil` is the System
  Integrity Protection tool; `bputil` is the Apple Silicon
  "Bypass Utility" for boot policy.
- **Windows**: PowerShell `Confirm-SecureBootUEFI` cmdlet.

The MOK verbs (`enroll_mok_key`, `list_mok_keys`) are
*Linux-only*.  On macOS and Windows they return `FAILED`
with stable detail token (Convention 5):

- macOS: `unsupported_no_mok_concept_macos`.
- Windows: `unsupported_no_mok_concept_windows`.

The contract's verb is reachable from any host; the host's
firmware-level concept just doesn't exist.

### 3. EFI Variables

Raw NVRAM variable read / write / delete / list.  This is
the *low-level escape hatch* for firmware automation that
doesn't fit one of the higher-level features.

Host-OS dispatch:

- **Linux**: `efivar` (or direct
  `/sys/firmware/efi/efivars` filesystem reads).
- **macOS**: `nvram` — Apple's command for NVRAM access.
- **Windows**: PowerShell `[UEFI]` namespace (the
  `Get-SecureBootUEFI` family of cmdlets).

A test that needs to inspect or modify a vendor-specific
NVRAM variable uses the EFI-variables feature.  Most tests
don't need this; the higher-level features
(boot-management, secure-boot) call into EFI variables
internally.

### 4. Capsule Updates

Firmware capsule discovery and application — installing
new firmware versions.

Host-OS dispatch:

- **Linux**: `fwupdmgr` — the LVFS-driven firmware update
  daemon.
- **macOS**: `softwareupdate` — Apple's update tool, with
  special filtering for firmware updates.
- **Windows**: PowerShell `PSWindowsUpdate` module —
  filtered to firmware-class updates.

Capsule updates are *high-stakes* — a failed firmware
update can brick a device.  The contract specifies:

- `list_available_capsules()` (Read) — what firmware
  updates are pending.
- `apply_capsule_file(path)` (Write) — apply a specific
  capsule.  Returns `SUCCESS, requires_reboot=True` per
  Convention 3 (firmware updates apply on reboot).

Implementation drift on macOS is significant: Apple's
`softwareupdate` doesn't accept arbitrary capsule files;
firmware updates flow through the integrated software-update
mechanism.  The macOS implementation of `apply_capsule_file`
returns `FAILED` with stable token
`unsupported_arbitrary_capsule_file_macos`.

### 5. ESP Management

EFI System Partition discovery, mount, and file I/O.  The
ESP is the FAT-formatted partition that holds the
boot-loader files (`grub.efi`, `BootMgfw.efi`,
`bootmgfw.efi.signed`, etc.).

Host-OS dispatch:

- **Linux**: `findmnt` + `mount` for discovery and
  mounting.
- **macOS**: `diskutil` for ESP discovery; the ESP is
  typically auto-mounted at `/Volumes/EFI` on Apple Silicon.
- **Windows**: `mountvol` + PowerShell `Get-Partition` for
  partition table inspection and mounting.

Once mounted, file I/O follows Convention 1 — through the
runner using OS-native file utilities (`cat`, `tee`, etc.
on Linux/macOS; `Get-Content`, `Set-Content` on Windows).

## The single-tier dispatch architecture

A reader comparing UEFI to the per-OS packages (chapters
4–7) will notice the structural difference.  The per-OS
packages are *multi-tier*:

```
codecraftai-sysman-linux-redhat → RHEL-specific managers
codecraftai-sysman-linux-ubuntu → Ubuntu-specific managers
codecraftai-sysman-macos        → macOS-specific managers
codecraftai-sysman-windows      → Windows-specific managers
```

Five packages × N features = many manager classes.

UEFI is *single-tier*:

```
codecraftai-sysman-uefi → 5 managers, each dispatches internally
```

One package, 5 features, internal host-OS dispatch.  The
choice maps to the *nature* of the feature area.  System
management (the per-OS tier) is genuinely OS-specific —
a `dnf install` and a `winget install` are different
*operations*, not just different commands for the same
operation.  Firmware management (UEFI tier) is
*host-OS-agnostic at the firmware level* — the firmware
itself doesn't care whether the OS is Linux or Windows;
only the OS-side tools that talk to the firmware differ.

So:

- Per-OS packages: per-OS managers, one per package.
- UEFI: one manager per feature, dispatching internally.

## The `override_host_os` field — Convention 10

UEFI managers carry a config field documented under
Convention 10:

```python
@dataclass
class UefiBootManagerConfig(FeatureConfig):
    """
        :param override_host_os: When set, forces the dispatch path
                                 to ``"linux"``, ``"darwin"``, or
                                 ``"windows"`` (case-insensitive)
                                 instead of calling
                                 :func:`platform.system`.  Reserved
                                 for testing.
        :param efibootmgr_path: Path / argv-name of ``efibootmgr``.
                                Override only for testing or
                                non-standard installations.
        :param bcdedit_path: Path / argv-name of ``bcdedit``.
        :param bless_path: Path / argv-name of ``bless``.
        :param nvram_path: Path / argv-name of ``nvram``.
    """
    override_host_os: Optional[str] = None
    efibootmgr_path: str = _DEFAULT_EFIBOOTMGR
    bcdedit_path: str = _DEFAULT_BCDEDIT
    bless_path: str = _DEFAULT_BLESS
    nvram_path: str = _DEFAULT_NVRAM
```

`override_host_os` is *testability infrastructure*.  When
set, it forces the dispatch to a specific OS regardless of
what `platform.system()` returns.

A worked example: an SDET on a macOS laptop wants to write
tests for the *Windows* dispatch path of `UefiBootManager`.
Without the override, the manager would dispatch to the
macOS path (`bless` / `nvram`) on the laptop.  With the
override, the manager dispatches to the Windows path
(`bcdedit`).  The test runs `bcdedit`-shaped argvs against
a mock runner; the manager's Windows path is exercised
without needing a Windows host.

```python
windows_config = UefiBootManagerConfig(
    override_host_os="windows",
    bcdedit_path="/path/to/mock-bcdedit",
)

mgr = UefiBootManager(runner=mock_runner, config=windows_config)
result = mgr.list_boot_entries()  # invokes _dispatch_windows_list_boot_entries
```

The same pattern works for testing the Linux and macOS
dispatch paths from any developer machine.  Without the
override, cross-host testing of UEFI would require three
test environments; with the override, one is enough.

The pattern is *unique to UEFI* in the sysman tier.  The
per-OS packages don't need it because their managers
*are* OS-specific — there's no "Linux dispatch path inside
the macOS package."  UEFI's internal dispatch is what makes
the override meaningful.

## Why UEFI is intentionally smaller

A reader might ask: *if UEFI tooling is more complex than
package management (it is), why does the UEFI package have
fewer features?*

The answer is **scope discipline**.  The 5 features are the
ones that:

- Generalise across all firmware (every UEFI host has boot
  entries, secure-boot state, NVRAM, and an ESP).
- Have well-defined cross-OS tooling.
- Are *operationally meaningful* for automation.

What the package *deliberately doesn't* cover:

- **Vendor-specific firmware features.**  Dell PowerEdge
  servers have firmware features Lenovo ThinkSystems don't.
  The UEFI package doesn't try to abstract these; tests
  that need them use vendor-specific tools directly.
- **Firmware development.**  Tools for *creating* UEFI
  firmware (EDK2 build, signing capsules) are not in scope.
  The package is for *operating* firmware, not authoring it.
- **Post-boot OS firmware drivers.**  The OS's interaction
  with firmware after boot (DXE drivers, runtime services)
  is the OS's concern, not the framework's.

The package's `README.md` explicitly states:

> *`codecraftai-sysman-uefi` is the firmware-management tier.
> It covers the cross-host firmware concerns that
> automation can manipulate from a running OS.  Vendor-
> specific firmware features, firmware development, and
> post-boot driver interactions are out of scope.*

The chapter is honest about the boundary.  An automation
campaign that needs vendor-specific firmware features
writes its own helpers; the framework covers what
generalises.

## Tests

The UEFI package has a *higher* test count than its feature
count would suggest — 175 test files.  Why so many for 5
features?

Because the cross-host dispatch matrix multiplies the
coverage:

- Each feature has 3 dispatch paths (Linux / macOS /
  Windows).
- Each path has 5–10 verbs.
- Each verb has multiple tests (command-construction,
  idempotency, dry-run, output-parsing, edge cases).

5 features × 3 paths × ~10 verbs × ~3 tests = ~450
parametrised tests (the file count of 175 reflects how
they're grouped).

The `override_host_os` field is what makes this test count
practical.  Without it, testing the Windows dispatch path
would require a Windows host; testing the macOS path a Mac;
testing the Linux path a Linux box.  With it, the entire
matrix runs on whatever host the CI happens to use — a Linux
runner, a macOS laptop, a Windows VM.

## What's next

Chapter 9 walks the **11 Implementation Conventions** in
full.  Chapters 1–8 have referenced them piecemeal —
Convention 4 (OS-prefixed names), Convention 5 (FAILED with
stable token), Convention 11 (Homebrew elevation
inversion), Convention 10 (`override_host_os`), Convention
6 (elevation_blocked).  Chapter 9 walks all eleven as a
unified discipline, with a worked example for each.

Chapter 10 then closes the volume by walking cross-OS test
parity — how the suite achieves consistent green-across-OSes
test runs at the scale of thousands of tests.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/09-spec-driven-development -->
# Chapter 9 — Spec-Driven Development for System Administration

> *Eight chapters walked the contracts and the
> implementations.  This chapter walks the **discipline** that
> keeps them aligned: the 11 Implementation Conventions
> documented at
> `codecraftai-sysman-common/specs/contracts/README.md:75–364`.
> The conventions are what prevent drift across 5 OSes ×
> ~50 features without a code generator.*

The volume's previous chapters referenced the conventions
piecemeal — Convention 4 (OS-prefixed names), Convention 5
(FAILED with stable token), Convention 11 (Homebrew
elevation inversion).  This chapter walks all eleven as a
*unified* discipline.  By the end, a reader writing a new
per-OS implementation knows the rules in detail.

The conventions emerged from real implementation work —
specifically the Phase-3 cross-OS push that brought Ubuntu,
macOS, and Windows online alongside the original RHEL
package.  Each convention is a *crystallisation* of a
problem the implementation work uncovered.

## Why conventions instead of code

A reader from a heavily-tooled engineering culture might
ask: *if these are uniform rules across all packages, why
not enforce them in code via metaclasses, decorators, or a
linter?*

Three reasons:

### Many of the conventions are *naming*

Convention 4 (OS-prefixed type names) is enforceable by a
linter.  But the discipline isn't *just* the prefix — it's
that the prefix appears consistently across types
(`UbuntuPackageManager`, `UbuntuPackageActionResult`,
`UbuntuPackageActionStatus`).  A linter that just checks
class-name prefixes misses the cross-type alignment.

### Many of the conventions are *trade-offs*

Convention 7 (reads degrade gracefully — return None /
empty / synthetic record vs. raise) gives implementations
*choice*.  The discipline is that the implementation picks
*one* and is *consistent*.  Code can't enforce "be
consistent in your choice."

### The conventions evolve

Phase-3 added Convention 6 (elevation_blocked).  A future
phase might add Convention 12.  Hard-coding the conventions
in framework code makes them harder to evolve; documenting
them in prose lets them grow.

## The eleven conventions

### Convention 1 — `ICommandRunner` is the file-I/O seam

> *File I/O MAY use `IFileOperator` if the host package
> provides one; the established pattern is to route file I/O
> through `ICommandRunner` using OS-native file utilities.*

The framework has *no separate file-operator abstraction*.
Reading, writing, removing files all goes through the same
`ICommandRunner` that runs commands.  On Linux, that means
invoking `cat` / `tee` / `rm` / `mv`.  On Windows, it means
invoking `Get-Content` / `Set-Content` / `Remove-Item`.

The convention exists because:

- A separate `IFileOperator` would duplicate concerns the
  runner already handles (elevation, dry-run, audit
  recording).
- Routing file I/O through the runner means the *audit
  trail* (every action records its argv per Convention 2)
  works for file operations too.
- Tests that mock the runner automatically also mock the
  file operations.

A worked example.  When a feature needs to write a config
file:

```python
# Wrong — bypasses the runner.
with open("/etc/myservice.conf", "w") as f:
    f.write(content)

# Right — routes through the runner per Convention 1.
self._runner.run(
    ["tee", "/etc/myservice.conf"],
    elevated=True,
    stdin=content,
)
```

The runner-routed version:

- Goes through the manager's elevation logic.
- Captures the argv in the action result.
- Honours dry-run (the runner doesn't invoke when
  `dry_run=True`).
- Routes through SSH when the runner is an `SshAgent` (so
  the file write happens on the *remote* host, not the
  controller's filesystem).

### Convention 2 — `command: list[str] | None` field on action results

> *Every per-OS `*ActionResult` dataclass carries a
> `command` field that records the argv that was (or would
> have been) executed.*

The `command` field on every result enables:

- **Audit logging** — what changed and how.
- **Dry-run inspection** — see the would-have-been argv.
- **Test verification** — assert on the constructed command.
- **Forensic debugging** — when a test fails, the command
  field tells you exactly what ran.

`featurebase._dispatch` populates this automatically.  A
manager that doesn't use `_dispatch` (chapter 4's
`PackageManager` example) has to populate it manually:

```python
result = PackageActionResult(
    status=PackageActionStatus.SUCCESS,
    package_name=name,
    version=installed_version,
    command=command_copy,  # Convention 2
    exit_code=run_result.exit_code,
    stdout=run_result.stdout,
    stderr=run_result.stderr,
)
```

The field is `Optional[list[str]]` because `SKIPPED`
results don't have a command (no command was needed).

### Convention 3 — `requires_reboot: bool` on action results that mutate boot-time-only state

> *Features that mutate boot-time-only state (Windows
> hostname rename, UEFI capsule-update) carry a
> `requires_reboot: bool = False` field set to `True` when
> the underlying tool reports the change was staged but a
> reboot is required.*

Some operations *succeed* in the sense that the tool
accepted the change, but the change *doesn't take effect
until reboot*.  The result needs to carry both signals:
"yes, this succeeded" and "but the system needs to reboot."

Worked example: Windows hostname rename.

```python
# WindowsHostnameManager.rename_computer

def rename_computer(self, new_name: str) -> WindowsHostnameActionResult:
    # ... build PowerShell Rename-Computer argv ...
    result = self._dispatch(
        command=ps_argv,
        new_name=new_name,
    )
    # Rename-Computer reports success but the new name
    # isn't visible until reboot.
    if result.status == WindowsHostnameActionStatus.SUCCESS:
        result.requires_reboot = True
    return result
```

The test calling `rename_computer` checks
`result.requires_reboot` and either schedules the reboot or
flags the test as needing a manual restart.

### Convention 4 — OS-prefixed concrete type names

Already covered extensively (chapters 4 and 5).  Recap:

- Per-OS implementations prefix every public type:
  `UbuntuPackageManager`, `MacosFirewallManager`,
  `WindowsServiceManager`.
- RHEL retains unprefixed names (`PackageManager`, etc.)
  for historical reasons; this is the documented exception.
- The prefix applies to managers, configs, results,
  statuses, info classes — every public type the package
  exposes.

### Convention 5 — `FAILED` with a stable detail token for unsupported `(host_os, verb)` combos

> *When a feature has no scriptable implementation on a
> given host OS, the manager returns `*ActionResult` with
> `status=FAILED` and `detail` set to a machine-readable
> token like `unsupported_on_host_os_darwin`.*

This replaces the older "raise `NotImplementedError`"
pattern.  The convention's value:

- The contract verb is *reachable* from any host.
- The result is *machine-discoverable* — test code checks
  `result.detail` for known tokens.
- The error path is *uniform* with other failures —
  `status=FAILED` with a structured `detail`.

Stable tokens documented in the contracts.  Examples:

| Token | Meaning |
|---|---|
| `unsupported_on_host_os_darwin` | The feature has no macOS implementation. |
| `unsupported_on_host_os_windows` | No Windows implementation. |
| `ufw_no_zones` | ufw doesn't support multiple zones. |
| `unsupported_no_mok_concept_macos` | macOS has no MOK equivalent. |
| `unsupported_runtime_change_macos` | The change requires recovery mode. |

A test checking for *any* unsupported case can match the
`unsupported_*` prefix.  Tests checking for a *specific*
case match the full token.

### Convention 6 — `elevation_blocked: bool` on Windows `RunResult`

Already covered in chapter 7.  Recap:

- Windows can't drive UAC non-interactively.
- The Windows runner pre-detects elevation status.
- When a manager requests `elevated=True` and the calling
  process isn't elevated, the runner returns a
  `RunResult` with `elevation_blocked=True`.
- Manager code propagates the flag onto the
  `*ActionResult`.
- Test code checks `result.elevation_blocked` and skips
  with a clear reason.

### Convention 7 — Reads degrade gracefully

> *Read methods MAY raise `*QueryError` OR return a None /
> empty / synthetic record.  Whichever the per-OS spec
> picks, it MUST be consistent and documented.*

The convention gives implementations *flexibility* on
read-failure semantics but *requires consistency* within an
implementation.

Two acceptable patterns:

#### Raise-on-error

```python
def get_package_info(self, name: str) -> PackageInfo:
    """
        Returns PackageInfo for the named package.

        :raises PackageQueryError: if the package isn't installed.
    """
    info = self._query(name)
    if info is None:
        raise PackageQueryError(f"Package '{name}' is not installed")
    return info
```

#### Return-None-on-not-found

```python
def get_package_info(self, name: str) -> Optional[PackageInfo]:
    """
        Returns PackageInfo for the named package, or None if
        not installed.
    """
    info = self._query(name)
    return info  # may be None
```

The **discipline** is that *all reads in a single
implementation* pick the same pattern.  A package whose
some reads raise and some return None is broken.

The per-OS spec documents which pattern the package uses.
Test code is written against the documented choice.

### Convention 8 — Per-OS backend enums

> *Features ship a single abstract concept on the contract
> (e.g., `BackupKind`, `VirtBackend`) that diverges into
> OS-specific enums in practice.*

Some features have an *abstract concept* (a backup kind,
a virtualisation backend) that has *different concrete
options* per OS.  The contract defines the abstract concept;
the per-OS implementation defines its own enum.

Worked example: `virtualization-management` ships with a
`VirtBackend` abstract enum on the contract, but each per-OS
implementation has its own:

```python
class UbuntuVirtBackend(Enum):
    LIBVIRT_KVM = "libvirt-kvm"
    LIBVIRT_LXC = "libvirt-lxc"
    LXC = "lxc"
    QEMU = "qemu"


class MacosVirtBackend(Enum):
    HYPERVISOR_FRAMEWORK = "hypervisor-framework"
    VIRTUALIZATION_FRAMEWORK = "virtualization-framework"
    PARALLELS = "parallels"
    VMWARE_FUSION = "vmware-fusion"


class WindowsVirtBackend(Enum):
    HYPER_V = "hyper-v"
    VMWARE_WORKSTATION = "vmware-workstation"
    VIRTUALBOX = "virtualbox"
```

Cross-OS test code that needs to handle the
`VirtBackend` argument has per-OS dispatch:

```python
if isinstance(backend, UbuntuVirtBackend):
    # Ubuntu-specific handling
    ...
elif isinstance(backend, MacosVirtBackend):
    # macOS-specific handling
    ...
```

The convention acknowledges that *some abstractions don't
collapse cleanly* and provides a structured way to handle
them.

### Convention 9 — Concrete-implementation naming overrides

> *The contract uses a clean abstract name (`SystemIdentity`,
> `MailTransportConfig`) while the per-OS implementation
> uses a name that fits its package
> (`UbuntuHostIdentity`, `UbuntuMailTransportConfig`).*

Convention 4 prefixes the *implementation* types.  Convention
9 covers the case where the *contract* type name doesn't
fit the per-OS package's terminology.

Worked example: the contract has a type called
`SystemIdentity` describing the host's identity profile.
The Ubuntu implementation calls its corresponding type
`UbuntuHostIdentity` because Ubuntu's terminology calls the
machine a *host*, not a *system*.

The convention allows this divergence with *cross-OS
adapter layers* translating at the boundary.  A test that
needs to use the contract's `SystemIdentity` against
multiple OSes either:

- Writes per-OS code that uses each OS's preferred name.
- Uses an adapter function that converts between the
  contract type and the implementation type.

### Convention 10 — UEFI managers carry `override_host_os`

Already covered in chapter 8.  Recap:

- UEFI managers detect host OS at construction.
- The `override_host_os: str | None = None` config field
  forces a specific dispatch path for testing.
- This is testability infrastructure unique to UEFI's
  single-tier dispatch architecture.

### Convention 11 — Elevation-model inversion

Already covered in chapters 6 and 9.  Recap:

- Some tools (Homebrew on macOS) refuse to run as root.
- The manager passes `elevated=False` unconditionally.
- When the calling process *is* root, the manager wraps
  the argv with `sudo -u <non_root_user>`.
- The pattern is documented per-feature in the
  Implementation Drift section of the contract.

## Credential-delivery conventions

In addition to the eleven implementation conventions,
the contracts document **credential-delivery conventions**
at lines 299–328 of the README.

The three patterns, in order of preference:

### 1. Stdin (preferred)

```python
result = self._dispatch(
    command=["luksFormat", device_path],
    stdin=passphrase,  # secret stays out of argv
    detail=device_path,
)
```

Secrets flow through stdin.  The `RunResult` doesn't carry
the stdin data, so the `command` field never contains the
secret.  The audit trail records the argv (`["luksFormat",
"/dev/sdb"]`) without leaking the passphrase.

### 2. In-process secure-string (Windows)

```powershell
$securePassword = ConvertTo-SecureString -String $env:PWD -AsPlainText -Force
Set-LocalUser -Name $username -Password $securePassword
```

PowerShell has SecureString — an in-memory representation
that's not paged to disk and not visible in the process
listing.  Windows managers route credentials through
SecureString when the underlying cmdlet supports it.

### 3. Argv with redaction (last resort)

```python
# Some tools accept credentials only on argv.
command = ["smbpasswd", "-a", username, "-p", password]
result = self._dispatch(
    command=command,
    detail=username,
    redact_argv_indices=[4],  # mark the password slot for redaction
)
```

When a tool only accepts credentials on argv, the manager
*marks* the secret-bearing positions so the action
result's `command` field has the secret redacted before
recording.  The framework's argv-redaction logic replaces
the marked slots with `"[REDACTED]"` in the recorded
command.

The convention prefers stdin → SecureString → argv-with-
redaction.  Argv-with-redaction is acknowledged as the
fallback for tools that won't accept the better patterns.

## How the conventions are *enforced*

The conventions aren't enforced by tooling.  They're
enforced by:

### Code review

Every PR that touches a sysman package goes through review.
Reviewers cross-reference the change against the
conventions document.  A convention violation is a review
comment.

### Per-OS test coverage

The contract's "Tests" section prescribes minimum tests.
Tests catch convention violations indirectly:

- A manager that doesn't populate `command` (Convention 2)
  fails its dry-run test (the test asserts
  `result.command == [...]`).
- A manager that doesn't honour `dry_run` (Convention 1's
  scope) fails its dry-run test.
- A manager that elevates reads (against the contract)
  fails its elevation test.

Tests catch the *symptoms* of convention violations even
when the convention isn't tested directly.

### Cross-OS comparisons

When two OSes implement the same feature, comparing their
tests catches drift.  An RHEL `PackageManager.install` test
that's structurally different from a `UbuntuPackageManager.install`
test is a sign one of them is wrong.

## What the conventions give you

A reader who understands the eleven conventions can:

- Read any sysman feature manager and know what to expect.
- Add a new feature to an existing OS by following the
  conventions.
- Add a new OS implementation by following the conventions
  + the contracts.
- Spot convention violations during code review.
- Write tests that exercise the convention's behaviour.

The conventions *are* the discipline.  The framework's
50 contracts × 5 OSes = ~250 implementations stay aligned
because the conventions are documented, reviewed, and
tested.

## What's next

Chapter 10 closes the volume by walking cross-OS test
parity — how the suite achieves consistent green-across-OSes
test runs at the scale of thousands of tests.  The chapter
explains how the contracts + featurebase + 11 conventions
all compose to make a multi-OS test campaign tractable.


<!-- https://code-craft.ai/references/codecraftai/04-cross-platform-sysman/10-cross-os-test-parity -->
# Chapter 10 — Building Cross-OS Test Parity

> *Nine chapters walked the contracts, the implementations,
> and the conventions.  This chapter is the volume's payoff:
> how the discipline turns into a green-across-OSes test
> campaign at scale.  Thousands of tests; five OSes; one
> uniform shape; predictable outcomes.*

The volume's earlier chapters established the *machinery*.
This chapter walks the *operational shape* — what an SDET's
day-to-day work looks like on a project that's adopted the
sysman tier.  The chapter is the volume's closer; by the
end, a reader should know how to set up, run, and triage a
cross-OS test campaign against the framework.

## The reference numbers

The `codecraftai-sysman-*` packages collectively ship:

- **5 reference implementations** (RHEL, Ubuntu, macOS,
  Windows, UEFI).
- **~245 manager classes** (the per-OS package counts vary;
  the rough total is 5 × ~50 features).
- **~270 test files** combined (RHEL 53 + Ubuntu 41 + macOS
  45 + Windows 45 + UEFI 175 — UEFI's count is
  disproportionate because of the 3-path dispatch matrix).
- **Several thousand individual test invocations** —
  parametrised tests in pytest's sense, one test file
  containing many test functions.

The volume's outline mentioned "3,656 / 3,656 testbase
tests passing."  That count is *test invocations*, not test
files.  A single test file might have 30 test functions,
each parametrised across 5–10 input combinations, totalling
hundreds of individual `assert` calls.

The discipline of getting that count to *pass* everywhere
across five OSes is what this chapter walks.

## The setup

A cross-OS test campaign typically requires:

### One CI runner per OS

The framework's tests aren't fully cross-host (you can't
run a Windows test against a Linux runner without a Windows
host somewhere).  A typical setup:

- One Linux runner for RHEL and Ubuntu tests.
- One macOS runner for macOS tests.
- One Windows runner for Windows tests.
- The Linux runner usually doubles as the UEFI test runner,
  using `override_host_os` (Convention 10) to exercise the
  Windows and macOS dispatch paths.

Volume 2 chapter 3's Pod model applies — each runner is a
*Pod*; the test campaign is *more Pods*, not more parallel
tests within a Pod.

### Pinned tool versions

`dnf` 4.x produces different output than `dnf` 5.x.  `winget`
1.0 differs from 1.5.  The test discipline *pins* tool
versions per OS (the per-OS spec documents the supported
range).  When a CI runner gets an unexpected tool version,
the parser tests catch it before the broader test suite
runs.

### The Aspects defaults

Per Volume 5, every sysman manager runs commands through an
`ICommandRunner` that takes `Aspects`.  Test code typically
uses defaults, but slow OSes (macOS waiting for `softwareupdate`,
Windows waiting for service start) need longer timeouts:

```python
SLOW_ASPECTS = AspectsCmd(
    completion_timeout=300,    # 5 minutes for slow operations
    completion_interval=15,
    action_pattern=ActionPattern.DO_UNTIL_SUCCESS,
)

result = service_mgr.start("nginx", aspects=SLOW_ASPECTS)
```

Tests that exercise long-running operations override the
defaults.  Tests that exercise quick operations use the
short defaults and fail fast on hangs.

## A worked test scenario

A test that exercises *the same feature on three OSes*:

```python
import pytest

from ccai.sysman.linux.redhat.packagemanagement import PackageManager as RhelPM
from ccai.sysman.linux.ubuntu.packagemanagement import UbuntuPackageManager
from ccai.sysman.macos.packagemanagement import MacosPackageManager

import os

OS = os.environ.get("TARGET_OS", "linux-redhat").lower()


@pytest.fixture
def package_mgr(runner):
    if OS == "linux-redhat":
        return RhelPM(runner=runner, config=None)
    elif OS == "linux-ubuntu":
        return UbuntuPackageManager(runner=runner, config=None)
    elif OS == "macos":
        return MacosPackageManager(runner=runner, config=brew_config())
    else:
        pytest.skip(f"Unsupported OS for this test: {OS}")


def test_install_nginx_idempotent(package_mgr):
    """
        Verify nginx installs idempotently across all supported OSes.
    """
    # Initial install — may already be present.
    first = package_mgr.install("nginx")
    assert first.status in (
        # contract-specified outcomes
        type(first.status).SUCCESS,
        type(first.status).SKIPPED,
    )

    # Second install — must be SKIPPED per contract.
    second = package_mgr.install("nginx")
    assert second.status == type(second.status).SKIPPED
```

Three things to notice:

1. **Per-OS manager selection.**  The fixture picks the
   right manager class based on `TARGET_OS`.  The test body
   doesn't care which.
2. **Test against the contract.**  The assertions check
   `status in (SUCCESS, SKIPPED)` — the contract's
   vocabulary, not the OS's.
3. **`type(first.status)`** — the status enum is per-OS
   (`PackageActionStatus` for RHEL,
   `UbuntuPackageActionStatus` for Ubuntu, etc.), but they
   all have the same value names.  The test uses
   `type(...)` to look up the enum dynamically.

The test runs *unchanged* on three CI runners.  Each runner
sets `TARGET_OS` differently.  The test's *assertions* are
the contract's promise.

## What gets tested in a contract suite

A representative test suite for one feature (e.g.,
`PackageManager`) covers:

### Command-construction tests

For each write verb, assert the constructed argv matches
the expected pattern:

```python
def test_install_constructs_dnf_argv():
    config = FeatureConfig(dry_run=True)
    mgr = RhelPM(runner=mock_runner, config=config)

    result = mgr.install("nginx", version="1.21.6")

    assert result.command == ["dnf", "install", "-y", "nginx-1.21.6"]
    assert mock_runner.last_call is None  # dry-run; runner not invoked
```

Multiplied across every write verb across every feature
across every OS, this is hundreds of tests.  Each is small
and fast (no real subprocess calls).

### Idempotency tests

For each write verb that mutates state, verify a second
call returns SKIPPED:

```python
def test_install_skipped_when_already_installed(monkeypatch):
    mock_runner.queue_query_response(installed_version="1.21.6")
    mgr = RhelPM(runner=mock_runner, config=...)

    result = mgr.install("nginx", version="1.21.6")

    assert result.status == PackageActionStatus.SKIPPED
    assert mock_runner.last_install_call is None  # no real install attempted
```

The test mocks the *query* response (saying nginx 1.21.6 is
installed), then asserts that calling `install` doesn't
result in a runner call.  The idempotency check passed.

### Dry-run tests

For each write verb, verify `dry_run=True` skips the runner:

```python
def test_install_dry_run(mock_runner):
    config = FeatureConfig(dry_run=True)
    mgr = RhelPM(runner=mock_runner, config=config)

    result = mgr.install("nginx")

    assert result.status == PackageActionStatus.DRY_RUN
    assert mock_runner.calls == []  # no calls made
    assert result.command == ["dnf", "install", "-y", "nginx"]
```

### Elevation tests

Verify writes elevate (when `elevated=True`) and reads
don't:

```python
def test_install_elevates(mock_runner):
    config = FeatureConfig(elevated=True)
    mgr = RhelPM(runner=mock_runner, config=config)

    mgr.install("nginx")

    last_call = mock_runner.calls[-1]
    assert last_call.elevated is True


def test_query_does_not_elevate(mock_runner):
    config = FeatureConfig(elevated=True)  # caller asks for elevation
    mgr = RhelPM(runner=mock_runner, config=config)

    mgr.query("nginx")

    last_call = mock_runner.calls[-1]
    assert last_call.elevated is False  # but reads ignore it
```

### Output-parsing tests

Verify query results parse correctly from representative
output:

```python
def test_list_installed_parses_dnf_output(mock_runner):
    mock_runner.queue_response(stdout=DNF_LIST_OUTPUT_FIXTURE)
    mgr = RhelPM(runner=mock_runner, config=...)

    packages = mgr.list_installed()

    assert len(packages) == 247
    nginx = next(p for p in packages if p.name == "nginx")
    assert nginx.version == "1.21.6"
```

Fixtures (`DNF_LIST_OUTPUT_FIXTURE`) are real captured
output from a representative system.  They're checked into
the test suite.

### Error-handling tests

Verify the manager handles failures correctly:

```python
def test_install_failed_when_package_doesnt_exist(mock_runner):
    mock_runner.queue_response(exit_code=1, stderr="No match for argument: bogus")
    mgr = RhelPM(runner=mock_runner, config=...)

    result = mgr.install("bogus-package")

    assert result.status == PackageActionStatus.FAILED
    assert result.exit_code == 1
    assert "No match" in result.stderr
```

## How the volume of tests is *manageable*

A reader doing the math sees the test count is large.  ~50
features × 8 minimum-required tests per feature × 5 OSes =
2000 tests *minimum*.  Real packages have more (each verb
has command-construction + idempotency + dry-run +
elevation + output-parsing tests).

Three things make this manageable:

### The conventions are enforced by tests indirectly

A manager that follows the conventions passes the tests by
construction.  A manager that violates a convention fails a
test.  Engineers don't write per-convention tests; they
write per-feature tests, and the conventions surface in the
patterns.

### Mock runners run fast

A test that uses a mock runner doesn't invoke `dnf` or
`apt-get` or `Get-Service`.  The test runs in milliseconds.
A package's full unit-test suite typically completes in
under a minute even at 500+ tests.

### Test parameters are reusable

Many tests are parametrised across feature variants.  A
single test function plus pytest's `parametrize` runs the
same logic against five different verbs, ten different
input combinations, three different status outcomes — one
test source becomes dozens of test invocations.

## Integration tests against real OSes

The unit tests use mock runners.  For *integration*
testing — actually invoking dnf / apt-get / brew / winget —
the framework's discipline is:

- Run integration tests *separately* from unit tests.
- Run them periodically (nightly), not per-PR.
- Use real CI runners (RHEL VM, Ubuntu VM, macOS host,
  Windows VM).
- Treat integration-test failures as *infrastructure
  issues* — the test logic is unit-tested; the integration
  test verifies the OS hasn't changed underneath.

This is consistent with Volume 1 chapter 1's discipline:
unit tests verify the *framework's logic*; integration tests
verify the *system under test's behaviour*.  Both are
needed; they have different cadences and different failure
implications.

## Triaging a cross-OS failure

A failure pattern an SDET runs into: "the test passes on
RHEL and Ubuntu but fails on macOS."  Triage steps:

### Step 1 — Read the failure record

The failed test's `*ActionResult` carries:

- `status` — `FAILED`.
- `command` — the argv that ran (Convention 2).
- `stdout` / `stderr` — what the tool produced.
- `exit_code` — the tool's exit.
- `detail` — a stable token if Convention 5 applies.

A common failure: macOS's `result.detail ==
"unsupported_arbitrary_capsule_file_macos"`.  The test was
trying to apply a UEFI capsule via
`apply_capsule_file`; macOS doesn't support arbitrary
capsule files.  The test should skip on macOS, not fail.

### Step 2 — Check Convention 5 tokens

A failure with a stable `unsupported_*` token usually
indicates the test should skip on that OS:

```python
result = capsule_mgr.apply_capsule_file(path)
if result.status == ActionStatus.FAILED and result.detail.startswith("unsupported_"):
    pytest.skip(f"Feature unsupported on this OS: {result.detail}")
assert result.status == ActionStatus.SUCCESS
```

The test code becomes more sophisticated — it checks for
the stable token and skips gracefully.  Multiplied across
the test suite, this discipline keeps the test count *high*
(comprehensive coverage) without forcing every test to pass
on every OS.

### Step 3 — Check Convention 6 (Windows elevation)

If the failure is on Windows with
`result.elevation_blocked == True`, the test was
running under unelevated PowerShell.  Either:

- Run the test under elevated PowerShell.
- Skip the test when not elevated.

```python
result = mgr.install("nginx")
if getattr(result, "elevation_blocked", False):
    pytest.skip("This test requires elevated PowerShell.")
```

### Step 4 — Check macOS elevation inversion

If the failure is on macOS with `brew` errors mentioning
"running as root," the test wasn't configured with
`brew_user`.  Convention 11 requires the operator to set
this.

```python
config = MacosPackageManagerConfig(
    brew_user=os.environ.get("BREW_USER", "ci-runner"),
)
mgr = MacosPackageManager(runner=runner, config=config)
```

### Step 5 — Check the per-OS spec for known limitations

Each per-OS spec's "Implementation Drift" section
documents acknowledged divergences.  A test failing because
of a documented divergence is a *test bug* (didn't
account for the divergence), not a *manager bug*.

## What this discipline produces

The volume's earlier chapters described a discipline.  This
chapter shows what the discipline *produces*:

### Predictable test failures

Failures fall into three buckets:

1. **Real bugs in the manager** — fix the manager.
2. **Tool / OS-version drift** — fix the parser or update
   the supported-version range.
3. **Convention misuse** — fix the test (skip on
   unsupported, configure brew_user, etc.).

There's no fourth bucket of "I have no idea why this
failed."  The framework's structured output (Convention 2,
Convention 5, Convention 6) gives the failed test enough
information to triage.

### Reproducible cross-OS test runs

Same test code, same expected outcomes (modulo OS-specific
skips), green across all supported OSes.  An SDET who
writes a feature test against the contract gets cross-OS
coverage *for free* — the contract's verbs, status
vocabulary, and conventions take care of the per-OS
variation.

### Maintainable scale

50 features × 5 OSes is a lot of code.  The discipline
makes it *maintainable*:

- The contracts document the cross-OS API.
- The conventions document the cross-OS shape.
- The tests verify the conventions are followed.
- Code review catches convention violations.

A new engineer onboarding to the sysman tier reads the
conventions, picks a feature, follows the patterns.  No
deep tribal knowledge required.

## End of Volume 4

The volume started by asking why a contract layer exists
(chapter 1) and ended by walking a green-across-OSes test
campaign (this chapter).

The five reference implementations (RHEL, Ubuntu, macOS,
Windows, UEFI) demonstrate the discipline across genuinely
different operating systems.  Each implementation has its
quirks (Homebrew elevation, Windows PowerShell, UEFI single-
tier dispatch), and the contract layer absorbs them
without forcing test code to know about them.

The chapters that come next are in other volumes.  Volume
2's distributed automation invokes sysman feature execution
through bridge tasks.  Volume 3's orchestration triggers
sysman campaigns.  Volume 5's interop layer is the wire
that sysman managers use.  Volume 6's landscape-and-topology
is the environment sysman operates against.

Volume 4's contribution to the series is the *cross-OS
discipline* — the proof that genuinely different operating
systems can be automated through a single contract layer
when the layer is *honest about divergences* (Convention 5,
Convention 11) and *uniform in shape* (Conventions 2, 4, 6,
7).  Where Volume 5 said "talk to remote systems
consistently," Volume 4 says "ask remote systems to do
things consistently."


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols -->
# Volume 5 — Interop and Protocols

*The Aspects Pattern, the Wire, and the Extension Model*

## About this volume

Volume 5 is **Measured Interoperability** — the framework's
discipline for talking to external systems through wire
protocols.  Where Volume 6 covers the *environment model*
(what resources exist, how they're organised), Volume 5 covers
the *protocols* the framework uses to reach into the
environment.

The volume has two sides:

- **The Behaviour side** — Measured Interoperability's
  central thesis.  The Aspects pattern as the delegation
  contract.  The four principles that govern persistence
  behaviours.  The typed exception model that reverts control
  to the application layer.
- **The Mechanism side** — concrete protocol implementations.
  SSH from packet up to agent.  REST foundations.  The
  auto-generated GitHub client.  The four extension packages
  (UPnP, Tasker, Bluetooth, CAN).  The pattern for adding
  your own.

The two sides correspond to the userguide's Mechanism vs.
Behaviour separation — *the* defining commitment of the
package.

## Who it is for

Integration engineers writing tests against external systems.
Protocol implementors adding new protocols to the framework.
Anyone whose project's automation reaches across machine
boundaries through any of: SSH, REST, serial, UPnP, Tasker,
Bluetooth, CAN, or a custom protocol.

## Source material

- `codecraftai-interop` — the foundation: Aspects, ActionPattern,
  LoggingPattern, the agent base, the local command agent,
  shipped protocols (DNS / mDNS, serial, power), `am-interop`
  CLI.
- `codecraftai-protocol-ssh` — SSH from packet to agent.
- `codecraftai-networking` — networking primitives.
- `codecraftai-restclient-base` — REST foundation.
- `codecraftai-restclient-github-public` — auto-generated worked
  example.
- `codecraftai-interop-ext-upnp`, `-tasker`, `-bluetooth`, `-can`
  — protocol extension packages.

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — Philosophy and Foundation | 1, 2, 3 | Measured Interoperability; the Aspects pattern; `codecraftai-interop` survey |
| II — Worked Protocols | 4, 5, 6, 7 | SSH from scratch; networking primitives; REST foundations; the auto-generated GitHub client |
| III — Extensions | 8, 9 | UPnP / Tasker / Bluetooth / CAN; recipe for your own |

- [Chapter 1 — Measured Interoperability — The Philosophy](chapters/01-measured-interoperability.md)
- [Chapter 2 — The Aspects Pattern — The Delegation Contract](chapters/02-the-aspects-pattern.md)
- [Chapter 3 — `codecraftai-interop` — Agents, Protocols, CLI](chapters/03-codecraftai-interop.md)
- [Chapter 4 — SSH from Scratch](chapters/04-ssh-from-scratch.md)
- [Chapter 5 — Networking Toolkit Primitives](chapters/05-networking-toolkit.md)
- [Chapter 6 — REST Client Foundations](chapters/06-rest-client-foundations.md)
- [Chapter 7 — The Auto-Generated GitHub Public-API Client](chapters/07-github-auto-generated-client.md)
- [Chapter 8 — Extension Packages — UPnP, Tasker, Bluetooth, CAN](chapters/08-extension-packages.md)
- [Chapter 9 — Building Your Own Interop Extension](chapters/09-building-your-own.md)
- [Chapter 10 — Remote Objects over a Wire: `codecraftai-python-rpc`](chapters/10-remote-objects-over-a-wire.md)

## How it connects to the other volumes

- **Volume 0** — the foundation tier (errors, waiting,
  application identity, `wait_for_it` primitive at ch.5,
  Aspects pattern introduced philosophically at ch.4).
- **Volume 1 ch.5** — the JSOS streaming format that
  recordings use.
- **Volume 2 ch.5** — tandem validators apply the same
  four-principle discipline at the framework layer.
- **Volume 2 ch.10** — sketches the SSH layer; Volume 5 ch.4
  is the depth-walk.
- **Volume 6 ch.3** — the Coordinator/Agent/ProtocolExtension/
  Feature vocabulary.  Volume 5 zooms into Agents.
- **Volume 6 ch.13** — GitLab integration (REST consumer
  side); Volume 5 ch.6/7 is the foundation Volume 6 ch.13
  builds on.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/01-measured-interoperability -->
# Chapter 1 — Measured Interoperability — The Philosophy

> *Networks flake.  Services take time to start.  A command that
> succeeds nine times out of ten will fail on the tenth run and
> leave a test suite in an unknown state.  The naive solution —
> `time.sleep(30)` before every interaction — wastes time on
> fast machines and still fails on slow ones.  Hand-written
> retry loops scatter timing decisions throughout the codebase.
> `codecraftai-interop` solves this with **Measured
> Interoperability**: a clean separation of *Mechanism* (what
> to do) from *Behaviour* (how persistently to do it).*

This chapter establishes the philosophy that shapes the entire
interop layer.  Every protocol package built on
`codecraftai-interop` — SSH, REST, UPnP, Tasker, Bluetooth, CAN,
serial, power — implements **Measured Interoperability**.  The
discipline is what makes the protocol stack predictable,
testable, and tunable across the wildly different timing
profiles real-world automation runs against.

The chapter is the volume's foundation.  Chapter 2 walks the
*mechanism* that implements the discipline — the Aspects
pattern.  The remaining seven chapters walk the protocol stack
itself.  But none of those make sense without this chapter's
framing.

## The premise — what the interop layer's workload actually is

Distributed automation and interop are dominated by
**persistence behaviours**: actions that must keep trying,
polling, waiting, or reconnecting until a condition is met or
a deadline passes.

Concrete examples from the portfolio:

- Run a command on a remote host; the command takes a
  variable amount of time; **retry** the read of stdout / stderr
  until the command exits.
- Reboot a device; **wait** for it to become reachable again
  via SSH.
- Apply a configuration change; **poll** until the change has
  propagated to all four nodes in the cluster.
- Check that a service is healthy; **retry** the health probe
  if it returns transient errors.
- Stream events from a device; **reconnect** when the wire
  drops.
- Send a power-cycle command; **wait** for the device to
  return to a known state.

Most interop API calls are not "do X once and return."  They
are "do X, then verify X took effect, retrying or polling
until success criteria are met."  The interop layer is built
around this premise; the framework's `ActionPattern` enum
names the canonical persistence behaviours:

```python
# codecraftai-interop/source/packages/ccai/interop/aspects.py:24–32

class ActionPattern(IntEnum):
    SINGLE_CALL = 0                    # Try once, return the result.
    SINGLE_CONNECTED_CALL = 1          # Wait for connection, then try once.
    DO_UNTIL_SUCCESS = 2               # Retry until success or deadline.
    DO_WHILE_SUCCESS = 3               # Poll while the condition holds.
    DO_UNTIL_CONNECTION_FAILURE = 4    # Retry until the wire drops.
```

Five named persistence behaviours.  Each is a *strategy* the
interop layer can apply to a call.  Each call selects one.

## What "measured" means

The userguide's framing:

> *`codecraftai-interop` solves this with Measured
> Interoperability: a clean separation of two concerns.*
>
> *Mechanism: The act of sending bytes to a target — invoking
> `subprocess.Popen`, opening a paramiko channel, writing to
> a serial port.  This is the agent's job.*
>
> *Behaviour: How tenaciously to retry, how long to wait
> before giving up, when to log results, which error codes to
> treat as transient.  This is the `Aspects` object's job.*
>
> — `codecraftai-docs/userguide/packages/codecraftai-interop.rst:25`

The word **measured** carries weight.  The interop layer is
not merely *interoperable* — it is *measured* in its
interoperability.  The framework's calls don't lock onto the
wire and refuse to give up.  They don't give up after the
first hiccup.  They apply *measured* persistence — exactly the
amount the application layer asked for, no more, no less.

The discipline is what distinguishes Measured Interoperability
from naive retry loops.  The naive approach has timing
decisions everywhere: `time.sleep(30)` here, `for i in
range(5)` there, `try / except` blocks scattered through test
bodies.  Measured Interoperability has a single mechanism —
the Aspects parameter — that carries timing decisions from
the application layer down to the interop layer where they're
applied.

## The four principles

Measured Interoperability rests on four load-bearing
principles.  These principles describe *who owns what
concerns* across the polling-dominated workload of the interop
layer.

### Principle 1 — Persistence behaviours live in the interop layer, not the application layer

Tests and helper code should *not* contain hand-written
persistence behaviours.  No `for _ in range(N): try: ...
except: time.sleep(...)` loops in test bodies.  Persistence
behaviours are the **interop layer's responsibility**, not the
test author's.

If they lived in the application layer:

- Every test would reinvent persistence, with subtly different
  semantics.
- Bugs would proliferate — off-by-one timeouts, wrong
  intervals, missing back-off, swallowed exceptions, retry-
  past-real-failure patterns that hide bugs.
- Behaviour would be inconsistent across the codebase.  One
  test polls every 0.5 seconds, another every 5 seconds,
  neither author thought about it.
- Code review couldn't catch persistence-bug patterns
  because they'd be scattered.

Centralising persistence at the interop layer means: one
implementation, well-tested, consistently applied.  The test
author calls `device.power.cycle()` — under the hood, the
function applies the chosen `ActionPattern` to the call.  The
test author doesn't write the loop.

### Principle 2 — Decisions about flow control stay in the application layer

The library / low-level protocol code does NOT make
application-level decisions about what to do when things go
wrong.  The interop layer doesn't decide "wait 30 seconds and
try again, but only twice" on its own — that's a *behaviour*
the application layer chose.

When the interop layer *does* execute such decisions (which
it must, to perform persistence behaviours), it does so under
a **delegation contract**.  The application layer passes the
*details of the behaviour* to the interop layer; the interop
layer follows the delegation faithfully.

**The Aspects pattern is the delegation contract.**

The `Aspects` parameter on every interop call (chapter 2 walks
it in detail) is how the application layer hands the interop
layer its policy:

- Which `ActionPattern` to apply.
- How long to wait (`completion_timeout`,
  `connection_timeout`, `inactivity_timeout`).
- How often to poll (`completion_interval`,
  `connection_interval`, `inactivity_interval`).
- What counts as success (`expected_status`,
  `allowed_error_codes`).
- What `LoggingPattern` to emit while running.

Different callers use different policies on the same library
function; the library code doesn't change.

This is why decorators are wrong for this purpose (Volume 0
chapter 2's discipline lands here).  A `@retry(times=3,
delay=10)` decorator hard-codes the policy at the *library*
level — every call site gets the same policy whether or not
it's the right one.  An `aspects=AspectsCmd(...)` parameter
passes the policy *from the caller per call* — every caller
chooses.

### Principle 3 — Neither too brief nor too persistent

The interop layer cannot:

- **Immediately give up.**  A single failed packet, a
  momentary connection blip, a transient timeout — these
  would manifest as test failures despite the underlying
  system being fine.  False negatives.
- **Be too persistent.**  Retrying forever, retrying through
  real failures, polling past actual breakage — these *hide
  bugs*.  A test that should have failed (because the system
  is genuinely broken) appears to pass because the interop
  layer kept retrying until coincidentally succeeding.
  False positives.

All interop code adheres to **flow control contracts** that
specify **persistence criteria**.  The criteria define *when
to stop trying*:

- Total time elapsed (`completion_timeout`).
- Time without progress (`inactivity_timeout`).
- Number of attempts.
- A specific error code that means "permanent failure, don't
  retry."
- A specific success condition that means "yes, done."

The criteria are encoded in the Aspects object.  The interop
layer follows them — *exactly* as long as they say to try,
*exactly* when they say to stop.

The framework's defaults (from
`codecraftai-interop/source/packages/ccai/interop/aspects.py:43–62`):

```python
DEFAULT_COMPLETION_TIMEOUT = 600          # 10 minutes total
DEFAULT_COMPLETION_INTERVAL = 10          # check every 10 seconds
DEFAULT_CONNECTION_TIMEOUT = 16           # 16 seconds to establish
DEFAULT_CONNECTION_INTERVAL = 2           # retry connection every 2s
DEFAULT_ALLOWED_ERROR_CODES: Tuple[int] = []
DEFAULT_ALLOWED_CONNECTION_EXCEPTIONS: Tuple[BaseException] = []
DEFAULT_MUST_CONNECT = False
```

These defaults are sensible starting points.  A specific call
that needs different criteria builds its own
`AspectsCmd(completion_timeout=...)` and passes it.

### Principle 4 — When the contract's bounds are exceeded, control reverts via exception

The interop layer never makes the decision "we tried hard
enough, let's give up silently and report success."  It also
never makes the decision "we'll keep trying past the agreed
budget because we *think* it'll work eventually."  When the
persistence criteria from the application layer are
*exhausted* — the timeout elapsed, the attempt count reached,
the deadline passed — the interop layer **raises an
exception** and returns control to the application layer.

The exception is the *exit mechanism* of the delegation
contract.  The application layer:

- *Delegated* persistence responsibility (principle 1).
- *Specified* the criteria via Aspects (principle 2).
- The interop layer *honoured* the criteria (principle 3).
- The interop layer *reverts control* when the criteria are
  exhausted (principle 4).

The application layer then decides what to do.  Catch the
exception and retry with different aspects.  Fail the test.
Escalate to the orchestrator.  Try a fallback approach.  The
interop layer doesn't choose; it surfaces the situation and
lets the application choose.

This is why the interop layer's exception types are *typed*
(`SshAuthError`, `SshSFTPError`, `TimeoutError`,
`ConnectionError`) and not generic `RuntimeError`.  The
exception's type tells the application layer *which kind* of
contract bound was hit, so the application can dispatch on
the type:

```python
try:
    device.ssh.run_cmd(cmd, aspects=fast_aspects)
except TimeoutError:
    # The completion_timeout elapsed.  Try with longer aspects.
    device.ssh.run_cmd(cmd, aspects=patient_aspects)
except SshAuthError:
    # Auth failed.  Credentials might have rotated.  Re-resolve.
    device.refresh_credentials()
    device.ssh.run_cmd(cmd, aspects=fast_aspects)
except ConnectionError:
    # The wire dropped.  Different recovery — escalate.
    raise InfrastructureError("Pod is unhealthy")
```

The typed exceptions are the *vocabulary* through which the
contract reverts control.

## The status-vs-exception split

A subtlety worth pinning down because it surprises readers:
**a non-zero exit status from a remote command does NOT raise
in the interop layer.**

The reason: a non-zero status is *within* the contract.  The
contract was "run this command, capture status / stdout /
stderr."  The contract was honoured even when the command
exited 1.  The application layer reads the status and
decides whether the non-zero is meaningful.

A test that *expects* a non-zero status (say, "this command
should fail because the file doesn't exist") wants
`status == 1`, not an exception.  Treating the non-zero as
an exception would force every caller to wrap in `try /
except` even for expected failures.

By contrast, a *transport failure* (network drop, SSH auth
failure, connection timeout) **exceeds the contract** —
"capture status / stdout / stderr" cannot be honoured if the
session never opened.  The interop layer raises a typed
exception per principle 4.

The split is:

- **Status as data** — a non-zero exit code is data the
  application reads.
- **Exception as control reversion** — a contract violation
  reverts control.

Together, they cover the full surface.  Application code
reads status when it cares about the command's exit; catches
typed exceptions when the contract was exceeded.

## How the principles interlock

The four principles compose into one design:

1. The interop layer *does the persistence work* (principle 1) →
2. *under a delegation contract* (principle 2) →
3. *with persistence criteria that bound behaviour* (principle 3) →
4. *and reverts control via typed exception when the criteria are exhausted* (principle 4).

The Aspects parameter is the artifact carrying all four.  The
application layer *delegates* persistence (principle 1),
passes its *decisions about flow control* via the Aspects
parameter (principle 2), the parameter encodes the
*persistence criteria* (principle 3), and the interop layer
follows the criteria — including raising on exhaustion
(principle 4).

## Where this philosophy applies

**The whole interop layer for all protocols uses Measured
Interoperability.**  Every protocol package in the portfolio
implements it:

- `codecraftai-interop` — the core.  Local commands, SSH (paramiko-
  wrapped), serial-over-TCP, power, DNS / mDNS.
- `codecraftai-protocol-ssh` — from-scratch SSH.
- `codecraftai-restclient-base` — REST foundation; `AspectsRest`
  is the REST-shaped Aspects subclass.
- `codecraftai-restclient-github-public` — auto-generated GitHub
  client; every method takes `aspects=AspectsRest(...)`.
- `codecraftai-interop-ext-upnp`, `-tasker`, `-bluetooth`, `-can`
  — protocol extensions.

The discipline is uniform.  An engineer who understands this
chapter can read any protocol agent in the portfolio and know
what the Aspects parameter is for.

## Why this design holds up at scale

Three properties make Measured Interoperability work in
production:

### Configuration consistency

Because every interop call accepts `Aspects`, a project that
needs longer timeouts in CI (slower lab) and shorter timeouts
locally (faster laptop) sets the *Aspects* differently per
environment.  No code changes; the same library calls work
both places.

### Concurrent-safe sharing

`Aspects` and its subclasses inherit from `Freezable`.  Once
constructed, an `Aspects` instance is immutable.  This makes
it safe to share across concurrent calls — no risk of one
thread mutating an aspects instance another thread is using.
Volume 2 chapter 11's cross-host fan-out relies on this
property.

### Composability with the framework

The framework's testjob lifecycle, validators, configuration
profiles, and orchestration all compose with Measured
Interoperability cleanly.  A tandem validator (Volume 2
chapter 5) is *itself* an application of the four principles
at a higher framework layer: validators centralise polling
(principle 1), take their criteria as constructor parameters
(principle 2), bound persistence (principle 3), raise
`AssertionError` on contract exhaustion (principle 4).  The
discipline scales.

## What this chapter set up

By the end of this chapter a reader should:

- Know the formal name **Measured Interoperability** and the
  Mechanism / Behaviour separation.
- Recognise **persistence behaviours** as the umbrella term
  for retry / poll / wait / reconnect patterns.
- Be able to name the five `ActionPattern` modes:
  `SINGLE_CALL`, `SINGLE_CONNECTED_CALL`,
  `DO_UNTIL_SUCCESS`, `DO_WHILE_SUCCESS`,
  `DO_UNTIL_CONNECTION_FAILURE`.
- Understand the four principles: persistence in interop,
  decisions in application, criteria bound persistence,
  control reverts via exception.
- Distinguish status-as-data from exception-as-control-
  reversion.

The next chapter walks the **Aspects pattern** in code
detail: the `Aspects` and `AspectsCmd` classes, their fields,
their immutability, the `LoggingPattern` enum, the `MonitoredScope`
deadlock-detection mechanism, and the typical aspect-passing
flow through an interop call.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/02-the-aspects-pattern -->
# Chapter 2 — The Aspects Pattern — The Delegation Contract

> *Chapter 1 carried the philosophy: persistence behaviours
> live in the interop layer, decisions stay in the application
> layer, the Aspects parameter is the delegation contract.
> This chapter walks the *code*.  `Aspects`, `AspectsCmd`,
> `ActionPattern`, `LoggingPattern`, `Freezable`,
> `MonitoredScope` — the classes and enums that make Measured
> Interoperability work.*

The previous chapter established the four principles of
Measured Interoperability.  This chapter walks the
implementation: the classes and the conventions that turn the
principles into working code.  By the end, a reader should be
able to construct an `AspectsCmd` for any specific call need
and know what each field does.

## The class — `Aspects`

**File:**
`codecraftai-interop/source/packages/ccai/interop/aspects.py:63–130`

```python
class Aspects(Freezable):
    """
        Aspects encapsulates the runtime behaviour parameters that
        govern interop API calls — timeouts, intervals, retry strategy,
        logging mode, and which transient errors to ignore.

        Aspects is immutable after construction (Freezable).
    """

    def __init__(self, *,
                 action_pattern: ActionPattern = ActionPattern.SINGLE_CALL,
                 completion_timeout: float = DEFAULT_COMPLETION_TIMEOUT,
                 completion_interval: float = DEFAULT_COMPLETION_INTERVAL,
                 connection_timeout: float = DEFAULT_CONNECTION_TIMEOUT,
                 connection_interval: float = DEFAULT_CONNECTION_INTERVAL,
                 inactivity_timeout: float = DEFAULT_INACTIVITY_TIMEOUT,
                 inactivity_interval: float = DEFAULT_INACTIVITY_INTERVAL,
                 logging_pattern: LoggingPattern = DEFAULT_LOGGING_PATTERN,
                 allowed_error_codes: Tuple[int, ...] = DEFAULT_ALLOWED_ERROR_CODES,
                 allowed_connection_exceptions: Tuple[BaseException, ...] = DEFAULT_ALLOWED_CONNECTION_EXCEPTIONS,
                 must_connect: bool = DEFAULT_MUST_CONNECT,
                 logger: Optional[logging.Logger] = None) -> None:
        super().__init__()

        self.action_pattern = action_pattern
        self.completion_timeout = completion_timeout
        self.completion_interval = completion_interval
        self.connection_timeout = connection_timeout
        self.connection_interval = connection_interval
        self.inactivity_timeout = inactivity_timeout
        self.inactivity_interval = inactivity_interval
        self.logging_pattern = logging_pattern
        self.allowed_error_codes = tuple(allowed_error_codes)
        self.allowed_connection_exceptions = tuple(allowed_connection_exceptions)
        self.must_connect = must_connect
        self.logger = logger

        self.freeze()
        return
```

Twelve fields.  Each is a behaviour knob the application layer
sets to control how the interop layer applies persistence.

### The fields

The fields divide into three groups by purpose:

**Persistence strategy:**

- `action_pattern` — the `ActionPattern` enum value naming the
  persistence behaviour (below).
- `must_connect` — when True, the call requires a connection;
  when False, connection failure is acceptable for a single-
  call attempt.

**Timing budget:**

- `completion_timeout` — total time before the call gives up
  (default 600 seconds = 10 minutes).
- `completion_interval` — how often to check for completion
  during the wait (default 10 seconds).
- `connection_timeout` — total time to establish the
  connection (default 16 seconds).
- `connection_interval` — how often to retry the connection
  (default 2 seconds).
- `inactivity_timeout` — time without progress before giving
  up (default 600 seconds).
- `inactivity_interval` — granularity of progress detection
  (default 0.5 seconds).

**Tolerance:**

- `allowed_error_codes` — tuple of status codes the call
  accepts as success-equivalent.  A status in this tuple
  doesn't trigger retry.
- `allowed_connection_exceptions` — tuple of exception types
  the call ignores during connection attempts (e.g., a
  `ConnectionRefusedError` from a service that's still
  starting up).

**Logging:**

- `logging_pattern` — the `LoggingPattern` enum value
  controlling when to log (below).
- `logger` — optional explicit logger; otherwise the
  call uses the framework default.

A reader studying the field list might notice that two
distinct timing concepts coexist: *completion* (the overall
"is the work done?" budget) and *inactivity* (the "are we
making progress?" budget).  A long-running command can
exceed the completion timeout while still showing progress;
the inactivity timeout catches the case where the command
hangs without producing any output.  Both serve as escape
hatches for different failure modes.

## `AspectsCmd` — command-shaped subclass

**File:**
`codecraftai-interop/source/packages/ccai/interop/aspects.py:133–197`

```python
class AspectsCmd(Aspects):
    """
        Aspects subclass for command-shaped calls: shell commands,
        SSH commands, serial commands.

        Adds:
        - expected_status: the exit code(s) treated as success.
        - user: the user identity to run the command as.
        - pty_params: pseudo-terminal parameters.
    """

    def __init__(self, *,
                 expected_status: Union[int, Tuple[int, ...]] = 0,
                 user: Optional[str] = None,
                 pty_params: Optional[dict] = None,
                 # ... inherited Aspects fields ...
                 **kwargs) -> None:
        super().__init__(**kwargs)
        # Note: the freeze() in super().__init__ has already happened.
        # AspectsCmd-specific fields must be set before freeze.
        # The actual implementation handles this carefully.
        self.expected_status = expected_status
        self.user = user
        self.pty_params = pty_params
        return
```

Three command-specific additions:

- **`expected_status`** — the status code (or tuple of codes)
  the call treats as success.  Default `0`.  A test that
  expects a non-zero status sets `expected_status=1` and the
  call returns normally when the command exits 1.
- **`user`** — the user identity to run the command as.  For
  SSH calls, this picks one of the credentials registered in
  the `SshAgent`'s `users` dict.  For local commands, this
  is the user the subprocess runs as (when the framework
  has the privilege to switch).
- **`pty_params`** — optional dict of pseudo-terminal
  parameters when the command needs an interactive PTY.

Other protocol-specific Aspects subclasses follow the same
pattern.  `AspectsRest` (chapter 6) adds REST-specific fields
like `retry_logging_interval` and `session`.  Future
protocols can subclass to add their own protocol-specific
behaviour parameters without touching the base class.

## `ActionPattern` — the persistence behaviours

**File:** `aspects.py:24–32`

```python
class ActionPattern(IntEnum):
    SINGLE_CALL = 0
    SINGLE_CONNECTED_CALL = 1
    DO_UNTIL_SUCCESS = 2
    DO_WHILE_SUCCESS = 3
    DO_UNTIL_CONNECTION_FAILURE = 4
```

Five named behaviours.  Each is a complete persistence
strategy.  The `ActionPattern` field on the Aspects object is
how the application layer selects which strategy applies.

### `SINGLE_CALL`

> *Try once.  Return whatever happens.*

The default.  No persistence — the simplest behaviour.
Appropriate for:

- Calls whose success is *expected* and whose failure is
  meaningful (e.g., "is the file present?").
- Calls where retrying would corrupt state (e.g., "submit
  this transaction").
- Quick health checks where a single failure is the
  diagnostic signal.

A `SINGLE_CALL` honours the timing budgets only for the
single attempt.  If the call exceeds `completion_timeout`,
the interop layer raises `TimeoutError` per principle 4.

### `SINGLE_CONNECTED_CALL`

> *Wait for a connection.  Then try once.*

A two-phase strategy: persist the *connection* (using
`connection_timeout` / `connection_interval`), then issue
the call once.  Appropriate when:

- The remote service might not be reachable yet (just
  started, mid-deployment).
- The actual call is one-shot (not a polling operation), but
  reaching the service is the unreliable part.

### `DO_UNTIL_SUCCESS`

> *Retry until the expected status is observed, or until the
> deadline.*

The most common persistence behaviour.  Repeatedly issues
the call; treats each non-`expected_status` result as a
retry-trigger; gives up only when:

- The call returns `expected_status` (success → return), or
- `completion_timeout` elapses (failure → raise
  `TimeoutError`), or
- The deadline is hit while a retry is in progress (failure
  → raise).

Useful for:

- "Wait for the cluster to converge."  Poll a status command
  until it reports converged.
- "Wait for the device to come back after reboot."  Poll an
  SSH connection until it succeeds.
- "Wait for the service to register itself in DNS."  Resolve
  the hostname until it returns the expected address.

### `DO_WHILE_SUCCESS`

> *Keep running while the expected status holds.  Stop on
> mismatch.*

The mirror image.  Useful when *observing* a steady-state
condition that's expected to be true *until something
changes*:

- "Hold the connection open and watch logs; stop when the
  service reports a fault."
- "Monitor the cluster's health while a test runs; stop if
  health degrades."
- "Stream events from the device and capture them."

A tandem validator (Volume 2 chapter 5) is a higher-level
construct that uses `DO_WHILE_SUCCESS` semantics under the
hood.

### `DO_UNTIL_CONNECTION_FAILURE`

> *Retry until the connection drops.*

The least common pattern.  Useful for *observing-until-end*
scenarios where the call should keep going as long as the
connection holds.  When the connection drops, the call
returns whatever was accumulated.

## `LoggingPattern` — what to log

**File:** `aspects.py:34–40`

```python
class LoggingPattern(IntEnum):
    ALL_RESULTS = 0
    FAILURE_ONLY = 1
    SUCCESS_ONLY = 2
```

Three modes for emit-while-running logging:

- **`ALL_RESULTS`** (default) — log every attempt's outcome.
  Useful for diagnosing flake.
- **`FAILURE_ONLY`** — log only failures.  Useful for
  production runs where success is expected and shouldn't
  fill the logs.
- **`SUCCESS_ONLY`** — log only successes.  Useful for
  capturing what *did* happen during long-running observers.

The `LoggingPattern` field is independent of the framework's
overall log-level setting.  Even with the framework's logger
at INFO, an Aspects with `LoggingPattern.FAILURE_ONLY` keeps
successful retries quiet — the application layer chose to
suppress the noise.

## `Freezable` — immutability after construction

**File:**
`codecraftai-xmodules/source/packages/ccai/xmodules/freezable.py:13`

```python
class Freezable:
    """
        Mixin that enforces post-construction immutability.

        A subclass calls self.freeze() at the end of __init__.  After
        freeze, any attempt to set an attribute raises AttributeError.
    """

    def __init__(self) -> None:
        object.__setattr__(self, "__frozen__", False)
        return

    def freeze(self) -> None:
        object.__setattr__(self, "__frozen__", True)
        return

    def __setattr__(self, name: str, value: object) -> None:
        if getattr(self, "__frozen__", False):
            err_msg = "Cannot set attribute '{}' on frozen {} instance.".format(
                name, type(self).__name__,
            )
            raise AttributeError(err_msg)
        object.__setattr__(self, name, value)
        return
```

The mechanism is small and efficient:

- `__init__` sets `__frozen__ = False` (via direct
  `object.__setattr__` to bypass the override).
- The subclass's `__init__` does its setup.
- `self.freeze()` flips `__frozen__ = True`.
- Subsequent `obj.x = ...` calls hit `__setattr__`, see
  `__frozen__` is True, and raise.

The result: an `Aspects` instance is *immutable* after its
constructor returns.  This is what makes Aspects safe to
share across concurrent calls — no thread can mutate an
aspects instance another thread is using.

The cost is that creating a *modified* aspects requires
constructing a new instance.  Idiomatic code:

```python
fast_aspects = AspectsCmd(
    completion_timeout=10,
    action_pattern=ActionPattern.SINGLE_CALL,
)

# Later, want a slightly different version — construct anew.
patient_aspects = AspectsCmd(
    completion_timeout=300,
    completion_interval=5,
    action_pattern=ActionPattern.DO_UNTIL_SUCCESS,
)

# Use both freely; they don't interfere.
```

The Aspects-construction overhead is negligible compared to
the cost of the protocol calls themselves.

## `MonitoredScope` — the deadlock detector

**File:**
`codecraftai-xmodules/source/packages/ccai/xmodules/xlogging/monitoredscope.py:34`

```python
class MonitoredScope:
    """
        Context manager that registers thread-scoped monitoring with
        a global ScopeMonitor daemon.  If the thread does not exit
        the scope before timeout + notify_delay, the monitor logs an
        error.

        Used by interop calls to detect when a thread has gotten
        stuck inside a blocking I/O call past the agreed timeout.
    """

    def __init__(self, label: str, message: str, timeout_context: TimeoutContext,
                 notify_delay: float = 30):
        # ...
        return
```

`MonitoredScope` is the *deadlock detector* the interop layer
wraps every persistent call in.  When a call enters a
`MonitoredScope`, the global `ScopeMonitor` daemon thread is
told "expect this thread to leave the scope within
`timeout + notify_delay` seconds."  If the thread doesn't
leave (because it's stuck in a syscall, or the underlying
library is hanging), the monitor logs an error reporting
the thread is past its budget.

Use sites:

- `LocalCommandAgent.run_cmd` at line 104 — wraps subprocess
  execution.
- `SshBase.run_cmd` at line 545 — wraps SSH command
  execution.

The monitor doesn't *kill* the thread (Python doesn't make
that safe).  But the log entry is the diagnostic signal that
something has gone wrong at the wire level — typically the
network stack hanging on a syscall or a paramiko bug.

A reader debugging "why did this hang past the timeout" works
backwards from the monitor's log entry to the thread's stuck
syscall.

## The default constants

**File:** `aspects.py:43–62`

```python
DEFAULT_COMPLETION_TIMEOUT = 600
DEFAULT_COMPLETION_INTERVAL = 10
DEFAULT_CONNECTION_TIMEOUT = 16
DEFAULT_CONNECTION_INTERVAL = 2
DEFAULT_ALLOWED_ERROR_CODES: Tuple[int] = []
DEFAULT_ALLOWED_CONNECTION_EXCEPTIONS: Tuple[BaseException] = []
DEFAULT_MUST_CONNECT = False
```

And the default instances at line 202:

```python
DEFAULT_ASPECTS = Aspects()
DEFAULT_CMD_ASPECTS = AspectsCmd()
```

The default values are deliberate but conservative:

- 10-minute completion timeout — long enough for slow
  operations on real lab gear.
- 10-second completion interval — frequent enough for
  responsive feedback, infrequent enough to not hammer the
  system.
- 16-second connection timeout — enough to handle the
  occasional slow-handshake case.

Most calls use the defaults.  Calls that need different
budgets construct their own AspectsCmd.  The defaults are
*starting points*, not rules.

## The aspect-passing flow through a call

A worked example.  A test calls
`device.ssh.run_cmd("uptime", aspects=fast_aspects)`.  The
flow:

1. **Test code** — declares the call with explicit aspects.
2. **`device.ssh`** — the `SshProtocolExtension` (Volume 6 chapter 3),
   wrapping the SSH agent.
3. **`SshAgent.run_cmd`** — receives `aspects=fast_aspects`.
   If `aspects is None` (caller passed nothing), falls back to
   `self._aspects` (set at agent construction).
4. **`SshBase._run_cmd`** — the template method.  Resolves
   `completion_timeout`, `inactivity_timeout`, etc. from the
   aspects.  Constructs a `TimeoutContext`.
5. **Action-pattern dispatch** — examines
   `aspects.action_pattern`.  For `SINGLE_CALL`, executes
   once.  For `DO_UNTIL_SUCCESS`, enters a retry loop using
   the timeout context.
6. **`MonitoredScope` wrap** — wraps each attempt in a scope
   with the relevant timeout.
7. **`_ssh_execute_command`** — runs the actual paramiko
   call.
8. **Return path** — status / stdout / stderr returned to the
   test, OR a `TimeoutError` / `ConnectionError` raised when
   the contract is exceeded.

Steps 2 through 7 are inside the interop layer.  The test
sees only steps 1 and 8.  The aspect parameter is the only
thing the test specified about *how* the call should behave;
everything else is the framework following the contract.

## The relationship to the framework's wider patterns

A few connections the chapter has gestured at and the rest
of Volume 5 makes explicit:

- **Volume 0 chapter 4 (Aspects pattern philosophy)** —
  introduced Aspects as a Volume-0-level discipline.  This
  chapter is the implementation of that discipline.
- **Volume 0 chapter 5 (waiting primitives)** — the
  `wait_for_it` function that Volume 5 chapter 4's SSH agent
  uses internally is the same primitive validators (Volume 2
  chapter 5) use.
- **Volume 0 chapter 3 (error vocabulary)** — the typed
  exceptions raised by the interop layer follow Volume 0's
  bubble-the-failure discipline.
- **Volume 6 chapter 3 (Coordinator/Agent/ProtocolExtension)** —
  the Agents whose calls take Aspects are the Agents Volume 6
  named.

These cross-references are what make the volume hold together
with the rest of the series.  Measured Interoperability is
not a Volume 5 invention; it's the implementation of
foundation-tier disciplines from Volume 0, applied to the
specific concerns of cross-machine wire calls.

## What's next

Chapter 3 walks `codecraftai-interop` itself — the package
structure, the Agent base classes, the shipped protocols
(local commands, SSH paramiko-wrapped, serial-over-TCP, DNS,
power), and the `am-interop` CLI scaffolding.  Where this
chapter walked the *delegation contract*, the next chapter
walks the *agents that fulfil it*.

Chapter 4 then deep-dives the from-scratch SSH stack in
`codecraftai-protocol-ssh` — packets up to agents — as an
in-depth worked example of Measured Interoperability applied
to a complex protocol.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/03-codecraftai-interop -->
# Chapter 3 — `codecraftai-interop` — Agents, Protocols, CLI

> *Chapters 1 and 2 walked the philosophy and the delegation
> contract.  This chapter walks the package itself — the
> Agent base classes, the shipped protocols (local commands,
> paramiko-based SSH, serial-over-TCP, DNS / mDNS, power),
> and the `am-interop` CLI scaffolding.  By the end, a reader
> can locate any Agent in the package and predict its shape.*

`codecraftai-interop` is a foundation tier package.  It supplies
the Agent base classes that protocol-specific code subclasses,
ships a handful of common protocols directly, and provides
the `am-interop` CLI as a command-line surface for ad-hoc
interop tasks.

This chapter is a *survey*.  Where chapter 4 deep-dives one
protocol (the from-scratch SSH stack), this chapter walks the
*breadth* of `codecraftai-interop` so a reader knows what's in
the package and where to find each thing.

## Package layout

```
codecraftai-interop/source/packages/ccai/interop/
├── aspects.py             # chapter 2 covered this
├── agents/
│   ├── systemagentbase.py     # base for all system agents
│   ├── localcommandagent.py   # local subprocess
│   └── poweragentbase.py      # base for power-control agents
├── protocols/
│   ├── ssh/                   # paramiko-wrapping SSH
│   │   ├── sshbase.py
│   │   ├── sshagent.py
│   │   ├── sshsession.py
│   │   └── sshaspects.py
│   ├── serial/                # serial-over-TCP
│   │   └── tcpserialagent.py
│   ├── dns/                   # DNS / mDNS support
│   └── power/                 # DLI power switches
├── cli/
│   ├── am_interop_command.py  # CLI entry
│   └── cmdtree/               # currently empty
└── ...
```

Three top-level concerns:

- **`agents/`** — Agent base classes and the local-command
  agent.
- **`protocols/`** — protocol-specific code shipped with the
  package.  SSH, serial, DNS, power.
- **`cli/`** — the `am-interop` command-line tool.

A note up front: the SSH code in `codecraftai-interop/protocols/ssh/`
is *paramiko-wrapping* — it uses the `paramiko` library
under the hood and adds the Aspects-driven persistence
behaviours on top.  This is the production SSH stack used by
most projects.  A *separate* package, `codecraftai-protocol-ssh`,
implements SSH from scratch (no paramiko).  Chapter 4 walks
the from-scratch implementation as an in-depth worked example;
most readers using SSH in production are using the
paramiko-wrapping version surveyed here.

## Agents — the base hierarchy

### `SystemAgentBase`

**File:**
`codecraftai-interop/source/packages/ccai/interop/agents/systemagentbase.py:19`

The pure abstract base.  Most production Agents inherit from
the protocol-specific bases (`SshBase`, `SerialAgentBase`,
`PowerAgentBase`) which themselves implement
`ISystemContext`; `SystemAgentBase` is the shared parent.  The
class itself has minimal behaviour — it's a marker that
declares "I'm an Agent" for type-checking and discovery.

### `LocalCommandAgent`

**File:**
`codecraftai-interop/source/packages/ccai/interop/agents/localcommandagent.py:35–247`

```python
class LocalCommandAgent(ISystemContext):
    """
        An agent for running commands on the local machine via
        subprocess.

        Implements all five ActionPattern modes via subprocess.Popen
        plus MonitoredScope plus TimeoutContext.
    """

    def run_cmd(self, command: str,
                exp_status: Union[int, Sequence] = 0,
                aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
        """
            Run a command locally, return (status, stdout, stderr).

            Honours the chosen ActionPattern via the aspects parameter.
        """
        # ... implementation walks the action_pattern dispatch ...
```

The local-command agent is the simplest concrete agent in
the package.  It runs commands on the same machine via
`subprocess.Popen`.  Despite simplicity, it implements *all
five* `ActionPattern` modes — the same mechanism the SSH and
serial agents use.  This makes it a good reading-order
starting point for understanding the agents pattern: the
Aspects logic is identical across agents; only the underlying
mechanism differs.

A use case for `LocalCommandAgent` in a real test:

```python
from ccai.interop.agents.localcommandagent import LocalCommandAgent
from ccai.interop.aspects import AspectsCmd, ActionPattern

agent = LocalCommandAgent()

# Quick health check.
status, stdout, stderr = agent.run_cmd("which python3")

# Wait for a service that's starting up — DO_UNTIL_SUCCESS.
patient = AspectsCmd(
    action_pattern=ActionPattern.DO_UNTIL_SUCCESS,
    completion_timeout=60,
    completion_interval=2,
)
status, stdout, stderr = agent.run_cmd(
    "systemctl is-active myservice",
    aspects=patient,
)
```

The first call uses defaults; the second declares an
explicit aspects for the persistence behaviour.

### `PowerAgentBase`

**File:**
`codecraftai-interop/source/packages/ccai/interop/agents/poweragentbase.py:24–68`

The base for power-control agents — devices that can turn
outlets on and off, query outlet state, monitor power
consumption.  The shipped concrete implementation targets
DLI Web Power Switches (under
`interop/protocols/power/dlipower/`); a project with a
different power controller subclasses `PowerAgentBase` to
talk to its own hardware.

The base provides:

- `_lookup_power_interface(interface_name)` — routes the
  request to the right concrete `dlipower.PowerSwitch` based
  on configuration.
- Common state-query and state-set methods that subclasses
  fill in.

The class is an example of the framework's
"abstract-base-with-concrete-children" pattern from Volume 0
chapter 4.  An SDET adding a new power-controller integration
follows the pattern.

## Protocols shipped in `codecraftai-interop`

### SSH (paramiko-wrapping)

Three classes co-operate:

**`SshBase`** at `protocols/ssh/sshbase.py:61–627` is the
abstract foundation.  Both `SshAgent` and `SshSession`
inherit from it.  The class implements `_run_cmd` (line 241)
— the template method that walks the chosen `ActionPattern`
applying it to a paramiko-driven SSH command.  Five
`ActionPattern` modes; same algorithm as `LocalCommandAgent`,
different underlying mechanism.

**`SshAgent`** at `protocols/ssh/sshagent.py:37–150` is the
*stateless* SSH agent.  Each call creates and tears down its
own paramiko client.  Useful when:

- Calls are infrequent (no benefit from connection reuse).
- The remote host can't sustain a long-held connection.
- Concurrent calls to the same host benefit from independent
  sessions.

```python
class SshAgent(SshBase):

    def open_session(self, user: str = None, ...) -> "SshSession":
        """
            Vend an SshSession bound to this agent's host and
            credentials.
        """
        # Construct an SshSession; caller uses it as a context manager.
        session = SshSession(self._host, session_credential, ...)
        return session

    def open_tunnel(self, local_port: int, remote_host: str,
                    remote_port: int) -> "SshTunnel":
        """
            Open a port-forwarding tunnel.
        """
        # ...

    def reboot(self, aspects=None):
        """
            Issue a reboot to the remote host and wait for it to come back.
        """
        # ...
```

The agent vends `SshSession` instances and `SshTunnel`
instances.  These are the *stateful* siblings — a session
holds a paramiko client open across multiple calls; a tunnel
maintains an open port-forward.

**`SshSession`** at `protocols/ssh/sshsession.py:44–150` is
the *stateful* SSH session.  Used as a context manager:

```python
with agent.open_session(user="admin") as session:
    session.run_cmd("systemctl restart nginx", aspects=fast_aspects)
    session.run_cmd("systemctl is-active nginx", aspects=DO_UNTIL_SUCCESS)
```

The session holds the paramiko connection open for the
duration of the `with` block.  Multiple `run_cmd` calls reuse
the same connection.

### Serial-over-TCP

**File:**
`codecraftai-interop/source/packages/ccai/interop/protocols/serial/tcpserialagent.py:27–150`

```python
class TcpSerialAgent(SerialAgentBase):
    """
        Serial-console agent connected via TCP (a serial
        concentrator on the network).

        Uses telnetlib.Telnet under the hood; provides the same
        Aspects-driven interface as SshAgent.
    """
```

The serial agent connects to a *serial concentrator* — a
device that exposes serial ports as TCP sockets.  Serial
concentrators are common in lab gear: a single concentrator
holds eight or sixteen serial ports, each reachable over TCP
at a port number.  The agent telnets to the concentrator,
selects the right port, and operates the serial console.

The Volume 6 chapter 1 worked example of a Landscape file
showed device declarations like `serial: serial-concentrator: 1`
— the framework's Feature dispatcher (Volume 6 chapter 2)
resolves `serial-concentrator` to a `TcpSerialAgent` and
attaches a `SerialProtocolExtension` to the device.  A test's
`device.serial.write(...)` call goes through the extension to
the agent.

### DNS / mDNS

**Path:**
`codecraftai-interop/source/packages/ccai/interop/protocols/dns/`

The DNS module contains stateless utilities for DNS protocol
work:

- Record types (A, AAAA, SOA, MX, NAPTR, TXT, PTR).
- mDNS browser for local-network service discovery.
- Validation helpers.
- Flag definitions.

There's no `DnsAgent` class in the same shape as
`SshAgent` — DNS work is mostly stateless lookups, and the
shipped utilities are functions.  Projects that need a DNS
*polling* agent (e.g., "wait until this hostname resolves")
construct their own using `LocalCommandAgent` to invoke
`dig` with `DO_UNTIL_SUCCESS`, or use one of the mDNS
discovery utilities directly.

### Power

**Path:**
`codecraftai-interop/source/packages/ccai/interop/protocols/power/`

The shipped power implementation targets DLI Web Power
Switches:

```
power/
└── dlipower/
    ├── dlipowerclient.py   # the per-switch client
    └── ...
```

Subclasses of `PowerAgentBase` handle the protocol-level
details for each kind of power switch.  The `PowerSwitch`
class exposes outlets, supports on/off/cycle operations, and
provides metering when the hardware supports it.

A project automating against a different power switch (an APC
PDU, a custom relay board) writes its own
`PowerAgentBase` subclass.

## The `am-interop` CLI

**File:**
`codecraftai-interop/source/packages/ccai/interop/cli/am_interop_command.py:48–74`

```python
@click.group("am-interop")
@click.option('-v', '--verbose', count=True)
def am_interop_root_command(verbose):
    """
        am-interop — Command-line interface for the ccai interop layer.
    """
    # ...
```

A note on the CLI's current state: the `am-interop` CLI uses
`click` (not `arcli` from Volume 1 chapter 8) and the command
tree under `cli/cmdtree/` is **currently empty** (only a
`README.md` placeholder).  The discovery mechanism
(`discover_command_groups()` at line 24) exists; the
subcommands haven't been added yet.

The chapter is honest about this: the CLI is *scaffolded* for
ad-hoc interop tasks — running a command on a remote host,
opening a tunnel, querying DNS, controlling power — but the
shipped subcommands are still in flight.  A reader using
`codecraftai-interop` today uses the Python APIs directly; the
CLI will be useful when the subcommands land.

The framework's other CLIs (`testbase`, `taskplus`, etc.) use
arcli; that consistency is broken here.  Future work might
unify the CLI surface, but the chapter doesn't predict that.

## How protocols connect to the Landscape

A reader who's read Volume 6 chapters 1–3 already knows the
connection.  This chapter is a quick recap from the
interop-side perspective:

- A Landscape file declares devices and services with
  `deviceType` / `serviceType` fields.
- Each `deviceType` (e.g., `network/ssh`) maps to a
  *Coordinator* in `codecraftai-landscaping`.
- The Coordinator instantiates one of `codecraftai-interop`'s
  Agents (e.g., `SshAgent`) for each device.
- A Coordinator wraps the Agent in a `ProtocolExtension` and
  attaches it to the `LandscapeDevice`.
- Test code calls `device.ssh.run_cmd(...)` — under the hood,
  the call goes through the extension to the agent, which
  applies the Aspects-driven persistence behaviour from
  chapter 2.

So `codecraftai-interop` provides the *agents*; `codecraftai-landscaping`
provides the *coordinators and protocol extensions*.  The two
packages compose.  Volume 6 walks the landscape side; this
chapter walks the interop side.

## What this chapter set up

By the end of this chapter a reader should:

- Locate the three Agent base classes (`SystemAgentBase`,
  `SshBase`, `SerialAgentBase`, `PowerAgentBase`).
- Know that `LocalCommandAgent` is the simplest concrete
  agent and a good study target.
- Recognise the paramiko-wrapping SSH stack and distinguish
  it from the from-scratch SSH stack chapter 4 walks.
- Know what the shipped protocols are (local commands, SSH,
  serial-over-TCP, DNS / mDNS, power) and where each lives.
- Understand the current state of the `am-interop` CLI
  (scaffolded, subcommands pending).

The next chapter walks the from-scratch SSH stack at
`codecraftai-protocol-ssh` in depth — packets up to agents.  It
is the volume's most code-anchored chapter, and reading it
gives the reader a complete picture of what a substantial
Measured Interoperability protocol looks like all the way
down.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/04-ssh-from-scratch -->
# Chapter 4 — SSH from Scratch

> *Most projects use the paramiko-wrapping SSH agent in
> `codecraftai-interop`.  But the portfolio also ships a *from-
> scratch* SSH implementation in `codecraftai-protocol-ssh` —
> packets up to agents, no paramiko.  This chapter walks that
> stack as the volume's worked example of Measured
> Interoperability applied to a substantial protocol all the
> way down.*

A reader has two reasons to care about
`codecraftai-protocol-ssh`:

1. **Pedagogical**: the package shows what a full Measured
   Interoperability protocol looks like end to end, from
   binary packet framing up to the agent surface.  The
   chapter walks five layers; a reader who follows them gets
   a complete picture of the discipline applied to a
   complex protocol.
2. **Operational**: in environments where paramiko isn't
   available or has a known issue, the from-scratch stack is
   the alternative.  Its public API mirrors the paramiko-
   wrapping agent's, so swapping is a one-line change.

The chapter is the volume's longest.  By the end, a reader
should be able to answer "what happens when I call
`SshAgent.run_cmd(...)` on the from-scratch stack" at every
layer.

## The five layers

```
Layer 5 — Agents and Sessions     (sshbase / sshagent / sshsession)
                ↓
Layer 4 — Client wrapper          (sshclient.py)
                ↓
Layer 3 — Authentication          (sshauth.py / sshkeys.py)
                ↓
Layer 2 — Connection (channels)   (sshconnection.py)
                ↓
Layer 2 — Transport (encryption)  (sshtransport.py)
                ↓
Layer 1 — Packets                 (sshpacket.py)
```

The package's file count by layer (from
`codecraftai-protocol-ssh/source/packages/ccai/protocols/ssh/`):

- `sshpacket.py` — 132 lines.
- `sshtransport.py` — 685 lines.
- `sshconnection.py` — 223 lines.
- `sshauth.py` — 89 lines.
- `sshkeys.py` — 197 lines.
- `sshclient.py` — 133 lines.
- `sshbase.py` — 781 lines.
- `sshagent.py` — 376 lines.
- `sshsession.py` — 429 lines.
- `sshsftp.py` — 294 lines.
- `sshtunnel.py` — 371 lines.
- `sshhelpers.py` — 684 lines.
- `sshaspects.py` — 228 lines.

About 4,800 lines of from-scratch SSH code.  Substantial, but
small enough that a reader can hold the whole thing in their
head with this chapter as a guide.

## Layer 1 — Packets

**File:** `sshpacket.py` (132 lines)

The lowest layer.  An SSH packet is a binary blob with a
specific encoding rules.  `SshPacket` is the class that does
the encoding.

```python
class SshPacket:
    """
        Represents an SSH Packet and provides methods for
        packing/unpacking data types.
    """

    def __init__(self, message_id: int = None, payload: bytes = b''):
        self.message_id = message_id
        self.payload = bytearray(payload)
        self._position = 0
```

The class is two things:

- A **mutable buffer** while the packet is being constructed
  (`add_string`, `add_mpint`).
- A **read cursor** while the packet is being parsed
  (`get_byte`, `get_uint32`, `get_mpint`).

### Packing primitives

SSH's binary formats are specific.  Strings carry a 32-bit
length prefix:

```python
# Lines 80–84
def add_string(self, value: bytes) -> None:
    """
        Encode a string as uint32 length followed by the data.
    """
    length = len(value)
    self.payload.extend(struct.pack(">I", length))
    self.payload.extend(value)
    return
```

Multi-precision integers (used for cryptographic operations)
have a different shape:

```python
# Lines 86–100
def add_mpint(self, value: int) -> None:
    """
        Encode a multi-precision integer per RFC 4251 §5.
    """
    if value == 0:
        bytes_value = b''
    else:
        # Compute byte length; positive numbers prepend 0x00 if MSB is set
        byte_length = (value.bit_length() + 7) // 8
        bytes_value = value.to_bytes(byte_length, "big")
        if bytes_value[0] & 0x80:
            bytes_value = b'\x00' + bytes_value
    self.payload.extend(struct.pack(">I", len(bytes_value)))
    self.payload.extend(bytes_value)
    return
```

The `bytes_value[0] & 0x80` check is RFC 4251's rule:
positive integers whose most-significant bit would be
interpreted as a sign bit get a `0x00` prefix.

### Unpacking

The cursor-based read methods (`get_byte`, `get_uint32`,
`get_string`, `get_mpint`) advance `_position` as they read.
Errors are simple — running off the end of the payload
raises an explicit error.

### Packet finalisation

`to_bytes()` (lines 105–116) prepends the message ID:

```python
def to_bytes(self) -> bytes:
    """
        Return the on-wire representation: message_id + payload.
    """
    if self.message_id is None:
        raise ValueError("message_id must be set before to_bytes()")
    return bytes([self.message_id]) + bytes(self.payload)
```

Layer 1 is small and *pure* — no I/O, no state beyond the
buffer, no cryptography.  Just packing and unpacking.

## Layer 2 — Transport (encryption + key exchange)

**File:** `sshtransport.py` (685 lines)

The transport layer is the heart of SSH.  It handles:

- The TCP socket I/O.
- Version exchange ("SSH-2.0-...").
- Key exchange (Diffie-Hellman).
- Cipher activation.
- Message authentication codes (MACs).
- Packet sequencing.

`SshTransport` runs on its own thread:

```python
class SshTransport(threading.Thread):
    """
        SSH Transport Layer implementation.

        Handles encryption, decryption, compression, and key exchange.
        Run as a thread to handle incoming data.
    """

    def __init__(self, sock: socket.socket):
        self.sock = sock
        self.active = False
        self.encryptor = None
        self.decryptor = None
        self.seqno_out = 0
        self.seqno_in = 0
```

### The handshake

`connect()` at line 78 drives the SSH handshake:

1. Send the version banner (`SSH-2.0-ccai`).
2. Receive the server's banner.
3. Send a `KEXINIT` proposing algorithm sets.
4. Receive the server's `KEXINIT`.
5. Negotiate algorithms (key exchange, cipher, MAC,
   compression).
6. Perform Diffie-Hellman key exchange (`send_kex_init` at
   227, `_handle_kexdh_reply` at 450).
7. Verify the server's host key.
8. Activate encryption.

After step 8, the transport is in *encrypted mode*.  Every
packet is encrypted, MAC'd, and sequenced.

### Key derivation

The `_activate_encryption()` method at line 579 derives the
session keys from the Diffie-Hellman shared secret per RFC
4253:

```python
def _activate_encryption(self):
    """
        Derive session keys via the SSH KDF and activate
        encryption.

        Per RFC 4253:
            HASH(K || H || letter || session_id)

        where K is the shared secret, H is the exchange hash,
        and letter selects which key (A through F).
    """
    # K = f^x mod p (Diffie-Hellman shared secret, already computed)
    # session_id = H from the first key exchange

    c2s_iv = self._compute_key(b'A', iv_len)
    c2s_key = self._compute_key(b'C', key_len)
    c2s_mac_key = self._compute_key(b'E', mac_key_len)

    s2c_iv = self._compute_key(b'B', iv_len)
    s2c_key = self._compute_key(b'D', key_len)
    s2c_mac_key = self._compute_key(b'F', mac_key_len)

    cipher_enc = Cipher(algorithms.AES(c2s_key), modes.CTR(c2s_iv))
    self.encryptor = cipher_enc.encryptor()
    cipher_dec = Cipher(algorithms.AES(s2c_key), modes.CTR(s2c_iv))
    self.decryptor = cipher_dec.decryptor()
```

Six keys derived from a single shared secret using SHA1
(modern SSH uses SHA-256; the implementation supports both).
Three for client-to-server (encryption IV, encryption key,
MAC key), three for server-to-client.

### The packet I/O loop

`run()` at line 96 is the transport thread's main loop:

```python
def run(self):
    """
        Read packets from the socket; dispatch them to the
        connection layer.
    """
    while self.active:
        try:
            packet = self._read_packet()
            self.dispatch(packet)
        except Exception as ex:
            # Transport-level failures bubble up to the
            # session, which raises ConnectionError.
            self._fail(ex)
            return
```

Reading a packet (`_read_packet` at line 262):

1. Read 4 bytes (the encrypted length field — first block).
2. Decrypt the block; extract the packet length.
3. Read the rest of the packet (still encrypted).
4. Decrypt the remainder.
5. Verify the MAC against the decrypted payload.
6. Strip padding.
7. Increment `seqno_in`.

Sending a packet (`send_packet` at line 140) is the mirror.

### Failure modes

The transport layer raises `ConnectionError` on:

- Socket-level failure (broken pipe, timeout, refused).
- Key exchange failure (no common algorithm, bad signature).
- MAC verification failure.
- Packet framing error.

These bubble up through the layers above; eventually the
agent's `run_cmd` propagates them to the application layer
per principle 4.

## Layer 2 — Connection (channel multiplexing)

**File:** `sshconnection.py` (223 lines)

A single transport carries multiple *channels* — independent
streams multiplexed over the same encrypted connection.  A
typical SSH session uses one channel for the shell, another
for the SFTP subsystem, more for tunnels.

### `SshChannel`

```python
class SshChannel(threading.Thread):
    """
        Represents a logical channel within the SSH connection.
        Implements a file-like interface.
    """

    def __init__(self, connection, local_id: int):
        self.local_id = local_id
        self.remote_id = None
        self.max_packet_size = 32768
        self.window_size = 1024 * 1024
        self.in_buffer = Queue()
```

Each channel has:

- A `local_id` and `remote_id` — both sides of the
  connection assign IDs to track packets.
- A `window_size` — flow-control window; the receiver
  advertises how much data it can accept.
- An `in_buffer` — incoming data queued for the consumer.

Channel methods:

- `send(data)` (line 35) — chunks data, wraps in
  `SSH_MSG_CHANNEL_DATA`, gives to the transport.
- `recv(size, timeout)` (line 50) — pulls from `in_buffer`
  with optional timeout.
- `request_pty()` (line 80) — allocate a PTY for interactive
  shells.
- `request_exec()` (line 74) — execute a single command.
- `request_shell()` (line 71) — request an interactive shell.

### `SshConnection`

`SshConnection` at line 161 multiplexes the channels:

```python
class SshConnection:
    """
        Multiplexes channels over a single transport.
    """

    def dispatch_message(self, packet: SshPacket):
        """
            Route incoming packets to the correct channel based on
            channel ID.
        """
        # SSH_MSG_CHANNEL_DATA: route to channel.in_buffer
        # SSH_MSG_CHANNEL_OPEN_CONFIRMATION: complete channel open
        # SSH_MSG_CHANNEL_CLOSE: close channel
        # ... etc.

    def open_channel(self, kind="session") -> SshChannel:
        """
            Open a new channel of the given kind.
        """
        channel = SshChannel(self, local_id=self._next_id())
        # Send SSH_MSG_CHANNEL_OPEN; wait for confirmation.
        # Return the channel.
```

The connection layer's job is *traffic routing*.  It doesn't
care what the channels carry; it just delivers bytes from
the right packet to the right channel.

## Layer 3 — Authentication

**File:** `sshauth.py` (89 lines)

Authentication runs over the established transport.  The
client sends an `SSH_MSG_USERAUTH_REQUEST`; the server
responds with success, failure, or a list of further methods
to try.

```python
class SshAuthError(Exception):
    pass


class SshAuthAgent:
    """
        Handles Client-side User Authentication.
    """

    def authenticate_password(self, username: str, password: str):
        self._send_auth_request("password", password=password)
        self._wait_for_response()

    def authenticate_publickey(self, username: str, key: SshKey):
        # Send the key; sign a challenge; wait for confirmation.
        ...

    def _wait_for_response(self):
        # Block until SSH_MSG_USERAUTH_SUCCESS or USERAUTH_FAILURE.
        # On failure raise SshAuthError.
        ...
```

`SshAuthError` (line 11) is the typed exception that reverts
control to the application layer when authentication fails.
The application can catch it and decide whether to refresh
credentials, escalate, or fail the test.

The supported methods are:

- `"none"` — query the server for which methods it accepts.
- `"password"` — username + password.

(Additional methods like `"publickey"` are implemented in
`sshkeys.py` integration.)

## Layer 3 — Keys

**File:** `sshkeys.py` (197 lines)

Key handling for public-key authentication and host-key
verification.  Two key types supported:

```python
class SshRSAKey(HostKey):
    """
        RSA key — load from PEM, sign with SHA1 PKCS#1 v1.5.
    """

    @classmethod
    def from_private_key_file(cls, filename, password=None):
        # Load PEM RSA key
        ...

    def sign(self, data: bytes) -> bytes:
        return self.private_key.sign(data, padding.PKCS1v15(), hashes.SHA1())

    def verify(self, data: bytes, signature: bytes) -> bool:
        # Verify signature; returns False on InvalidSignature
        ...


class SshEd25519Key(HostKey):
    """
        Ed25519 key — load from PEM, sign with EdDSA.
    """

    def verify(self, data: bytes, signature: bytes) -> bool:
        self.public_key.verify(signature, data)  # raises on failure
```

Both implement the abstract `HostKey` interface with `sign`,
`verify`, `get_name`.  The `cryptography` library does the
underlying math; the wrapper's job is to translate to and
from the SSH wire format.

## Layer 5 — Agents and Sessions

The top of the stack.  `SshAgent` and `SshSession` are the
public API.

### `SshBase` — the shared foundation

**File:** `sshbase.py` (781 lines)

Both `SshAgent` and `SshSession` inherit from `SshBase`.
The base provides the *template method* `_run_cmd` (line
501–632) that walks the chosen `ActionPattern`:

```python
def _run_cmd(self, ssh_runner, command: str,
             exp_status: Union[int, Sequence] = 0,
             user: str = None,
             pty_params: dict = None,
             aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
    # Resolve aspects fallback
    if aspects is None:
        aspects = self._aspects

    # Build the timeout / interval envelope
    completion_toctx = TimeoutContext(aspects.completion_timeout,
                                      aspects.completion_interval)
    inactivity_timeout = aspects.inactivity_timeout
    inactivity_interval = aspects.inactivity_interval
    monitor_delay = aspects.monitor_delay
    logging_pattern = aspects.logging_pattern

    # Wrap in MonitoredScope (chapter 2)
    if aspects.action_pattern == ActionPattern.SINGLE_CALL:
        with MonitoredScope("RUNCMD-SINGULAR", monmsg, completion_toctx,
                            notify_delay=monitor_delay):
            status, stdout, stderr = _ssh_execute_command(
                ssh_runner, command, ...)
        self._log_command_result(command, status, stdout, stderr,
                                 exp_status, logging_pattern)
        return status, stdout, stderr

    elif aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
        # Retry loop honoring completion_toctx.
        while not completion_toctx.final_attempt:
            with MonitoredScope(...):
                status, stdout, stderr = _ssh_execute_command(...)
            self._log_command_result(...)
            if self._matches_expected(status, exp_status):
                return status, stdout, stderr
            completion_toctx.next_attempt(aspects.completion_interval)

        # Loop exhausted — raise per principle 4.
        raise completion_toctx.create_timeout(what_for="command success")

    elif aspects.action_pattern == ActionPattern.DO_WHILE_SUCCESS:
        # Mirror loop ...
```

The same shape across `SINGLE_CALL`, `DO_UNTIL_SUCCESS`, and
`DO_WHILE_SUCCESS`.  The `_ssh_execute_command` helper does
the actual SSH operation; the rest is the persistence
behaviour from chapter 1's principle list.

### `SshAgent` — stateless

**File:** `sshagent.py` (376 lines)

```python
class SshAgent(SshBase):
    """
        Stateless agent.  Each operation creates and destroys
        its own SSH client.
    """

    def open_session(self, user: str = None, primitive: bool = False,
                     pty_params: Optional[dict] = None,
                     interactive=False,
                     basis_session: Optional[SshSession] = None,
                     aspects: Optional[AspectsCmd] = None) -> SshSession:
        """
            Vend a SshSession bound to this agent's host and credentials.
        """
        session_credential = self._primary_credential
        if user is not None:
            session_credential = self._users[user]
        session = SshSession(self._host, session_credential, ...)
        return session

    def open_tunnel(self, local_port: int = 0,
                    remote_host: str = 'localhost',
                    remote_port: int = 5432) -> SshTunnel:
        """
            Open a port-forwarding tunnel.
        """
        ssh_client = self._create_client()
        try:
            tunnel = SshTunnel._create_internal(ssh_client, local_port,
                                                remote_host, remote_port)
            return tunnel
        except Exception as e:
            ssh_client.close()
            raise RuntimeError(f"Failed to create SSH tunnel: {e}") from e
```

`SshAgent.run_cmd` (line 186) creates a temporary client,
runs the command via `_run_cmd`, cleans up:

```python
def run_cmd(self, command, exp_status=0, user=None, pty_params=None,
            aspects=None, ssh_client=None):
    cleanup_client = False
    try:
        if ssh_client is None:
            ssh_client = self._create_client(user)
            cleanup_client = True
        status, stdout, stderr = self._run_cmd(ssh_client, command, ...)
    finally:
        if cleanup_client:
            ssh_client.close()
    return status, stdout, stderr
```

The optional `ssh_client` parameter lets callers reuse a
client (typically because they're inside an `SshSession` and
want to skip the create/destroy cycle).

### `SshSession` — stateful, context-managed

**File:** `sshsession.py` (429 lines)

```python
class SshSession(SshBase):
    """
        Stateful session.  Holds the SSH client open across
        multiple calls.  Used as a context manager.
    """

    def __enter__(self):
        # Lines 88–176
        ssh_client = self._create_client()
        if self._interactive:
            # Open a channel, request PTY, request shell, wait for prompt
            channel = ssh_client.connection.open_channel("session")
            channel.request_pty(...)
            channel.request_shell()
            self._wait_for_prompt(channel)
            self._ssh_runner = channel
        else:
            self._ssh_runner = ssh_client
        return self

    def __exit__(self, ex_type, ex_val, ex_tb):
        # Lines 178–186
        self.close()
        return False

    def run_cmd(self, command, exp_status=0, pty_params=None, aspects=None):
        # Lines 356–370
        status, stdout, stderr = self._run_cmd(self._ssh_runner, command,
                                               exp_status=exp_status,
                                               pty_params=pty_params,
                                               aspects=aspects)
        return status, stdout, stderr
```

The session's `__enter__` is more complex than `SshAgent`'s
single-call setup.  An interactive session opens a channel,
requests a PTY, requests a shell, waits for the shell's
prompt — so subsequent `run_cmd` calls execute *inside the
shell*, not as separate exec channels.

### Basis sessions — connection reuse

`SshSession.open_session` at line 293 supports a *basis
session* — a parent session whose client is reused:

```python
def open_session(self, user: str = None, ...) -> SshSession:
    """
        Open a derived session.  When this session has a basis,
        the new session reuses the basis's client.
    """
    if self._basis_session is not None:
        # Reuse the basis session's client.
        bs = self._basis_session
        session = SshSession(bs._host, session_credential, ...,
                             basis_session=bs, ...)
    else:
        session = SshSession(self._host, session_credential, ...)
    return session
```

A test that needs to run many commands as different users on
the same host benefits: open one basis session; derive
per-user sessions from it; the underlying SSH connection is
shared.  Avoids the overhead of multiple TCP connections
plus key exchanges.

## Specialised features

### SFTP

**File:** `sshsftp.py` (294 lines)

SFTP runs as a *subsystem* over an SSH channel:

```python
class SshSFTPClient:
    def __init__(self, channel):
        self.channel = channel
        self.request_id = 0
        self.channel.request_subsystem("sftp")
        self._init_sftp()
```

The protocol is request/response: send a numbered request,
receive the response with the same number.  Operations:

- `open(filename, pflags)` (line 56) — open a file, return
  handle.
- `read(handle, offset, length)` (line 90).
- `write(handle, offset, data)` (line 111).
- `listdir(path)` (line 126).
- `stat(path)` (line 173).

`SshSFTPError` (line 26) is the typed exception for
protocol-level errors.  `EOFError` (line 293) is raised
when the SFTP `SSH_FX_EOF` status code is received during
a read.

### Tunnels

**File:** `sshtunnel.py` (371 lines)

Port forwarding over SSH.  A `SshTunnel` listens on a local
port; for each incoming connection, opens a new SSH channel
to the remote target, then bidirectionally pumps bytes:

```python
class SshTunnel:
    def __init__(self, ssh_client: SshClient, local_port: int,
                 remote_host: str, remote_port: int,
                 _internal_create: bool = False):
        self._ssh_client = ssh_client
        self._local_port = local_port
        self._remote_host = remote_host
        self._remote_port = remote_port
        self._start_tunnel()

    def _forward_loop(self):
        # Lines 183
        # Accept incoming connections in a daemon thread.
        # For each: open SSH channel "direct-tcpip", spawn pump thread.

    def _pump_channel(self, sock, channel):
        # Lines 266
        # Use select() to relay bytes between socket and channel.
```

The use case: tests against a service that's only reachable
through a jump host.  Open a tunnel; point the test client at
`localhost:<local_port>`; the tunnel forwards traffic to the
service.

## Aspects — SSH-specific defaults

**File:** `sshaspects.py` (228 lines)

The from-scratch SSH stack ships its own `AspectsCmd` subclass
with SSH-tuned defaults:

```python
DEFAULT_COMPLETION_TIMEOUT = 600    # 10 minutes total
DEFAULT_CONNECTION_TIMEOUT = 16     # 16 seconds to establish SSH
DEFAULT_INACTIVITY_TIMEOUT = 600    # 10 minutes without progress
DEFAULT_INACTIVITY_INTERVAL = 0.5   # poll for output every 500ms
DEFAULT_MONITOR_DELAY = 30          # 30s buffer for MonitoredScope
```

These defaults are *the same* as the interop-level defaults
(chapter 2) — SSH doesn't need different baseline values
than other protocols.  The class exists to add SSH-specific
fields (e.g., paramiko-allowed exceptions for the
paramiko-wrapping version) where needed.

## A request flow — start to finish

A test calls `agent.run_cmd("uptime")`.  The full flow on the
from-scratch stack:

1. **Test code** calls `agent.run_cmd("uptime")`.
2. **`SshAgent.run_cmd`** — line 186.  No `ssh_client`
   passed; creates one.
3. **`SshAgent._create_client`** — opens TCP socket to host:port.
4. **`SshTransport.connect`** — performs SSH handshake;
   exchanges versions; runs Diffie-Hellman; activates
   encryption.
5. **`SshAuthAgent.authenticate_password`** — runs
   user-auth using the credential.
6. **`SshClient`** — wrapper around the now-authenticated
   transport.
7. **`SshAgent._run_cmd`** — line 186 redirects to
   `SshBase._run_cmd` (line 501).
8. **`SshBase._run_cmd`** — resolves aspects defaults,
   wraps the call in `MonitoredScope`, dispatches on
   `action_pattern` (default `SINGLE_CALL`).
9. **`_ssh_execute_command`** — opens a channel, sends
   `SSH_MSG_CHANNEL_REQUEST` with `exec` and the command;
   reads channel data until close.
10. **Status / stdout / stderr** — extracted from the channel.
11. **`SshBase._log_command_result`** — emits log per
    `LoggingPattern`.
12. **`SshAgent.run_cmd`** — closes the temporary client.
13. **Test code** receives `(status, stdout, stderr)`.

Each step has a chapter reference somewhere in this volume.
Steps 4–6 are layers 2–3 above; step 8's
`_run_cmd` walks the four principles from chapter 1.

## What the from-scratch stack gives you

Three things:

### Pedagogical clarity

Reading the from-scratch stack is the way to *understand*
SSH.  The paramiko-wrapping version hides the protocol
details behind paramiko's API.  The from-scratch version
shows every layer, every packet, every key exchange.

### Operational fallback

When paramiko has a known bug (and over the years it has),
the from-scratch stack is the alternative.  Its public API
mirrors the paramiko-wrapping agent's (`open_session`,
`run_cmd`, `open_tunnel`, etc.), so a project can swap
implementations with minimal disruption.

### A worked example for new protocols

A reader building a new protocol package follows this
chapter as a *template*.  Layer the protocol from packets up
to agents.  Apply the same Aspects-driven persistence
discipline.  Ship `LayerXBase` / `LayerXAgent` /
`LayerXSession` classes mirroring the SSH shape.

## Limitations

A few points of honesty about the from-scratch stack:

- **Algorithm support.**  The stack implements *enough* of
  SSH to talk to typical Linux / macOS servers.  Older
  algorithms are not supported.  Modern strict servers
  (FIPS-mode, restricted cipher lists) may need tuning.
- **Performance.**  Paramiko has had years of performance
  tuning.  The from-scratch implementation is correct but
  not aggressively optimised; for very high-throughput SFTP,
  paramiko is faster.
- **Rare features.**  Agent-forwarding, X11-forwarding,
  GSS-API authentication — not implemented.  Tests needing
  these features should use a paramiko-based agent or
  contribute the missing features.

A reader weighing which stack to use should treat the
from-scratch as the *educational* and *fallback* option, the
paramiko-wrapping as the *production* default.

## What the next chapter does

Chapter 5 walks `codecraftai-networking` — the foundation
utilities for DNS resolution, ARP table queries, multicast /
unicast / broadcast socket handling.  Where this chapter
walked one substantial protocol in depth, chapter 5 walks the
*primitives* every protocol package builds on.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/05-networking-toolkit -->
# Chapter 5 — Networking Toolkit Primitives

> *Below the protocol layer is the networking layer — the
> primitives every protocol implementation needs but no single
> protocol owns.  Address resolution, interface enumeration,
> multicast / unicast / broadcast socket creation.
> `codecraftai-networking` ships these as a foundation utility.*

`codecraftai-networking` is the *only* package in the volume
that's not itself a protocol implementation.  It's a
foundation utility — a small library of stateless helpers
and well-shaped socket factories that the protocol packages
above it depend on.

The chapter is short.  The package is small (one flat
directory of modules, no class hierarchies, no agents).  But
its contents are load-bearing: the protocol stacks, the
extension packages, and even the landscape's reachability
checks all depend on these primitives.

## What's in the package

```
codecraftai-networking/source/packages/ccai/networking/
├── interfaces.py             # interface enumeration
├── resolution.py             # DNS / IPv4 / IPv6 / ARP
├── multicast.py              # multicast socket helpers
├── unicast.py                # unicast socket helpers
├── broadcast.py              # broadcast socket helpers
├── arp.py                    # ARP protocol helpers
├── trafficcapturecontext.py  # traffic capture context manager
├── constants.py              # IP/regex constants
└── __init__.py
```

A flat module layout.  No subdirectories.  No agents.  Each
module is a small set of helper functions plus a few enums.

## Interface enumeration

**File:** `interfaces.py:24–82`

The first concern: what network interfaces does this host
have?

```python
class InterfaceClass(IntEnum):
    """
        Classification of a network interface.
    """
    LOOPBACK = 0
    BRIDGE = 1
    WIRED = 2
    WIRELESS = 3
    TUNNEL = 4
```

Five classes covering most lab scenarios.  Helpers:

- `get_ipv4_address(ifname)` — return IPv4 for an interface
  by name, using `netifaces`.
- `get_all_ipv4_interfaces()` / `get_all_ipv6_interfaces()`
  — enumerate all interfaces with addresses of each family.
- `encode_address(address)` — pack IPv4 or IPv6 to
  on-wire bytes via `socket.inet_pton`.

A test that needs to bind a socket to a specific interface
walks `get_all_ipv4_interfaces()`, picks the right one based
on `InterfaceClass`, and uses `get_ipv4_address` to get the
bind address.

The `InterfaceClass` enum is a small but useful abstraction:
classifying interfaces by *class* (loopback / wired /
wireless / tunnel / bridge) makes test logic readable.  Code
asking "is this device on a tunnel interface?" is clearer
than code parsing interface name patterns.

## DNS / IPv4 / IPv6 / ARP

**File:** `resolution.py:23–192`

The largest module in the package.  Three concerns:

### Address validation

```python
def is_ipv4_address(candidate: str) -> bool:
    """
        Return True if the candidate string is a syntactically
        valid IPv4 address.

        Uses regex matching plus octet-bounds checking; does NOT
        perform DNS resolution.
    """
    # ... regex match plus per-octet bounds 0-255 ...

def is_ipv6_address(candidate: str) -> bool:
    """
        Same for IPv6, including '::' wildcard expansion.
    """
    # ...
```

These helpers are *syntactic* — they don't resolve names,
don't query DNS, just check whether a string parses as an
address.  Useful at configuration-load time when you want to
know whether a `host:` field in a Landscape file is an
address or a hostname.

### IPv6 expansion

```python
def expand_ipv6_addr(addr: str) -> str:
    """
        Expand the IPv6 '::' shorthand to a full address.

        e.g. '::1' -> '0000:0000:0000:0000:0000:0000:0000:0001'
    """
    # Handles the '::' wildcard expansion per RFC 4291.
```

A small but tedious helper.  Comparing IPv6 addresses for
equality without expansion is unreliable (the same address
has multiple shorthand forms); expansion normalises.

### ARP table

```python
def get_arp_table(normalize_hwaddr: bool = True) -> List[ArpEntry]:
    """
        Parse /proc/net/arp on Linux into ArpEntry tuples.
    """
    # ...

def refresh_arp_table() -> None:
    """
        Ping the local subnet to populate the ARP cache.
    """
    # Uses ARPING_RANGE_CMD; ranges from constants.py
```

ARP table access is Linux-specific (parses `/proc/net/arp`).
Useful for tests that need to know which devices are on the
local network — for example, a test that wakes a device via
Wake-on-LAN and then waits for it to appear in the ARP table.

A note on portability: the package targets Linux primarily;
macOS users get partial functionality from `netifaces` but
ARP table access and some interface classification work less
well.  The chapter doesn't pretend otherwise.

## Multicast / unicast / broadcast sockets

Three modules with parallel APIs.

### `multicast.py:25–199`

```python
def create_multicast_listener_socket(
    family: int,
    multicast_addr: str,
    port: int,
    ttl: int = 1,
    loopback: bool = False,
    interfaces: Optional[List[str]] = None,
) -> socket.socket:
    """
        Create a UDP socket joined to the given multicast address.

        Handles per-platform IGMP/MLD membership requests, TTL,
        loopback flag, and per-interface joining.
    """
    # ...
```

Multicast listening is fiddly across platforms.  Linux
requires `IP_ADD_MEMBERSHIP`; some OSes need `IPV6_JOIN_GROUP`
for IPv6; loopback flags differ.  The factory handles the
per-platform variations and returns a socket ready to
`recvfrom`.

A worked use: mDNS-based service discovery (Volume 6 chapter
1's services section is sometimes populated by mDNS at
startup) opens a multicast listener on `224.0.0.251` port
5353, listens for service announcements, populates the
landscape.

### `unicast.py` and `broadcast.py`

Same shape, different transport.  `unicast` for
point-to-point UDP; `broadcast` for subnet broadcast.

Most automation tests prefer TCP (reliable, ordered) but
discovery and some control protocols use UDP.  The networking
package's UDP helpers are what they reach for.

## Traffic capture

**File:** `trafficcapturecontext.py`

```python
class TrafficCaptureContext:
    """
        Context manager that starts a packet capture on entry
        and stops it on exit.

        Wraps tcpdump (Linux) or tshark, dumps to a file the
        caller specifies.
    """

    def __enter__(self):
        # Spawn capture process.
        # Wait for it to be ready.

    def __exit__(self, *args):
        # Signal stop; wait for clean shutdown.
        # Capture file is now usable.
```

The use case: a test that asserts on wire-level behaviour
opens a capture context, does its work, exits the context,
then parses the capture file.  Useful for tests that verify
"this protocol exchange happened correctly" rather than just
"the operation succeeded."

The capture is *external* (subprocess `tcpdump`); the package
just wraps the lifecycle as a context manager.

## How `codecraftai-networking` integrates with the rest of the stack

A point worth being explicit about: `codecraftai-networking` is a
**foundation utility, not an interop extension**.

The package's README states:

> *Provides the reusable primitives that higher-level protocol,
> discovery, and landscaping packages build on top of, without
> pulling in any heavy third-party networking stacks.*

What this means concretely:

- `codecraftai-networking` has zero hard dependency on
  `codecraftai-interop`.
- Higher-level packages (the protocol stacks, the extensions)
  depend on `codecraftai-networking`, not the reverse.
- The package doesn't ship Aspects-driven agents.  Its
  helpers are *synchronous and stateless* — single-call
  functions that return immediately.

This is correct for primitives.  The Aspects discipline from
chapters 1–2 applies to *protocol calls* — the agents that
talk to remote systems.  Networking primitives are below that
layer; they don't need persistence behaviours because they
*are* the things the persistence behaviours retry.

## When to use these primitives directly

A reader who's building a new protocol package or extension
uses these primitives at the lowest layer.  Specifically:

- **Service discovery** — open a multicast listener with
  `multicast.create_multicast_listener_socket`; parse
  announcements.
- **Address validation** — at configuration-load time, use
  `resolution.is_ipv4_address` / `is_ipv6_address` to
  validate `host:` fields.
- **Interface awareness** — when binding to a specific
  interface, use `interfaces.get_all_ipv4_interfaces` and
  `InterfaceClass` to pick the right one.
- **Wire-level test assertions** — use
  `TrafficCaptureContext` to capture and verify protocol
  exchanges.

A reader writing typical interop test code (calling
`device.ssh.run_cmd`, checking responses, etc.) probably
never reaches into `codecraftai-networking` directly — the
protocol packages above it do that work.

## What this chapter set up

A short chapter for a small, important package.  By the end
a reader should:

- Know the package is a *foundation utility*, not a protocol
  implementation.
- Locate the four kinds of primitives: interface
  enumeration, address resolution, socket factories, traffic
  capture.
- Understand why the package has no Aspects-driven agents
  (its helpers are below the layer where Aspects apply).

The next chapter walks `codecraftai-restclient-base` — the REST
foundation.  Where SSH (chapter 4) was a stream-oriented
protocol, REST is request/response.  The chapter walks the
`RestClientBase` class, the `AspectsRest` subclass, the
`RestApiGroup` model, and the `ItemsResult` / `Paging` lazy
container.  Chapter 7 then walks the auto-generated GitHub
client built on top.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/06-rest-client-foundations -->
# Chapter 6 — REST Client Foundations

> *REST is request / response — a different shape from SSH's
> stream orientation.  But Measured Interoperability applies
> the same way: every method takes an `AspectsRest` parameter
> carrying its persistence criteria; the client honours the
> contract; control reverts via typed exception when the
> contract is exceeded.*

`codecraftai-restclient-base` is the REST foundation that
`codecraftai-restclient-github-public` (chapter 7) and
Volume 6's `codecraftai-restclient-gitlab` (chapter 13 of Volume 6)
build on.  This chapter walks the foundation classes —
`RestClientBase`, `AspectsRest`, `RestApiGroup`,
`ItemsResult` — and the auto-generation infrastructure that
turns OpenAPI specs into Python clients.

## The package layout

```
codecraftai-restclient-base/source/packages/ccai/restclient/base/
├── restclientbase.py         # the core client
├── restclientgenerator.py    # the OpenAPI generator
├── aspectsrest.py            # AspectsRest + ActionPattern
├── restapigroup.py           # base for operation groupings
├── itemsresult.py            # lazy paged container
├── pagedlist.py              # older paging container
├── schemaobject.py           # generated schema types base
├── schemafield.py            # generated schema field base
├── restclientutils.py        # helpers
└── __init__.py
```

Ten modules.  Each handles one concern.  The package is
small enough to read in a single sitting.

## `RestClientBase` — the core client

**File:** `restclientbase.py:41–88`

```python
class RestClientBase:
    """
        Generic REST client base.  Subclasses provide the
        per-API specifics; this class provides authentication,
        retry, and the perform_call pipeline.
    """

    AUTH_LEAF = ""
    VERIFY_CERTIFICATE = True
    AUTHENTICATION_FAILURES = (HttpForbiddenError,)

    def __init__(self, host: str, credential: BaseCredential,
                 port: Optional[int] = None,
                 scheme: Optional[str] = "https",
                 headers: Optional[Dict[str, str]] = None,
                 verify_certificate: Optional[bool] = None,
                 aspects: AspectsRest = DEFAULT_REST_ASPECTS):
        self._host = host
        self._credential = credential
        self._headers = headers if headers is not None else {}
        if verify_certificate is None:
            self._verify_certificate = self.VERIFY_CERTIFICATE
        else:
            self._verify_certificate = verify_certificate
        self._aspects = aspects

        self._host_url = f"{scheme}://{self._host}"
        if port is not None:
            self._host_url = f"{self._host_url}:{port}"

        self._session: Session = None
        self._logger = logging.getLogger()
        return
```

The class accepts the standard set of REST connection
parameters: host, credential, optional port, scheme (default
HTTPS), default headers, TLS verification flag, and the
default `AspectsRest` for all calls.

A few class-level conventions:

- **`AUTH_LEAF`** — the URL leaf used for an authentication
  preflight.  Default empty (no preflight); GitHub-shaped
  clients override to e.g. `"/user"` to verify the token
  against a known endpoint.
- **`VERIFY_CERTIFICATE`** — the default TLS-verification
  setting.  Most projects keep True; lab-only tests against
  self-signed services flip to False per call (or per client
  instance).
- **`AUTHENTICATION_FAILURES`** — tuple of exception classes
  that are interpreted as authentication failures.  The
  client catches these and surfaces them through the
  delegation contract (principle 4).

The `_session: Session = None` is the lazy-init slot.  The
client doesn't open a `requests.Session` at construction; it
opens one the first time `perform_call` runs.  This avoids
work for clients that are constructed but never called.

## `AspectsRest` — REST-specific persistence parameters

**File:** `aspectsrest.py:24–116`

`AspectsRest` is the REST-shaped Aspects subclass.  It lives
*here* (in `codecraftai-restclient-base`), not in
`codecraftai-interop`'s aspects module.  This is by design —
REST has specific concerns the base `Aspects` doesn't carry,
and the REST foundation owns those.

```python
class ActionPattern(IntEnum):
    """
        REST-shaped action patterns.  Same five values as
        codecraftai-interop's ActionPattern.
    """
    SINGLE_CALL = 0
    SINGLE_CONNECTED_CALL = 1
    DO_UNTIL_SUCCESS = 2
    DO_WHILE_SUCCESS = 3
    DO_UNTIL_CONNECTION_FAILURE = 4


class AspectsRest:
    """
        Aspects container specialised for REST operations.
    """

    def __init__(self, *,
                 action_pattern: ActionPattern = ActionPattern.SINGLE_CALL,
                 completion_timeout: float = DEFAULT_COMPLETION_TIMEOUT,
                 completion_interval: float = DEFAULT_COMPLETION_INTERVAL,
                 connection_timeout: float = DEFAULT_CONNECTION_TIMEOUT,
                 inactivity_timeout: float = DEFAULT_INACTIVITY_TIMEOUT,
                 monitor_delay: float = DEFAULT_MONITOR_DELAY,
                 logging_pattern: LoggingPattern = DEFAULT_LOGGING_PATTERN,
                 retry_logging_interval: int = DEFAULT_RETRY_LOGGING_INTERVAL,
                 allowed_error_codes: Tuple[int, ...] = DEFAULT_ALLOWED_ERROR_CODES,
                 allowed_connection_exceptions: Tuple[BaseException, ...] = DEFAULT_ALLOWED_CONNECTION_EXCEPTIONS,
                 must_connect: bool = DEFAULT_MUST_CONNECT,
                 logger: Optional[logging.Logger] = None,
                 session: Optional[Session] = None):
        object.__setattr__(self, "__frozen__", False)

        self.action_pattern = action_pattern
        self.completion_timeout = completion_timeout
        self.completion_interval = completion_interval
        self.connection_timeout = connection_timeout
        self.inactivity_timeout = inactivity_timeout
        self.monitor_delay = monitor_delay
        self.logging_pattern = logging_pattern
        self.retry_logging_interval = retry_logging_interval
        self.allowed_error_codes = tuple(allowed_error_codes)
        self.allowed_connection_exceptions = tuple(allowed_connection_exceptions)
        self.must_connect = must_connect
        self.logger = logger
        self.session = session

        object.__setattr__(self, "__frozen__", True)
        return
```

Three REST-specific extensions over the base `Aspects`:

- **`retry_logging_interval`** — when retrying, log every Nth
  attempt rather than every one.  Useful for chatty long
  retries where every-attempt logging would spam.
- **`session`** — optional explicit `requests.Session`.  When
  provided, the call uses that session; when None, the client
  uses its own.  Useful for test code that wants to share a
  session across calls or inject a mocked session.
- **`allowed_error_codes`** — at the *HTTP-status* level,
  not the exit-code level.  E.g., `(404,)` says "treat 404
  as success-equivalent" — useful for "delete this resource
  if it exists" patterns where 404 is fine.

A point of attention: `AspectsRest` does *not* inherit from
`Freezable`.  It implements its own freeze pattern via
`object.__setattr__("__frozen__", True)` plus a
`__setattr__` override.  Functionally equivalent to the
`Freezable` mixin; the implementation is duplicated rather
than shared.

A reader noticing the duplication is right that it's a small
inconsistency — `Aspects` (in interop) inherits from
`Freezable`, `AspectsRest` (in restclient-base) re-implements
the same pattern.  Future work might unify them.  The
chapter is honest about this; for the present, both work.

## `perform_call` — the central dispatcher

The `RestClientBase.perform_call` method (the actual line
range varies; the method is the heart of the client) is the
function every generated method calls.  Its shape:

```python
def perform_call(self, *,
                 leaf: str,
                 method: str,
                 params: Optional[Dict] = None,
                 body: Any = None,
                 exp_status: List[int],
                 headers: Optional[Dict] = None,
                 context_msg: Optional[str] = None,
                 aspects: Optional[AspectsRest] = None) -> Response:
    """
        Issue an HTTP call.  Honour the chosen ActionPattern;
        return a requests.Response on success; raise on
        contract failure.
    """
    if aspects is None:
        aspects = self._aspects

    if self._session is None:
        self._session = Session()
        self.authenticate()

    url = self._host_url + leaf

    if aspects.action_pattern == ActionPattern.SINGLE_CALL:
        # Single attempt with completion_timeout enforcement.
        with MonitoredScope(...):
            response = self._session.request(
                method=method, url=url, params=params, json=body,
                headers=headers, timeout=aspects.completion_timeout,
                verify=self._verify_certificate)
        if response.status_code not in exp_status \
                and response.status_code not in aspects.allowed_error_codes:
            self._raise_for_status(response, exp_status)
        return response

    elif aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
        # Retry loop honoring completion_toctx.
        completion_toctx = TimeoutContext(aspects.completion_timeout,
                                          aspects.completion_interval)
        while not completion_toctx.final_attempt:
            try:
                with MonitoredScope(...):
                    response = self._session.request(...)
                if response.status_code in exp_status:
                    return response
                if response.status_code in aspects.allowed_error_codes:
                    return response
                # ... not yet success; retry after interval ...
            except aspects.allowed_connection_exceptions:
                pass  # transient connection error; retry
            completion_toctx.next_attempt(aspects.completion_interval)

        raise completion_toctx.create_timeout(what_for="HTTP success")

    elif aspects.action_pattern == ActionPattern.DO_WHILE_SUCCESS:
        # ... mirror loop ...
```

Same shape as `SshBase._run_cmd` from chapter 4 — different
underlying mechanism, identical persistence-behaviour logic.
The five `ActionPattern` modes are uniform across protocols.

## Authentication

`RestClientBase.authenticate` is the method subclasses
override to apply credentials to the session:

```python
def authenticate(self) -> None:
    """
        Apply the credential to self._session.

        Override per-credential-type.
    """
    if isinstance(self._credential, BasicAuthCredential):
        self._session.auth = (self._credential.username,
                              self._credential.password)
    elif isinstance(self._credential, BearerTokenCredential):
        self._session.headers["Authorization"] = \
            f"Bearer {self._credential.token}"
    elif isinstance(self._credential, ApiKeyCredential):
        self._session.headers[self._credential.header_name] = \
            self._credential.value
    else:
        raise ConfigurationError(
            f"Unsupported credential type: {type(self._credential).__name__}")
    return
```

Three credential types covered out-of-the-box: basic auth,
bearer tokens, API keys.  Subclasses for specific APIs
override (e.g., GitHub's client adds the `Accept:
application/vnd.github.v3+json` header at construction;
GitLab's client adds the project token's specific header).

The authentication is applied to the *session*, not per-call.
Once the session has the auth, every subsequent request
inherits it.  This is what makes the auto-generated methods
(chapter 7) lightweight — they don't think about
authentication.

## `RestApiGroup` — the operation grouping base

**File:** `restapigroup.py:1–43`

```python
class RestApiGroup:
    """
        Base class for REST API operation groups.

        A typical client has many groups (e.g., GitHub has 42:
        repos, issues, pulls, actions, etc.).  Each group is
        a separate class with the methods for its endpoints.
    """

    def __init__(self, client: "RestClientBase"):
        self._client_ref = weakref.ref(client)
        return

    @property
    def client(self) -> "RestClientBase":
        return self._client_ref()

    def _parse_item(self, data: Dict[str, Any], item_type: type[T]) -> T:
        """
            Parse a dict into a SchemaObject instance.
        """
        try:
            if hasattr(item_type, 'from_dict'):
                return item_type.from_dict(data)
            else:
                return item_type(**data)
        except Exception:
            return data
```

Three things:

- **Weak reference to the client.**  The group holds a *weak*
  reference to its parent client.  This avoids reference
  cycles when the client owns multiple groups and each group
  holds a reference back.
- **`_parse_item` deserialiser.**  Generated methods call
  this to turn API response dicts into typed
  `SchemaObject` instances.  The fallback (return raw dict
  on parse failure) is for forward-compatibility when the
  API returns fields the client's schema doesn't know about.
- **No public methods.**  All actual operations live in
  *generated* subclass methods.  The base just provides the
  client reference and the parser.

A worked example in chapter 7 walks a real `ReposApiGroup`
with its 100+ generated methods.

## `ItemsResult` and `Paging` — lazy pagination

**File:** `itemsresult.py:210–275`

REST APIs paginate large collections.  GitHub's "list all
repos" returns 30 at a time; the response includes links to
next / previous pages.  `ItemsResult` is the framework's lazy
container for paged responses.

```python
class ItemsResult(Generic[CT]):
    """
        Holds a list of returned items and optional paging
        information.

        - Non-paged: paging is None; just a list.
        - Paged: paging is a Paging object with next/prev links.
    """

    def __init__(self, items: List[CT], paging: Optional[Paging] = None):
        self._items = items
        self._paging = paging

    @property
    def is_paged(self) -> bool:
        rtnval = True if self._paging is not None else False
        return rtnval

    @property
    def items(self) -> List[CT]:
        return self._items

    @property
    def paging(self) -> Union[Paging, None]:
        return self._paging

    def next_page_result(self) -> "ItemsResult[CT]":
        """
            Retrieve the next page lazily.
        """
        result = None
        if self._paging is not None and self._paging.request_next is not None:
            result = self._paging.request_next.perform_call()
        return result

    def prev_page_result(self) -> "ItemsResult[CT]":
        """
            Retrieve the previous page lazily.
        """
        result = None
        if self._paging is not None and self._paging.request_prev is not None:
            result = self._paging.request_prev.perform_call()
        return result
```

The container holds:

- The current page's items.
- An optional `Paging` object with `request_next` and
  `request_prev` *pre-canned* requests.

`next_page_result` invokes the pre-canned next request,
returns a fresh `ItemsResult`.  No HTTP traffic is made
until `next_page_result` is called — *lazy*.

A test that wants the first page only:

```python
result = client.repos.list_user_repos(username="alice")
for repo in result.items:
    print(repo.name)
```

A test that wants all pages:

```python
result = client.repos.list_user_repos(username="alice")
while result is not None:
    for repo in result.items:
        print(repo.name)
    result = result.next_page_result()
```

The lazy pattern means tests can stop iterating partway
through without paying for unfetched pages.

## `RestClientGenerator` — OpenAPI to Python

**File:** `restclientgenerator.py:77–100`

```python
class RestClientGenerator:
    """
        Shapes the patterns for the REST API generation.
    """

    REST_CLIENT_BASE_CLASS: Type[RestClientBase] = RestClientBase
    GROUP_BASE_TYPE: Type[RestApiGroup] = RestApiGroup
    SCHEMA_BASE_TYPE: Type[SchemaObject] = SchemaObject
    SCHEMA_FIELD_TYPE: Type[Field] = Field
    ASPECTS_TYPE: Type[AspectsRest] = AspectsRest
    PAGEDLIST_TYPE: Type[PagedList] = PagedList

    SCHEMA_MODULE_ROOT = "ccai.github.rest.clients.public.schemas"
    GROUP_MODULE_ROOT = "ccai.github.rest.clients.public.groups"
    CLIENT_MODULE_ROOT = "ccai.github.rest.clients.public"

    DEFAULT_PAGINATION_DETECTION_HINTS = {
        "detection_rules": {
            "query_parameters": {
                "parameters": ["page", "per_page"],
                "enabled": True,
            }
        },
        # ...
    }
```

The generator class is *configurable*: subclasses override
`*_BASE_CLASS` / `*_BASE_TYPE` attributes to customise the
generated code's parent classes (e.g., the GitHub generator
overrides `REST_CLIENT_BASE_CLASS` to a GitHub-aware subclass
that adds the `Accept:` header).

The generator's job:

1. Read the OpenAPI JSON spec.
2. For each schema definition, generate a `SchemaObject`
   subclass.
3. For each operation (path + method), generate a method on
   the appropriate group class.
4. For each tag (logical group), generate a `RestApiGroup`
   subclass.
5. Generate a top-level client class that instantiates each
   group as an attribute.
6. Detect pagination (using `DEFAULT_PAGINATION_DETECTION_HINTS`).

The output is a tree of generated Python modules.  Every
generated file starts with the auto-generated banner from
chapter 7's worked example (`# Auto-generated by RestClientGenerator. DO NOT EDIT.`).

## How the generated code calls back to the foundation

A generated method (chapter 7 walks a real one):

```python
def list_user_repos(self, username: str, *,
                    exp_status: Optional[List[int]] = None,
                    headers: Optional[Dict[str, str]] = None,
                    aspects: Optional[AspectsRest] = None) -> ItemsResult[Repository]:
    client = self.client
    if aspects is None:
        aspects = client.aspects

    leaf_template = "/users/{username}/repos"
    path_params = {'username': username}
    call_leaf = leaf_template.format(**path_params)

    if exp_status is None:
        exp_status = [200]

    resp = client.perform_call(
        leaf=call_leaf,
        method="GET",
        body=None,
        exp_status=exp_status,
        headers=headers,
        context_msg=None,
        aspects=aspects,
    )

    raw_items = resp.json()
    items = [self._parse_item(item, Repository) for item in raw_items]

    paging = self._extract_paging(resp)
    result = ItemsResult(items, paging=paging)
    return result
```

Five steps:

1. Resolve aspects (use caller's, fall back to client's
   default).
2. Format the URL path with caller-supplied parameters.
3. Determine expected statuses.
4. Call `client.perform_call` (the chapter-2-style
   delegated persistence behaviour).
5. Parse the response and wrap in `ItemsResult` if paged.

The same shape repeats across hundreds of methods.  The
foundation classes carry all the variation; the generated
methods are templated.

## What this chapter set up

By the end:

- A reader knows the four foundation classes
  (`RestClientBase`, `AspectsRest`, `RestApiGroup`,
  `ItemsResult`) and can construct one of each by hand.
- The Measured Interoperability discipline applied to REST
  is clear: every method takes Aspects; the client follows
  the contract; control reverts via exception when the
  contract is exceeded.
- The generator's role is understood as a *tool* that
  produces methods following the foundation classes'
  conventions.

The next chapter walks `codecraftai-restclient-github-public` —
the auto-generated GitHub client built on this foundation.
A 571-module package; the chapter shows what's worth
testing and what to trust by construction.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/07-github-auto-generated-client -->
# Chapter 7 — The Auto-Generated GitHub Public-API Client

> *GitHub's public REST API has hundreds of endpoints.
> Hand-writing a client for it is infeasible.
> `codecraftai-restclient-github-public` is the worked example of
> auto-generation: 571 generated modules built on the
> foundation from chapter 6.  This chapter walks what's worth
> testing in such a package and what to trust by construction.*

`codecraftai-restclient-github-public` is the largest package in
the volume by file count — roughly 571 Python modules —
because GitHub's API is enormous.  The package is a practical
demonstration that the foundation classes from chapter 6
scale.  The chapter walks how a generated client is
structured, what tests exercise it, and the
*smoke-vs-comprehensive* testing strategy the framework
recommends for any auto-generated client at this scale.

## What's in the package

```
codecraftai-restclient-github-public/source/packages/ccai/
├── github/
│   └── rest/
│       └── clients/
│           └── public/
│               ├── githubpublicrestclient.py   # the entry-point client
│               ├── groups/                     # 42 group files
│               ├── schemas/                    # ~525 schema files
│               └── ...
└── descriptions/                                # 76 OpenAPI JSON spec files
```

Counting:

- **571 Python modules** — generated from the OpenAPI spec.
- **525 schema classes** — one per JSON schema in the spec.
- **42 operation groups** — one per logical API tag (repos,
  issues, pulls, actions, etc.).
- **76 spec JSON files** — the OpenAPI source split into
  manageable chunks.

The package is mechanically generated.  Edits to generated
files don't survive regeneration.

## The auto-generated banner

Every generated file starts with:

```python
# Auto-generated by RestClientGenerator. DO NOT EDIT.
```

This banner is the *contract*.  An engineer attempting to fix
a bug in a generated method by editing the generated file
would see the banner and know to fix the *generator* (or the
OpenAPI spec) instead.  Edits to generated files do not
survive regeneration.

The banner is also a useful greppable marker.  A reader
exploring the codebase can quickly distinguish
hand-written code (no banner) from generated code (banner).

## A representative generated method

**File:**
`source/packages/ccai/github/rest/clients/public/groups/repos.py:84–162`

```python
def accept_invitation_for_authenticated_user(
    self,
    invitation_id: int,
    *,
    exp_status: Optional[List[int]] = None,
    headers: Optional[Dict[str, str]] = None,
    aspects: Optional[AspectsRest] = None,
) -> Any:
    """
        Accept a repository invitation
    """
    client = self.client
    if aspects is None:
        aspects = client.aspects

    header_params = client.headers
    if headers is not None:
        header_params = headers

    leaf_template = "/user/repository_invitations/{invitation_id}"
    path_params = {
        'invitation_id': invitation_id,
    }
    call_leaf = leaf_template.format(**path_params)

    query_params = None

    if exp_status is None:
        exp_status = [204]

    resp = client.perform_call(
        leaf=call_leaf,
        method="PATCH",
        params=query_params if query_params else None,
        body=None,
        exp_status=exp_status,
        headers=header_params if header_params else None,
        context_msg=None,
        aspects=aspects,
    )
    return resp.json()
```

A more complex generated method that takes a body and parses
the response into typed schema objects:

```python
def add_app_access_restrictions(
    self,
    owner: str, repo: str, branch: str,
    body: Dict[str, Any],
    *,
    exp_status: Optional[List[int]] = None,
    headers: Optional[Dict[str, str]] = None,
    aspects: Optional[AspectsRest] = None,
) -> List[Integration]:
    """
        Add app access restrictions

        Protected branches are available in public repositories
        with GitHub Free and GitHub Free for organizations, ...
    """
    client = self.client
    if aspects is None:
        aspects = client.aspects

    header_params = client.headers
    if headers is not None:
        header_params = headers

    leaf_template = "/repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps"
    path_params = {
        'owner': owner,
        'repo': repo,
        'branch': branch,
    }
    call_leaf = leaf_template.format(**path_params)

    query_params = None

    if exp_status is None:
        exp_status = [200]

    resp = client.perform_call(
        leaf=call_leaf,
        method="POST",
        params=query_params if query_params else None,
        body=body,
        exp_status=exp_status,
        headers=header_params if header_params else None,
        context_msg=None,
        aspects=aspects,
    )
    raw_data = resp.json()
    items = [self._parse_item(item, Integration) for item in raw_data]
    return items
```

Five steps repeat across hundreds of methods:

1. Resolve aspects (use caller's, fall back to client's
   default).
2. Format the URL path with caller-supplied parameters.
3. Determine expected statuses (allow caller override).
4. Call `client.perform_call` (chapter 6's foundation).
5. Parse the response — `resp.json()` for opaque returns,
   `_parse_item(...)` for typed-schema returns.

The repetition is *the point* of generation.  Every method
follows the same template; the generator instantiates the
template per OpenAPI operation.  Reviewers who can read one
method can read all of them.

## The entry-point client

**File:**
`source/packages/ccai/github/rest/clients/public/githubpublicrestclient.py:53–70`

```python
class GithubPublicRestClient(GithubRestClientBase):
    """
        GitHub public REST API client generated from the
        OpenAPI specification.
    """

    def __init__(self, host: str, credential: BaseCredential,
                 port: Optional[int] = None,
                 scheme: Optional[str] = "https",
                 aspects: AspectsRest = DEFAULT_REST_ASPECTS):
        super().__init__(host, credential, port, scheme=scheme, aspects=aspects)

        self._grp_actions = ActionsApiGroup(self)
        self._grp_activity = ActivityApiGroup(self)
        self._grp_apps = AppsApiGroup(self)
        self._grp_billing = BillingApiGroup(self)
        # ... 38 more groups instantiated as attributes ...
        self._grp_repos = ReposApiGroup(self)
        # ...
```

The client's constructor:

1. Calls the GitHub-specific base class's constructor
   (`GithubRestClientBase` adds the `Accept:
   application/vnd.github.v3+json` header).
2. Instantiates each of the 42 API groups as a private
   attribute.
3. Exposes each as a public property:

```python
    @property
    def actions(self) -> "ActionsApiGroup":
        return self._grp_actions

    @property
    def repos(self) -> "ReposApiGroup":
        return self._grp_repos

    # ... etc.
```

Test code uses the groups directly:

```python
client = GithubPublicRestClient("api.github.com", bearer_credential)
result = client.repos.list_user_repos(username="alice")
```

The flow: client → group → method → `perform_call` →
HTTP → response → parsed schema → returned to test.  Every
generated method follows the same path.

## The hand-written customisation point

**File:** `githubrestclientbase.py:34–49`

```python
class GithubRestClientBase(RestClientBase):
    """
        GitHub-specific REST client base.

        Adds the GitHub Accept header for API versioning.
    """

    def __init__(self, host: str, credential: BaseCredential,
                 port: Optional[int] = None,
                 scheme: Optional[str] = "https",
                 aspects: AspectsRest = DEFAULT_REST_ASPECTS):
        # GitHub-specific: pin to API v3 via Accept header.
        headers = {
            "Accept": "application/vnd.github.v3+json",
        }
        super().__init__(
            host=host, credential=credential, port=port, scheme=scheme,
            headers=headers, aspects=aspects,
        )
        return
```

This is the *one hand-written class* that customises the
auto-generated client.  Two things it does:

- Adds the `Accept` header to pin to GitHub API v3.
- Inherits everything else from `RestClientBase`.

The customisation point is small because GitHub's REST API
is well-behaved — most of its specifics are already in the
OpenAPI spec the generator consumed.  A different API might
need more customisation (custom auth flow, request signing,
etc.); the pattern is the same — subclass `RestClientBase`,
add what's specific.

## What gets tested

A fair question for a 571-module package: *which methods do
you test?*

The portfolio's discipline is **smoke-vs-comprehensive**:

### Smoke-test the foundation classes

`RestClientBase`, `AspectsRest`, `RestApiGroup`,
`ItemsResult`, `RestClientGenerator` get explicit unit tests.
Failures in these affect *every* generated method, so testing
them is high-leverage.

### Smoke-test the generator itself

The generator is run against a reference OpenAPI spec; the
output is verified to be well-formed Python (parses,
imports, types check).  This catches generator bugs before
they propagate to hundreds of generated methods.

### Smoke-test a representative method per group

One method from each of the 42 GitHub groups (repos /
issues / pulls / actions / etc.) is exercised as an
integration test.  This catches:

- Authentication issues affecting that group.
- Path templating issues.
- Response parsing issues for that group's schemas.

The chosen methods are typically *idempotent* (GET requests,
not POSTs that mutate state) and *well-known* (e.g., "get
the authenticated user" rather than an obscure endpoint).

### Don't comprehensively test every method

Writing 571 individual method tests would be:

- **Expensive** to write — at least a day per method to
  cover happy path, error cases, parameter variations.
- **Expensive to maintain** — every spec update could
  require updates to every test.
- **Low marginal value** — methods fail in *patterns*, not
  individually.  A change to `perform_call` that breaks one
  method probably breaks them all; testing one catches it.

The trade-off makes peace with the realistic budget
constraint.  Smoke-test the substrate; trust the generator
for the rest; rely on integration tests at the next layer
(tests that *use* the GitHub client to exercise specific
workflows) to catch real bugs.

## When the API spec changes

GitHub releases new API versions.  The integration's
evolution loop:

1. **Update the OpenAPI spec.**  Drop the new spec into
   `descriptions/`.
2. **Run the generator.**  `RestClientGenerator` regenerates
   the schemas, groups, and client.
3. **Review generator diffs.**  New methods appear; existing
   methods may have parameter additions; response shapes
   may change.
4. **Run the smoke tests.**  Most should still pass; failures
   indicate spec changes that need consumer-side updates.
5. **Hand-written code usually doesn't change.**
   `GithubRestClientBase` (the API-version pin) only
   changes when GitHub deprecates v3.

A small caveat: when the generator itself needs to evolve
(e.g., to support a new OpenAPI feature like callbacks or
webhooks), the *generator's* code is hand-written and gets
edited.  But the generated client code stays in sync
mechanically.

## What this pattern generalises to

Volume 6 chapter 13 walked GitLab integration using the same
pattern.  The pattern works whenever an external API
provides a machine-readable specification:

- **OpenAPI / Swagger** for REST APIs.
- **Protocol Buffers** for gRPC APIs.
- **WSDL** for SOAP services.
- **AsyncAPI** for event-driven APIs.
- **GraphQL schema** for GraphQL APIs (with appropriate
  generator extensions).

For each, the framework's pattern is the same: hand-write a
small shared foundation, write a generator that consumes the
spec, regenerate when the spec changes.  The
`codecraftai-restclient-github-public` package is one worked
example of an OpenAPI-driven generator at scale.

## Reading order for a contributor

A reader wanting to *contribute* to the GitHub client (or
fork it for a similar API) should read in this order:

1. **`RestClientBase`** (chapter 6) — the foundation.
2. **`RestClientGenerator`** in `codecraftai-restclient-base`
   — how generation works.
3. **`GithubRestClientBase`** — the hand-written
   customisation.
4. **One representative group** (e.g.,
   `groups/repos.py`) — to see what the generator
   produces.
5. **One representative schema** (e.g.,
   `schemas/Repository`) — to see how typed parsing
   works.

By the end, the reader can run the generator against an
updated spec and ship the new client.

## What this chapter set up

A short chapter for a large package.  The reader should now:

- Understand auto-generation as a viable pattern for large
  REST APIs.
- Recognise the auto-generated banner as the signal that a
  file isn't hand-edited.
- Know the smoke-vs-comprehensive testing strategy.
- See the connection between
  `codecraftai-restclient-github-public` (this chapter) and
  `codecraftai-restclient-gitlab` (Volume 6 chapter 13) — same pattern,
  different APIs.

The next chapter walks the four protocol extension packages
— UPnP, Tasker, Bluetooth, CAN.  Where this chapter walked
*one* large auto-generated package, chapter 8 walks *four*
smaller hand-written packages, each adding a protocol the
core `codecraftai-interop` doesn't ship.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/08-extension-packages -->
# Chapter 8 — Extension Packages — UPnP, Tasker, Bluetooth, CAN

> *The portfolio ships four protocol-extension packages —
> UPnP for service discovery, Tasker for distributed task
> coordination, Bluetooth for BLE work, CAN for vehicle bus
> automation.  Each follows one of two integration patterns:
> landscape-integrated (UPnP, Tasker) or standalone
> (Bluetooth, CAN).  This chapter walks the four with code
> anchors.*

The shipped extension packages cover protocols
`codecraftai-interop` doesn't address directly.  They
demonstrate two distinct integration patterns:

- **Landscape-integrated** (UPnP, Tasker) — the protocol's
  agent inherits from `ProtocolExtension` and is
  instantiated by a Coordinator at landscape activation
  time.  Test code accesses the protocol through
  `device.<protocol>` (the typical Volume 6 pattern).
- **Standalone** (Bluetooth, CAN) — the protocol's
  coordinator is a normal class that test code uses
  directly.  No Landscape integration; the test owns the
  lifecycle.

The chapter is a tour.  Each section walks one package with
the package's class hierarchy, registration mechanism (if
any), and a representative method.

## UPnP — landscape-integrated

**Package:** `codecraftai-interop-ext-upnp`

UPnP (Universal Plug and Play) is a service-discovery
protocol used by media devices, IoT gear, network printers,
and consumer hardware.  The package provides device
discovery, service enumeration, and SOAP action invocation.

### Layout

```
codecraftai-interop-ext-upnp/source/packages/ccai/
├── interop/
│   ├── protocols/upnp/
│   │   ├── model/devices/             # device classification
│   │   ├── mapping/, content/, metadata/
│   │   └── upnpextensionprotocol.py
│   └── manufacturers/bose/upnp/       # vendor-specific helpers
└── landscaping/
    ├── coordinators/upnp/
    └── extensions/protocol/
        └── upnpprotocolextension.py
```

The package straddles two namespaces:

- `ccai.interop.protocols.upnp` — the UPnP protocol
  stack (no landscape dependency).
- `ccai.landscaping.coordinators.upnp` and
  `ccai.landscaping.extensions.protocol` — the
  landscape integration pieces.

### `UpnpProtocolExtension`

**File:**
`codecraftai-interop-ext-upnp/source/packages/ccai/landscaping/extensions/protocol/upnpprotocolextension.py:24–38`

```python
class UpnpProtocolExtension(UpnpRootDevice, ProtocolExtension):
    """
        UPnP agent integrated as a protocol extension for a
        landscape device.
    """

    def __init__(self, device_detail: "UpnpDeviceDetail",
                 presence: Optional["UpnpPresence"] = None):
        UpnpRootDevice.__init__(self, device_detail, presence)
        ProtocolExtension.__init__(self)
        return

    def initialize(self, coord_ref: weakref.ReferenceType,
                   basedevice_ref: weakref.ReferenceType,
                   extid: str, location: str,
                   configinfo: dict, deviceinfo: dict):
        UpnpRootDevice.initialize(self, coord_ref, basedevice_ref,
                                   extid, location, configinfo, deviceinfo)
        ProtocolExtension.initialize(self, coord_ref, basedevice_ref,
                                      extid, location, configinfo,
                                      deviceinfo=deviceinfo)
        return
```

The class uses **dual inheritance**:

- From `UpnpRootDevice` — the UPnP protocol-state root
  (services list, device metadata, action invocation
  endpoints).
- From `ProtocolExtension` — the framework's binding base
  (Volume 6 chapter 3) for attaching to a `LandscapeDevice`.

The `initialize` method calls *both* parents' initialisers.
After initialisation, the object is *both* a fully-formed
UPnP root device *and* a landscape protocol extension.

### Use in a test

Once a device's UPnP extension is attached, test code uses it
through the device:

```python
device = LandscapeSingleton().device("media-renderer-1")

# device.upnp is the UpnpProtocolExtension instance
service = device.upnp.find_service("AVTransport")
service.invoke_action("Play", InstanceID=0, Speed=1)
```

The `find_service` walks the UPnP root's service list; the
`invoke_action` makes a SOAP call via the underlying
protocol stack.  The SOAP call uses Aspects-driven
persistence under the hood — same pattern as the SSH /
REST agents.

## Tasker — distributed task coordination

**Package:** `codecraftai-interop-ext-tasker`

The Tasker protocol is a custom-shaped distributed-task
RPC.  A *tasker controller* manages a network of *tasker
nodes*; each node executes tasks the controller submits.
Used in the portfolio for tests that need to coordinate
work across many hosts in parallel (e.g., applying a
configuration to twelve hosts simultaneously, with
per-host result tracking).

### Layout

```
codecraftai-interop-ext-tasker/source/packages/ccai/
├── interop/protocols/tasker/
│   ├── taskercontroller.py
│   ├── taskerservice.py
│   ├── taskernode.py
│   ├── tasking.py
│   ├── taskingevent.py
│   ├── taskinggroupscope.py
│   └── taskeraspects.py
└── landscaping/clients/taskers/
    └── clientroles.py
```

Three layers of the Tasker stack:

- **Controller** — the orchestrating node.
- **Nodes** — workers that execute tasks.
- **Tasking** — the task descriptor / payload model.

### `TaskerController`

**File:**
`codecraftai-interop-ext-tasker/source/packages/ccai/interop/protocols/tasker/taskercontroller.py:50–75`

```python
class TaskerController:
    """
        The TaskerController object lets you startup and control
        task processing across a collection of clients.
    """

    def __init__(
        self,
        logging_directory: Optional[str] = None,
        summary_progress: Optional[SummaryProgressDelivery] = None,
        aspects: Optional[TaskerAspects] = DEFAULT_TASKER_ASPECTS,
    ):
        self._logging_directory = logging_directory
        self._summary_progress = summary_progress
        self._aspects = aspects

        self._tasker_nodes: List[TaskerNode] = []
        self._network_started = False
        return
```

The controller takes a `TaskerAspects` (a Tasker-specific
Aspects subclass) at construction.  Its job:

- Manage the lifecycle of a network of tasker nodes.
- Distribute `Tasking` objects to the nodes.
- Aggregate results.

A typical flow:

```python
controller = TaskerController(
    logging_directory="results/tasker-logs/",
    aspects=DEFAULT_TASKER_ASPECTS,
)

# Add nodes (real implementations register with the landscape).
for host in fleet:
    controller.add_node(TaskerNode(host=host, ...))

controller.startup()

# Submit a tasking to all nodes.
tasking = Tasking(payload="apply-config", config_data=config)
results = controller.execute_tasking_on_all_nodes(tasking)

# Aggregate.
for node, result in results.items():
    ...

controller.shutdown()
```

### Why Tasker exists

Tasker is the framework's answer to "I need to drive twenty
hosts in parallel from the controller's process" without
spawning twenty SSH sessions.  The trade-off:

- **SSH fan-out** (Volume 2 chapter 11's pattern) is general
  and works against any host.  But it has per-call SSH
  handshake overhead.
- **Tasker** requires a tasker-aware service running on
  each node.  But once running, task submission is cheap and
  results can be aggregated centrally.

Pick Tasker when:

- The fleet is large enough that SSH overhead matters.
- The hosts are under your control (you can install the
  tasker service).
- You need rich result aggregation (per-task status, custom
  payload types).

### Landscape integration

Tasker integrates with landscape similarly to UPnP — a
`TaskerCoordinator` discovers tasker nodes, attaches a
`TaskerProtocolExtension` to each device, and exposes
`device.tasker` for test code to use.

Tasker's protocol-extension class is at
`landscaping/clients/taskers/clientroles.py` and follows the
same dual-inheritance pattern as `UpnpProtocolExtension`.

## Bluetooth — standalone

**Package:** `codecraftai-interop-ext-bluetooth`

Bluetooth Low Energy via the Bumble library.  Not
landscape-integrated; the coordinator manages its own
lifecycle.

### Layout

```
codecraftai-interop-ext-bluetooth/source/packages/ccai/interop/bluetooth/
├── bluetoothcoordinator.py
├── bluetoothdevice.py
├── bluetoothpresence.py
├── bluetoothsearchwindow.py
└── deviceevidence.py
```

### `BluetoothCoordinator`

**File:**
`codecraftai-interop-ext-bluetooth/source/packages/ccai/interop/bluetooth/bluetoothcoordinator.py:10–72`

```python
class BluetoothCoordinator:
    """
        Coordinator for Bluetooth operations using Bumble.

        Manages its own background thread and asyncio loop to
        provide a synchronous API.
    """

    def __init__(self, transport_address="usb:0", device_config=None):
        self.transport_address = transport_address
        self.device_config = device_config or {
            "name": "MojoBluetooth",
            "address": "F0:F1:F2:F3:F4:F5",
        }
        self.device = None
        self.transport = None

        self._loop = None
        self._loop_thread = None
        self._running = False

    def startup(self):
        """Start the coordinator and its background loop."""
        if self._running:
            return

        self._running = True
        self._loop = asyncio.new_event_loop()
        self._loop_thread = threading.Thread(
            target=self._run_loop, args=(self._loop,),
            daemon=True, name="am-bt-coordinator-loop",
        )
        self._loop_thread.start()

        try:
            self.submit_coroutine(self._async_startup())
        except Exception as e:
            self.shutdown()
            raise

    def shutdown(self):
        """Stop the coordinator and its background loop."""
        if self._loop and self._loop.is_running():
            try:
                self.submit_coroutine(self._async_shutdown())
            except Exception as e:
                logger.warning(f"Error during async shutdown: {e}")

            self._loop.call_soon_threadsafe(self._loop.stop)

        if self._loop_thread and self._loop_thread.is_alive():
            self._loop_thread.join(timeout=5.0)
```

The coordinator has its own asyncio loop running in a
background thread.  Tests using Bluetooth call synchronous
methods on the coordinator; the coordinator submits
coroutines to the loop and waits for results.

A few observations worth flagging:

- **No Aspects.**  The coordinator does not consume an
  `Aspects` parameter.  Its persistence behaviours are
  ad-hoc (each method's own retry / timeout choices),
  not framework-uniform.
- **Self-managed lifecycle.**  Tests `startup()` and
  `shutdown()` the coordinator explicitly.
- **No landscape integration.**  Bluetooth devices are
  discovered through `start_scanning()` rather than
  declared in a Landscape file.

### Honest assessment

`BluetoothCoordinator` is the *least integrated* of the four
extension packages.  It works as a standalone helper but
doesn't compose with the rest of the framework:

- It doesn't take Aspects.
- It doesn't surface as `device.bluetooth` on a
  LandscapeDevice (no protocol extension).
- It doesn't participate in landscape verification (chapter
  7 of Volume 2).

A reader using it should treat it as an *external dependency
wrapped for convenience*, not as a fully-integrated
framework component.  Future work might bring it into the
Aspects discipline.

## CAN — standalone

**Package:** `codecraftai-interop-ext-can`

Controller Area Network (CAN) bus messaging via the
`python-can` library.  Like Bluetooth, standalone and
unintegrated.

### Layout

```
codecraftai-interop-ext-can/source/packages/ccai/interop/ext/can/
├── cancoordinator.py
├── candevice.py
├── canpresence.py
├── cansearchwindow.py
└── candeviceevidence.py
```

### `CanCoordinator`

**File:**
`codecraftai-interop-ext-can/source/packages/ccai/interop/ext/can/cancoordinator.py:9–49`

```python
class CanCoordinator:
    """
        Coordinator for CAN bus interactions.
    """

    def __init__(self, channel: str, bustype: str = 'virtual',
                 bitrate: int = 500000):
        """
            :param channel: CAN interface name (e.g. 'can0', 'vcan0').
            :param bustype: Bus type (e.g. 'socketcan', 'virtual').
            :param bitrate: Bitrate of the CAN bus.
        """
        self._channel = channel
        self._bustype = bustype
        self._bitrate = bitrate
        self._bus: Optional[can.BusABC] = None

    def connect(self):
        """Connect to the CAN bus."""
        try:
            self._bus = can.Bus(channel=self._channel,
                                bustype=self._bustype,
                                bitrate=self._bitrate)
        except Exception as e:
            raise

    def disconnect(self):
        """Disconnect from the CAN bus."""
        if self._bus:
            self._bus.shutdown()
            self._bus = None

    def send_message(self, arbitration_id: int,
                     data: Union[List[int], bytes],
                     is_extended_id: bool = False):
        """
            Send a message on the CAN bus.
        """
        if not self._bus:
            raise RuntimeError("Not connected to CAN bus")

        msg = can.Message(arbitration_id=arbitration_id, data=data,
                          is_extended_id=is_extended_id)
        try:
            self._bus.send(msg)
        except can.CanError as e:
            raise
```

A direct wrapper around `python-can`.  Same observations as
Bluetooth:

- No Aspects.
- Self-managed lifecycle.
- No landscape integration.

CAN automation in the portfolio is for vehicle-bus testing
and ECU integration — niche use cases that don't typically
overlap with the rest of the test framework's concerns.  The
package gives those tests a working CAN client without
trying to integrate it deeply with Landscape / Topology.

## The two integration patterns

The four packages divide cleanly into two patterns:

| Package | Pattern | Aspects? | Landscape integration? |
|---|---|---|---|
| UPnP | Landscape-integrated | Yes (via Aspects subclass) | Yes (`UpnpProtocolExtension`) |
| Tasker | Landscape-integrated | Yes (`TaskerAspects`) | Yes |
| Bluetooth | Standalone | No | No |
| CAN | Standalone | No | No |

The pattern choice is pragmatic.  UPnP and Tasker target
*lab gear* that the landscape models (media renderers,
worker hosts).  Bluetooth and CAN target *test rigs* that
sit alongside the landscape (a Bluetooth dongle plugged into
the controller; a CAN bus connected to a test vehicle).

A reader writing a new extension package should ask:

- Will my devices be declared in a Landscape file?  →
  Landscape-integrated.
- Are my devices ad-hoc test-rig hardware that the test
  controls directly?  → Standalone.

## The registration pattern (UPnP example)

For the *landscape-integrated* extensions, registration uses
Python's namespace-package discovery.

### `pyproject.toml`

```toml
[tool.poetry]
name = "codecraftai-interop-ext-upnp"
packages = [{include="ccai", from="source/packages"}]

[tool.poetry.dependencies]
python = ">=3.10,<4.0"
codecraftai-interop = ">=1.0.0,<2.0.0"
codecraftai-landscaping = ">=1.0.0,<2.0.0"
```

The package declares itself under the `ccai` namespace
package.  When installed (via poetry / pip), Python's
implicit namespace-package mechanism makes
`ccai/interop/protocols/upnp/` and
`ccai/landscaping/extensions/protocol/upnpprotocolextension.py`
discoverable as if they were part of the framework.

### Discovery at framework startup

At framework startup, the landscape coordinator scans
installed packages for submodules under
`ccai.landscaping.extensions.protocol`.  When it finds
`UpnpProtocolExtension`, it knows to dispatch UPnP-shaped
device declarations to that extension.

The mechanism is described in Volume 0 chapter 7's factory
discovery walkthrough.  The pattern repeats here: *no
explicit entry-points configuration*; the namespace
package's structure is the registration.

A consequence: installing the UPnP package by adding it to
`pyproject.toml`'s dependency list is *enough* to enable
UPnP support in the framework.  The framework discovers the
extension automatically.

### Standalone packages don't need registration

Bluetooth and CAN bypass the namespace mechanism for
landscape integration (since they have none).  They're just
ordinary Python packages that test code imports directly:

```python
from ccai.interop.bluetooth.bluetoothcoordinator import BluetoothCoordinator

bt = BluetoothCoordinator(transport_address="usb:0")
bt.startup()
# ... use bt ...
bt.shutdown()
```

Registration is moot when there's nothing to register *into*.

## What this chapter set up

By the end of the chapter:

- A reader knows the four shipped extension packages and
  what each one does.
- The two integration patterns (landscape-integrated vs
  standalone) are clear.
- The dual-inheritance pattern (`UpnpProtocolExtension(UpnpRootDevice,
  ProtocolExtension)`) is recognised as the way landscape
  integration works.
- The honest limitations of Bluetooth and CAN (no Aspects,
  no landscape integration) are flagged.

The next chapter — the volume's last — gives the *recipe*
for adding a new protocol extension.  Step-by-step: define
the protocol's agent, define its Aspects subclass,
implement the protocol stack, decide on integration pattern,
register via namespace package.  The recipe synthesises
everything the volume has covered.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/09-building-your-own -->
# Chapter 9 — Building Your Own Interop Extension

> *Eight chapters established the discipline, the patterns,
> and the worked examples.  This chapter is the *recipe*: the
> reproducible steps for adding a new protocol extension to
> the framework.  Following the recipe gives a project a
> clean, idiomatic, Aspects-driven extension that composes
> with everything else the framework offers.*

This chapter is the volume's payoff.  A reader who's followed
chapters 1–8 has the mental model; this chapter walks the
*action items* for turning that model into a working
extension.

The recipe is organised as a checklist.  Each step has:

- *What* to do.
- *Where* to put the code.
- *Why* — which principle from chapters 1–2 the step
  serves.

By the end, a reader writing a new MQTT, Modbus, or
custom-protocol extension will have a clear plan.

## Before you start — a few decisions

Three questions to answer before writing any code:

### 1. Is this a new protocol or an additional agent shape for an existing protocol?

If the protocol is *new* (no existing agent in the framework),
you're writing a new extension package.

If you're adding *another shape* of an existing protocol
(e.g., a session-pooled SSH variant, a connection-cached
REST variant), you're subclassing an existing agent —
follow the patterns from chapters 3, 4, or 6 directly.

### 2. Landscape-integrated or standalone?

The decision tree from chapter 8:

- Will my devices be declared in Landscape files?  → Landscape-
  integrated.
- Are my devices ad-hoc test-rig hardware?  → Standalone.

Most production protocols are landscape-integrated.
Standalone is fine for niche cases (test rigs, vehicle bus,
specialised hardware).

### 3. What's the protocol's natural call shape?

Some protocols are command-and-response (SSH, serial, CAN
message send).  Others are request / response (REST).
Others are event-driven (Bluetooth scanning, UPnP discovery).

The natural shape determines which Aspects subclass to
extend:

- **Command-shaped** → subclass `AspectsCmd`.  Add a
  `expected_status` analogue if your protocol has one.
- **Request / response** → subclass `AspectsRest` or
  `Aspects` directly.  Add HTTP-status-style allowed-codes.
- **Event-driven** → subclass `Aspects`.  Add timeout
  parameters specific to event-listening (e.g.,
  `discovery_timeout`, `event_buffer_size`).

The chosen shape determines the rest of the recipe.

## The recipe

Eleven steps.  Each maps to a concrete piece of work in the
new package.

### Step 1 — Create the package skeleton

```
codecraftai-interop-ext-mything/
├── pyproject.toml
└── source/
    └── packages/
        └── ccai/
            ├── interop/
            │   └── protocols/
            │       └── mything/
            │           ├── __init__.py
            │           ├── mythingaspects.py
            │           ├── mythingagent.py
            │           └── mythingsession.py
            └── landscaping/             # only if landscape-integrated
                └── extensions/
                    └── protocol/
                        └── mythingprotocolextension.py
```

The directory structure mirrors the shipped extensions
(chapter 8).  Both subtrees (`interop/...` and
`landscaping/...`) sit under the `ccai` namespace.

The `pyproject.toml`:

```toml
[tool.poetry]
name = "codecraftai-interop-ext-mything"
version = "0.1.0"
description = "MyThing protocol extension for the ccai framework"
packages = [{include = "ccai", from = "source/packages"}]

[tool.poetry.dependencies]
python = ">=3.10,<4.0"
codecraftai-interop = ">=1.0.0,<2.0.0"
codecraftai-landscaping = ">=1.0.0,<2.0.0"
# ... your protocol's third-party dependencies ...
```

The two `codecraftai-*` git dependencies are required for
landscape-integrated extensions.  Standalone extensions can
drop the `codecraftai-landscaping` dependency.

**Why this step:** the namespace structure is what makes the
framework discover your extension at startup (chapter 8's
"registration pattern").  No `pyproject.toml` magic
required — just put the code in the right namespace.

### Step 2 — Define the Aspects subclass

In `mythingaspects.py`:

```python
from typing import Optional, Tuple
from ccai.interop.aspects import Aspects, AspectsCmd, ActionPattern, LoggingPattern
from ccai.interop.aspects import (
    DEFAULT_COMPLETION_TIMEOUT, DEFAULT_COMPLETION_INTERVAL,
    DEFAULT_CONNECTION_TIMEOUT, DEFAULT_CONNECTION_INTERVAL,
    # ... etc
)


# MyThing-specific defaults — tune to your protocol's typical
# behaviour.
DEFAULT_MYTHING_RESPONSE_TIMEOUT = 30
DEFAULT_MYTHING_HEARTBEAT_INTERVAL = 5


class MyThingAspects(Aspects):
    """
        Aspects subclass for MyThing protocol calls.

        Adds protocol-specific fields:
        - response_timeout: time to wait for a response after sending
        - heartbeat_interval: how often to send heartbeats during long calls
    """

    def __init__(self, *,
                 response_timeout: float = DEFAULT_MYTHING_RESPONSE_TIMEOUT,
                 heartbeat_interval: float = DEFAULT_MYTHING_HEARTBEAT_INTERVAL,
                 # ... pass through the standard Aspects parameters ...
                 **kwargs):
        super().__init__(**kwargs)
        self.response_timeout = response_timeout
        self.heartbeat_interval = heartbeat_interval
        # ... self.freeze() if subclassing Aspects directly ...
```

Three things to get right:

- **Inherit from the right base.**  Command-shaped → extend
  `AspectsCmd`.  Other → extend `Aspects` directly.
- **Add only protocol-specific fields.**  Don't redeclare
  fields the base already has.  Don't override defaults
  unless your protocol genuinely needs different ones.
- **Pass through `**kwargs` to the base class's `__init__`.**
  Callers should be able to set the standard Aspects fields
  (`completion_timeout`, `action_pattern`, etc.) without
  fighting the subclass's signature.

A `DEFAULT_MYTHING_ASPECTS = MyThingAspects()` constant at
module level is conventional.

**Why this step:** principle 2 of Measured Interoperability
(chapter 1) — the application layer specifies behaviour.
The Aspects subclass is *the* delegation contract for your
protocol.

### Step 3 — Implement the agent base

In `mythingagent.py`:

```python
from typing import Optional, Tuple
from ccai.interop.aspects import ActionPattern
from ccai.xmodules.xlogging import getEnhancedLogger
from .mythingaspects import MyThingAspects, DEFAULT_MYTHING_ASPECTS


class MyThingAgent:
    """
        Agent for the MyThing protocol.

        Vends sessions; supports direct one-shot calls; honours the
        chosen ActionPattern for retry / persistence.
    """

    def __init__(self,
                 host: str,
                 credential,
                 port: int = 12345,
                 aspects: MyThingAspects = DEFAULT_MYTHING_ASPECTS):
        self._host = host
        self._credential = credential
        self._port = port
        self._aspects = aspects
        self._logger = getEnhancedLogger()
        return

    def send_message(self, message: bytes,
                     aspects: Optional[MyThingAspects] = None
                     ) -> bytes:
        """
            Send a message; receive the response.

            Honours the action_pattern from aspects.
        """
        if aspects is None:
            aspects = self._aspects

        # Dispatch on action pattern.
        if aspects.action_pattern == ActionPattern.SINGLE_CALL:
            return self._do_single_call(message, aspects)
        elif aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
            return self._do_until_success(message, aspects)
        else:
            err_msg = f"Unsupported action pattern: {aspects.action_pattern}"
            raise ConfigurationError(err_msg)
```

The agent's job (per chapter 3's discussion of agents):

- Take an `Aspects` parameter on every public method.
- Dispatch on `action_pattern`.
- Honour the timing / retry / logging parameters.
- Wrap calls in `MonitoredScope` for deadlock detection.
- Raise typed exceptions when the contract is exceeded.

The implementation of `_do_single_call`, `_do_until_success`,
etc. follows the template from `LocalCommandAgent` (chapter
3) or `SshBase` (chapter 4).  Copy the structure; replace the
underlying mechanism.

**Why this step:** principle 1 of Measured Interoperability
— persistence behaviours live in the interop layer.  The
agent is *where* the persistence behaviours live.

### Step 4 — Define typed exceptions

```python
# In mythingexceptions.py (or alongside the agent)

class MyThingError(Exception):
    """
        Base class for MyThing-protocol-specific errors.
    """
    pass


class MyThingAuthError(MyThingError):
    """
        Authentication or authorisation failed.
    """
    pass


class MyThingProtocolError(MyThingError):
    """
        Protocol violation (malformed message, unexpected
        sequence, etc.).
    """
    pass
```

The agent's persistence loops raise these on contract
exceeded.  The application layer catches them per the
typed-dispatch pattern from chapter 1.

For timeouts, the convention is to raise the standard
Python `TimeoutError` rather than a custom subclass —
consistent with `SshBase` and `RestClientBase`.  For
connection failures, raise `ConnectionError`.

**Why this step:** principle 4 of Measured Interoperability
— typed exceptions revert control to the application layer
when the contract is exceeded.  Your typed exceptions are
the vocabulary for that reversion.

### Step 5 — Implement the session (if stateful)

If your protocol has a *connected* state worth preserving
across calls (like SSH session reuse), implement a session
class as a context manager:

```python
class MyThingSession:
    """
        Stateful MyThing session.  Holds the connection open
        across calls.  Used as a context manager.
    """

    def __init__(self, agent: "MyThingAgent",
                 aspects: MyThingAspects = None):
        self._agent = agent
        self._aspects = aspects or agent._aspects
        self._connection = None

    def __enter__(self):
        self._connection = self._open_connection(self._agent._host,
                                                 self._agent._port,
                                                 self._aspects)
        return self

    def __exit__(self, ex_type, ex_val, ex_tb) -> bool:
        if self._connection is not None:
            self._connection.close()
            self._connection = None
        return False

    def send_message(self, message: bytes,
                     aspects: Optional[MyThingAspects] = None
                     ) -> bytes:
        """
            Send via the open connection.  Cheaper than agent.send_message.
        """
        # ... same dispatch as agent.send_message, but reusing self._connection ...
```

If your protocol is stateless (every call opens / closes its
own connection — like REST), skip this step.  The agent's
methods are sufficient.

**Why this step:** matches the pattern from chapter 4
(`SshAgent` stateless + `SshSession` stateful).  Some
protocols benefit from the duality; others don't need it.

### Step 6 — Decide on integration: landscape-integrated or standalone

If standalone, skip to step 9 (write the test scaffolding).

If landscape-integrated, continue with steps 7 and 8.

### Step 7 — Implement the ProtocolExtension subclass

In `landscaping/extensions/protocol/mythingprotocolextension.py`:

```python
import weakref
from typing import Optional
from ccai.landscaping.extensions.protocol.protocolextension import ProtocolExtension


class MyThingProtocolExtension(ProtocolExtension):
    """
        MyThing protocol exposed as a landscape device extension.
    """

    def __init__(self, agent: "MyThingAgent"):
        super().__init__()
        self._agent = agent
        return

    def initialize(self, coord_ref: weakref.ReferenceType,
                   extends_ref: weakref.ReferenceType,
                   extid: str, location: str,
                   configinfo: dict, **kwargs) -> None:
        super().initialize(coord_ref, extends_ref, extid, location, configinfo, **kwargs)
        return

    def send_message(self, message: bytes,
                     aspects: Optional["MyThingAspects"] = None) -> bytes:
        """
            Forward to the underlying agent.
        """
        return self._agent.send_message(message, aspects=aspects)
```

Two patterns to choose between:

- **Composition** (above) — the extension *holds* an agent
  and forwards calls.  Simpler.
- **Dual inheritance** (chapter 8's UPnP example) — the
  extension *inherits from* both the protocol's root
  class and `ProtocolExtension`.  More integrated but more
  complex.

Composition is the right starting point for most cases.
Dual inheritance is for protocols where the extension's
interface needs to be the protocol's full root surface (UPnP
needs to expose all the UPnP root device methods, not just a
handful of forwards).

**Why this step:** Volume 6 chapter 3's vocabulary —
ProtocolExtension binds a protocol Agent to a Device.  Your
extension is what makes `device.mything` work in test code.

### Step 8 — Implement the Coordinator

In `landscaping/coordinators/mything/mythingcoordinator.py`:

```python
from ccai.landscaping.coordinatorbase import CoordinatorBase


class MyThingCoordinator(CoordinatorBase):
    """
        Coordinator for MyThing devices.
    """

    def create_landscape_device(self, device_config: dict,
                                friendly_id) -> "LandscapeDevice":
        """
            Instantiate a LandscapeDevice with a MyThingProtocolExtension
            attached.
        """
        # ... construct the device ...
        # ... construct the agent ...
        # ... wrap in MyThingProtocolExtension ...
        # ... attach ...
        return device

    def establish_connectivity(self, activation_params) -> List[Tuple]:
        """
            Verify reachability to all managed devices.

            Returns list of (host, ipaddr, status, stdout, stderr, exc) tuples.
        """
        results = []
        for device in self.children.values():
            try:
                # Test the device.
                self._probe_device(device)
                results.append((device.host, device.ipaddr, 0, "", "", None))
            except Exception as ex:
                results.append((device.host, device.ipaddr, None, "", "", ex))
        return results
```

The Coordinator is the framework's hook for activating your
protocol's devices during landscape activation (Volume 2
chapter 6).  Your Coordinator:

- Subclasses the appropriate `CoordinatorBase` variant
  (`ClientCoordinatorBase` for apod clients;
  `ServiceCoordinatorBase` for infrastructure services).
- Creates `LandscapeDevice` instances when called.
- Implements `establish_connectivity` returning the standard
  result-tuple format.

**Why this step:** the Coordinator is what makes your
extension *visible* to the framework's activation flow.
Without it, the framework wouldn't know how to bring your
devices online.

### Step 9 — Register a CoordinatorCoupling

The CoordinatorCoupling is the declarative metadata that maps
deviceTypes to Coordinators.  In
`landscaping/coordinators/mything/mythingcoordinatorcoupling.py`:

```python
from ccai.landscaping.coupling.coordinatorcoupling import CoordinatorCoupling
from .mythingcoordinator import MyThingCoordinator


INTEGRATION_CLASS_MYTHING = "network/mything"


class MyThingCoordinatorCoupling(CoordinatorCoupling):
    """
        Registers MyThingCoordinator as the handler for
        deviceType 'network/mything'.
    """

    pathbase = "/mything"
    integration_root: str = "infrastructure"   # or "apod"
    integration_section: str = "devices"
    integration_leaf: str = "deviceType"
    integration_class: str = INTEGRATION_CLASS_MYTHING
    COORDINATOR_TYPE = MyThingCoordinator
```

When the framework activates a Landscape, it walks every
device's `deviceType`, looks up the matching Coupling by
`integration_class`, and instantiates the
`COORDINATOR_TYPE`.  A device declared as
`deviceType: network/mything` automatically routes to your
Coordinator.

**Why this step:** Volume 6 chapter 3's coupling-vs-
coordinator distinction.  The Coupling is the registration
metadata; the Coordinator is the runtime manager.

### Step 10 — Tests

Three test layers:

- **Unit tests** for the protocol logic.  Mock the wire;
  verify the message construction, parsing, error handling.
- **Aspects tests** verifying that each `ActionPattern` mode
  works.  Mock the wire to fail-then-succeed; verify
  `DO_UNTIL_SUCCESS` retries.  Verify timeouts raise.
- **Integration tests** with a real or fake server.  Run the
  full agent against a mock service; verify protocol-level
  correctness.

Place tests under `source/tests/ccai_tests/`:

```
source/tests/ccai_tests/
└── interop/
    └── protocols/
        └── mything/
            ├── test_aspects.py
            ├── test_agent.py
            └── test_session.py
```

The test discipline mirrors the rest of the framework
(Volume 1 chapter 2's testbase tests).

**Why this step:** consistency with the rest of the
portfolio's test discipline (Volumes 0–4).  An extension
that ships without these tests doesn't meet the framework's
bar.

### Step 11 — Documentation

Add a `userguide/packages/codecraftai-interop-ext-mything.rst`
file in the docs repo (or alongside the package's README)
that walks:

- What the protocol is.
- The class hierarchy.
- A representative call.
- The Aspects defaults.
- Limitations and caveats.

The shipped extensions' userguide pages (e.g.,
`codecraftai-docs/userguide/packages/codecraftai-interop.rst`) are
the reference shape.

**Why this step:** the framework's "Measured
Interoperability" name only works if the *Behaviour* side is
documented for the protocol's specific concerns.  A reader
can't understand your extension's persistence model from the
code alone.

## A worked tour of the recipe

A concrete example.  Suppose you're adding MQTT support to
the framework.

### Step-by-step

1. **Package skeleton** — `codecraftai-interop-ext-mqtt/` with
   the standard structure.
2. **Aspects** — `MqttAspects(Aspects)` with
   `qos_level` (0/1/2), `keep_alive_interval`, the standard
   timeouts.
3. **Agent** — `MqttAgent` wrapping `paho-mqtt`'s client.
   `publish(topic, payload, aspects)` and
   `subscribe(topic, callback, aspects)` are the primary
   methods.
4. **Exceptions** — `MqttError`, `MqttAuthError`,
   `MqttProtocolError`.
5. **Session** — `MqttSession` for tests that publish to
   many topics in sequence (saves connection setup).
6. **Decision** — landscape-integrated.  MQTT brokers are
   declared as services in Landscape files.
7. **ProtocolExtension** — composition pattern; forward
   to the agent.
8. **Coordinator** — `MqttCoordinator(ServiceCoordinatorBase)`;
   establish_connectivity opens a test connection to verify
   the broker.
9. **Coupling** — `MqttCoordinatorCoupling` with
   `integration_class = "messaging/mqtt"`.
10. **Tests** — happy path, retry on disconnect, timeout
    handling, QoS-level semantics.
11. **Documentation** — `codecraftai-interop-ext-mqtt.rst` with
    the protocol overview and API examples.

A test using the new extension:

```python
broker = LandscapeSingleton().service("mqtt-broker-1")

# Publish a message with QoS 1, retry until acknowledged.
patient_aspects = MqttAspects(
    action_pattern=ActionPattern.DO_UNTIL_SUCCESS,
    completion_timeout=30,
    qos_level=1,
)
broker.mqtt.publish("sensors/temperature", b"42.0", aspects=patient_aspects)
```

The test reads naturally because the recipe was followed.
`broker.mqtt` is the ProtocolExtension; `publish` is the
agent's method; `aspects` is the delegation contract.

## Common pitfalls

A short list of mistakes that surface when writing a new
extension:

### Decorating with retry rather than passing aspects

Wrong:

```python
@retry(times=3, delay=10)
def send_message(self, message: bytes) -> bytes:
    # ...
```

Right:

```python
def send_message(self, message: bytes,
                 aspects: Optional[MyThingAspects] = None) -> bytes:
    if aspects is None:
        aspects = self._aspects
    # Dispatch on aspects.action_pattern
    # ...
```

The decorator hard-codes the retry policy.  The Aspects
parameter delegates the policy to the caller.  Volume 0
chapter 2's discipline.

### Mutating Aspects after construction

Wrong:

```python
aspects = MyThingAspects()
aspects.completion_timeout = 30  # AttributeError if Freezable
```

Right:

```python
aspects = MyThingAspects(completion_timeout=30)
```

If your Aspects subclass inherits from `Freezable` (it
should), the mutation raises.  Construct a new instance.

### Catching exceptions and silently retrying

Wrong:

```python
try:
    response = self._send(message)
except Exception:
    # Log and pretend success.
    pass
```

Right:

```python
try:
    response = self._send(message)
except (TimeoutError, ConnectionError):
    # If the action_pattern says retry, retry.  Otherwise re-raise.
    if aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
        # ... retry loop ...
    else:
        raise
```

Principle 4 — control reverts via exception when the
contract's bounds are exceeded.  Silently swallowing
exceptions hides bugs.

### Skipping the typed exceptions

Generic `RuntimeError` is a code smell in interop code.
Define typed exceptions for each meaningful failure mode so
application code can dispatch on the type.

### Skipping the integration tests

Unit tests that mock the wire don't catch *protocol*-level
bugs.  An integration test against a real or fake server is
the only way to verify the protocol implementation is correct
end-to-end.

## What this volume covered

Looking back across the volume:

- **Chapter 1** — Measured Interoperability and the four
  principles.
- **Chapter 2** — the Aspects pattern as the delegation
  contract.
- **Chapter 3** — `codecraftai-interop` as the foundation.
- **Chapter 4** — SSH from scratch as a worked deep-dive.
- **Chapter 5** — networking primitives below the protocol
  layer.
- **Chapter 6** — REST foundations.
- **Chapter 7** — auto-generation at scale (the GitHub
  client).
- **Chapter 8** — four shipped extension packages and two
  integration patterns.
- **Chapter 9** — the recipe for adding your own.
- **Chapter 10** — remote objects over a wire:
  `codecraftai-python-rpc`, where references rather than
  operations cross between processes.

A reader who's followed the volume now has:

- The vocabulary (Mechanism vs Behaviour, persistence
  behaviours, ActionPattern, LoggingPattern).
- The discipline (the four principles).
- The shipped agents (`LocalCommandAgent`, `SshAgent`,
  `RestClientBase`, the four extensions).
- The pattern for adding more.

The protocol stack is no longer a black box.

## One more package

Chapter 10 closes the volume with the newest package on this
floor, `codecraftai-python-rpc`.  Everything above — the
Aspects contract, the wire discipline, the four principles — is
what makes it possible to let *objects* cross between
processes rather than operations, and the chapter reads the
package from the frame upward.  The end-of-volume notes move
there.


<!-- https://code-craft.ai/references/codecraftai/05-interop-and-protocols/10-remote-objects-over-a-wire -->
# Chapter 10 — Remote Objects over a Wire: `codecraftai-python-rpc`

> *Every protocol in this volume moves bytes and hands the
> caller a result.  `codecraftai-python-rpc` moves something
> stranger: a reference.  A client holds an object that lives
> in another process, calls its methods, passes it callbacks,
> and lets it go — and the framework keeps the two processes
> agreeing about which objects still exist.  This chapter is
> the newest package on the volume's floor and the one that
> depends most heavily on everything the earlier chapters
> established.*

Chapters 3 through 8 walked protocols that expose *operations*:
run a command over SSH, resolve a name, issue a REST call, drive
a UPnP device.  The caller assembles arguments, the protocol
carries them, a result comes back.  The object model on each
side is private; only the operation crosses the wire.

`codecraftai-python-rpc` crosses the line those chapters stayed
behind.  It lets a process publish a Python object and lets
another process use that object *as an object* — attribute
access, method calls, callbacks travelling in both directions,
futures for long operations — with the framework responsible
for identity, lifetime and failure semantics in between.  That
is a considerably harder contract than "carry this request",
and the package's design is mostly a catalogue of the ways
the naive version goes wrong.

The chapter reads the package from the wire upward: the frame,
the codec, the session, the proxy, the lease, and finally the
two entry points that a reader actually types.  Throughout, the
question is the same one the volume has been asking since
Chapter 1: *what does the caller measure, and what does the
framework guarantee?*

## What's in the package

```
codecraftai-python-rpc/source/packages/ccai/rpc/
├── __init__.py                 # the public surface
├── aspects.py                  # AspectsRpc, rpc_aspects_scope, remaining_time
├── endpoints.py                # endpoint descriptors and resolution
├── errors.py                   # the RPC exception vocabulary
├── rpcsocketserver.py          # RpcSocketServer
├── rpcsocketclient.py          # RpcSocketClient
├── core/
│   ├── looperqueue.py          # bounded queue with shutdown
│   ├── looper.py               # one worker thread over a queue
│   ├── looperpool.py           # elastic pool of loopers
│   ├── flattenable.py          # by-value contract for user types
│   ├── futures.py              # RemoteFuture, Progress, wait_all, as_completed
│   ├── leases.py               # ILeaseAware
│   ├── protocol/               # constants, 24-byte header, message classes
│   ├── serialization/          # tags, registry, placeholder, codec
│   ├── transport/              # duplex socket I/O, in-process pairs, fault injection
│   ├── session/                # DuplexSessionBase, dispatch, heartbeat, client, server
│   ├── proxy/                  # RemoteProxy, reference table, cache, wrappers
│   ├── dispatch/               # CallContext and ordering lanes
│   └── observability/          # metrics, live-state dump
└── security/                   # authenticators and TLS upgraders
```

About 4,600 lines of package code and 174 tests, including a
cross-process test that starts a real server in a child
process.  The only internal dependency is
`codecraftai-foundations` (Volume 0): the exception vocabulary,
the base `Aspects` class, and `Freezable`.

Two things the tree announces before a line of it is read.
First, there is no `pickle` anywhere.  Second, the transport,
session and proxy layers are separate directories with
separate tests, which is what lets the package test session
semantics over an in-process socket pair rather than a real
network.

## The frame

Everything on the wire is a *frame*: an eight-byte length
prefix, a fixed twenty-four-byte header, and a payload.  The
header is a `struct` with five fields
(`core/protocol/header.py:23–35`):

```python
_HEADER_FORMAT = "!HBB16sI"

@dataclass(frozen=True)
class MessageHeader:
    version: int
    mtype: MessageType
    mcode: int
    correlation_id: uuid.UUID
    payload_length: int
```

`version` is the protocol version, checked on every header
against a supported range (`core/protocol/constants.py:15–16`).
`mtype` is one of *request*, *reply* or *exception*
(`constants.py:22`), and `mcode` says which request or reply
(`constants.py:28–58`).  The sixteen-byte `correlation_id` ties
a reply to the request that caused it.  `payload_length` lets a
reader allocate before it reads.

The header is decoded *before* the payload, and the payload
never has to be parsed to route a message.  That ordering is
the whole point of a fixed header: the session's reader thread
can match a reply to its waiter, or hand a request to the
dispatcher, without touching the codec.  The reader is never
blocked on a slow decode, so a heartbeat reply is seen
promptly even while a large reply is queued behind it.

The message classes in `core/protocol/messages.py` are thin
dataclasses registered by `(type, code)` through a decorator
(`messages.py:90`).  There are twelve requests, twelve
replies and one exception message.  The names are the
vocabulary of the rest of the chapter: `Hello`, `Ping`,
`Close`, `GetService`, `GetAttr`, `SetAttr`, `DelAttr`, `Call`,
`Release`, `Renew`, `Cancel`, `Progress`.

## The codec: a closed set of types, no `pickle`

The temptation in any Python RPC design is `pickle`.  It
handles every type, it round-trips faithfully, and it is a
remote-code-execution primitive by construction — a peer that
can send you a pickle can run code in your process.  The
package refuses it outright and instead ships a tagged binary
codec (`core/serialization/codec.py`) over a *closed* set of
types.

The tags are an `IntEnum` (`core/serialization/tags.py:19`).
The set they cover is deliberately short: `None`, `bool`,
`int` (varint, zigzag for negatives — `codec.py:39–72`),
`float`, `str`, `bytes`, `list`, `tuple`, `dict` with string
keys, `set`, `frozenset`, timezone-aware `datetime`, `date`,
`timedelta`, `UUID`, exceptions, and three extension points:

- **Registered enums** (`registry.py:28`): an enum class maps to
  a string tag, and the value's *name* travels.
- **Registered `Flattenable` types** (`registry.py:37`) and
  **registered dataclasses** (`registry.py:46`): the type maps
  to a tag and its `flatten()` dictionary travels.  A dataclass
  is registered with one call; the codec never introspects an
  unregistered type.
- **References** (`placeholder.py:19`): an object that travels
  *by reference* is replaced by a `ReferencePlaceholder` naming
  the owning session and the reference id.  The proxy layer
  turns placeholders back into proxies on the way in.

Anything outside that set fails *before* a byte is sent, with
`SerializationError` naming the offending type and the path to
it inside the argument tree.  Chapter 2's framing applies
directly: the codec is a *mechanism*, and the mechanism's job
is to fail loudly when asked to do something outside its
contract, never to guess.

Depth is bounded (`tags.py:16`, `MAX_DEPTH = 64`) so a
self-referential structure cannot hang an encoder, and the
maximum message size is a session setting
(`constants.py:19`, sixty-four megabytes by default) enforced
when the length prefix is read, before any allocation.

Exceptions deserve a note.  A remote exception arrives as the
*same type* when the type lives in a preloaded module —
`builtins` and the Volume 0 vocabulary (`codec.py:36`) — with
the remote traceback attached as `remote_traceback`.  Any other
type arrives as `RemoteError` carrying the original type name.
The codec never imports a module on the say-so of a peer.
That single rule is the difference between "an exception
crossed the wire" and "the peer chose which module to import
into my process".

## Work dispatch: queues, loopers and pools

Before the session can be understood, the threading substrate
under it needs a paragraph.  Three small classes in `core/`
carry every unit of work in the package:

- `LooperQueue` (`core/looperqueue.py:30`) — a bounded queue
  with an explicit shutdown sentinel (`looperqueue.py:21`), so a
  worker blocked on `get()` wakes when the queue is closed
  instead of when its timeout happens to expire.
- `Looper` (`core/looper.py:24`) — one named thread draining
  one queue.
- `LooperPool` (`core/looperpool.py:22`) — an elastic set of
  loopers over a shared queue, growing between a minimum and a
  maximum worker count when the queue's depth passes a
  high-water mark.

The pool's bounds come from `AspectsRpc`
(`dispatch_min_workers`, `dispatch_max_workers`,
`dispatch_highwater`).  The choice to make them *Aspects* rather
than constructor arguments is Chapter 2 again: they are
behaviour knobs, and behaviour knobs travel in the Aspects
bundle so a caller who needs different concurrency for one
session sets it in one place.

Every thread the package starts is named
`rpc-<role>-<purpose>-<session>`.  When a process is dumped
with `faulthandler` or inspected in a debugger, the names say
which session and which purpose each thread serves.  Volume 0's
first principle — *debuggability over performance* — is cheap
to honour here and expensive to retrofit.

## The session

`DuplexSessionBase` (`core/session/base.py:103`) is the centre
of the package, and at 660 lines it is the largest file.  A
session owns one transport, one reader thread, one writer
queue, a dispatch pool, a heartbeat monitor and a reference
table.  Client and server sessions
(`core/session/client.py`, `core/session/server.py`) differ
only in who speaks first.

### Request correlation

`send_request` (`base.py:306`) allocates a correlation id,
registers a `Waiter` (`base.py:69`), writes the frame and blocks
on the waiter with a deadline.  The reader thread matches
replies to waiters by correlation id and never blocks on
anything but the socket.  Because requests are correlated
rather than sequenced, a session can have many calls in flight
from many threads, and — this matters for callbacks — a
*request arriving from the peer* can be dispatched while one of
our own requests is still waiting.  That is what lets a server
call back into a client listener during the client's own call.

### Heartbeat and inactivity

`HeartbeatMonitor` (`core/session/heartbeat.py:20`) sends a
`Ping` every `heartbeat_interval` seconds of silence and
declares the peer gone after `inactivity_timeout` seconds
without *any* inbound frame.  Both are Aspects.  The detection
is measured, in the Chapter 1 sense: the session does not
"assume" a peer is alive because a socket is open; it observes
frames and acts on their absence.  When the monitor gives up,
every waiter is failed with `ConnectionLostError` and every
reference the peer held is expired — which brings us to leases.

### Dispatch, deadlines and lanes

Inbound requests go to a `DispatchLooper`
(`core/session/dispatch.py:27`).  Each request is wrapped in a
`CallContext` (`core/dispatch/context.py:25`) carrying the
caller's session, the *remaining time* the caller sent with the
request, the caller's context pairs, and a cancellation flag.
Service code reads these through module functions —
`remaining_time()`, `check_cancelled()`, `report_progress()`
(`context.py:87–107`) — so a service method needs no framework
type in its signature to cooperate with deadlines and
cancellation.

Ordering is the subtle part.  The package has *lanes*
(`core/dispatch/lanes.py:19`): a per-target queue that
serialises deliveries to one object.  Lanes apply to *one-way
notifications only* (`dispatch.py:53–62`).  The comment at that
site records the reason, and it is worth quoting because it is
the kind of decision that only a failing test teaches:

> Ordering lanes apply to one-way callbacks only.  An
> acknowledged call already serialises its caller, and a lane
> on an acknowledged call would deadlock a re-entrant chain
> that targets the same object.

A calls B, B calls back into A's listener, the listener calls B
again: if every delivery to one target waited its turn in a
lane, the chain deadlocks on itself.  Acknowledged calls are
already ordered by the fact that the caller is blocked waiting
for them.  Only fire-and-forget notifications need the lane,
and only for them is it safe.

## Proxies and references

`RemoteProxy` (`core/proxy/remoteproxy.py:56`) is what a caller
holds.  Attribute reads become `GetAttr` requests, writes become
`SetAttr`, method calls become `Call`, and a small list of
dunder methods (`remoteproxy.py:22`) is forwarded so a remote
sequence supports `len()` and indexing.  Access to
underscore-prefixed names is refused at the *owner* unless the
type is decorated `@expose_private`, and writes to the root
service object are refused unless it is `@mutable_service`
(`core/proxy/wrappers.py:114–127`).  Both are opt-in: the
default is the conservative one.

Method access returns a `_RemoteMethod` (`remoteproxy.py:25`)
that carries three verbs.  Calling it is the ordinary
acknowledged call.  `rpc_notify(...)` sends the call one-way
and returns immediately.  `rpc_call_async(...)` returns a
`RemoteFuture`.  A method decorated `@oneway` on the owner's
side (`wrappers.py:106`) is always delivered as a notification
regardless of how the caller invokes it, which is how a service
declares "this method's result is never interesting" once
instead of at every call site.

### By value or by reference — never by guess

An argument crosses the wire in exactly one of two ways, and the
caller must say which.  Types in the codec's closed set travel
by value.  A class decorated `@by_ref` (`wrappers.py:86`)
always travels as a reference: the owner registers the object
in its reference table and sends a placeholder; the holder
receives a proxy.  A caller can also decide per call with the
`ByRef` and `ByValue` wrappers (`wrappers.py:48–84`).  An
unwrapped, unregistered object raises `SerializationError`
before the request is sent.

The design rejects the friendlier alternative — "proxy anything
we can't serialise" — because the friendlier alternative hides
the most expensive decision in the system.  A by-value argument
costs one copy.  A by-reference argument costs a lease, a table
entry in the owner, a proxy in the holder, and one round trip
*per attribute access* for the rest of its life.  Making the
choice explicit at the type or the call site keeps that cost
visible where it is paid.

### The reference table and the cache

The owner's side is a `ReferenceTable` (`core/proxy/table.py`):
reference id to object, plus the set of holders and each
holder's lease expiry.  The holder's side is a proxy cache
(`core/proxy/cache.py`) so two placeholders for one remote
object resolve to one proxy, and so garbage collection of the
proxy can release the reference.

That last sentence hides a race worth recording.  A proxy's
method objects were originally created on each attribute
access and held only a weak path back to the proxy; a caller
that stored `listener.on_event` and dropped `listener` could
see the proxy collected, the reference released, and the stored
method fail on its next call.  The fix is
`RemoteMethodDescriptor` (`wrappers.py:21`) plus a rule that
every derived view — a method, an Aspects view — holds a strong
reference to its base proxy.  A reader who inherits this
package should treat that invariant as load-bearing.

## Leases: agreeing about what exists

Two processes holding references to each other's objects need a
rule for when an object may be freed.  Reference counting
across a network is a known bad idea — a lost `Release` message
leaks the object forever, and a crashed holder never sends one.
The package uses *leases* instead (`specs/leases`), and the
mechanism is small enough to state completely.

- When an owner sends a reference, it records the holder with a
  lease of `lease_duration` seconds (`base.py:424–432`).
- The holder renews every live reference it still holds, in one
  `Renew` request carrying all of the ids, well inside the
  duration.  The owner replies with the ids it no longer knows
  (`base.py:520–522`), so a holder learns promptly that a
  reference is gone rather than at its next call.
- A holder that drops a proxy sends `Release` as a notification
  (`base.py:409–413`).
- The owner sweeps expired leases (`base.py:488`), and an
  expired or released object with an `on_lease_lapsed` method
  (`core/leases.py:22`, `ILeaseAware`) is told so
  (`base.py:461–476`).  A session-level callback,
  `on_lease_lapse` (`base.py:455`), sees every lapse.
- When a peer is declared gone by the heartbeat monitor, every
  lease it held lapses at once.

Two refinements exist because tests found them.  A released
reference leaves a *tombstone* so a late `Renew` for it is
answered "unknown" rather than resurrecting it, and the owner
remembers *recently released* objects briefly so that a
reference released and re-sent within one round trip does not
race its own release.

The effect, for the reader who only ever uses the entry points,
is that a callback object passed to a server simply *works* for
as long as the server keeps it, is freed when the server drops
it or dies, and can ask to be told when that happens.  The
whole apparatus exists so that the user-visible rule can be
that short.

## Aspects, applied to a wire

`AspectsRpc` (`aspects.py:33`) extends the Volume 0 `Aspects`
base with the knobs a wire needs:

| Knob | Default | Governs |
|---|---|---|
| `completion_timeout` | 600 s | how long a call may take |
| `connection_timeout` | 30 s | one connect attempt |
| `max_attempts`, `completion_interval` | 1, 2 s | connect retries and their spacing |
| `heartbeat_interval`, `inactivity_timeout` | 10 s, 60 s | liveness |
| `lease_duration` | 90 s | reference lifetime without renewal |
| `dispatch_min_workers`, `dispatch_max_workers`, `dispatch_highwater` | 2, 8, 4 | the dispatch pool |
| `logging_pattern`, `retry_logging_interval` | failure-only, 5 s | what gets logged, how often |

The bundle is frozen after validation (`aspects.py:38`), and
`replace(**changes)` (`aspects.py:59`) is the way to derive a
variant.  Three scopes apply it:

1. **Per client or server**, in the constructor.
2. **Per proxy**, with `proxy.rpc_with_aspects(aspects)`, which
   returns a *view* sharing the same reference.
3. **Per block**, with `rpc_aspects_scope(aspects)`
   (`aspects.py:124`), a context manager that overrides for
   every call made on the current thread inside it.

The caller's `completion_timeout` does not stay on the caller's
side.  The remaining time travels in the request, the service
reads it with `remaining_time()` (`aspects.py:137` on the
caller, `context.py:87` on the service), and a service that
forwards to a second service can pass on what is left.  This is
the *delegation contract* of Chapter 2 realised end to end: the
caller states the budget once and every hop below it inherits
the budget rather than inventing its own.

## Long operations

`RemoteFuture` (`core/futures.py:40`) is the async form of a
call.  `result(timeout)` blocks; `cancel()` sends a `Cancel`
request that the service observes through `check_cancelled()`;
`add_progress_callback` receives `Progress` records
(`futures.py:24`) that the service emits with
`report_progress(fraction, message)`.  `wait_all` and
`as_completed` (`futures.py:146–155`) fan a set of futures back
in.

Progress and cancellation are ordinary one-way messages in the
protocol (`Progress`, `Cancel`), which is why they work while
the call they concern is still in flight: they share the wire
with it, correlated by the same id, and the dispatcher treats
them as notifications rather than queueing them behind the
call they are trying to influence (`dispatch.py:72`).

## Transport and the fault injector

`Transport` (`core/transport/base.py`) is a small duplex
interface; `DuplexSocketIO` (`core/transport/socketio.py`) is
the real one.  Two details are worth the reader's attention.
First, `close()` *always* releases the file descriptor, even
when the peer closed first — the package once leaked one
descriptor per peer-initiated close, and the fix is a single
`_released` flag.  Second, an in-process socket pair
(`core/transport/inprocess.py`) lets the whole session stack be
tested without a listening port, and `FaultInjectingTransport`
(`core/transport/faults.py:21`) lets tests drop, delay or
truncate frames on demand.

Testing a protocol by injecting faults at the transport seam,
rather than by mocking the session, is the single most
important testing decision in the package.  It is why the
lease, heartbeat and correlation logic have real coverage for
the failure paths that only happen on a bad network.

## The entry points

The two classes a reader types are the smallest part of the
package.  `RpcSocketServer` (`rpcsocketserver.py:33`) accepts
connections, upgrades them if asked, authenticates them if
asked, and gives each one a server session over the same
service object.  `RpcSocketClient`
(`rpcsocketclient.py:32`) connects with the Aspects' retry
budget, performs the `Hello` exchange, and hands back a proxy
typed with the caller's protocol so an IDE sees the methods:

```python
from ccai.rpc import AspectsRpc, RpcSocketClient, RpcSocketServer

server = RpcSocketServer(Greeter(), ("0.0.0.0", 9040))
server.start()

client = RpcSocketClient(GreeterProtocol, ("server-host", 9040),
                         aspects=AspectsRpc(max_attempts=30, completion_interval=2.0))
client.connect()
greeter = client.get_service()
greeter.greet("world", loud=True)
```

Security lives beside the entry points rather than inside the
protocol.  An *authenticator* (`security/auth.py:20`) checks a
credential presented in `Hello`; an *upgrader*
(`security/tls.py:21–37`) wraps the accepted socket in TLS or
mutual TLS before the first frame.  Both are optional callables
handed to the constructors.  The protocol itself never learns
whether it is running over TLS, which is the right amount of
ignorance: the wire discipline is the same either way, and the
security decision is made once at the edge.

Observability follows the same edge principle.
`server.describe()` and `client.session.describe()` return plain
dictionaries; `dump_state()` (`core/observability/state.py:30`)
renders every live server and client in the process; and
`install_dump_on_signal` (`state.py:44`) wires that dump to a
signal so a stuck process can be asked what it is doing without
a debugger.  The `ccai.rpc.wire` logger, at debug level, traces
every frame header.

## Where it sits in the portfolio

The package is the newest arrival on this floor, and its
dependencies say where it belongs: only Volume 0's foundations
below it, and nothing in the volume above it depends on it
yet.  The intended consumers are Volume 2's distributed
sequencer — which today moves results over SSH and JSOS
(Volume 2 ch.10, ch.13) and which a reference-carrying wire
would let hold *live* worker objects rather than streams — and
Volume 7's agent runtime, where a tool that runs in another
process is exactly a remote object with a lease.

For a reader deciding whether to reach for it, the test is the
volume's usual one.  If the interaction is *operations* — run
this, fetch that — one of the protocols in Chapters 3 to 8 is
the smaller tool and should win.  If the interaction is a
*conversation between objects* — callbacks, long-lived
handles, progress, cancellation — the leases and correlation
machinery here are what you would otherwise be writing badly by
hand.

## What this chapter set up

- **The frame**: an eight-byte length, a twenty-four-byte header
  with a correlation id, routing decided before decoding.
- **The codec**: a closed type set, explicit registration for
  user types, references as placeholders, exceptions rebuilt
  only from preloaded modules — and no `pickle`.
- **The session**: correlated requests, a reader that never
  decodes, heartbeat-measured liveness, deadlines and
  cancellation carried in a `CallContext`, lanes for
  notifications only.
- **Proxies**: by-value or by-reference, never by guess;
  conservative defaults for private and mutable access; views
  that keep their base alive.
- **Leases**: the rule a holder sees is one sentence; the
  mechanism is renewals, tombstones and lapse callbacks.
- **Aspects on a wire**: one budget, stated once, inherited by
  every hop.

## End of Volume 5

The chapters that come next are in other volumes.  Volume 4
(*Cross-Platform System Management*) walks the sysman tier
that uses these protocols heavily.  Volume 7 (*Building an AI
Agent SDK*) builds on Volume 5's REST foundations for the
agent's tool-calling layer, and is the first candidate for
the remote-object model this chapter closed on.  Volume 8
(*Time-Travel Debugging in Production*) instruments wire calls
for replay.

Volume 5's contribution to the series is the *protocol
discipline*.  Every other volume that involves cross-machine
communication relies on what was established here:
**Measured Interoperability**, applied to whatever protocol
the situation needs, with the Aspects pattern as the
delegation contract that makes the discipline scale — and,
after this chapter, with a way to let objects themselves cross
the wire when operations are not enough.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui -->
# Volume 6 — Topology, Provisioning, and UI Automation

*Modelling the Environment Under Automation*

## About this volume

Volume 6 is the *environment model* the rest of the platform
operates on.  Volumes 1, 2, and 3 referenced "the Landscape" and
"the Topology" as inputs the framework had already loaded;
Volume 6 walks what's *in* those inputs.  Specifically:

- Landscape: physical inventory + wiring as a configuration
  category.  The framework supplies the loader and the device-
  control machinery (Coordinators, Agents, ProtocolExtensions);
  the project supplies the device-specific schemas via
  subclassing.
- Topology: logical organisation + roles + verification.  The
  framework supplies the canonical `clusters` shape consumed by
  `LandscapeOperationalLayer._create_clusters`; the project
  supplies the rest by subclassing `Topology`.
- Provisioning: the machinery that *creates* a Landscape and
  Topology from a Specification.  Volume 3 chapter 4 walked the
  orchestration side; Volume 6 walks the implementation.
- UI automation: the special case where the system under test
  is a graphical application.  Cross-platform via the
  `codecraftai-uia` package; declarative application mappings
  for known applications; the same coupling /
  ProtocolExtension model applied to UI elements rather than
  network protocols.

## Who it is for

Test / automation engineers wiring up complex environments.
Platform engineers building automation infrastructure.
Anyone whose project needs the framework to recognise a new
deviceType, a new Feature, a new Topology shape, or a new
GUI application.

## Source material

- `codecraftai-landscaping` — the environment model itself.
- `codecraftai-provisioning` — cloud provisioning backends.
- `codecraftai-uia` — cross-platform UI automation.
- `codecraftai-restclient-gitlab` — REST integration worked example.
- `codecraftai-webservice-flask` — web-service scaffolding (retired; see the note at the top of chapter 14).

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — The Environment Model | 1, 2, 3, 4 | Landscape, device declaration, Coordinator/Agent/ProtocolExtension/Feature, FriendlyIdentifier |
| II — Topology and Customisation | 5, 6, 7, 8, 9 | Canonical clusters shape, verification overload, subclassing, BaseExt, Pod realisation |
| III — Beyond the Environment | 10, 11, 12, 13, 14 | Provisioning scaffolding, UI automation, declarative mappings, GitLab, Flask |
| IV — Worked Example | 15 | 4-OS sysman feature campaign with the full machinery |

- [Chapter 1 — Landscape — Physical Inventory and Wiring](chapters/01-landscape-physical-inventory.md)
- [Chapter 2 — Inside a Device Declaration](chapters/02-inside-a-device-declaration.md)
- [Chapter 3 — Coordinators, Agents, ProtocolExtensions, Features](chapters/03-coordinators-agents-protocol-extensions-features.md)
- [Chapter 4 — Device Discovery and Friendly Identifiers](chapters/04-friendly-identifier.md)
- [Chapter 5 — Topology — Logical Organisation and Roles, with Verification](chapters/05-topology-logical-organisation.md)
- [Chapter 6 — Topology as an Executable Assertion](chapters/06-topology-as-executable-assertion.md)
- [Chapter 7 — Subclassing `Landscape` and `Topology` — The Customisation Mechanism](chapters/07-subclassing-landscape-and-topology.md)
- [Chapter 8 — Service-Specific Framework Extensions](chapters/08-service-specific-baseext.md)
- [Chapter 9 — The Pod Model — Landscape + Topology Realised](chapters/09-the-pod-model.md)
- [Chapter 10 — Cloud Provisioning Model](chapters/10-cloud-provisioning-model.md)
- [Chapter 11 — Cross-Platform UI Automation](chapters/11-cross-platform-ui-automation.md)
- [Chapter 12 — Declarative UI Mappings](chapters/12-declarative-ui-mappings.md)
- [Chapter 13 — GitLab Integration](chapters/13-gitlab-integration.md)
- [Chapter 14 — Web-Service Scaffolding with Flask](chapters/14-flask-scaffolding.md)
- [Chapter 15 — Case Study: A Landscape That Drives a Sysman Feature Test Across Four OSes](chapters/15-case-study.md)

## How it connects to the other volumes

- **Volume 0** — foundation tier (configuration loading, error
  vocabulary, factory model from ch.7).
- **Volume 1** — single-host substrate; ch.9's landscape
  boundary contract.
- **Volume 2** — distributed test execution; the three
  activation layers walked at the contract level.  Volume 6
  walks them at the schema / implementation level.
- **Volume 3** — orchestration; ch.4 (Specifications), ch.5
  (Landscape files), ch.6 (Topology files) — Volume 3 walked
  the orchestration-side; Volume 6 walks the
  implementation-side.
- **Volume 4** — sysman; UI automation in Volume 6 chapter 11
  is one *substrate* for sysman tests; Volume 4 walks the
  sysman feature contracts.
- **Volume 5** — interop and protocols; the wire that
  ProtocolExtensions wrap.  Volume 6 references the wire as
  given.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/01-landscape-physical-inventory -->
# Chapter 1 — Landscape — Physical Inventory and Wiring

> *Volumes 1, 2, and 3 referenced "the Landscape" as input the
> framework had loaded.  This chapter walks what's *in* a
> Landscape file and how `codecraftai-landscaping` represents
> it.  Two top-level sections: `apod` (the automation
> participants — runners, drivers, controllers) and
> `infrastructure` (services and devices the automation
> operates against).  The shape is intentional, the
> implementation is small, and the user's customisation
> point is clear.*

A Landscape is the answer to the question *what physical
resources exist and how are they connected?*  The Landscape
file is a YAML or JSON document; the loader is in
`codecraftai-configuration`; the consumer is `codecraftai-landscaping`.
Volume 3 chapter 5 walked the file's role at the orchestration
layer; this chapter walks what's inside the file and how the
framework's classes represent it once loaded.

This is the first of nine chapters that walk the environment
model end to end.  By chapter 9 a reader will have walked
Landscape, devices, services, the Coordinator/Agent/
ProtocolExtension chain, FriendlyIdentifier, the canonical
Topology shape, the verification overload, the subclassing
mechanism, the BaseExt extension model, and the Pod model.

## The two top-level sections

A real Landscape (the example shipped at
`codecraftai-automation-kit/userguide/examples/landscape.yaml`)
has the shape:

```yaml
environment:
    label: production

apod:
    clients:
        - deviceType: network/client-linux
          name: casey-01
          host: 192.168.1.21
          role: taskerserver
          credentials:
              - pi-cluster
          features:
              isolation: false
          skip: false
        # ... more clients ...

infrastructure:
    services:
        - serviceType: network/service
          name: someservice
          credentials:
              - adminuser

    devices:
        - deviceType: network/ssh
          name: dut-rhel-1
          host: 10.0.1.10
          platform: rhel
          credentials:
              - dut-root
          features:
              serial: serial-concentrator: 1
              power: lpc934: 1
              isolation: false
          skip: false
        # ... more devices ...
```

Three top-level keys:

- **`environment:`** — a small metadata block with the
  environment's label.  Used in result records to identify
  which environment a run was against.
- **`apod:`** — the **automation pod**, naming the hosts that
  *participate in the automation*.  Runners, controllers,
  drivers.  These are the machines that *do the work*.
- **`infrastructure:`** — the systems the automation
  *operates against*.  Services (databases, vaults, message
  queues) and devices (the systems under test).

## Why `apod` and `infrastructure` are different

Volume 3 chapter 5 walked the operational distinction.  The
short version: only `apod` hosts may be rebooted, reset, or
re-provisioned by the orchestration without disrupting the
automation itself.  Infrastructure hosts are the system under
test — touching them is the test's business, and only when
the test is the actor (chapter 2 of Volume 2's commitment).

The two-section split also explains the etymology of "Pod"
in the framework's vocabulary.  The `apod` is the
*automation pod*; "Pod" (the unit of horizontal CI
parallelism — Volume 2 chapter 3, Volume 3 chapter 27) is
the runtime instance of *one* automation pod plus its
infrastructure plus its topology realised together.  The word
comes from the configuration; the concept gets richer as it
moves through the framework.

## How the Landscape gets loaded

The chain of loading is straightforward:

1. `codecraftai-configuration`'s `resolve_landscape_configuration`
   reads the named Landscape files (per the
   `AM_CONFIG_LANDSCAPE_NAMES` variable from Volume 0 chapter
   9), merges them via `MergeMap` overlay, and stores the
   result in `LANDSCAPE_CONFIGURATION_MAP`.
2. `LandscapeConfigurationLayer.load_landscape` in
   `codecraftai-landscaping` reads the merged dict and stores it
   as `landscape_info` (Volume 2 chapter 6 walked the
   activation phase).
3. `LandscapeIntegrationLayer.initialize_landscape` walks the
   `landscape_info` and instantiates `LandscapeDevice` and
   `LandscapeService` objects for each declared device and
   service.

After step 3, the `Landscape` singleton's `devices` and
`services` properties return live objects representing the
declared resources.

## The `Landscape` class

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/landscape.py:50`

```python
class Landscape:
    """
        The base class for all derived :class:`Landscape` objects.
        ... transitions through activation stages:

        * Configuration
        * Integration
        * Operational
    """

    this_app = ThisApplicationSingleton()
    context = this_app.context
    logger = getEnhancedLogger()
    landscape_lock = threading.RLock()

    landscape_type = None
    interactive_mode: bool = False
    instance = None
    instance_initialized = False
    configuring_thread_id = None

    _configured_gate: threading.Event = threading.Event()
    _integration_gate: threading.Event = None
    _operational_gate: threading.Event = None
```

Three threading gates (`_configured_gate`,
`_integration_gate`, `_operational_gate`) coordinate
multi-thread activation.  In a distributed test job, the
controller's main thread drives activation, but other threads
(diagnostic captures, validators) might query the landscape
mid-activation.  The gates ensure they wait for the relevant
phase to complete before reading.

The instance's four-layer attribute references:

```python
def __init__(self):
    # ...
    self._layer_install: LandscapeInstallationLayer = None
    self._layer_configuration: LandscapeConfigurationLayer = None
    self._layer_integration: LandscapeIntegrationLayer = None
    self._layer_operational: LandscapeOperationalLayer = None
    self._topology_description: TopologyIntegrationLayer = None
```

Five layer references.  Volume 2 chapter 6 covered three of
them (Configuration / Integration / Operational); the
Installation layer is a fifth that handles installed-coupling
discovery (couplings registered through the factory protocol
from Volume 0 chapter 7), and `_topology_description` is the
topology integration layer that holds the loaded topology.

## The `LandscapeDevice` class

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/landscapedevice.py:41`

```python
class LandscapeDevice(FeatureAttachedObject):
    """
        The base class for all landscape devices.  ... provides
        attachment points and mechanisms for adding DeviceExtensions
        to the :class:`LandscapeDevice`.
    """
    FEATURE_TAGS = []

    def __init__(self, lscape: "Landscape", coordinator: "CoordinatorBase",
                 friendly_id: FriendlyIdentifier, device_type: str, device_config: dict):
        super().__init__()
        self._lscape_ref = weakref.ref(lscape)
        self._coord_ref = weakref.ref(coordinator)
        self._friendly_id = friendly_id
        self._device_type = device_type
        self._device_config = device_config
        self._group = ""
        if "group" in device_config:
            self._group = device_config["group"]
        self._device_lock = threading.RLock()
        self._extensions: Dict[str, ProtocolExtension] = {}
        self._features = {}
        if "features" in device_config:
            self._features = device_config["features"]
        self._has_ssh_credential = False
        self._ssh_credential = None
```

Several pieces matter:

- **`FeatureAttachedObject` base.**  This base class (in
  `codecraftai-testbase`) provides the dispatch mechanism for
  Feature handlers.  A subclass-specific Feature is a method
  that checks `self._features` and acts accordingly.
- **Weak references.**  `_lscape_ref` and `_coord_ref` are
  weak refs to avoid circular-reference garbage-collection
  issues.  The device exists for the duration of the
  landscape's run; the references through `lscape` and
  `coordinator` properties are guaranteed to dereference
  successfully during that window.
- **`_extensions: Dict[str, ProtocolExtension]`.**  The
  per-protocol attachment table.  When a Coordinator attaches
  an SSH ProtocolExtension to the device, it lands in this
  dict at key `"network/ssh"`.  The test's
  `device.ssh.run_cmd` call walks through the dict.
- **`_features = device_config.get("features", {})`.**  The
  features tag from the YAML is stored verbatim.  Feature
  handlers in subclasses query `_features` directly.
- **The credentials story.**  `_has_ssh_credential` and
  `_ssh_credential` are pre-extracted convenience fields for
  SSH-shaped devices; the `_credentials` dict (set later)
  holds all the resolved credentials.  Volume 2 chapter 11
  covered the credentials side of activation.

## The `LandscapeService` class

**File:** `landscapeservice.py:41`

```python
class LandscapeService(FeatureAttachedObject):
    """
        The base class for all landscape services.
    """
    FEATURE_TAGS = []

    def __init__(self, lscape: "Landscape", coordinator: "CoordinatorBase",
                 friendly_id: FriendlyIdentifier, service_type: str, service_config: dict):
        super().__init__()
        self._lscape_ref = weakref.ref(lscape)
        self._coord_ref = weakref.ref(coordinator)
        self._friendly_id = friendly_id
        self._service_type = service_type
        self._service_config = service_config
        self._service_lock = threading.RLock()
```

Smaller than `LandscapeDevice`.  Services are simpler:
they don't have Features the way devices do (most services
just have a connection profile and credentials), and they
don't have the device-specific things (deviceType, host,
platform).  A serviceType identifies what *kind* of service —
`network/service`, `database/postgres`, `messaging/kafka` —
and the framework's per-serviceType `ServiceCoordinatorBase`
subclass knows how to manage it.

## How `apod` becomes `LandscapeDevice` instances

The integration layer's flow:

1. `LandscapeIntegrationLayer.initialize_landscape` reads
   `landscape_info["apod"]["clients"]`.
2. For each client entry, the layer looks up the right
   *Coordinator* by `deviceType` (e.g.,
   `network/client-linux` → `LinuxClientCoordinator`).
3. The Coordinator's `create_landscape_device` instantiates
   a `LandscapeDevice` (or a subclass for the deviceType)
   with the parsed `device_config` dict.
4. The Coordinator attaches initial ProtocolExtensions
   based on the deviceType.
5. The new device lands in
   `_layer_integration._integrated_devices[name]`.

After the loop, `_integrated_devices` holds every apod
client as a live `LandscapeDevice` instance.

## How `infrastructure.devices` becomes `LandscapeDevice`

Same flow as apod clients, but the source is
`landscape_info["infrastructure"]["devices"]` and the
`installed_integration_couplings` (configured through the
factory protocol) determine which Coordinator handles each
deviceType.

The framework supports both `apod` and `infrastructure` devices
in the same activation pass; the difference is *which list
the device appears in*, not *what class of object the device
becomes*.  An apod client and an infrastructure device are
both `LandscapeDevice` instances; what distinguishes them is
operational discipline (the orchestration treats them
differently — see Volume 3 chapter 5).

## How `infrastructure.services` becomes `LandscapeService`

Walks `landscape_info["infrastructure"]["services"]`.  Same
shape: per-serviceType Coordinator instantiates
`LandscapeService` (or subclass) instances.

After the integration phase completes, the `Landscape`
singleton's `services` property returns a dict of these
instances keyed by name.

## What's in the `device_config` dict

For the device YAML earlier in this chapter:

```yaml
- deviceType: network/ssh
  host: 10.0.1.10
  platform: rhel
  credentials:
      - dut-root
  features:
      serial: serial-concentrator: 1
      power: lpc934: 1
      isolation: false
  skip: false
```

The `device_config` dict the framework passes to
`LandscapeDevice.__init__` is essentially this YAML decoded:

```python
device_config = {
    "deviceType": "network/ssh",
    "host": "10.0.1.10",
    "platform": "rhel",
    "credentials": ["dut-root"],
    "features": {
        "serial": "serial-concentrator: 1",
        "power": "lpc934: 1",
        "isolation": False,
    },
    "skip": False,
}
```

The framework parses the dict, extracts `device_config["host"]`,
`device_config["features"]`, etc., and stores them on the
`LandscapeDevice` instance.  Subsequent chapters of this
volume walk what each part means in detail (chapter 2 walks
the device declaration shape; chapter 3 walks the
Coordinator/Agent/ProtocolExtension/Feature vocabulary).

## Composability

Per the Volume 3 chapter 3 universal-composability
property, a Landscape is the merged result of one or more
named files.  An automation campaign typically composes:

```
Landscape names: ["lab-base", "experiment-overrides"]
→ Loader reads lab-base.yaml + experiment-overrides.yaml
→ MergeMap overlay (later names win)
→ Single landscape_info dict
→ LandscapeIntegrationLayer instantiates devices/services
```

The composition is *file-level*, not device-level.  An
override file might add new devices, replace properties on
existing devices (matching by name), or change the services
section.  The merged result is what the integration layer
sees.

This is what makes the four-environment ladder from Volume 3
chapter 13 work for landscapes the same way it works for
runtimes.  Local-dev composes `lab-base + dev-overrides`;
CI composes `lab-base + ci-overrides`; staging composes
`lab-base + staging-overrides`.

## What the next chapter does

Chapter 2 walks inside a single device declaration in detail
— the three layers (primary access, Features, declarative
metadata) and the Feature dual-shape (boolean capability flags
vs service-reference Features that materialise into
ProtocolExtensions at runtime).  The chapter is short and
concrete; it answers *exactly* what the YAML shape means.

Chapter 3 then walks the framework's vocabulary for the
device-control machinery: Coordinators, Agents,
ProtocolExtensions, Features.  The vocabulary is precise; the
chapter's job is to make casual usage of "extension" or
"agent" disambiguate uniformly.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/02-inside-a-device-declaration -->
# Chapter 2 — Inside a Device Declaration

> *A device entry in a Landscape file has three layers:
> primary access (the deviceType plus its required fields),
> Features (additional capabilities), and declarative metadata
> (platform, skip, group).  Features themselves come in two
> shapes — boolean flags and service references.  This chapter
> walks each layer with code anchors.*

The previous chapter walked the Landscape's two top-level
sections — `apod` and `infrastructure` — and showed how
`LandscapeDevice` instances are created from the declared
devices.  This chapter zooms in on the device declaration
itself.  By the end, every line of a real device YAML has a
named purpose.

## The reference declaration

A real-shape device declaration:

```yaml
- deviceType: network/ssh
  host: 192.168.1.30
  platform: linux
  credentials:
      - casey-node
  features:
      isolation: true
      serial: serial-concentrator: 1
      power: LPC934: 1
  skip: false
```

Six fields plus a Features section.  Three semantic layers:

1. **Primary access** — `deviceType`, `host`, `credentials`.
2. **Features** — `features:` map.
3. **Declarative metadata** — `platform`, `skip`.

(Plus optional `name`, `role`, `group` not shown here.  The
shape is open at the top level; the framework reads what it
recognises and ignores what it doesn't, leaving the rest for
user subclasses to interpret.)

## Layer 1 — Primary access

The `deviceType` field selects the **Coordinator** (chapter 3)
that manages this device.  Examples shipped:

- `network/ssh` → `SshCoordinator` (primary access via SSH).
- `network/client-linux` → `LinuxClientCoordinator`.
- `network/client-windows` → `WindowsClientCoordinator`.
- Project-specific deviceTypes register their own Coordinators
  through the factory protocol from Volume 0 chapter 7.

The Coordinator is what knows how to instantiate the device's
primary ProtocolExtension (chapter 3).  For `network/ssh`,
the Coordinator wires up an `SshAgent` and a `SshProtocolExtension`
that exposes `device.ssh`.

The fields that configure the primary access depend on the
deviceType:

- `host` — the address the Coordinator's primary protocol
  reaches.  IP, DNS name, hostname.
- `credentials:` — composable list of credential names.  The
  first name is the *primary* credential; alternates can be
  used by per-call overrides (Volume 2 chapter 11 walked this).

For an SSH device, `host` and `credentials` are sufficient;
the Coordinator opens an SSH session to `host` using the
first listed credential.  For other deviceTypes, the
Coordinator might require additional fields (a serial device
might want `port`; a Bluetooth device might want
`bdaddr`).  The framework's per-deviceType Coordinator is the
authority on what the primary-access fields are.

## Layer 2 — Features

The `features:` map declares *additional* capabilities beyond
the primary access.  Each key is a Feature name.  Each value
is one of two shapes.

### Boolean capability flags

```yaml
features:
    isolation: true
    has_gpu: false
```

A boolean flag declares that the device has (or doesn't have)
a property the test code or framework can query.  No Agent
gets attached; no ProtocolExtension materialises.  The
`LandscapeDevice` stores the flag in its `_features` dict and
project code can check it:

```python
if device._features.get("isolation"):
    # Apply isolation-specific test logic
    ...
```

The framework ships several boolean flags it recognises by
name (`isolation` is one); projects add their own freely.

A common pattern: a project-specific `LandscapeDevice` subclass
exposes the flag through a property:

```python
class MyProjectDevice(LandscapeDevice):

    @property
    def is_isolated(self) -> bool:
        rtnval = self._features.get("isolation", False)
        return rtnval
```

The property reads a property; no framework changes needed.

### Service-reference Features

```yaml
features:
    serial: serial-concentrator: 1
    power: LPC934: 1
```

A service-reference Feature names a service from the
Landscape's `infrastructure.services` section plus a port (or
other discriminator).  At runtime, the framework resolves the
service, instantiates the matching Agent against the
service's address, and attaches a ProtocolExtension on the
device.

The flow for `serial: serial-concentrator: 1`:

1. The `LandscapeIntegrationLayer` walks the device's
   `_features` dict.
2. It encounters the `serial` Feature with value
   `serial-concentrator: 1`.
3. It looks up `serial-concentrator` in
   `infrastructure.services`.  Finds it.
4. It uses the service's address and the port `1` to
   construct a `SerialAgent`.
5. It wraps the agent in a `SerialProtocolExtension` and
   attaches it to the device:
   `device.attach_extension("serial", serial_ext)`.

After integration, the test calls `device.serial.write(...)`
exactly as it would call `device.ssh.run_cmd(...)`.  Both go
through ProtocolExtensions.  The difference is that
`device.ssh` was wired by the *primary deviceType*'s
Coordinator; `device.serial` was wired by the Feature's
resolution flow.

### How the framework dispatches

Per the research findings on `landscapedevice.py:356–376`,
Feature-driven extension attachment looks like:

```python
# Roughly — the framework's Feature-attachment dispatcher.

def attach_features(self):
    if "power" in self._features:
        power_spec = self._features["power"]
        # ... resolve the service, instantiate the Agent, build the extension
        power_ext = build_power_extension(power_spec)
        self.attach_extension("power", power_ext)

    if "serial" in self._features:
        serial_spec = self._features["serial"]
        serial_ext = build_serial_extension(serial_spec)
        self.attach_extension("serial", serial_ext)

    # ... and so on for built-in Features ...
```

This is **config-driven dispatch**, not decorator-based.
The dispatcher checks `if "power" in self._features` and
acts.  A project-specific Feature is added by subclassing
`LandscapeDevice` and adding a per-Feature attach step:

```python
class MyProjectDevice(LandscapeDevice):

    def attach_features(self):
        super().attach_features()  # built-ins

        if "cooling" in self._features:
            cooling_spec = self._features["cooling"]
            cooling_ext = build_cooling_extension(cooling_spec)
            self.attach_extension("cooling", cooling_ext)
        return
```

The subclass is registered through the factory protocol so
the Coordinator instantiates `MyProjectDevice` instead of the
base `LandscapeDevice`.  Chapter 7 walks the subclassing
mechanism in detail.

### Why two shapes?

Because the Features-tag has to express two different ideas
without bifurcating into two different sections:

- **Capability flags** are *properties of the device that have
  no off-device counterpart*.  "Is the device isolated?" is
  about the device's network position; there's no service to
  point at.
- **Service-reference Features** are *capabilities that come
  from another resource the device is wired to*.  "How do I
  reach this device's serial console?" is answered by a
  service, not by the device itself.

A unified `features:` section that allows both shapes keeps
the YAML compact.  The cost is the dual-form parsing logic in
the dispatcher; the benefit is the readability of declarations
like the reference at the top of this chapter.

## Layer 3 — Declarative metadata

The remaining fields are *neither primary access nor
Features*.  They're metadata the framework or test code can
query.

- `name:` — the device's friendly name (also wrapped in a
  `FriendlyIdentifier` — chapter 4).  If absent, the framework
  derives one from the deviceType + host.
- `role:` — a string identifying the device's role in the
  test.  Used by Topology declarations (chapter 5) and by
  test code (`landscape.devices_by_role("worker")`).
- `platform:` — for OS-aware tests.  RHEL / Ubuntu / macOS /
  Windows / etc.  Useful when a single device's behaviour
  differs across OSes and the test wants to skip on the
  wrong OS.
- `group:` — used by the Topology's `clusters` section
  (chapter 5) to group devices.
- `skip:` — a declarative test-applicability flag at the
  device level.  When true, tests that target this device
  are skipped without running.

The framework's parsing of these fields is mostly literal —
copy from the YAML to the `LandscapeDevice` instance.  Test
code reads them through properties (`device.name`,
`device.platform`, `device.role`).

## What goes where

A summary table:

| Field | Layer | Purpose |
|---|---|---|
| `deviceType` | Primary access | Selects the Coordinator |
| `host`, `port`, `credentials` | Primary access | Configure the primary Coordinator |
| `features:` (boolean) | Features | Capability flags queryable as data |
| `features:` (service-ref) | Features | Materialise into ProtocolExtensions at runtime |
| `name`, `role`, `platform`, `group`, `skip` | Metadata | Read by framework / test code |

The clean separation lets the framework parse without
ambiguity.  The user adding a new field for a project-specific
purpose puts it under `features:` (if it's a capability or a
service-reference) or as top-level metadata (if it's a query
target).  The framework ignores fields it doesn't recognise;
the project's `LandscapeDevice` subclass knows to read them.

## A worked declaration

Bringing it together with annotations:

```yaml
- deviceType: network/ssh           # Primary access type → SshCoordinator
  name: dut-rhel-1                  # Friendly name
  host: 10.0.1.10                   # Primary access address
  role: worker                      # Test-code-queryable role
  platform: rhel                    # OS-aware test discriminator
  credentials:                      # Composable credential list
      - dut-root
      - dut-emergency
  features:
      isolation: false              # Boolean flag — device is NOT isolated
      serial: serial-concentrator: 1   # Service-ref → device.serial
      power: LPC934: 1                 # Service-ref → device.power
      gpu_count: 4                  # Project-specific flag (subclass-handled)
  skip: false                       # Test-applicability flag
```

After the integration layer processes this:

- The framework's `LandscapeDevice` instance carries `name`,
  `host`, `role`, `platform`, `credentials`, `_features`,
  `skip`.
- The `SshCoordinator` has wired `device.ssh` (an
  `SshProtocolExtension`).
- The framework's Feature dispatcher has wired
  `device.serial` and `device.power` (both
  `ProtocolExtension`s).
- A project's `MyProjectDevice` subclass has read
  `_features["gpu_count"]` and exposed it as
  `device.gpu_count`.
- `skip=false` lets tests against this device proceed
  normally.

A test asks the framework for the device by name (or by role),
calls into the ProtocolExtensions, and gets work done.  None
of the test's code knows the difference between the framework's
built-in Features and the project's custom ones; the
ProtocolExtensions look the same.

## What the next chapter does

Chapter 3 walks the four-word vocabulary for the device-control
machinery: Coordinators, Agents, ProtocolExtensions, Features.
Each word has a precise meaning; casual usage is the source of
most reader confusion in the rest of the volume.  By the end
of chapter 3, the layered chain
`device → Coordinator → ProtocolExtension → Agent → service`
is established as the framework's mental model, and chapter
4's discussion of `FriendlyIdentifier` for device discovery
becomes natural.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/03-coordinators-agents-protocol-extensions-features -->
# Chapter 3 — Coordinators, Agents, ProtocolExtensions, Features

> *Four words.  Each has a precise meaning.  Casual usage of
> "extension" or "agent" is the source of most reader
> confusion in distributed automation.  This chapter pins
> down the vocabulary with code anchors and explains the full
> Coordinator → Agent → ProtocolExtension → Device chain.*

The framework's device-control machinery has four named
parts.  Each one does *one thing*; mixing them up makes the
code hard to read and the documentation harder to write.  This
chapter establishes the vocabulary the rest of the volume — and
indeed the rest of the series — uses uniformly.

## The four words

| Term | Role |
|---|---|
| **Coordinator** | Manages a class of devices and attaches Agents to them |
| **Agent** | Speaks a wire protocol |
| **ProtocolExtension** | Binds a specific Agent to a specific Device, exposing the protocol surface |
| **Feature** | A capability declared on a Device in the Landscape file |

The full layering for a service-reference Feature:

```
Landscape file
├── services section
│     └── named service (e.g. serial-concentrator at 10.0.0.50)
└── devices section
      └── device-1
            └── features tag: { serial: serial-concentrator: 1 }
                                ↓ at runtime
              device-1 instance
                ├── managed by Serial Coordinator
                └── .serial → ProtocolExtension
                                └── wraps → Serial Agent
                                              └── connects to serial-concentrator port 1
```

Each layer has a class.  Each class has a job.  Each job is
distinct from the others.  The rest of the chapter walks each
class.

## Coordinators

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/coordinatorbase.py:32`

```python
class CoordinatorBase:
    """
        Base class for all coordinators.
    """

    # Abstract methods (line 148–182)
    def activate(self, activation_params):
        raise AbstractMethodError(...)

    def configure_devices(self, landscape):
        raise AbstractMethodError(...)

    def establish_connectivity(self, activation_params):
        raise AbstractMethodError(...)
```

A **Coordinator** owns a *class of devices* and is responsible
for them through the activation lifecycle.  The base class is
abstract; subclasses implement:

- `activate(activation_params)` — bring the coordinator's
  devices online.  Probe each device, attach its
  ProtocolExtensions, populate metadata.
- `configure_devices(landscape)` — read the device
  configurations from the landscape and prepare per-device
  state.
- `establish_connectivity(activation_params)` — verify each
  device responds.  Returns
  `(host, ipaddr, status, stdout, stderr, exc)` tuples per
  device (Volume 2 chapter 6 walked this).

Three Coordinator hierarchies in the framework:

### `ClientCoordinatorBase`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/clients/clientcoordinatorbase.py:55`

For *apod* clients — the runners and controllers.  The
Coordinator manages the framework's own machines, not the
system under test.

### `ServiceCoordinatorBase`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/services/servicecoordinatorbase.py:57`

For *infrastructure services* — the named services from
the Landscape's `services` section.  Per-serviceType
subclasses handle different service types.

### `NodeCoordinatorBase`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/clusters/nodecoordinatorbase.py:57`

For *cluster nodes* — devices that participate in clusters
declared by the Topology (chapter 5).  The Coordinator
materialises `LandscapeDeviceCluster` instances from the
Topology's `clusters` section (Volume 2 chapter 6 walked
the materialisation; chapter 9 of this volume walks the
result).

### Protocol-specific subclasses

Concrete Coordinator classes for specific deviceTypes:

- **`SshCoordinator`** at
  `coordinators/ssh/sshcoordinator.py:53`.  Handles
  `network/ssh` deviceTypes.  Its `establish_connectivity`
  (line 137–156) executes a remote echo command via
  `device.ssh.run_cmd()` and collects results.
- **`SerialCoordinatorBase`** at
  `coordinators/serial/serialcoordinatorbase.py:26`.  Handles
  serial-protocol devices.

Each protocol-specific Coordinator's `create_landscape_device`
method (e.g. `sshcoordinator.py:89–135`) instantiates the
`LandscapeDevice`, wires up the protocol's Agent, and
attaches the matching ProtocolExtension.

## Coordinator couplings — the registration mechanism

A Coordinator subclass doesn't just *exist* — it has to be
*discoverable* by the framework.  The discovery mechanism is
the **CoordinatorCoupling**.

**Example:** `coordinators/ssh/sshcoordinatorcoupling.py:44`

```python
class SshCoordinatorCoupling(CoordinatorCoupling):
    """
        The SshCoordinatorCoupling handle the requirement registration for the SSH coordinator.
    """
    pathbase = "/ssh"
    integration_root: str = "apod"
    integration_section: str = "devices"
    integration_leaf: str = "deviceType"
    integration_class: str = INTEGRATION_CLASS_NETWORK_SSH

    @classmethod
    def attach_to_environment(cls, landscape: "Landscape"):
        """
            This API is called so that the IntegrationCoupling can
            process configuration information.
        """
```

A coupling is **declarative metadata**:

- `integration_root` — `"apod"` or
  `"infrastructure"` (which Landscape section).
- `integration_section` — `"devices"` or `"services"`.
- `integration_leaf` — `"deviceType"` or `"serviceType"`.
- `integration_class` — the string the deviceType / serviceType
  is matched against (`"network/ssh"` for SSH).

When the framework activates the integration layer, it walks
the installed couplings, matches each device's deviceType
against the couplings' `integration_class`, and uses the
matched coupling's `COORDINATOR_TYPE` to instantiate the
right Coordinator.

The coupling is stateless metadata; the Coordinator is
stateful runtime.  One coupling type → one Coordinator type.
A project adding a new deviceType writes a Coupling subclass
and a Coordinator subclass; both are registered through the
factory protocol from Volume 0 chapter 7.

## Agents

The framework's `Agent` term is loose in the source code —
different protocols call them different things.  The
canonical example, `SshAgent` from `codecraftai-protocol-ssh`
(Volume 5), wraps a per-host SSH connection state and vends
`SshSession` instances tailored to specific tasks (Volume 2
chapter 10 walked the SSH side).

For the purpose of Volume 6's vocabulary:

> *An Agent is the protocol-speaking object.  Given a host
> address and credentials, it knows how to talk the wire
> protocol.*

Agents are *not* directly attached to `LandscapeDevice`
instances.  A ProtocolExtension wraps an Agent; the device
exposes the ProtocolExtension.  This separation lets the
ProtocolExtension expose a *device-level* surface
(`device.ssh.run_cmd`) while the Agent does the actual
protocol work (open SSH session, exchange packets, return
results).

## ProtocolExtensions

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/extensions/protocol/protocolextension.py:28`

```python
class ProtocolExtension:
    """
        ... allows for the extension of functionality for a
        :class:`LandscapeDevice` or :class:`LandscapeService`.
        The :class:`ProtocolExtension` is used by the UPnP, SSH
        and Other coordinators to extend landscape devices with
        metadata and device connectivity for the UPnP, SSH and
        Other protocols.
    """

    def __init__(self):
        self._coord_ref = None
        self._extends_ref = None
        self._configinfo = None
        self._extid = None
        self._location = None

    def initialize(self, coord_ref, extends_ref, extid, location, configinfo, **kwargs):
        ...
```

A **ProtocolExtension**:

- Holds a weak reference to its owning Coordinator
  (`_coord_ref`).
- Holds a weak reference to the Device or Service it extends
  (`_extends_ref`).
- Stores the protocol-specific configuration (`_configinfo`),
  the extension's unique identifier (`_extid`), and the
  network location (`_location`).
- Is attached to a Device via `device.attach_extension(name,
  ext)` — the device's `_extensions` dict at line 113 of
  `landscapedevice.py` carries the attachment.

A ProtocolExtension subclass implements the *device-level
methods* the test calls — `run_cmd` on an SSH extension,
`write` and `read` on a serial extension, `cycle` on a
power extension.  Each method is a thin wrapper around the
underlying Agent's protocol calls.

The Coordinator is responsible for *creating* the
ProtocolExtension, *initializing* it with the right Agent and
configuration, and *attaching* it to the Device.  The Device
is responsible for *exposing* the ProtocolExtension as a
device-level attribute (`device.ssh`).

### A `ProtocolExtension` is not a `BaseExt`

A point of vocabulary worth pinning down: `ProtocolExtension`
is **not** the same as `BaseExt` (chapter 8).  They serve
different purposes:

- `ProtocolExtension` extends a `LandscapeDevice` with
  protocol connectivity.  The most common kind of extension.
- `BaseExt` is a service-specific framework-extension class
  (e.g., the vSphere extension model).  It extends a
  service's behaviour, not a device's protocol surface.

Chapter 8 walks `BaseExt` in detail.  When the volume says
"extension" without qualification, the chapter context will
disambiguate, but readers should keep the distinction in mind.

## ControlExtension — the service control point

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/extensions/control/controlextension.py:27`

```python
class ControlExtension:
    """
        ... allows for addition of controls to extend the
        functionality of a :class:`LandscapeDevice`.  The
        :class:`ControlExtension` is used by the various services
        to link controls back to services.
    """

    def __init__(self):
        self._service_ref = None
        self._extends_ref = None
        self._control_id = None
        self._control_info = None
        self._extid = None
        self._location = None
```

A **ControlExtension** is the *service-side* of a
service-reference Feature.  Where a ProtocolExtension extends
a Device with protocol connectivity, a ControlExtension links
a Service's control point to the Device that uses it.

Example: a power-control service has a `ControlExtension` per
device-port pair.  The `LPC934` power controller has 16
ports; 16 ControlExtensions, each linking to the device
plugged into that port.  The Device's
`PowerProtocolExtension` holds a back-reference to the
service's ControlExtension; calls to `device.power.cycle()`
go through the protocol extension to the control extension to
the service.

## Features

Per chapter 2, **Features** are *declarations on a device in
the Landscape*.  A Feature is configuration data, not a class.

- A boolean Feature (`isolation: true`) is just a flag in the
  device's `_features` dict.
- A service-reference Feature (`serial: serial-concentrator: 1`)
  triggers the framework's Feature-attachment dispatcher to
  resolve the service, build an Agent, build a
  ProtocolExtension, and attach it to the device.

The dispatch is **config-driven**, not decorator-based.  The
framework's `LandscapeDevice` (or its subclass) checks
`if "serial" in self._features` and acts.  Adding a new
Feature name means subclassing `LandscapeDevice` and adding
a per-Feature attach step (chapter 2 worked an example).

## The full chain — a worked traversal

A test calls `device.serial.write("hello")`.  The chain:

1. **Test code** holds a reference to a `LandscapeDevice`.
2. **`device.serial`** is the `SerialProtocolExtension`
   attached at activation time by the framework's Feature
   dispatcher.
3. **The ProtocolExtension's `write(data)`** method delegates
   to its wrapped Agent.
4. **The Agent** is a `SerialAgent` instance bound to the
   serial concentrator service at the device's declared port.
5. **The Agent's `write` method** issues the protocol-level
   command — opening a TCP connection to the concentrator,
   selecting port 1, writing the bytes.

Going the other direction (the activation flow that built the
chain):

1. **Coordinator activation** — the integration layer's
   per-deviceType Coordinator (e.g., `SshCoordinator` for the
   primary access) starts up.
2. **`create_landscape_device`** — Coordinator instantiates
   `LandscapeDevice` from the YAML config.
3. **Primary ProtocolExtension** — Coordinator wires the
   device's primary protocol extension (`device.ssh`).
4. **Feature dispatcher** — framework walks the device's
   `_features` dict.  For `serial`, it resolves the
   `serial-concentrator` service, builds a `SerialAgent`,
   wraps in a `SerialProtocolExtension`, attaches via
   `device.attach_extension("serial", serial_ext)`.
5. **Operational layer** — `establish_connectivity` verifies
   each ProtocolExtension actually connects.

Two sequences, two directions.  The activation flow builds
the chain at startup; the test-call flow walks the chain at
runtime.  Both use the same chain; the chain is the
contract.

## Disambiguating "extension"

The word "extension" appears in three places in the
portfolio.  Always disambiguate:

- `codecraftai-extension` is the namespace-package factory
  discovery package (Volume 0 chapter 7).  Has nothing to do
  with Landscape.
- `ProtocolExtension` is the device-level protocol binding
  (this chapter).
- `ControlExtension` is the service-side control-point linkage
  (this chapter).
- `BaseExt` (chapter 8) is the service-specific
  framework-extension model (vSphere example).

When the book says "the extension," it's usually either
ProtocolExtension or BaseExt.  Context makes it clear; when
in doubt, the chapter will disambiguate.

## What the next chapter does

Chapter 4 walks `FriendlyIdentifier` — the framework's
mechanism for naming and resolving devices and services.  The
class supports both regex-based identity matching and
substring-based equality with a non-trivial history (a
pass-4 audit fixed an `__eq__` bug that always returned
True).  The chapter walks the class's design and the
identity-vs-hint distinction with code anchors.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/04-friendly-identifier -->
# Chapter 4 — Device Discovery and Friendly Identifiers

> *Names matter.  Devices in a Landscape have full identifiers
> (often IPs or DNS names) that are correct but unwieldy, and
> short hints (often human-friendly names) that are convenient
> but ambiguous.  `FriendlyIdentifier` is the framework's
> answer — pair them, optionally bind them with a regex, and
> implement careful equality semantics.*

A `LandscapeDevice` has a name.  The Landscape's YAML
declared `name: casey-01`.  But the device also has a host
address (`192.168.1.21`), and a fully-qualified DNS name
(`casey-01.lab.internal`), and possibly more identifiers
depending on the project's discovery patterns.  A test might
want to look up the device by any of these.

The framework's answer is `FriendlyIdentifier` — a small
class that pairs a *full identifier* with a *hint* and
optionally a *regex* for canonical-identity extraction.  The
class also implements careful equality semantics: two
`FriendlyIdentifier`s are equal when their full_identifiers
match; a `FriendlyIdentifier` compared against a string
returns equality if the string is a substring of the full
identifier.

This chapter walks the class with code anchors and explains
the design.  It also notes a real bug in the class's history
— the audit-pass-4 `__eq__` bug that always returned `True`,
caught and fixed in commit `1545f67`.

## The class

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/friendlyidentifier.py:24`

```python
class FriendlyIdentifier:
    """
        Associates a short identifier with a full identifier string and an
        optional identity match pattern.
    """

    def __init__(self, full_identifier: str, hint: str,
                 identity_match: Optional[Union[re.Pattern, str]]=None):
        """
            :param full_identifier: The full identifier string.
            :param hint: A short name used for quick reference.
            :param identity_match: Optional regular expression used to extract an identity value.
        """
        self._full_identifier = full_identifier
        self._hint = hint
        self._identity_match = identity_match
        if self._identity_match is not None and isinstance(identity_match, str):
            self._identity_match = re.compile(identity_match)

        # If a short_match expression is specified, it must have a match in the full identifier.
        if self._identity_match is not None:
            mobj = self._identity_match.match(self._full_identifier)
            if mobj is None:
                errmsg = "The specified identifier is not compatible with the short match expression provided."
                raise SemanticError(errmsg)
```

Three instance variables.  Three properties:

- `_full_identifier` — the canonical, unambiguous string for
  the device.  Often a fully-qualified hostname or an IP.
- `_hint` — a short, human-friendly name.  Often the same
  as the device's `name:` in the Landscape.
- `_identity_match` — optional regex.  When present, the
  pattern must match the full identifier at construction
  time; otherwise `SemanticError` raises.

The `SemanticError` at construction is the Volume 0 chapter
3 discipline applied here: building a `FriendlyIdentifier`
with an incompatible regex is API misuse, not a runtime
condition; raise `SemanticError` and let it bubble.

## The `identity` property

**File:** `friendlyidentifier.py:68–107`

```python
@property
def identity(self) -> str:
    """
        Returns the canonical identity for this object.
        - If identity_match was specified, returns the first capturing group.
        - Otherwise, returns the hint.
        - Otherwise, returns the full identifier.
    """
    if self._identity_match is not None:
        mobj = self._identity_match.match(self._full_identifier)
        if mobj is None:
            errmsg = "The full_identifier no longer matches the identity_match pattern."
            raise SemanticError(errmsg)

        groups = mobj.groups()
        if len(groups) > 0:
            rtnval = groups[0]
        else:
            errmsg = "The identity_match pattern has no capturing group."
            raise SemanticError(errmsg)
    elif self._hint is not None:
        rtnval = self._hint
    else:
        rtnval = self._full_identifier

    return rtnval
```

The `identity` property returns the *canonical extracted name*
when a regex is set.  Three failure cases the property guards
against:

1. The regex no longer matches the full identifier — surfaces
   a `SemanticError`.  This happens if `_full_identifier` is
   later modified inconsistently with the pattern.
2. The pattern has no capturing group — surfaces a
   `SemanticError`.  The pattern's job is to *extract* the
   identity; a pattern with no group is incomplete.
3. Neither pattern nor hint — fall back to the full
   identifier itself.

The fallback chain (regex → hint → full) is what makes
`identity` always return *something*.  The most-canonical name
the object knows about; the property's contract is that the
return value uniquely identifies the device within its scope.

## The `match` method

**File:** `friendlyidentifier.py:109–116`

```python
def match(self, hint: str) -> bool:
    """
        Returns True if the given hint substring is found in this object's
        full_identifier and the hint exists in the hint string.
    """
    rtnval = (self._full_identifier.find(hint) > -1) and (self._hint.find(hint) > -1)
    return rtnval
```

The `match` method answers "does this `FriendlyIdentifier`
match the given hint?"  The answer is yes if the hint appears
as a substring of *both* the full identifier and the hint.

This is *bidirectional substring*, not equality.  Useful when
test code says `"casey"` and wants to find any device whose
full identifier and hint both contain `"casey"`.

The dual-substring requirement prevents false matches.  If a
device has `_full_identifier = "casey-01.lab.internal"` and
`_hint = "casey-01"`, asking `match("casey")` returns True.
But if a different device has
`_full_identifier = "casey-01.lab.internal"` and
`_hint = "node-7"`, the same `match("casey")` returns False
— the hint doesn't contain the requested substring, so the
test author's "casey" doesn't match this device.

## The `__eq__` method — and its history

**File:** `friendlyidentifier.py:137–165`

```python
def __eq__(self, other):
    """
        Compares two identifiers for equivalence.
        - FriendlyIdentifier vs FriendlyIdentifier: full_identifier equality.
        - FriendlyIdentifier vs str: substring match (full_identifier.find(other) > -1).
        - Other types: returns NotImplemented.
    """
    if isinstance(other, FriendlyIdentifier):
        result = self._full_identifier == other._full_identifier
    elif isinstance(other, str):
        result = self._full_identifier.find(other) > -1
    else:
        result = NotImplemented
    return result
```

Three cases:

1. **Two `FriendlyIdentifier`s.**  Equal if their full
   identifiers match exactly.
2. **`FriendlyIdentifier` vs a string.**  Equal if the string
   is a substring of the full identifier.  Asymmetric — this
   lets `device == "casey"` work in test code without
   constructing a comparison `FriendlyIdentifier`.
3. **Other types.**  Returns `NotImplemented` to let Python
   fall back to other comparisons.

The asymmetry (substring match for strings, exact match for
other `FriendlyIdentifier`s) is *intentional* but worth
calling out — readers expecting `==` to be exact will be
surprised by `device == "casey"` returning True for any
device whose full identifier contains "casey".  The
discipline: the framework's preference is for *test code to
be permissive*; tests that need strict equality use the
`identity` property and compare strings.

### The audit-pass-4 bug

The current implementation is correct.  The audit history
isn't — pass 4 of the six-pass landscaping audit (commit
`1545f67`) caught a version of `__eq__` that always returned
True:

```python
# BUG (caught and fixed)
def __eq__(self, other):
    if isinstance(other, FriendlyIdentifier):
        result = self._full_identifier == self._full_identifier  # ← wrong
    # ...
```

The bug compared `self._full_identifier` to itself rather
than to `other._full_identifier`.  Two `FriendlyIdentifier`s
were *always* equal, regardless of their full identifiers.

The bug was *silent* — it never raised, and tests that
happened to use `==` between `FriendlyIdentifier`s would
incidentally pass when they should have failed.  The audit
caught it through code review, not through a test failure.

The lesson worth pulling out for the volume's reader: silent
bugs in comparison code are particularly dangerous because
they break the *invariant* that comparison reflects equality.
Volume 2 chapter 15 walked the audit-diary discipline that
caught this and 14 other silent bugs across the six-pass
landscaping audit.

## Using `FriendlyIdentifier` in code

A test that wants a specific device:

```python
device = LandscapeSingleton().device("casey-01")
```

The `device(name)` method looks up the device whose
`FriendlyIdentifier.identity` matches the name string.

A test that wants any device matching a pattern:

```python
candidates = LandscapeSingleton().get_devices(
    include_filters=["casey-*"],
    exclude_filters=[],
)
```

The framework walks every device's `FriendlyIdentifier` and
keeps the ones whose identity matches the include filter.

A test that wants devices by role:

```python
workers = LandscapeSingleton().devices_by_role("worker")
```

Different lookup, different mechanism — the role is a
top-level field on the device declaration, not part of the
`FriendlyIdentifier`.  But the result is a list of devices,
each with its own `FriendlyIdentifier`.

## Project-specific identity patterns

A project might want a specific identity-extraction pattern.
For example, a project using devices named like
`region-az-shard-replica` (e.g., `us-east-1-2-3`) might want
the identity to be only the shard-replica part:

```python
fid = FriendlyIdentifier(
    full_identifier="us-east-1-2-3",
    hint="2-3",
    identity_match=r"^[a-z]+-[a-z]+-\d+-(\d+-\d+)$",
)

print(fid.identity)  # → "2-3"
```

The regex captures just the last two number-dash-number
segment; that's what `identity` returns.  Test code working
with shards uses the canonical short form; logging and
debugging see the full identifier.

The project doesn't subclass `FriendlyIdentifier` — it just
constructs instances with the right pattern.  The framework's
class is sufficient.

## Why a class instead of plain strings

A reader's natural question: why not just use strings?  A
device has a name; the name is a string; why wrap it?

Three reasons:

1. **Multi-name lookup.**  A device has a full identifier
   *and* a hint.  Test code might use either; the
   `FriendlyIdentifier` provides both as separate fields and
   a unified equality.
2. **Identity extraction.**  The regex pattern lets the
   project define a canonical identity that's a *function* of
   the full identifier, not just one of its substrings.  This
   matters for projects with structured naming conventions.
3. **Type safety.**  A function that takes a
   `FriendlyIdentifier` is asking for a *device identity*,
   not just any string.  Type checkers catch the difference;
   readers see the intent.

The cost is a class allocation per device.  A landscape with
50 devices has 50 `FriendlyIdentifier`s.  At 8 instance
variables each, that's negligible.

## What the next chapter does

Chapter 5 walks Topology — the logical organisation overlay
on the Landscape's physical inventory.  The canonical
`clusters` shape (with `name`/`group`/`nodes`/`spares`),
the verification contract from Volume 2 chapter 7 walked at
the schema level, the relationship to Roles, and the
distinction from Landscape that the previous chapters set up
for the reader.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/05-topology-logical-organisation -->
# Chapter 5 — Topology — Logical Organisation and Roles, with Verification

> *Where the Landscape is the physical inventory, the Topology
> is the logical organisation laid on top of it.  The framework
> imposes one canonical schema — `clusters` with
> `name`/`group`/`nodes`/`spares` — consumed by
> `LandscapeOperationalLayer._create_clusters`.  Everything
> else is left to the user to implement.  This chapter walks
> what the framework provides; chapter 6 walks the verification
> overload; chapter 7 walks how a project subclasses Topology
> for everything else.*

The Landscape answered *what physical resources exist?*  The
Topology answers *how are those resources logically organised?*

The two are *separate categories* in `codecraftai-configuration`
(separate `MergeMap`s — `LANDSCAPE_CONFIGURATION_MAP` and
`TOPOLOGY_CONFIGURATION_MAP`).  The same Landscape can support
many Topologies (one fleet organised differently for different
test campaigns).  The same Topology pattern can apply to many
Landscapes (the cluster-with-spare shape applied to different
fleets).

Volume 3 chapter 6 walked the file role at the orchestration
layer; this chapter walks the schema and its consumption.

## Why Topology is left to the user

A point Volume 3 chapter 6 made and this chapter reinforces:
**there is no `codecraftai-topology` package.**  Topology is a
*configuration category*, not a package.  The framework's
implementation lives across two repos:

- `codecraftai-configuration` provides the loader for the
  Topology configuration files (the
  `TOPOLOGY_CONFIGURATION_MAP`, the resolver function, the
  `AM_CONFIG_TOPOLOGY_*` variables).
- `codecraftai-landscaping` provides the runtime consumer
  (`LandscapeConfigurationLayer.load_topology` reads the
  loaded data into `_topology_info`;
  `LandscapeOperationalLayer._create_clusters` consumes one
  canonical section).

Beyond the canonical `clusters` shape (below), the framework
imposes no schema.  Per the userguide
(`codecraftai-docs/userguide/03-03-topology-file.rst:7–10`):

> *The Automation Kit currently does not have a fixed format
> for the topology file.  The topology file is intended for
> organizations to be able to configure landscape resources
> to the specific unique project specific states.*

This is the meta-thesis from Volume 0 chapter 1 in action:
the framework supplies the skeleton (the loader, the canonical
shape, the verification hooks) and the patterns (subclass
`Topology` to interpret your custom shape).  The organisation
supplies the content.

## The canonical `clusters` shape

The one schema the framework consumes directly is the
`clusters` section of a Topology file.  The shape:

```yaml
clusters:
    - name: db-cluster
      group: replication
      nodes:
          - host-a
          - host-b
          - host-c
      spares:
          - host-d
```

Each cluster has:

- **`name`** — the cluster's identifier.
- **`group`** — the device group the cluster's nodes belong
  to.  Devices in the Landscape carry a `group:` field; the
  cluster collects devices from that group.
- **`nodes:`** — the active members of the cluster, by
  Landscape device name.
- **`spares:`** — the spare members, by Landscape device name.

The framework's consumer:

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/layers/landscapeoperationallayer.py:601–636`

```python
def _create_clusters(self, integ_layer: LandscapeIntegrationLayer, topology_info: Dict[str, Any]):
    """
        Creates operational clusters from topology configuration.
    """
    if "clusters" in topology_info:
        clusters = topology_info["clusters"]
        for cinfo in clusters:
            cname = cinfo["name"]
            group_name = cinfo["group"]
            nodes = cinfo.get("nodes", [])
            spares = cinfo.get("spares", [])

            if group_name in table_of_device_groups:
                cgroup = table_of_device_groups[group_name]
                coordinator: NodeCoordinatorBase = cgroup.coordinator
                cluster = coordinator.create_cluster_for_devices(cname, cgroup, nodes, spares)
                cluster.enhance_metadata()
                self._operational_clusters[cname] = cluster
```

For each cluster declaration:

1. Look up the device group by the `group` name.
2. Use the group's `NodeCoordinatorBase` to create a
   `LandscapeDeviceCluster` instance from the nodes and
   spares.
3. Call `cluster.enhance_metadata()` to populate cluster-
   specific metadata (cluster type, internal identifiers).
4. Store the cluster in `_operational_clusters` keyed by name.

After the loop, the framework's `_operational_clusters` dict
holds one `LandscapeDeviceCluster` instance per declared
cluster.

## The `LandscapeDeviceCluster` class

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/landscapedevicecluster.py:23`

```python
class LandscapeDeviceCluster:
    """
        A :class:`LandscapeDeviceCluster` object is used to manage a group of
        nodes that are part of a computer cluster and a collection of spares
        that are eligible to participate in the cluster.
    """

    def __init__(self, name: str, nodes: Dict[str, NodeBase],
                 spares: Dict[str, NodeBase], group: LandscapeDeviceGroup) -> None:
        self._name = name
        self._nodes = nodes
        self._spares = spares
        self._group = group
```

Four instance variables, four properties:

- `name` — the cluster's name.
- `nodes` — dict of `node_name → node_object`.
- `spares` — dict of `spare_name → spare_object`.
- `group` — the device group.

The class is small because the *runtime* concept of a cluster
is small.  A test that wants to query the cluster's
membership uses `cluster.nodes`; a test that wants to swap a
node for a spare uses the cluster's swap method (added by
project-specific subclasses); the framework's job is to
*hold* the cluster shape, not to *operate* the cluster.

Operating the cluster — running cluster-management commands,
verifying replication state, performing failovers — is the
project's responsibility, written into `LandscapeDeviceCluster`
subclasses or into test bodies.

## Why the canonical shape is just `clusters`

The framework picks `clusters` as the one canonical shape
because *every distributed test the portfolio has shipped*
needed cluster management.  A cluster is a recurring concept
across automation domains: distributed databases, distributed
queues, replicated services, redundant device fleets.  The
shape is uniform enough that the framework can offer
materialisation without imposing semantics.

What a cluster's *members do* — the protocol they speak, the
state machine they implement, the failover discipline — is
project-specific.  The framework provides the membership
materialisation; the project provides the operations.

## Beyond `clusters` — open schema

A typical Topology file goes well beyond `clusters`.
A representative shape with project-defined sections:

```yaml
environment:
    label: cross-region-failover

clusters:
    - name: primary
      group: web-tier
      nodes: [web-1, web-2, web-3]
      spares: [web-4]
    - name: secondary
      group: web-tier
      nodes: [web-5, web-6]
      spares: []

dependencies:
    - upstream: primary
      downstream: db-cluster
      direction: write
    - upstream: secondary
      downstream: db-cluster
      direction: read-only

roles:
    primary_lb: lb-1
    secondary_lb: lb-2
    monitor: monitor-1

regions:
    east: [primary, db-cluster]
    west: [secondary]
```

The `clusters` section is consumed by the framework.  The
`dependencies`, `roles`, and `regions` sections are
*user-defined*.  The framework loads them into the
`TOPOLOGY_CONFIGURATION_MAP` as raw dict data, but doesn't
*do* anything with them — the project's `Topology` subclass
interprets them.

## Composability

Per Volume 3 chapter 3's universal composability, Topology
files compose like every other category.  An automation
campaign typically composes:

```
Topology names: ["base-topology", "cross-region-overrides"]
→ Loader reads base-topology.yaml + cross-region-overrides.yaml
→ MergeMap overlay (later names win)
→ Single topology_info dict
→ LandscapeOperationalLayer._create_clusters consumes the
  merged clusters section
→ Project's Topology subclass consumes the rest of the merged dict
```

The composition is at the file level.  An override file
might add new clusters, replace a cluster's spares list,
add new project-specific sections, or override existing
ones.  The merged result is what activation sees.

This is what makes the four-environment ladder from Volume 3
chapter 13 work for Topologies.  Local-dev uses
`base-topology + dev-overrides`; CI uses
`base-topology + ci-overrides`; staging uses
`base-topology + staging-overrides`.  Same suite of tests,
four Topologies, four Pods.

## The role of `roles`

The `roles:` section in a Topology declares named roles for
specific devices.  Volume 6 chapter 1's Landscape declarations
already had `role:` per device; the Topology's `roles`
section adds a *Topology-specific* role overlay.

A device's `role:` in the Landscape is its *intrinsic* role
("this is a worker," "this is a controller").  A Topology's
`roles:` is a *configuration-specific* role assignment ("in
this Topology, the primary load balancer is `lb-1`").  The
two coexist; tests can query either:

```python
# Intrinsic role — Landscape level
workers = LandscapeSingleton().devices_by_role("worker")

# Topology role — Topology level
primary_lb = LandscapeSingleton().topology.role("primary_lb")
```

The `topology` accessor returns the framework's `Topology`
object (the subclass the project provided); `role(name)` is a
method the project's subclass implements to interpret the
`roles` section.

## When the canonical shape is enough

For projects whose distributed automation maps onto cluster
membership and not much else, the canonical `clusters` shape
is sufficient.  No subclass needed; the framework's
`LandscapeDeviceCluster` does the job.  Test code uses
`cluster.nodes` and `cluster.spares` directly.

For projects with richer Topology semantics, chapter 7 walks
the subclassing mechanism.

## What the next chapter does

Chapter 6 walks the verification overload — Topology as an
*executable assertion*, not just a description.  The framework
ships a `validate_topology()` stub; the project's subclass
overrides it to verify the configured state matches the
Topology's claim.  Chapter 6 walks the override pattern
in detail with worked examples for the canonical clusters
shape and a project-specific section.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/06-topology-as-executable-assertion -->
# Chapter 6 — Topology as an Executable Assertion

> *A Topology file declares the configured state of resources.
> The framework treats that declaration as an executable
> assertion: at job startup, the framework's `validate_topology`
> hook runs; if the configured state doesn't match the
> Topology's claim, the job refuses to run.  The framework
> ships a stub validator; the project supplies the override.*

The previous chapter walked the canonical `clusters` shape and
established that beyond that shape, Topology is open to the
user.  This chapter walks the *verification* overload — the
mechanism by which a Topology file becomes more than a
description.

The framing matters.  A Topology that's *just a description*
is useful documentation but doesn't catch CI mis-provisioning.
A Topology that's an *executable assertion* fails loudly when
the CI system has handed off resources that don't match the
declared configuration.  Volume 2 chapter 7 walked the
verification contract at the contract level; this chapter
walks the implementation in detail.

## Where verification happens

The activation flow from Volume 2 chapter 6:

1. `fire_activate_configuration()` — loads files, initialises
   credentials, calls `validate_topology()`.
2. `fire_activate_integration()` — instantiates devices and
   services from the loaded config.
3. `fire_activate_operations()` — brings devices online via
   Coordinators; runs `establish_connectivity` for Landscape
   verification (chapter 7 of Volume 2 walked the
   reachability check).

`validate_topology()` runs in phase 1, before devices are
even instantiated.  This is intentional: a Topology that
doesn't parse or doesn't match the Landscape's resource list
should fail *before* the framework spends time bringing
devices online.

## The shipped stub

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/layers/landscapeconfigurationlayer.py:536`

```python
def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
    """
        Validates the topology configuration.
    """
    errors = []
    warnings = []
    return errors, warnings
```

The shipped implementation passes everything.  Empty errors,
empty warnings.  Per the meta-thesis, this is intentional:
the framework supplies the *hook*; the organisation supplies
the *body*.

A reader's natural question: *if the stub passes everything,
how does verification ever fail?*

The answer: when the project subclasses `Landscape` (or
`Topology`) and overrides `validate_topology`.  Without the
override, no verification happens.  With the override, the
override decides what counts.

## The override — pattern

A representative override that verifies the canonical
`clusters` shape:

```python
class MyProjectLandscape(Landscape):

    def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
        errors = []
        warnings = []

        # Inherit the framework's checks (currently a no-op).
        framework_errors, framework_warnings = super().validate_topology(topology_info)
        errors.extend(framework_errors)
        warnings.extend(framework_warnings)

        # Verify each cluster's nodes are actually members.
        clusters = topology_info.get("clusters", [])
        for cdecl in clusters:
            cname = cdecl["name"]
            declared_nodes = cdecl.get("nodes", [])
            declared_spares = cdecl.get("spares", [])

            for node_name in declared_nodes:
                node_dev = self.devices.get(node_name)
                if node_dev is None:
                    errors.append(
                        "Cluster '{}' declares node '{}' but no such device "
                        "is in the integrated landscape.".format(cname, node_name)
                    )
                    continue

                # Project-specific membership query.
                actual_membership = node_dev.cluster_membership_query()
                if actual_membership.cluster_name != cname:
                    errors.append(
                        "Node '{}' should be member of cluster '{}' per topology, "
                        "but reports membership in '{}'.".format(
                            node_name, cname, actual_membership.cluster_name
                        )
                    )

                if actual_membership.role == "spare":
                    errors.append(
                        "Node '{}' should be active member of cluster '{}' per topology, "
                        "but reports spare role.".format(node_name, cname)
                    )

            for spare_name in declared_spares:
                spare_dev = self.devices.get(spare_name)
                if spare_dev is None:
                    errors.append(
                        "Cluster '{}' declares spare '{}' but no such device "
                        "is in the integrated landscape.".format(cname, spare_name)
                    )
                    continue

                actual_membership = spare_dev.cluster_membership_query()
                if actual_membership.role != "spare":
                    errors.append(
                        "Node '{}' should be spare of cluster '{}' per topology, "
                        "but reports role '{}'.".format(
                            spare_name, cname, actual_membership.role
                        )
                    )

        return errors, warnings
```

The override:

- Calls `super().validate_topology(topology_info)` first (good
  citizen — preserves framework checks if any get added).
- Walks each cluster declaration.
- For each declared node, queries the live device for its
  cluster membership (a project-specific method
  `cluster_membership_query()` on the project's
  `LandscapeDevice` subclass).
- Reports errors when the live state doesn't match the
  declaration.
- Returns `(errors, warnings)`.

When the framework calls `validate_topology(topology_info)`
and the return list of errors is non-empty, activation fails.
The Pod is not realised.  Tests do not run.

## The override — verifying user-defined sections

For a Topology with project-specific sections beyond
`clusters`, the override extends to verify them too.  Suppose
the Topology has:

```yaml
clusters:
    - { name: primary, group: web-tier, nodes: [w1, w2, w3], spares: [w4] }

dependencies:
    - upstream: primary
      downstream: db-cluster
      direction: write
```

The override adds dependency verification:

```python
def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
    errors = []
    warnings = []

    # ... cluster verification as before ...

    # Verify each dependency's upstream and downstream are real clusters.
    dependencies = topology_info.get("dependencies", [])
    for dep in dependencies:
        upstream_name = dep["upstream"]
        downstream_name = dep["downstream"]

        if upstream_name not in self._operational_clusters:
            errors.append(
                "Dependency declares upstream '{}' that is not an operational cluster.".format(
                    upstream_name
                )
            )

        if downstream_name not in self._operational_clusters:
            errors.append(
                "Dependency declares downstream '{}' that is not an operational cluster.".format(
                    downstream_name
                )
            )

        # Project-specific: verify the actual wiring.
        if upstream_name in self._operational_clusters and downstream_name in self._operational_clusters:
            upstream = self._operational_clusters[upstream_name]
            downstream = self._operational_clusters[downstream_name]

            wiring = check_wiring(upstream, downstream)
            if wiring.direction != dep["direction"]:
                errors.append(
                    "Dependency '{}' → '{}' should be '{}' per topology, "
                    "but wiring shows '{}'.".format(
                        upstream_name, downstream_name,
                        dep["direction"], wiring.direction
                    )
                )

    return errors, warnings
```

The pattern repeats.  Each project-specific section in the
Topology gets a verification block in the override.  The
framework holds the dispatch; the project writes the checks.

## What verification catches

The classes of failure verification catches:

### CI provisioning errors

The most common case.  CI provisioned a 3-node cluster but
the Topology declared 4.  Verification catches the mismatch
and refuses to run; the orchestrator (Volume 3 chapter 21)
treats it as an infrastructure failure, re-provisions, and
retries.

### Configuration drift

A previously-correct Pod was modified between CI runs (a
manual reconfiguration, an automated tool, a partial
deployment).  Verification catches the drift; the
orchestrator can decide whether to repair the Pod or fail.

### Topology / Landscape mismatch

The Topology declares a cluster with `nodes: [w1, w2, w3]`
but the Landscape only declares `w1` and `w2` (typo,
incomplete merge, missing override file).  Verification's
"no such device in the integrated landscape" error catches
the mismatch.

### Stale Topology files

A Topology file from yesterday's experiment still in the
config repo, accidentally selected by today's CI.
Verification's structural checks fail because today's Pod
shape doesn't match yesterday's Topology.  Better than
running tests against a wrong-shape Pod and getting confusing
results.

## What verification deliberately does NOT do

Three things verification deliberately doesn't catch:

### Application-level state

"The database has 1,000 rows of seed data."  This is fixture
territory (Volume 1 chapter 3), not verification territory.
Verification is about *infrastructure shape*, not data.

### Software versions

"Every node runs Postgres 16."  Useful, but not a Topology
concern — closer to a separate "version-pinning" verification
the project might add as its own validator.  The framework's
verification is for declared topology-level state.

### Behavioural correctness

"The cluster fails over within 30 seconds."  This is *test*
territory — a tandem validator (Volume 2 chapter 5) is the
right shape for behavioural observation during a test, not a
pre-test verification.

## When verification fails — what the orchestrator sees

The framework propagates verification failures as errors that
abort `activate_configuration`.  The error includes the list
of `errors` returned by the override, joined for human
reading.  A representative orchestrator log:

```
[2026-05-01 15:34:22] Activation failed in fire_activate_configuration:
    Topology validation failed.
    ERRORS:
        Cluster 'primary' declares node 'w3' but no such device is in the integrated landscape.
        Cluster 'primary' declares spare 'w4' but no such device is in the integrated landscape.
        Dependency 'primary' → 'db-cluster' should be 'write' per topology, but wiring shows 'none'.
    Job refused to proceed.
```

The orchestrator reads the structured failure (Volume 3
chapter 21's verified-contract step), decides what to do, and
either re-provisions or escalates.  The framework's job ends
at "refused to proceed"; the orchestrator's job picks up.

## The `validate_topology` flag

`LandscapeActivationParams.validate_topology` (default
`True`) is the on/off switch for verification.  When `False`,
the override is skipped and any topology mismatch goes
uncaught.

When to set `False`:

- **Topology development.**  When a project is *writing* its
  override and wants to observe what raw Topology data is
  loaded without the override running.
- **Single-host smoke runs.**  Where the Topology is
  irrelevant because no clusters exist.
- **Inspection-only runs.**  Where the test job is just
  examining configuration without exercising it.

When to keep `True` (the default):

- **Every production CI run.**

The strict default is intentional.  A project that
accidentally relaxes verification has to *deliberately* set
the flag to `False`; the framework refuses to silently
downgrade.

## The relationship to Vol 2 ch.5's validators

Topology verification is *pre-test* — runs once at activation,
before any test fires.  Tandem validators (Volume 2 chapter
5) are *during-test* — run alongside the test, observing
mid-test state.  The two are complementary:

- **Verification** catches "the Pod is wrong" failures
  before they confuse tests.
- **Tandem validators** catch "the Pod's behaviour is wrong"
  failures during tests.

A test-with-validators against a verified-Topology Pod is
the framework's full guarantee: the starting state is right
(verified), the actor's effects are observed (validators),
the test's outcome is reproducible (job_seed plus verified
state).

## What the next chapter does

Chapter 7 walks the subclassing mechanism in full — how a
project subclasses `Landscape` and `Topology` to plug in its
custom Feature handling, its custom Topology shapes, and its
custom verification.  The chapter walks the
`codecraftai-landscaping/wellknown.py:LandscapeSingleton`
factory-overridable singleton, the registration pattern, and
the integration with the `TestJob` customisation cascade
from Volume 2 chapter 4.

The skeleton + patterns meta-thesis from Volume 0 chapter 1
lands here at its sharpest: subclassing is the concrete
mechanism that ties every "left to the user" note in the
volumes to a real engineering action.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/07-subclassing-landscape-and-topology -->
# Chapter 7 — Subclassing `Landscape` and `Topology` — The Customisation Mechanism

> *Volume 0 chapter 1's meta-thesis: framework supplies the
> skeleton and patterns; the organisation customises and
> refines.  This chapter walks the *concrete mechanism* for the
> environment model: subclass `Landscape` (and possibly
> `Topology` and `LandscapeDevice`), register through the
> factory protocol, plug into the user's `TestJob` subclass.
> One mechanism, many extension points.*

The previous chapters set up everything a project needs to
know about *what* the framework provides.  This chapter walks
*how the project's customisations land in the framework's
machinery*.

The mechanism: subclassing.  Three subclass points the
project typically uses, and one factory protocol that
registers them so the framework picks them up.

## What gets subclassed

Three classes most projects subclass:

### `Landscape`

The project's Landscape subclass adds:

- Recognition of project-specific Feature names (the Feature
  dispatcher from chapter 2).
- A `validate_topology()` override (chapter 6).
- Project-specific properties that surface configuration
  data to test code (`landscape.region`, `landscape.tier`,
  etc.).
- Project-specific accessors that interpret user-defined
  Topology sections (`landscape.topology.dependencies`,
  `landscape.topology.regions`).

### `LandscapeDevice` (and per-deviceType subclasses)

The project's per-deviceType `LandscapeDevice` subclasses
add:

- Per-Feature attach steps for project-specific Features.
- Project-specific properties exposing Feature data
  (`device.gpu_count`, `device.cooling_loop`).
- Per-deviceType helper methods that wrap common operations
  (`device.cluster_membership_query()` from chapter 6's
  worked example).

### `Topology` (when the framework's defaults aren't enough)

If the framework ships a `Topology` base class with parsing
behaviour (and as of writing, the consumption is in
`LandscapeOperationalLayer._create_clusters`, not in a
`Topology` class per se — the architecture is evolving), a
project subclasses to add user-defined-section parsing.

In current source, the project's customisation entry for
Topology is the `validate_topology` override on the
`Landscape` subclass plus accessor methods on the
`Landscape` subclass that read `_topology_info` for
project-specific sections.  An emerging `Topology` class with
explicit parsing methods would be a natural extension point.

## How subclasses get registered

The mechanism is the **factory protocol** from Volume 0
chapter 7.  Briefly:

1. The framework defines a *factory protocol* — an interface
   (typically an `ccai.factories.protocols.<Something>FactoryProtocol`
   class) declaring the methods that produce framework
   objects.
2. The project provides an *implementation* — a class that
   inherits from the framework's `FactoryExtension` base and
   implements the protocol's methods to return the project's
   subclasses.
3. The project's implementation is registered through a
   *namespace package entry point* (the
   `ccai.factories` namespace in the project's
   `pyproject.toml` or equivalent).
4. At framework startup,
   `ConfiguredSuperFactorySingleton()` discovers the
   project's implementation by walking the namespace package.

For the Landscape, the relevant protocol is
`LandscapingFactoryProtocol`:

```python
# codecraftai-landscaping/source/packages/ccai/landscaping/landscapingfactoryprotocol.py
# (approximate — actual class name from the singleton lookup)

class LandscapingFactoryProtocol:

    @classmethod
    def get_landscape_type(cls) -> Type[Landscape]:
        """
            Return the Landscape class to instantiate for this project.
        """
        return Landscape  # default
```

A project's implementation:

```python
# my_project/factories.py

from ccai.factories.factoryextension import FactoryExtension
from ccai.landscaping.landscapingfactoryprotocol import LandscapingFactoryProtocol

from my_project.landscape import MyProjectLandscape


class MyProjectLandscapingFactory(FactoryExtension, LandscapingFactoryProtocol):

    PRECEDENCE = 30

    @classmethod
    def get_landscape_type(cls) -> Type[Landscape]:
        return MyProjectLandscape
```

The `PRECEDENCE = 30` is the priority; multiple registered
implementations are sorted by precedence.  Higher precedence
wins.

The framework's `LandscapeSingleton()` function (chapter 1):

```python
# codecraftai-landscaping/source/packages/ccai/landscaping/wellknown.py:28

def LandscapeSingleton() -> "Landscape":
    global LANDSCAPE_SINGLETON

    if LANDSCAPE_SINGLETON is None:
        super_factory = ConfiguredSuperFactorySingleton()

        SINGLETON_LOCK.acquire()
        try:
            from ccai.landscaping.landscapingfactoryprotocol import LandscapingFactoryProtocol

            if LANDSCAPE_SINGLETON is None:
                LandscapeType = super_factory.get_override_types_by_order(
                    LandscapingFactoryProtocol.get_landscape_type)

                if LandscapeType is None:
                    from ccai.landscaping.landscape import Landscape
                    LandscapeType = Landscape

                LANDSCAPE_SINGLETON = LandscapeType()
        finally:
            SINGLETON_LOCK.release()

    return LANDSCAPE_SINGLETON
```

The factory lookup walks registered implementations of
`get_landscape_type`, sorted by precedence.  The first one
that returns a non-None class wins.  If no implementation is
registered, falls back to the base `Landscape`.

## The integration with `TestJob` (Volume 2 chapter 4)

Volume 2 chapter 4 walked the `TestJob` inheritance chain:

```
TestJob → InteropLibraryTestJob → DistributedTestJob
```

`DistributedTestJob._get_landscape()` returns
`LandscapeSingleton()`.  The singleton returns the project's
Landscape subclass (if registered) or the framework's default.

So the customisation cascade is:

1. Project subclasses `Landscape` → `MyProjectLandscape`.
2. Project subclasses `LandscapeDevice` → `MyProjectDevice`
   (per deviceType, possibly multiple).
3. Project registers a `LandscapingFactoryProtocol`
   implementation that returns `MyProjectLandscape`.
4. Project may also subclass `DistributedTestJob` if
   additional job-level customisation is needed (Volume 2
   chapter 4 covered this).
5. The CI / orchestration runs
   `distributed_test_entrypoint(job_type=YourJob)`.
6. The job's `_get_landscape()` calls `LandscapeSingleton()`,
   which returns `MyProjectLandscape`.
7. The framework's activation walks `MyProjectLandscape`'s
   `validate_topology`, instantiates `MyProjectDevice`s for
   each device, dispatches Features through the project's
   subclass methods.

The chain is uniform.  Every `codecraftai-*` extension point
goes through some version of this pattern: framework declares
a protocol, project implements it, namespace package
registers it, factory walks the registrations at startup.

## A worked subclass — start to finish

Suppose a project's automation domain involves devices with a
custom `cooling_loop` Feature that references a glycol-loop
service.

### 1. The custom Landscape

```python
# my_project/landscape.py

from ccai.landscaping.landscape import Landscape
from typing import List, Tuple


class MyProjectLandscape(Landscape):

    def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
        errors = []
        warnings = []

        # Inherit framework checks.
        framework_errors, framework_warnings = super().validate_topology(topology_info)
        errors.extend(framework_errors)
        warnings.extend(framework_warnings)

        # Verify custom 'cooling_zones' section.
        cooling_zones = topology_info.get("cooling_zones", [])
        for zone in cooling_zones:
            zone_name = zone["name"]
            zone_devices = zone.get("devices", [])
            for dev_name in zone_devices:
                dev = self.devices.get(dev_name)
                if dev is None:
                    errors.append(
                        "Cooling zone '{}' references device '{}' not in landscape.".format(
                            zone_name, dev_name
                        )
                    )

        return errors, warnings
```

### 2. The custom Device

```python
# my_project/devices.py

from ccai.landscaping.landscapedevice import LandscapeDevice
from my_project.coolingextension import CoolingProtocolExtension


class MyProjectDevice(LandscapeDevice):

    def attach_features(self):
        super().attach_features()  # built-in Features

        if "cooling_loop" in self._features:
            cooling_spec = self._features["cooling_loop"]
            cooling_ext = build_cooling_extension(self, cooling_spec)
            self.attach_extension("cooling_loop", cooling_ext)
        return

    @property
    def is_isolated(self) -> bool:
        rtnval = self._features.get("isolation", False)
        return rtnval

    @property
    def cooling_zone(self) -> str:
        rtnval = self._features.get("cooling_loop", {}).get("zone", "default")
        return rtnval
```

### 3. The factory implementation

```python
# my_project/factories.py

from typing import Type

from ccai.factories.factoryextension import FactoryExtension
from ccai.landscaping.landscape import Landscape
from ccai.landscaping.landscapingfactoryprotocol import LandscapingFactoryProtocol

from my_project.landscape import MyProjectLandscape


class MyProjectLandscapingFactory(FactoryExtension, LandscapingFactoryProtocol):

    PRECEDENCE = 30

    @classmethod
    def get_landscape_type(cls) -> Type[Landscape]:
        rtnval = MyProjectLandscape
        return rtnval
```

### 4. The namespace registration

In the project's `pyproject.toml`:

```toml
[project]
name = "my-project"

[tool.setuptools.packages.find]
namespaces = true

# Namespace package: my_project lives under ccai.extensions
[tool.setuptools]
package-dir = {"" = "src"}
```

And the project structure includes a `src/ccai/extensions/my_project/__init__.py`
that re-exports `MyProjectLandscapingFactory`.  Volume 0
chapter 7 walked the namespace-package pattern in detail.

### 5. Use

When a `DistributedTestJob` runs, `LandscapeSingleton()`
returns `MyProjectLandscape`.  The Landscape's
`validate_topology` runs, including the cooling-zone
verification.  Devices instantiate as `MyProjectDevice`
(through additional per-deviceType factory machinery), and
their `attach_features` recognises the `cooling_loop`
Feature, attaching a `CoolingProtocolExtension` so test code
can call `device.cooling_loop.set_target_temp(...)`.

The framework code never changed.  The project's code is
self-contained in its own package.  The integration is
through the factory + subclass pattern.

## What this gives the project

By subclassing, the project has:

- **Full control over what the Topology means.**  Custom
  sections, custom verification, custom accessors.
- **Full control over what Features mean.**  Built-in plus
  any number of project-specific.
- **No fork of the framework.**  Project lives in its own
  package; framework lives in its own package; integration
  through the factory protocol.
- **Composability with other projects.**  Multiple registered
  factories with different precedences let
  framework-supplied + tier-supplied + project-supplied
  customisations coexist.

This is the Volume 0 chapter 1 meta-thesis at its sharpest:
the framework opinionates the *patterns* (subclass +
register + plug into TestJob); the organisation supplies
the *bodies* (what the subclasses do).

## What's not customisable

A few things the framework deliberately doesn't expose for
customisation:

- **The TestJob lifecycle order.**  The five `fire_*` hooks
  fire in a specific order; subclasses override individual
  hooks but cannot reorder them.  The order is the contract
  Volume 1 chapter 2 established.
- **The single-actor + tandem-observer model.**  No subclass
  hook lets a project run two tests concurrently in a Pod.
  The determinism commitment is structural (Volume 2 chapter
  2).
- **The JSOS streaming format.**  Subclasses produce records
  with the same shape as the framework's; the format is fixed
  to keep the agents CLI (Volume 1 chapter 6) consumer
  contract stable.

These non-customisations are *features*: they prevent
subclasses from breaking framework-wide invariants the rest
of the series assumes.  A subclass that respected the
non-customisable bits while customising everything else is
the framework's intended shape.

## What the next chapter does

Chapter 8 walks `BaseExt` — a *different* extension model
the framework uses for service-specific code (the VMware
vSphere worked example).  It is *not* the same extension
point as `ProtocolExtension` or `Landscape` subclassing; it
exists for adding service-specific Coordinator helpers
(e.g., a vSphere agent that knows how to query topology from
vCenter).  The chapter distinguishes the two extension models
clearly.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/08-service-specific-baseext -->
# Chapter 8 — Service-Specific Framework Extensions

> *`BaseExt` is a different extension model from
> `ProtocolExtension`.  Where ProtocolExtensions wrap protocol
> agents and attach to devices, `BaseExt` is the
> service-specific framework-extension class for adding
> infrastructure-API integrations (the VMware vSphere model
> shipped in source).  This chapter walks `BaseExt` and
> distinguishes it from the other extension points the volume
> has covered.*

The framework has *two* extension models for the environment
layer.  Chapter 3 walked the first: `ProtocolExtension`
attached to a `LandscapeDevice`, providing the device's
protocol surface.  This chapter walks the second: `BaseExt`,
which extends a *service*'s machinery with infrastructure-API
helpers.

The two models look superficially similar — both are
"extensions" in name — but they serve different purposes.
The disambiguation matters because mixing them up makes the
code hard to read and the documentation confusing.

## The two models compared

| | `ProtocolExtension` | `BaseExt` |
|---|---|---|
| Wraps | A protocol Agent | A service-specific helper class |
| Attaches to | A `LandscapeDevice` | A service implementation (e.g. `VSphereAgent`) |
| Purpose | Expose protocol on device (`device.ssh`) | Add API capability to service (`agent.Topology`) |
| User-extension | Subclass for new protocols | Subclass for new infrastructure APIs |
| Scope | Per-device | Per-service-type |
| Reference | Chapter 3 of this volume | This chapter |

Both are "left to the user" extension points but they extend
*different parts of the framework*.

## The `BaseExt` shape

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/services/vmware/vsphere/ext/baseext.py:22`

```python
class BaseExt:
    """
        Base class for all vSphere extensions used by a :class:`VSphereAgent`.
    """

    def __init__(self, agent: "VSphereAgent"):
        """
            Creates a base extension bound to a vSphere agent.

            :param agent: Owning :class:`VSphereAgent` instance.
        """
        self._agent_ref = weakref.ref(agent)
```

A `BaseExt` is small.  It holds a weak reference to its
*owning agent* — in the shipped example, a `VSphereAgent` that
talks to vCenter.  The base class provides no methods beyond
the constructor; subclasses implement the actual API helpers.

## A worked subclass — `TopologyExt`

**File:** `codecraftai-landscaping/source/packages/ccai/landscaping/services/vmware/vsphere/ext/topologyext.py:23`

```python
class TopologyExt(BaseExt):
    """
        Extension that retrieves topology information from vCenter.
    """

    def __init__(self, agent: "VSphereAgent"):
        """
            Initializes the topology extension.

            :param agent: Owning :class:`VSphereAgent` instance.
        """
        super().__init__(agent)
        return
```

`TopologyExt` extends `BaseExt` to add vSphere-specific
topology queries.  In the full implementation, the class adds
methods like:

- `list_datacenters()` — returns the vCenter's known
  datacenters.
- `list_clusters(datacenter)` — returns clusters in a
  datacenter.
- `list_hosts(cluster)` — returns the hosts in a cluster.
- `query_vm_topology(vm)` — returns a VM's host /
  cluster / datacenter context.

The methods all reach through `self._agent_ref()` to the
vSphere agent, which holds the connection state and credentials.

The agent then exposes the extension as a property:

```python
# Approximate shape — the agent's __init__ wires up its extensions.

class VSphereAgent:

    def __init__(self):
        # ...
        self._topology_ext = TopologyExt(self)
        self._datacenter_ext = DatacenterExt(self)
        self._vm_ext = VmExt(self)
        # ...

    @property
    def Topology(self) -> TopologyExt:
        return self._topology_ext
```

Test code that wants vSphere topology data:

```python
agent = ... # vSphere agent for the lab
datacenters = agent.Topology.list_datacenters()
for dc in datacenters:
    clusters = agent.Topology.list_clusters(dc)
    # ...
```

The pattern is *namespacing*: `agent.Topology.<method>`
groups topology-related methods under a clear prefix.
`agent.Datacenter.<method>` would be datacenter-related;
`agent.VM.<method>` VM-related.  Without `BaseExt`, all the
agent's methods would be flat on the agent class.  With
`BaseExt`, the agent's surface is hierarchical.

## When to use `BaseExt`

The framework's discipline: when a *service*'s API surface
is large enough that flat exposure becomes unwieldy, group
related methods through `BaseExt` subclasses.

For the vSphere example, the agent has dozens of methods —
datacenters, clusters, hosts, VMs, networks, datastores,
snapshots, tags, alarms.  A flat agent class would be
hundreds of methods.  `BaseExt` partitions the surface into
intuitive namespaces.

For smaller services, `BaseExt` is overkill.  A service with
five methods just exposes them flat on the service class.
The decision is the project's; the framework provides the
pattern, the project uses it when warranted.

## Distinction from project-specific Topology subclassing

A reader might ask: *if `TopologyExt` exists for vSphere
topology queries, why is there also Topology subclassing
(chapter 7)?*  The two are different.

- **`TopologyExt`** queries vCenter for *vCenter's notion of
  topology* (datacenters, clusters of physical hosts).  This
  is the *infrastructure provider's* topology.
- **Project's `Topology` subclass** parses the project's
  *Topology configuration file* (the YAML the project
  authors).  This is the *automation's* topology — the
  cluster-with-spare shape, the project-specific sections.

The two coexist because a project running automation against
vSphere-provisioned infrastructure cares about both: the
*provider's* topology (which physical host is the VM running
on?) and the *automation's* topology (which logical cluster
is the VM a member of?).  Two questions, two answers, two
extension mechanisms.

## The factory protocol for `BaseExt`-based services

When a project wants to add a new service-specific extension
beyond vSphere — say, an Active Directory service or a
Kubernetes service — the pattern is:

1. **Subclass `ServiceCoordinatorBase`** (chapter 3) for the
   new service type.
2. **Implement the agent class** that talks to the service's
   API (e.g., `AdAgent` for Active Directory).
3. **Subclass `BaseExt`** for each logical group of API
   methods.
4. **Wire the extensions on the agent** in `__init__`.
5. **Register through the factory protocol** so the framework
   discovers the new service type.

The pattern reuses everything established in earlier
chapters.  The framework's `BaseExt` is the *organising
pattern* for the service's API surface; everything else is the
project's API.

## The relationship to `ControlExtension`

A point of confusion the chapter should clear up:
`ControlExtension` (chapter 3) is yet another extension
class.  Where:

- `ProtocolExtension` extends a `LandscapeDevice` with a
  protocol surface (`device.ssh`).
- `ControlExtension` extends a `LandscapeDevice` with a
  service-driven *control point* (e.g., a power-control
  service exposing `device.power`).
- `BaseExt` extends a *service's agent* with namespaced API
  helpers (`agent.Topology`).

Three different extension mechanisms, three different
purposes.  The Volume 6 reader keeps them straight by
remembering *what each one extends*:

- `ProtocolExtension` extends a Device.
- `ControlExtension` extends a Device (from a Service).
- `BaseExt` extends a Service-agent.

When in doubt, the chapter that introduces the relevant class
will disambiguate.

## What the next chapter does

Chapter 9 walks the **Pod model** in concrete detail — the
runtime instance produced by combining a Landscape and a
Topology, the materialisation of clusters from the canonical
shape, the unit of horizontal CI parallelism.  This chapter
established the two extension models; chapter 9 closes Part
II with the high-level picture of what the activated
environment looks like at runtime.

Part III then walks provisioning, UI automation, GitLab, and
Flask scaffolding — additional packages in the volume's
scope.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/09-the-pod-model -->
# Chapter 9 — The Pod Model — Landscape + Topology Realised

> *Chapters 1 through 8 walked the schemas and the
> implementation.  This chapter walks what's at runtime when
> activation completes — the **Pod**, the unit of horizontal
> CI parallelism, the integrated state of a Landscape plus a
> Topology that test code runs against.*

The Pod is a concept that crosses Volumes 1, 2, 3, and 6.
Each volume carries part of the picture:

- Volume 1 chapter 9 introduced the vocabulary at the
  contract level.
- Volume 2 chapter 3 walked the Pod as the unit of horizontal
  parallelism.
- Volume 3 chapter 4 walked Specifications as the
  Pod-provisioning mechanism.
- Volume 6 (this volume) walks the *runtime instance* — what
  the framework holds in memory after activation.

This chapter closes Volume 6's Part II by assembling all the
pieces into one runtime picture.

## What a Pod is at runtime

A Pod is the runtime instance produced when a
`DistributedTestJob` activates against a Landscape and a
Topology.  Operationally, a Pod is:

- A `Landscape` singleton instance (or a project's subclass).
- Its `_layer_configuration._landscape_info` and
  `_topology_info` dicts.
- Its `_layer_integration._integrated_devices` and
  `_integrated_services` dicts holding `LandscapeDevice` and
  `LandscapeService` instances.
- Its `_layer_operational._operational_clusters` dict holding
  `LandscapeDeviceCluster` instances materialised from the
  Topology.
- The Coordinators managing each subset of devices.
- The ProtocolExtensions attached to each device.
- The `CredentialManager` holding resolved credentials.
- The `DataProfileManager` (Volume 2 chapter 12) holding
  resolved data profiles.

The Pod *is* the in-memory state.  Test code reaches into it
through `LandscapeSingleton()` and the device / service /
cluster accessors.

## The realisation flow

A reader should be able to walk Pod realisation step-by-step.
The full flow:

1. **CI tooling** reads a Specification (Volume 3 chapter 4),
   checks out or creates resources, emits a Landscape file
   and a Topology file.
2. **Orchestration** invokes a `DistributedTestJob` with the
   Landscape and Topology names as inputs.
3. **`DistributedTestJob.__enter__`** runs `begin()`, which
   constructs the recorder and the `DistributedTestSequencer`.
4. **`fire_attach_to_environment`** and
   **`fire_attach_to_framework`** propagate the landscape to
   integration couplings.
5. **`fire_activate_configuration`** calls
   `landscape.activate_configuration()`:
   - `LandscapeConfigurationLayer.load_landscape` reads the
     Landscape file via the
     `LANDSCAPE_CONFIGURATION_MAP`.
   - `load_topology` reads the Topology file.
   - `initialize_credentials` resolves the credentials.
   - `validate_topology` (the project's override) verifies
     the Topology's claim against the actual environment.
6. **`fire_activate_integration`** calls
   `landscape.activate_integration()`:
   - For each declared device: look up the right Coordinator
     by deviceType, call
     `coordinator.create_landscape_device(...)`, get back a
     `LandscapeDevice` (or project subclass).
   - For each device, walk the `_features` dict: built-in
     Features get their ProtocolExtensions wired
     automatically; project-specific Features are dispatched
     through the project's `LandscapeDevice` subclass's
     `attach_features`.
   - Same for services and the special-purpose subsystems
     (power, serial).
   - Result: `_integrated_devices` and `_integrated_services`
     are populated.
7. **`fire_activate_operations`** calls
   `landscape.activate_operations()`:
   - `LandscapeOperationalLayer.activate_coordinators` —
     each Coordinator probes its devices, brings them
     online, populates metadata.
   - `_create_clusters` consumes the `clusters` section of
     the Topology, materialises `LandscapeDeviceCluster`
     instances.
   - `establish_connectivity` verifies every device is
     reachable.
8. **The Pod is realised.**  Test code can now call
   `landscape.device(name)`, `landscape.devices_by_role(role)`,
   `landscape.cluster(name)`, etc.

After step 8 the Pod's runtime state is complete.  Tests run
against it sequentially (Volume 2 chapter 8).

## What test code sees

A test inside a realised Pod has the following accessors:

```python
landscape = LandscapeSingleton()

# Single device by name
casey_01 = landscape.device("casey-01")
casey_01.ssh.run_cmd("uptime")          # primary access
casey_01.serial.write("...")            # service-reference Feature
casey_01.power.cycle()                  # service-reference Feature

# Devices by role (Landscape's role: field)
workers = landscape.devices_by_role("worker")
for w in workers:
    w.ssh.run_cmd("...")

# Cluster by name
db_cluster = landscape.cluster("db-cluster")
for node in db_cluster.nodes.values():
    node.ssh.run_cmd("...")
spares = list(db_cluster.spares.values())

# Project-specific subclass accessors
zone_a_devices = landscape.devices_in_cooling_zone("zone-a")  # custom

# Service by name
vault = landscape.service("lab-vault")
vault.api.get("/secrets/...")           # service-specific extension

# Topology accessors (project-specific)
deps = landscape.topology.dependencies("primary")  # custom
```

The accessors are *uniform* — devices, services, and
clusters are queried the same way regardless of how they
were declared (apod / infrastructure / project-specific
sections).  The Pod's runtime exposes a clean test surface.

## The Pod's properties as a unit of parallelism

Volume 2 chapter 3 walked these properties; this chapter
restates them with the schema-level grounding from Volume 6:

### Internal interoperability

Within a Pod, the framework has *wired* every device's
ProtocolExtensions, every Cluster's membership, every
service's connection.  Test code calling `device.ssh.run_cmd`
hits a real SSH session; calling `cluster.nodes`
returns the actual node instances; calling
`service.db.execute` hits the actual database connection.
The framework's activation built all the wires.

### External isolation

Pods don't share state.  When CI runs ten Specifications
producing ten Pods, each Pod has its *own* Landscape
singleton, its *own* Coordinators, its *own* Credentials
manager.  Effects in Pod A — a cluster failover, a service
restart, a credential rotation — *cannot* propagate to Pod
B because Pod B has no shared object referencing Pod A's
resources.

The isolation property depends on Pod independence at the
*infrastructure* level (different physical resources, different
network namespaces, different shared services).  The framework
assumes this; the orchestration tier (Volume 3 chapter 4) is
what provides it through Specifications.

### Single-actor execution

Within a Pod, one test runs at a time (Volume 2 chapter 2's
commitment).  Tandem validators (Volume 2 chapter 5) attach to
the test for parallel observation; the test is the only
actor.

## When Pod realisation fails

Pod realisation can fail at each step:

- **Step 5 — Configuration.**  Files don't parse, credentials
  don't resolve, `validate_topology` rejects the topology.
  The framework's `ConfigurationError` or `RuntimeError`
  bubbles; activation aborts.
- **Step 6 — Integration.**  Unknown deviceTypes, misconfigured
  Features, Coordinator init errors.  Activation aborts.
- **Step 7 — Operational.**  `establish_connectivity` finds
  unreachable devices.  If `allow_missing_devices=False`,
  activation aborts; if `True`, activation continues with the
  missing devices flagged.

Each failure mode produces a *structured error report* the
orchestrator can read (Volume 3 chapter 21).  The
orchestrator decides whether to re-provision the Pod, mark
the run as infrastructure-failed, or escalate the campaign.

## The Pod as the unit of horizontal CI parallelism

The framework's commitment from Volume 2 chapter 3:

| Level | Parallel? | Why |
|---|---|---|
| Within a test | No | Single actor commitment |
| Across tests in one Pod | No | Shared environment |
| Across Pods | **Yes** | Pods are independent |

Horizontal scaling at the CI layer is *more Pods*.  Volume 3
chapter 4 walked Specifications as the Pod-provisioning
mechanism.  When a campaign needs to finish faster, the
orchestrator runs more Specifications concurrently → more
Pods → more `DistributedTestJob` processes → more parallel
test execution at the *job* level, with serial test
execution within each job.

## What the next chapter does

Chapter 10 walks the **provisioning** layer that produces
Landscapes and Topologies from Specifications.  The chapter
is honest about the current state: `codecraftai-provisioning` is
in scaffolding stage — the abstract `CloudProvisioner` is
empty, the AWS / Azure / GCP backends are stub subclasses,
the `ProvisioningEngine` parses YAML but doesn't yet
implement checkout-or-create.  Chapter 10 walks the *intended
shape* with anchors on the present scaffolding and forward
references to where the implementation will land.

This is the volume's first chapter on a topic outside
Landscape/Topology proper.  Chapters 11 and 12 cover UI
automation; chapters 13 and 14 cover the GitLab and Flask
worked examples; chapter 15 is the volume's case study.

End of Part II.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/10-cloud-provisioning-model -->
# Chapter 10 — Cloud Provisioning Model

> *Volume 3 chapter 4 walked Specifications as the
> orchestration mechanism that produces Landscape and Topology
> files.  This chapter walks the package that's intended to do
> the *creation* side: `codecraftai-provisioning`.  An honest
> caveat first — the package is currently scaffolding.  The
> chapter walks the intended shape, anchors on the present
> scaffolding, and forward-references the implementation.*

A reader who reaches this chapter expecting a tour of working
AWS / Azure / GCP backends will be disappointed.
`codecraftai-provisioning` exists as a *scaffolding* in the
portfolio — the abstract `CloudProvisioner` is empty, the
vendor backends are stub subclasses, the `ProvisioningEngine`
parses YAML but doesn't yet perform checkouts or creations.

This chapter is honest about that.  The framework's *intent*
is clear (vendor-neutral cloud provisioning that produces
Landscape and Topology files for the next stage), but the
*implementation* is still under construction.  A reader
contributing to the package can use this chapter as the
entry point; a reader using the framework today should treat
the chapter as forward-reference and lean on Volume 3 chapter
4 for what's currently shipping.

## What the package looks like today

**File structure:**

```
codecraftai-provisioning/source/packages/ccai/provisioning/
├── model/cloud/
│   ├── cloudprovisioner.py     ← the abstract base
│   ├── cloudvenue.py           ← named cloud account / project
│   ├── cloudgroup.py           ← resource group within a venue
│   ├── cloudresourceprofile.py ← template for new resources
│   └── clouddevice.py          ← desired device entry
├── venues/
│   ├── aws/awscloudprovisioner.py    ← stub
│   ├── azure/azurecloudprovisioner.py ← stub
│   └── google/googlecloudprovisioner.py ← stub
└── provisioningengine.py       ← parses YAML; no execution yet
```

The model classes are the *vocabulary* the package will
eventually use: `CloudVenue` (a target cloud account),
`CloudGroup` (a resource group in that venue),
`CloudResourceProfile` (a template for new resources),
`CloudDevice` (a desired device declaration).  The
vocabulary is in place; the methods that act on it are not.

## The abstract base — currently empty

**File:**
`codecraftai-provisioning/source/packages/ccai/provisioning/model/cloud/cloudprovisioner.py:2-8`

```python
class CloudProvisioner:
    """
    Class to provision a cloud environment for the AutoMojo model.
    """

    def __init__():
        return
```

A literal placeholder.  The class is declared; the
constructor takes no arguments and returns; no methods exist
yet.

The shape that will eventually fill it: vendor-neutral
methods like `checkout_resources(spec)`,
`create_resources(spec)`, `release_resources(group)`,
`emit_landscape_file(group, output_path)`,
`emit_topology_file(group, output_path)`.  The vendor-specific
backends will override each method with cloud-API calls.

## The vendor backends — stubs

Each backend follows the same shape:

**File:**
`venues/aws/awscloudprovisioner.py:9-11`

```python
class AwsCloudProvisioner(CloudProvisioner):
    def __init__(self):
        return
```

**File:**
`venues/azure/azurecloudprovisioner.py:9-11`

```python
class AzureCloudProvisioner(CloudProvisioner):
    def __init__(self):
        return
```

**File:**
`venues/google/googlecloudprovisioner.py:9-11`

```python
class GoogleCloudProvisioner(CloudProvisioner):
    def __init__(self):
        return
```

Three vendors, three empty subclasses.  The intent is clear:
each vendor will eventually carry the cloud-API integration
for that vendor, exposing the same vendor-neutral interface
to callers above.

## The `ProvisioningEngine` — partial

**File:** `provisioningengine.py:11-52`

```python
class ProvisioningEngine:
    """
    The ProvisioningEngine class is responsible for managing the provisioning of resources.
    It handles the creation, deletion, and management of resources in a cloud environment.
    """

    def __init__(self, *, specinfo: dict):
        """
        Initializes the ProvisioningEngine instance.
        """
        self._specinfo = specinfo
        self._apodinfo = specinfo["apod"]
        self._envinfo = specinfo["environment"]

        self._cloud_venues = []
        if "venues" in self._envinfo:
            venues_list = self._envinfo["venues"]
            for venue in venues_list:
                self._cloud_venues.append(CloudVenue(**venue))
```

The engine *parses* the Specification (the same
Specification YAML Volume 3 chapter 4 walked) into Python
data structures: a list of `CloudVenue`s constructed from
the venue declarations, an `apodinfo` block, an `envinfo`
block.

The class's *operation* methods — anything that would
actually call into a cloud — are not present.  A reader of
the source today sees the parsing path complete and the
acting path absent.

## Where the implementation will land

When the package matures, the expected shape:

```python
class ProvisioningEngine:

    def provision(self) -> ProvisioningResult:
        """
            Walk the spec, dispatch to the right vendor backend per venue,
            checkout-or-create resources, return a result containing
            the emitted Landscape and Topology files.
        """
        result = ProvisioningResult()

        for venue in self._cloud_venues:
            backend = self._select_backend(venue)
            for group in venue.groups:
                if group.mode == "checkout":
                    backend.checkout_group(group)
                elif group.mode == "create":
                    backend.create_group(group)
                else:
                    raise ConfigurationError(...)

        # Each venue's backend has now populated its in-memory
        # representation of provisioned resources.
        landscape_dict = self._build_landscape()
        topology_dict = self._build_topology()

        landscape_path = result.write_landscape(landscape_dict)
        topology_path = result.write_topology(topology_dict)

        return result
```

The shape of "checkout-or-create" Volume 3 chapter 4 promised
lands here: each `CloudGroup` declares a mode; the engine
dispatches to the backend's checkout or create method per
group.  The result is the emitted Landscape and Topology
files the next stage (the orchestrator's
`DistributedTestJob`) consumes.

This is *not the current implementation*.  This is the shape
the implementation will land at when the package is filled
in.  A contributor wanting to bring the package to life
follows this template.

## The vendor-neutral interface — eventual shape

The `CloudProvisioner` base class will eventually carry:

```python
class CloudProvisioner(ABC):

    def checkout_group(self, group: CloudGroup) -> None:
        """
            Reserve an existing pool of resources for this group.

            :param group: The CloudGroup declaring required resources.
            :raises: CloudCheckoutError when the pool can't supply the resources.
        """
        ...

    def create_group(self, group: CloudGroup) -> None:
        """
            Create new resources matching the group's CloudResourceProfile.
        """
        ...

    def release_group(self, group: CloudGroup) -> None:
        """
            Return resources to the pool (for checkout) or destroy them (for create).
        """
        ...

    def landscape_dict_for(self, group: CloudGroup) -> dict:
        """
            Return the Landscape-shape dict for this provisioned group.
        """
        ...

    def topology_dict_for(self, group: CloudGroup) -> dict:
        """
            Return the Topology-shape dict for this provisioned group.
        """
        ...
```

Six methods; vendor backends implement each.  AWS uses Boto3
calls; Azure uses the Azure SDK; GCP uses
`google-cloud-compute`.  The engine doesn't know or care
which vendor handles each call; it asks the right backend
for the venue.

## What the chapter does for today's reader

A reader reaching this chapter today should understand:

- **The vocabulary** — `CloudVenue`, `CloudGroup`,
  `CloudResourceProfile`, `CloudDevice`,
  `CloudProvisioner`, `ProvisioningEngine`.
- **The shape** — vendor-neutral interface, vendor backends,
  Specification → engine → emitted files.
- **The state** — current scaffolding, not production-ready.
  A real CI campaign relies on existing Landscape / Topology
  files (Volume 3 chapter 6's "static path" or the user
  tooling at job startup) rather than expecting
  `codecraftai-provisioning` to produce them.

A contributor who wants to advance the package has the
roadmap above.  A user who wants to *use* what's there
today should treat the package as a planning artefact.

## Why include this chapter at all

The chapter exists because the *vocabulary* and the
*intent* are both load-bearing in the rest of Volume 6 (and
in Volume 3).  When Volume 3 chapter 4 says "CI tooling
emits Landscape and Topology files," the reader should know
where that emission is intended to live (here) and that the
package is currently empty.  Hand-waving over the gap would
mislead.

The chapter also serves as a *contributor's entry point*.  A
reader who wants to advance the framework's provisioning
story sees the scaffolding, the intended shape, and the
methods to fill in.  The chapter is shorter than the
equivalent for a mature package would be, but its job is
*orientation*, not depth.

## What the next chapter does

Chapter 11 walks the **UI automation** package
(`codecraftai-uia`) — a contrast to the provisioning package in
that it *is* mature.  Cross-platform UI automation across
Linux, macOS, and Windows; the `UIElement` /
`ScreenElement` / `UIWindow` / `UIApplication` hierarchy;
the `MacApplication` / `LinuxApplication` wrappers; the
recent 20-bug audit.  Chapter 12 then walks the declarative
application mappings — the project's mechanism for
naming UI elements semantically.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/11-cross-platform-ui-automation -->
# Chapter 11 — Cross-Platform UI Automation

> *Where the previous chapter walked a scaffolding-stage
> package, this one walks a mature one.  `codecraftai-uia`
> implements UI automation across Linux (AT-SPI / X11 /
> Wayland), macOS (Accessibility / scripting bridges), and
> Windows (UIA — currently stubbed with NotImplementedError).
> The class hierarchy is small, the platform wrappers are
> substantial, and a recent 20-bug audit hardened the
> package.*

The framework's UI automation package is the answer to a
specific kind of automation question: *the system under test
is a graphical application*.  Volume 1 chapter 1 listed test
shapes the framework supports; UI tests are one of them, and
`codecraftai-uia` is the substrate.

This chapter walks the platform-neutral hierarchy, the
per-platform wrappers, the factory dispatch model, and the
recent audit history.  Chapter 12 walks the declarative
application mappings on top of this substrate.

## The hierarchy

**File:** `codecraftai-uia/source/packages/ccai/uia/platforms/base.py`

```python
# Lines 13–33
class UIObject(ABC):
    """
        Base class for all UI objects.
    """

# Lines 35–72
class DisplayElement(UIObject):
    """
        Foundation class for any displayed UI object.
    """
    @property
    @abstractmethod
    def bounds(self) -> Tuple[int, int, int, int]: ...
    @property
    @abstractmethod
    def position(self) -> Tuple[int, int]: ...
    @property
    @abstractmethod
    def size(self) -> Tuple[int, int]: ...
    @property
    @abstractmethod
    def is_visible(self) -> bool: ...

# Lines 74–107
class ScreenElement(DisplayElement):
    """
        Foundation class for screen-level objects like windows and applications.
    """
    def __init__(self, parent: Optional['UIElement'] = None):
        self._parent = weakref.ref(parent) if parent is not None else None
    # Includes bring_to_front() and send_to_back()

# Lines 109–196
class UIElement(DisplayElement):
    """
        Abstract base class for UI elements (controls/widgets).
    """
    # Includes find_element, find_elements, wait_for_element, wait_until_gone

# Lines 496–536
class UIWindow(ScreenElement):
    """
        Abstract base class for windows.
    """
    @property
    @abstractmethod
    def name(self) -> str: ...
    @property
    @abstractmethod
    def title(self) -> str: ...

# Lines 560–601
class UIApplication(ScreenElement):
    """
        Abstract base class for applications.
    """
    def __init__(self, parent: Optional['UIElement'] = None):
        super().__init__(parent)
        self._declarative_model = None
```

Six classes.  Each inherits from the one above:

- `UIObject` — the abstract root.  Has `__repr__` and
  `exists`.
- `DisplayElement` — anything visible.  Adds `bounds`,
  `position`, `size`, `is_visible`.
- `ScreenElement` — top-level visible objects (windows,
  applications).  Adds `bring_to_front`, `send_to_back`.
- `UIElement` — interactive controls (buttons, text fields).
  Adds `find_element`, `find_elements`, `wait_for_element`,
  `wait_until_gone`.
- `UIWindow` — a window.  Adds `name`, `title`.
- `UIApplication` — a running application.  Adds
  `_declarative_model` (the linkage to chapter 12's
  declarative model).

The hierarchy is *small enough to memorise*.  Six classes;
two parallel branches (UIWindow and UIApplication both inherit
from ScreenElement; UIElement is its own branch).  A reader
of any UI test code should be able to locate every class
they encounter in this hierarchy.

## The platform wrappers

Each platform has its own per-class wrapper that
*implements* the abstract methods using the platform's
native UI APIs.

### macOS

**File:**
`codecraftai-uia/source/packages/ccai/uia/platforms/macos/wrappers/uiaapplication.py:29`

```python
class MacApplication(WrappedObject, UIApplication):
    """
        Wrapper for an application process. Use this object to attach to and
        communicate with the application process; window objects are exposed
        separately via window wrappers.
    """
    APPLICATION_NAME = "NotSet"
    BUNDLE_IDENTIFIER = "NotSet"

    def __init__(self, controller: "MacOSAutomation", native_obj: Any):
        super().__init__(controller, native_obj)
        self._sb_process = None  # AppleScript scripting-bridge process
        self._ax_app = None      # NSAccessibility app
```

`MacApplication` wraps two macOS-native interfaces:

- **`_sb_process`** — `appscript` / scripting-bridge for
  AppleScript-driven automation (file menus, scriptable
  applications).
- **`_ax_app`** — NSAccessibility's `AXUIElement` for the
  accessibility-tree-driven automation (buttons, text fields,
  menus that are AX-accessible).

The macOS layer has 51 source files and provides per-app
wrapper classes for 27 system applications (Calculator,
Calendar, Contacts, Excel, Mail, Safari, etc.).  Each
per-app class sets `APPLICATION_NAME` and
`BUNDLE_IDENTIFIER` for the framework's lookup.

### Linux

**File:**
`codecraftai-uia/source/packages/ccai/uia/platforms/linux/wrappers/uiaapplication.py:17`

```python
class LinuxApplication(LinuxElement, UIApplication):
    """
        Wrapper for Linux applications using Agent Architecture.
    """

    def __init__(self, native_obj: Any, access_agent: AccessibilityAgent,
                 wm_agent: WindowManagerAgent, parent: Optional[UIElement] = None):
        super().__init__(native_obj, access_agent, wm_agent, parent=parent)
```

`LinuxApplication` uses an *agent* architecture:

- **`access_agent`** — the accessibility agent.  The shipped
  implementation is `PyAtspiAccessibilityAgent` using AT-SPI
  (`pyatspi`) over D-Bus.
- **`wm_agent`** — the window-manager agent.  Two
  implementations: `X11WindowManagerAgent` for X11,
  `WaylandGnomeWindowManagerAgent` for GNOME on Wayland.

The agent split exists because Linux's UI stack is *not
unified*: AT-SPI handles accessibility (which buttons /
menus / text fields exist and what they say); the window
manager handles window lifecycle (which window is on top,
how to move a window, how to close one).  The two systems
talk different protocols.  The agent split keeps each
agent's responsibility clean.

### Windows

**File:**
`codecraftai-uia/source/packages/ccai/uia/platforms/windows/`

Currently a stub.  The factory's dispatch:

```python
elif system == "Windows":
    raise NotImplementedError("Windows automation is not yet implemented.")
```

Windows UI Automation (UIA) is the intended substrate when
the implementation lands.  The stub means projects that need
Windows UI tests today cannot use the framework's UI
automation; they fall back to other tools.  The chapter is
honest about this gap.

## The factory

**File:** `codecraftai-uia/source/packages/ccai/uia/factory.py`

```python
class AutomationFactory:
    """
        Factory class for creating automation engines based on the current platform.
    """

    @staticmethod
    def get_automation_engine() -> AutomationEngine:
        """
            Returns the automation engine for the current platform.
        """
        system = platform.system()

        if system == "Darwin":
            from ccai.uia.platforms.macos.automation import MacOSAutomation
            return MacOSAutomation()
        elif system == "Windows":
             raise NotImplementedError("Windows automation is not yet implemented.")
        elif system == "Linux":
             from ccai.uia.platforms.linux.automation import LinuxAutomation
             return LinuxAutomation()
        else:
            raise NotImplementedError(f"Unsupported platform: {system}")
```

The factory dispatches on `platform.system()`.  Three
branches: macOS, Linux, Windows (stubbed).  Other platforms
raise.

A note on the design: this is *not* a factory protocol like
the Landscape's (chapter 7).  It's a hardcoded
`if`/`elif`/`else`.  Adding a new platform requires *editing
the factory's source*, not registering through a namespace
package.

This is a small wart on an otherwise mature package.
Future work might lift the factory to use the same protocol
pattern as `LandscapingFactoryProtocol`, but the present
implementation is what ships.

## What test code sees

A test using UI automation:

```python
from ccai.uia.factory import AutomationFactory

engine = AutomationFactory.get_automation_engine()
calc = engine.get_application_by_name("Calculator")
calc.bring_to_front()

main_window = calc.find_element(role="window", name="Calculator")
button_5 = main_window.find_element(role="button", name="Five")
button_5.click()
```

The flow:

1. `AutomationFactory` returns the platform-specific engine.
2. `engine.get_application_by_name(...)` returns a
   platform-specific `UIApplication` subclass instance
   (`MacApplication` on macOS, `LinuxApplication` on Linux).
3. `calc.bring_to_front()` calls into the platform-specific
   implementation.
4. `calc.find_element(...)` returns a platform-specific
   `UIElement` subclass instance.
5. `.click()` performs the click using the platform's
   native click machinery (mouse events for X11; `AXPress`
   for macOS Accessibility; etc.).

The test code is *platform-neutral* in the sense that the
same code works against macOS or Linux.  The behaviour is
platform-specific in that the underlying machinery differs.

In practice, projects often pair UI tests with
**declarative application mappings** (chapter 12) for
better readability and reuse.

## The 20-bug audit

The package recently went through an audit pass that fixed
20 bugs across 17 actionable defects + 3 medium-severity
follow-ups, plus a 31-file docstring typo cleanup.

**Commits:**

- `79172d0` — "Fix audit-surfaced bugs across uia platform layers" (17 defects).
- `2479151` — 31-file docstring typo fix ("Represents and instance" → "Represents an instance").
- `e5e4cad` — three more medium-severity bugs.

**Bug classes caught:**

1. **Model layer:** `DeclarativeApplication.attach()`
   referenced an undefined `APPLICATION_NAME` class
   variable; `ScreenElement` had no `__init__`, causing
   `TypeError` on `super().__init__(parent)` from `UIApplication`;
   four bare `except:` clauses replaced with `except Exception:`.
2. **Linux platform:** Broken import path
   (`agents.wm.x11windowmanageragent` →
   `agents.x11.x11windowmanageragent`); `AccessibilityAgent`
   missing abstract `get_value` / `set_value` methods;
   `WaylandGnomeWindowManagerAgent`'s `_last_x` / `_last_y`
   uninitialised when sync=False; non-compliant JSON-RPC
   (hardcoded `req_id=1`, checking for non-existent "ok"
   field); debug `print()` polluting stdout on every
   keystroke; `MenuElement.close()` sending invalid
   `<Esc>` instead of `{ESC}`.
3. **macOS platform:** Bare `except:` and stdout pollution
   on optional HIServices import failure; `SbWindow.groups()`
   debug print loop on every call.
4. **Test suite:** Imports of nonexistent symbols; missing
   Linux platform guards on Linux-specific tests; missing
   defaults on `uia_controller` parameter.

**Test status post-audit:** 16/16 macOS tests passing
(was 9/15 before the first pass).

### The four bundle-identifier verifications

A subtle point: the audit *verified* — and chose not to
change — four legacy Apple bundle identifiers that initially
looked stale:

- **Calendar:** `com.apple.iCal` (the legacy iCal name).
- **Contacts:** `com.apple.AddressBook` (the legacy AddressBook name).
- **Numbers:** `com.apple.iWork.Numbers`.
- **Pages:** `com.apple.iWork.Pages`.

Live macOS still uses these IDs; updating to a "Calendar"
or "Contacts" name would have *broken* the lookup.  The
audit's discipline included verifying against live macOS
before "fixing" what wasn't broken.  The chapter calls this
out because it's a useful reminder: not every plausible
correction is correct.

## What the next chapter does

Chapter 12 walks the **declarative application mappings** —
the project-side mechanism for naming UI elements
semantically.  `DeclarativeApplication`, `ElementHint`, and
the per-app classes (`Calculator`, `MicrosoftExcel`,
`LibreOfficeCalc`) that ship in source.  The chapter shows
how a project declares an application's UI structure once
and writes test code against the *declared names* rather
than against role / role / hint chains.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/12-declarative-ui-mappings -->
# Chapter 12 — Declarative UI Mappings

> *Test code that says "click button five" is more readable
> than test code that says `find_element(role="button",
> name="Five").click()`.  Declarative application mappings are
> the framework's mechanism for assigning semantic names to
> UI elements once and using them throughout test code.
> `DeclarativeApplication` plus `ElementHint` plus a per-app
> class is the pattern.*

The previous chapter established the platform-neutral UI
automation hierarchy.  Test code calling `find_element(...)`
with role and name arguments works, but it's verbose and
brittle: the same `(role, name)` tuple appears in every test
that touches the same element.  A button rename means
editing N tests.

The framework's answer: **declare the application's UI
structure once**, and write test code against the
declarations.  A button rename means editing one declaration.

## The pattern

Three classes are involved:

- **`DeclarativeApplication`** — the base for project-side
  application classes.
- **`ElementHint`** (and specialised subclasses
  `WindowHint`, `ButtonHint`, `TextHint`, `MenuHint`, etc.) —
  declares one named UI element with its locating
  parameters.
- **A per-app subclass** that lists the application's named
  elements as class attributes.

A test then attaches to the application and uses the
declared names:

```python
calc = Calculator.attach()
calc.Button5.click()
calc.ButtonAdd.click()
calc.Button7.click()
calc.ButtonEqual.click()
result_text = calc.DisplayTextField.read_text()
```

Compare to the equivalent without declarative mappings:

```python
engine = AutomationFactory.get_automation_engine()
calc = engine.get_application_by_name("Calculator")

main_window = calc.find_element(role="window", name="Calculator")
btn_5 = main_window.find_element(role="button", name="Five")
btn_5.click()
btn_add = main_window.find_element(role="button", name="Add")
btn_add.click()
# ... and so on
```

The first form names what the test does.  The second names
how the test finds things.  Both achieve the same outcome;
the first is the readable one.

## `ElementHint`

**File:**
`codecraftai-uia/source/packages/ccai/uia/model/declarative.py:14`

```python
class ElementHint(Generic[T]):
    """
        Declarative hint to locate a UI element of type T.

        :param type_class: The expected class of the element (e.g. UIButton).
        :param name: The name of the element to match.
        :param role: The role of the element to match.
        :param access_name: Alternate accessibility name.
    """
    def __init__(self, type_class: Type[T] = UIElement, name: str = None, role: str = None,
                 access_name: str = None, path: str = None, identifier: str = None):
        self.type_class = type_class
        self.name = name
        self.role = role
        self.access_name = access_name
        self.path = path
        self.identifier = identifier

    def resolve(self, parent: UIElement) -> Optional[T]:
        """
            Resolves the hint to a concrete UI element within the parent context.
        """
        # ... dispatches to parent's find capability ...

    def matches(self, element: UIElement) -> bool:
        """
            Checks if the given element matches this hint's criteria.
        """
        # ... checks role / name / identifier ...
```

A `ElementHint` packages the *finding strategy* for a UI
element:

- **`type_class`** — the expected element type (e.g.,
  `UIButton`).  Used for type inference and optional runtime
  validation.
- **`name`** — the displayed name.
- **`role`** — the platform-specific role (e.g.,
  `AXButton` on macOS, `push button` on Linux AT-SPI,
  `Button` on Windows UIA).
- **`access_name`** — alternate accessibility name.
- **`path`** — XPath-like locator (`"//{name:Calc*}"`).
- **`identifier`** — platform-specific identifier (the
  `AXIdentifier` value on macOS, etc.).

The hint can use any combination.  A button uniquely named
within its window can use just `name`; a button shared by
name across windows might use `name` + `path` to specify
which window's button.

Specialised subclasses tighten the type:

```python
class WindowHint(ElementHint[UIWindow]):
    def __init__(self, ...):
        super().__init__(type_class=UIWindow, ...)

class ButtonHint(ElementHint[UIButton]):
    def __init__(self, ...):
        super().__init__(type_class=UIButton, ...)

# Similar for TextHint, MenuHint, MenuItemHint, MenuBarHint, TableHint, ...
```

The specialised hints make the test code's type inference
work — a `ButtonHint` resolves to a `UIButton`; a
`WindowHint` resolves to a `UIWindow`.  IDEs and type
checkers know the right methods.

## `DeclarativeApplication`

**File:**
`codecraftai-uia/source/packages/ccai/uia/model/declarative.py:489`

```python
class DeclarativeApplication:
    """
        Base class for declarative application models.
        Note: This is a factory/proxy, not the actual application instance.
        It returns a concrete UIApplication when valid.
    """

    APPLICATION_NAME: str = None
    APPLICATION_COMMAND: str = None

    @classmethod
    def attach(cls, automation_engine: Optional[AutomationEngine] = None) -> Optional[UIApplication]:
        """
            Attaches to the running application matching the hint.
        """
        if not automation_engine:
             automation_engine = AutomationFactory.get_automation_engine()

        if cls.APPLICATION_NAME:
            app = automation_engine.get_application_by_name(cls.APPLICATION_NAME)
            if app:
                # Inject the declarative model into the runtime object
                app.declarative_model = cls
            return app

        return None

    @classmethod
    def launch_or_attach(cls, automation_engine: Optional[AutomationEngine] = None) -> Optional[UIApplication]:
        """
            Launches the application if not running, or attaches to it.
        """
        app = cls.attach(automation_engine)
        if app:
            return app

        if cls.APPLICATION_COMMAND:
            import subprocess
            import shlex
            import time

            subprocess.Popen(shlex.split(cls.APPLICATION_COMMAND))

            for _ in range(20):
                time.sleep(1)
                app = cls.attach(automation_engine)
                if app:
                    return app

        return None
```

Two class methods:

- **`attach(engine=None)`** — looks up a running application
  by `APPLICATION_NAME` and binds the declarative model
  class to the returned `UIApplication` instance.  Returns
  None if the app isn't running.
- **`launch_or_attach(engine=None)`** — tries `attach` first;
  if not running, spawns the app via `APPLICATION_COMMAND`
  and waits up to 20 seconds for it to appear.

The "binding" step (`app.declarative_model = cls`) is what
makes the per-element accessors work.  When test code
references `calc.Button5`, the framework's
`UIApplication.__getattr__` (or equivalent) looks up
`Button5` on the bound declarative model class, finds the
`ButtonHint`, and resolves the hint to a concrete element.

## A worked per-app class — Calculator (macOS)

**File:**
`codecraftai-uia/source/packages/ccai/uia/platforms/macos/mappings/calculator.py:9`

```python
class Calculator(DeclarativeApplication):
    """
        Declarative mapping for the MacOS Calculator application.
    """

    APPLICATION_NAME = "Calculator"
    APPLICATION_COMMAND = "open -a Calculator"

    MainWindow = WindowHint(role=AX_WINDOW_ROLE, name="Calculator")
    """The main Calculator window."""

    DisplayTextField = TextHint(role=AX_TEXT_ROLE)
    DisplayStaticText = TextHint(role=AX_STATIC_TEXT_ROLE)
    """Display control(s) showing the current input or result."""

    # Digits (AXIdentifier values)
    Button0 = ButtonHint(name="Zero", role=AX_BUTTON_ROLE)
    Button1 = ButtonHint(name="One", role=AX_BUTTON_ROLE)
    # ... through Button9 ...

    # Operators
    ButtonAdd = ButtonHint(name="Add", role=AX_BUTTON_ROLE)
    # ... ButtonSubtract, ButtonMultiply, ButtonDivide, ButtonEqual ...

    ButtonClear = ButtonHint(name="AllClear", role=AX_BUTTON_ROLE)
    ButtonDelete = ButtonHint(name="Delete", role=AX_BUTTON_ROLE)
```

The class is a flat list of named hints.  `Button0` through
`Button9`, the four arithmetic operators, equals, clear,
delete.  Each is a `ButtonHint` with the platform-specific
role (`AX_BUTTON_ROLE`) and the macOS-specific name (`Zero`,
`One`, etc.) drawn from the live UI's `AXTitle` / `AXName`
values.

A test using this:

```python
from ccai.uia.platforms.macos.mappings.calculator import Calculator

def test_calculator_addition():
    """
        Verify 5 + 7 = 12 in the macOS Calculator.
    """
    calc = Calculator.launch_or_attach()
    if calc is None:
        raise SemanticError("Could not launch or attach to Calculator.")

    calc.Button5.click()
    calc.ButtonAdd.click()
    calc.Button7.click()
    calc.ButtonEqual.click()

    result_text = calc.DisplayTextField.read_text()
    rtnval = result_text == "12"
    assert rtnval
    return
```

The test reads naturally.  Each `calc.X` is a hint resolved
on-demand.  A button rename in macOS upgrades that need
*one* edit: the `ButtonHint` declaration in the per-app
class.

## Per-platform mapping — same shape, different names

The Linux LibreOffice Calc mapping:

**File:**
`codecraftai-uia/source/packages/ccai/uia/platforms/linux/mappings/libreoffice_calc.py:37`

```python
class LibreOfficeCalc(DeclarativeApplication):
    """
        Declarative mapping for LibreOffice Calc.
    """
    APPLICATION_NAME = "soffice"
    APPLICATION_COMMAND = "libreoffice --calc --norestore"

    MainWindow = WindowHint(role="frame", name="~LibreOffice Calc")

    class MenuBar(MenuBarHint):
        """Application Menu Bar."""
        File = MenuHint(name="File")

    SaveAsMenuItem = MenuItemHint(name="Save As...")
    SaveAs = SaveAsDialog(name="Save As")

    # Spreadsheet
    Spreadsheet = TableHint(role="table")
```

Same shape (`DeclarativeApplication` subclass, hints as
class attributes) but the *contents* are platform-specific:

- `APPLICATION_NAME` is `"soffice"` (the LibreOffice
  process name) rather than `"Calculator"`.
- The role values are AT-SPI roles (`"frame"`, `"table"`)
  rather than macOS AX roles.
- Nested classes (`MenuBar`) group related hints.

A project that needs the same logical app on multiple
platforms writes one per-platform mapping.  The test code
imports the right one for the platform it's running on; the
shape of the test is the same regardless.

## Composition with the test framework

Declarative UI mappings *integrate* with the rest of the
framework cleanly:

- A test attaches to an application and exercises it.  The
  test is the actor (Volume 2 chapter 2's commitment).
- A *tandem validator* (Volume 2 chapter 5) can attach to
  observe UI state during the test — e.g., a
  `ScreenshotValidator` that captures screenshots at
  intervals.
- The application launch / attach happens in fixture-style
  code (Volume 1 chapter 3's resource factory model) — a
  per-test fixture that yields the app and kills it on
  cleanup.
- UI test results land in the JSOS stream like any other
  test results (Volume 1 chapter 5).

The declarative mapping is a *substrate* for tests; the rest
of the framework wraps it as it would any other system under
test.

## Limitations and rough edges

A reader using the package today should know about a few
limitations:

- **Windows is stubbed.**  Chapter 11 noted this; it bears
  repeating.  Windows-only UI tests don't work today.
- **The factory is hardcoded.**  No registration of
  custom platforms.
- **The hint resolution dispatches to platform-specific
  finder methods.**  A hint with a `path` works on macOS
  through XPath-like resolution but the same path may need a
  different syntax on Linux.  Mappings for the same logical
  app on different platforms typically have different hint
  parameters.
- **Audit history.**  The 20 bugs caught in the recent audit
  (chapter 11) all surfaced from manual testing.  An SDET
  using the package today should still expect occasional
  rough edges — the framework is mature but not battle-
  hardened across every UI variation.

## What the next chapter does

Chapter 13 walks **GitLab integration** — a worked example
of a real-world REST integration in the portfolio.  The
distinction between auto-generated and hand-written code,
the OpenAPI source spec, the `RestClientBase` foundation,
and the Aspects-pattern integration for retry / polling
behaviour.  The chapter is short because the package is
narrowly-scoped, but the patterns it illustrates apply more
broadly.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/13-gitlab-integration -->
# Chapter 13 — GitLab Integration

> *`codecraftai-restclient-gitlab` is the framework's worked example of a
> real-world REST integration.  An OpenAPI spec auto-generates
> hundreds of method classes; a small hand-written foundation
> (RestClientBase, RestApiGroup, AspectsRest) carries the
> shared behaviour.  The chapter walks the generator pattern
> and the typical method shape.*

The portfolio includes `codecraftai-restclient-gitlab` as a worked example
of integrating with a REST API at scale.  GitLab's REST API
is large — hundreds of endpoints — and integrating with it
by hand would be infeasible.  Instead, the package
*generates* the client code from an OpenAPI specification;
the hand-written part is small and shared across all
generated methods.

This chapter walks the generator pattern, the auto-generated
output, the hand-written foundation, and the Aspects
integration for retry behaviour.  The patterns generalise to
any large REST API — projects building integrations with
their own external services follow the same shape.

## The package layout

```
codecraftai-restclient-gitlab/source/packages/ccai/gitlab/
└── rest/
    ├── restclientbase.py      ← hand-written foundation
    ├── restapigroup.py        ← hand-written
    ├── aspectsrest.py         ← hand-written
    ├── itemsresult.py         ← hand-written
    ├── clients/
    │   ├── gitlabrestclient.py    ← AUTO-GENERATED main client
    │   ├── groups/                ← AUTO-GENERATED operation groups
    │   ├── parameters/            ← AUTO-GENERATED parameter classes
    │   └── responses/             ← AUTO-GENERATED response classes
    ├── generation/
    │   └── restclientgenerator.py ← the generator itself
    └── descriptions/              ← OpenAPI YAML / JSON specs
```

Two halves:

- **Hand-written foundation**: `RestClientBase`,
  `RestApiGroup`, `AspectsRest`, `ItemsResult`.  Small,
  stable.
- **Auto-generated client**: hundreds of files under
  `clients/`.  Regenerated when the OpenAPI spec changes;
  not edited by hand.

The boundary is clear and enforced by a banner at the top of
every generated file.

## The auto-generated banner

**File:**
`codecraftai-restclient-gitlab/source/packages/ccai/gitlab/rest/generation/restclientgenerator.py:26`

```python
AUTO_GENERATED_BANNER = "# Auto-generated by RestClientGenerator. DO NOT EDIT.\n"
```

Every generated file starts with this banner.  An engineer
attempting to fix a bug in a generated method by editing the
generated file would see the banner and know to fix the
*generator* (or the OpenAPI spec) instead.

The banner is a *contract*: edits to generated files don't
survive regeneration.  Bug fixes in generated code must be
fixes to the generator.

## A representative auto-generated method

**File:**
`codecraftai-restclient-gitlab/source/packages/ccai/gitlab/rest/clients/groups/branches.py:16`

```python
def deleteapiv4projectsidrepositorybranchesbranch(
    self,
    params: Optional[parameters.BranchesDeleteapiv4projectsidrepositorybranchesbranchParams] = None,
    *,
    aspects: Optional[AspectsRest] = DEFAULT_REST_ASPECTS
) -> responses.BranchesDeleteapiv4projectsidrepositorybranchesbranchResponse:
    """
        Delete a branch
    """
    if params is None:
        params = parameters.BranchesDeleteapiv4projectsidrepositorybranchesbranchParams()
    call_leaf = "/projects/{id}/repository/branches/{branch}"
    path_params = params.path_params()
    if path_params:
        call_leaf = self.client.format_path(call_leaf, path_params)
    query_params = params.query_params()
    header_params = params.header_params()
    body_params = params.body_params()
    exp_status = [204]
    resp = self.client.perform_call(
        leaf=call_leaf,
        method="DELETE",
        params=query_params if query_params else None,
        body=body_params,
        exp_status=exp_status,
        headers=header_params if header_params else None,
        context_msg=None,
        aspects=aspects,
    )
    result = responses.BranchesDeleteapiv4projectsidrepositorybranchesbranchResponse(resp)
    return result
```

Five things to notice:

1. **The method name is the URL path.**
   `deleteapiv4projectsidrepositorybranchesbranch` is the
   verb plus path with `/` and `{}` and `:` stripped.
   Ugly but unambiguous — every endpoint has a unique method.
2. **Parameters are typed.**  The `params` argument has a
   class generated for this specific endpoint
   (`BranchesDeleteapiv4...Params`).  Parameter classes
   carry path / query / header / body parameters.
3. **The Aspects parameter.**  Per Volume 5 chapter 1's
   Aspects pattern, every call accepts a behaviour
   parameter.  Default is
   `DEFAULT_REST_ASPECTS`.
4. **Status validation.**  `exp_status = [204]` says "expect
   204 No Content."  The base client's `perform_call`
   surfaces a non-204 response as an error.
5. **Response wrapping.**  The raw HTTP response is wrapped
   in a typed response class
   (`BranchesDeleteapiv4...Response`).  Test code uses the
   wrapper's typed accessors rather than parsing JSON.

Every generated method has this shape.  The repetition is
why hand-writing is infeasible — but the repetition is also
why the generator works.

## The hand-written foundation

### `RestClientBase`

**File:**
`codecraftai-restclient-gitlab/source/packages/ccai/gitlab/rest/restclientbase.py:54`

```python
def __init__(self, host: str, credential: BaseCredential, port: Optional[int] = None,
             scheme: Optional[str] = "https", aspects: AspectsRest = DEFAULT_REST_ASPECTS):
    """
        Initialize the REST client base.
    """
    self._host = host
    self._credential = credential
    self._aspects = aspects

    self._host_url = f"{scheme}://{self._host}"
    if port is not None:
        self._host_url = f"{self._host_url}:{port}"

    self._session: Session = None
    return
```

The base class:

- Holds a host, credential, scheme, optional port.
- Holds default Aspects.
- Lazily creates a `requests.Session` for the connection
  pool.
- Provides `perform_call(...)` (above) that every generated
  method uses.

The credential authentication, the connection pooling, the
retry / polling behaviour through Aspects — all happen here.
Generated methods just construct parameters and call
`perform_call`.

### `RestApiGroup`

The base for the per-group classes (`Branches`, `Issues`,
`Projects`, etc.).  Each group is a small façade over the
`RestClientBase` instance, providing the methods for that
endpoint group.

### `AspectsRest`

The Aspects subclass for REST-shaped calls.  Carries:

- `completion_timeout` — total time before the call gives
  up.
- `connection_timeout` — time for individual TCP
  connections.
- `retry_count` — how many times to retry a failed call.
- `retry_pattern` — when to retry (`5xx_only`, `any_failure`,
  `none`).
- `polling_pattern` — when to poll (e.g., for async API
  responses).

Different calls need different aspects: a long-running CI
pipeline trigger wants a high `completion_timeout`; a quick
metadata query wants a low `connection_timeout` and an
aggressive retry on connection-failure.  The Aspects
parameter on every method lets the caller tune per-call.

## Authentication

The `BaseCredential` integration:

```python
def authenticate(self):
    """
        Apply credential to the session.  Override per-credential-type.
    """
    if self._session is None:
        self._session = Session()

    if isinstance(self._credential, BasicAuthCredential):
        self._session.auth = (self._credential.username, self._credential.password)
    elif isinstance(self._credential, BearerTokenCredential):
        self._session.headers["Authorization"] = f"Bearer {self._credential.token}"
    # ... etc
```

Authentication happens at session-creation time.  The
credential is set on the requests session; subsequent calls
inherit it.  The session-level auth means generated methods
don't need to know about credentials — they call
`perform_call`, which uses the already-authenticated session.

## A worked test using the GitLab integration

Suppose a CI orchestration's worker test verifies a branch
exists in a project:

```python
import testbase
from ccai.gitlab.rest.clients.gitlabrestclient import GitLabRestClient
from ccai.credentials.bearertokencredential import BearerTokenCredential

@testbase.attach.parameter(landscape, identifier="landscape")
def test_branch_exists_in_project(landscape):
    """
        Verify the configured branch exists in the project.
    """
    creds = landscape.credentials_manager.lookup_by_name("gitlab-readonly")
    bearer = BearerTokenCredential(token=creds.token)

    client = GitLabRestClient(host="gitlab.internal", credential=bearer)

    from ccai.gitlab.rest.clients.parameters import (
        BranchesGetapiv4projectsidrepositorybranchesParams as ListBranchesParams
    )

    params = ListBranchesParams(
        path={"id": "my-project"},
        query={"search": "main"},
    )
    result = client.branches.getapiv4projectsidrepositorybranches(params=params)

    rtnval = any(b.name == "main" for b in result.items)
    assert rtnval
    return
```

The test:

- Resolves the GitLab credential through the framework's
  credential manager (Volume 2 chapter 11).
- Constructs a `GitLabRestClient` with the credential.
- Calls a generated method with a typed parameter.
- Iterates the typed response.

The GitLab REST API has hundreds of endpoints; the project's
test exercises one or a few.  The same pattern applies to
*any* of GitLab's endpoints — the test author picks the
right method and the right parameter class.

## When the OpenAPI spec changes

The integration's evolution loop:

1. GitLab releases a new API version with new endpoints.
2. The new OpenAPI spec is downloaded.
3. The generator runs against the new spec.
4. New generated files appear; existing files may change
   (response shapes, parameter additions).
5. The hand-written foundation usually doesn't need changes
   — the methods that change are all auto-generated.

A small caveat: when the generator itself needs to evolve
(e.g., to support a new OpenAPI feature like callbacks), the
*generator's* code is hand-written and gets edited.  But the
generated client code stays in sync mechanically.

## What this pattern generalises to

The auto-generation pattern works whenever an external API
provides a machine-readable specification:

- **OpenAPI / Swagger** for REST APIs.
- **Protocol Buffers** for gRPC APIs.
- **WSDL** for SOAP services.
- **AsyncAPI** for event-driven APIs.

For each, the framework's pattern is the same: hand-write a
small shared foundation, write a generator that consumes the
spec, regenerate when the spec changes.  The
`codecraftai-restclient-gitlab` package is one worked example; a project
integrating with its own internal REST API can use the same
shape.

## Smoke vs comprehensive testing strategy

A package with hundreds of auto-generated methods raises the
question: *which methods do you test?*

The portfolio's discipline:

- **Smoke-test the foundation.**  `RestClientBase`'s
  authentication, connection pooling, retry behaviour, and
  Aspects handling are tested explicitly.  Failures in
  these affect *every* generated method.
- **Smoke-test the generator.**  Run the generator against a
  reference OpenAPI spec; verify the output is well-formed
  Python.
- **Smoke-test a representative method per group.**  One
  method from each major group (Branches, Issues, Projects,
  etc.) verifies the end-to-end happy path.
- **Don't comprehensively test every method.**  100% coverage
  of generated methods would mean writing N similar tests for
  N similar methods — high cost, low marginal value.

The trade-off makes peace with the realistic constraint that
no team has the budget to test 500+ methods individually.
Smoke-test the substrate; trust the generator for the rest.

## What the next chapter does

Chapter 14 walks the **Flask web-service scaffolding** —
`codecraftai-webservice-flask`.  Where GitLab integration is
the *consumer* side of REST (we call other people's APIs),
the Flask scaffolding is the *server* side (we build APIs
others can call).  The chapter walks `SecurityBlueprint`,
`RouteSecurityManager`, OAuth provider integration, and the
SQLAlchemy ORM patterns.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/14-flask-scaffolding -->
# Chapter 14 — Web-Service Scaffolding with Flask

> **Note (October 2026).** `codecraftai-webservice-flask` is no longer part of the portfolio.  The Code Craft AI website moved to
> [`codecraftai-webworks`](https://github.com/code-craft-ai-company/codecraftai-webworks), which replaces the Flask patterns
> described here.  The chapter is kept for the design it records: security-aware blueprints, role-based route protection and
> multi-provider OAuth.  For new work, use `codecraftai-webworks`.

> *Where GitLab integration was the *consumer* side of REST,
> Flask scaffolding is the *server* side.
> `codecraftai-webservice-flask` provides the patterns the
> portfolio's website front-end uses: security-aware
> blueprints, role-based route protection, OAuth integration
> with eight providers, and SQLAlchemy ORM mappings.*

The portfolio includes a Flask scaffolding package.  It's
not a "framework" in the `codecraftai-*` sense — it's a
*pattern library* for building Flask web services that
follow the portfolio's conventions: security-aware routing,
multi-provider OAuth, SQLAlchemy ORM with auditable
timestamps.

This chapter is a *survey* rather than a deep walk.  A reader
building a web service against the portfolio's conventions
follows the patterns; a reader who just wants to *understand*
what's in the package can skim the chapter.

## What the package provides

```
codecraftai-webservice-flask/source/packages/ccai/webservice/xflask/
├── basesecurity.py         ← RouteSecurityManager
├── securityblueprint.py    ← SecurityBlueprint(Blueprint)
├── baseauthentication.py   ← OAuth provider enum + auth-check helpers
├── baseendpoints.py        ← signup / login / logout / OAuth callbacks
├── baseoperations.py       ← user CRUD, token management
├── baseorm.py              ← SQLAlchemy ORM models
├── basestate.py            ← Flask app state and configuration
├── basemessages.py         ← localised messages
├── basevalidation.py       ← input validation helpers
└── ...
```

Ten modules.  Each addresses one concern: security routing,
auth, endpoints, ORM, app state.  A project building a
web service uses some or all.

## `SecurityBlueprint` — security-aware route registration

**File:**
`codecraftai-webservice-flask/source/packages/ccai/webservice/xflask/securityblueprint.py:17`

```python
class SecurityBlueprint(Blueprint):
    """
        A Flask Blueprint subclass that integrates with the route security manager.

        This blueprint automatically registers routes with the security manager when they
        are added, ensuring proper access control enforcement. Routes must be explicitly
        marked as public or secure using the provided decorators.
    """

    def __init__(self, name: str, import_name: str, route_security: RouteSecurityManager,
                 static_folder: Optional[str] = None, static_url_path: Optional[str] = None,
                 template_folder: Optional[str] = None, url_prefix: Optional[str] = None,
                 # ... other Blueprint parameters ...
                 ):
        super().__init__(
            name=name,
            import_name=import_name,
            static_folder=static_folder,
            # ...
        )

        self._route_security = route_security
        self._pending_security_registrations = []
```

`SecurityBlueprint` extends Flask's `Blueprint` to
automatically register every route with a
`RouteSecurityManager`.  When a route is added (via
`@blueprint.route("/foo")`), the blueprint records the
registration; the manager later evaluates which roles can
reach the route.

The pattern eliminates a common security bug: a developer
adds a new endpoint and forgets to add it to the security
table.  `SecurityBlueprint` makes registration *automatic*;
the developer marks each route as public or secure, but
can't forget to register it.

## `RouteSecurityManager` — the access-control decision point

**File:**
`codecraftai-webservice-flask/source/packages/ccai/webservice/xflask/basesecurity.py:24`

```python
class RouteSecurityManager:
    """
        Manage route metadata that associates endpoints with access control rules.

        * Public APIs
        * Token Authenticated
        * Domain Authenticated
        * Role Authentication
        * Group Authentication
    """

    def __init__(self, app: Flask, route_security_table: Optional[Dict[str, Set[str]]] = None):
        if route_security_table is None:
            route_security_table = {}

        self._app = app
        self._api_prefixes = set()
        self._static_prefixes = set()
        self._endpoint_security_table = route_security_table
        self._public_endpoints = set()

        self._endpoint_login = "login"
        self._endpoint_logout = "logout"
        self._endpoint_landing = "landing"
```

The manager holds:

- **The endpoint → required-roles map**
  (`_endpoint_security_table`).
- **The set of public endpoints** (no auth required).
- **API and static prefixes** for distinguishing endpoint
  classes (API routes return 401; UI routes redirect to
  login).
- **The conventional endpoints** for login / logout /
  landing.

At request time, the manager:

1. Looks up the requested endpoint.
2. If public, allow.
3. If authenticated, check roles against the required set.
4. If unauthenticated:
   - For an API route, return 401 / 403.
   - For a UI route, redirect to login.

The five-class taxonomy ("Public / Token / Domain / Role /
Group") is the manager's vocabulary; projects extend with
their own taxonomy by subclassing.

## OAuth integration — eight providers

**File:**
`codecraftai-webservice-flask/source/packages/ccai/webservice/xflask/baseauthentication.py:16`

```python
class OauthProviderNames(str, Enum):
    """
        Enumerates the supported OAuth providers for the automation site.
    """
    Amazon = 'amazon'
    Apple = 'apple'
    Google = 'google'
    GitHub = 'github'
    Microsoft = 'microsoft'
    Okta = 'okta'
    Facebook = 'facebook'
    Gitlab = 'gitlab'


OAUTH_PROVIDER_LOOKUP = {member.value: member.name for member in OauthProviderNames}
```

Eight named providers.  Each has its own callback endpoint
in `baseendpoints.py` and its own token-handling code in
`baseoperations.py`.  A project building a web service that
authenticates users typically uses some subset:

- A consumer-facing site uses Google, Apple, Facebook,
  Amazon.
- A developer-facing site uses GitHub, GitLab, Microsoft,
  Okta.
- An enterprise-facing site uses Microsoft, Okta plus the
  consumer ones.

The framework's role is to provide *the integration shape*
— callbacks, token storage, refresh, revocation on logout —
not to dictate which providers a project uses.  Projects
disable the providers they don't want by *not* registering
the endpoints; the framework supports them all.

## SQLAlchemy ORM — `Credential` and friends

**File:**
`codecraftai-webservice-flask/source/packages/ccai/webservice/xflask/baseorm.py:35`

```python
class Credential(WebServiceBase, SerializableMixIn):
    """
        Represents a user's credentials for authentication.
        This model is used for password-based authentication and is separate from OAuth user records.
    """

    __tablename__ = "credentials"

    id = Column('cred_id', VARCHAR(40), primary_key=True,
                doc="Unique identifier for the credential record.")
    password_hash = Column('cred_password_hash', VARCHAR(255), nullable=False,
                           doc="Hashed password for the user account")
    created_at = Column('cred_created_at', DateTime, default=utc_now, nullable=False,
                        doc="Timestamp when the credential record was created")
    updated_at = Column('cred_updated_at', DateTime, default=utc_now, onupdate=utc_now, nullable=False,
                        doc="Timestamp when the credential record was last updated")


class CredentialResetToken(WebServiceBase, SerializableMixIn):
    """
        Represents a token used for resetting a user's password.
    """

    __tablename__ = "credential_reset_tokens"

    id = Column('reset_token_id', VARCHAR(40), primary_key=True)
    user_id = Column('reset_token_user_id', VARCHAR(40), nullable=False, index=True,
                     doc="ID of the user for whom the reset token is issued")
    token_hash = Column('reset_token_hash', VARCHAR(255), nullable=False, unique=True,
                        doc="Hash of a unique token string")
    created_at = Column('reset_token_created_at', DateTime, default=utc_now, nullable=False)
    expires_at = Column('reset_token_expires_at', DateTime, default=reset_token_expiration, nullable=False)
```

The ORM patterns:

- **VARCHAR(40) primary keys.**  Keys are typically UUIDs or
  similar 40-char strings.
- **Hashed passwords.**  `password_hash` is the storage
  column; the framework's auth code never stores plaintext.
- **`created_at` / `updated_at` columns** with default
  `utc_now` and `onupdate=utc_now`.  Every table tracks its
  own lifecycle.
- **`SerializableMixIn`.**  Provides `to_dict()` /
  `from_dict()` for JSON serialisation across the wire.

A project subclassing these conventions for new tables gets
the same auditing for free.  The base mixin handles the
boilerplate.

## Distinction from `codecraftai-credentials`

A vocabulary point: this chapter's `Credential` ORM model
in the Flask web-service is *for storing the website's
own user passwords*.  It is *not* the same thing as the
`codecraftai-credentials` package (Volume 0 chapter 10, Volume
2 chapter 11), which is the runtime credential manager for
test jobs.

The two coexist:

- The website stores its users' credentials in its database
  (this chapter's `Credential` model).
- The website's *backend* might call into other services
  using credentials managed by `codecraftai-credentials` (the
  framework's runtime credentials).

A reader confused by overlapping vocabulary should remember:
"`Credential` ORM" is for the website's user; "`Credential`
in `codecraftai-credentials`" is for the test job's
authentication.  Different stores, different lifecycles,
different concerns.

## What the Flask scaffolding does NOT do

Three things the package deliberately leaves to the project:

- **The actual UI.**  The package provides the *backend*
  scaffolding (security, auth, ORM); the project provides
  the templates, the JavaScript, the CSS.
- **The deployment.**  Gunicorn / uWSGI / Docker / Kubernetes
  — the project's choice.  The package provides a Flask app;
  the project deploys it.
- **The database choice.**  SQLAlchemy supports many
  backends; the project picks Postgres / MySQL / SQLite based
  on its needs.

The package is opinionated about *patterns* (security
routing, auth flow, ORM shape) and unopinionated about
*deployment*.

## When a project should use this scaffolding

The scaffolding is a fit when:

- The project needs a Flask web service with role-based
  access control.
- The project authenticates users through any of the eight
  supported OAuth providers.
- The project's data model fits SQLAlchemy ORM.
- The project values consistency with the portfolio's
  conventions.

The scaffolding is overkill when:

- The project needs only a single-route public API.
- The project uses a different web framework (FastAPI,
  Django).
- The project's auth is a single API key, not user-level.

For overkill cases, project-specific scaffolding is the right
shape.  For appropriate cases, this scaffolding eliminates a
month of boilerplate.

## What the next chapter does

Chapter 15 is the volume's case study — a worked walk-through
of provisioning a Pod, activating it, running a UI test
against an application inside the Pod, integrating with
GitLab to record results, and exposing a status dashboard
through the Flask scaffolding.  The case study assembles the
chapters into one example.


<!-- https://code-craft.ai/references/codecraftai/06-topology-provisioning-ui/15-case-study -->
# Chapter 15 — Case Study: A Landscape That Drives a Sysman Feature Test Across Four OSes

> *Volume 6 walked the environment model in fourteen chapters.
> This chapter watches the model in motion.  A Pod with a
> custom Landscape (cooling Features), a custom Topology
> (cluster + spare), a verification override, an SSH-driven
> sysman feature test across four OS variants, and a status
> readout published to a Flask dashboard.  Every chapter's
> concept lands in the example.*

The case study is concrete.  A real-shape automation
campaign exercising the volume's machinery end to end.  The
target: verify that a sysman feature (Volume 4 covers sysman
in depth; this chapter just uses one as worked target —
"deploy a managed network configuration to a fleet") behaves
correctly across four DUT operating systems.  The reader's
takeaway is a *runtime picture* of how Volume 6's classes
compose at scale.

## The Pod

Volume 3 chapter 4's Specification declares the Pod:

```yaml
# spec-cross-os-network-config.yaml
pod:
    name: cross-os-config-pod
    resources:
        - controller-runner-1
        - dut-rhel-1
        - dut-ubuntu-1
        - dut-macos-1
        - dut-windows-1
        - serial-concentrator-1
        - lpc934-1
        - lab-vault-1
```

CI tooling reads the Specification, performs the pool
checkout, emits Landscape and Topology files.  The Landscape
declares:

```yaml
environment:
    label: cross-os-config

apod:
    clients:
        - deviceType: network/client-linux
          name: controller-runner-1
          host: 10.0.0.1
          role: controller
          credentials:
              - lab-runner

infrastructure:
    services:
        - serviceType: network/service
          name: lab-vault-1
          host: 10.0.0.20
          credentials:
              - vault-admin

        - serviceType: serial/concentrator
          name: serial-concentrator-1
          host: 10.0.0.50

        - serviceType: power/lpc934
          name: lpc934-1
          host: 10.0.0.51

    devices:
        - deviceType: network/ssh
          name: dut-rhel-1
          host: 10.0.1.10
          platform: rhel
          role: dut
          group: workers
          credentials: [dut-root]
          features:
              isolation: false
              serial: serial-concentrator-1: 1
              power: lpc934-1: 1
              cooling: zone-a
        # ... dut-ubuntu-1, dut-macos-1, dut-windows-1 with similar shape ...
```

The device declarations include a project-specific Feature
(`cooling: zone-a`) that the project's `MyProjectLandscape`
subclass (chapter 7) recognises.

## The Topology

```yaml
environment:
    label: cross-os-config

clusters:
    - name: workers
      group: workers
      nodes:
          - dut-rhel-1
          - dut-ubuntu-1
          - dut-macos-1
          - dut-windows-1
      spares: []

cooling_zones:
    - name: zone-a
      devices: [dut-rhel-1, dut-ubuntu-1, dut-macos-1, dut-windows-1]

roles:
    controller: controller-runner-1
```

Three sections:

- **`clusters`** — the canonical shape.  The framework's
  `LandscapeOperationalLayer._create_clusters` materialises
  a `LandscapeDeviceCluster` for the four DUTs.
- **`cooling_zones`** — project-specific.  The project's
  `MyProjectLandscape.validate_topology` (chapter 6) verifies
  the section.
- **`roles`** — names the controller for test code to
  query.

## The custom subclasses

The project ships:

```python
# my_project/landscape.py

from ccai.landscaping.landscape import Landscape
from typing import List, Tuple


class MyProjectLandscape(Landscape):

    def validate_topology(self, topology_info: dict) -> Tuple[List[str], List[str]]:
        errors = []
        warnings = []

        framework_errors, framework_warnings = super().validate_topology(topology_info)
        errors.extend(framework_errors)
        warnings.extend(framework_warnings)

        # Verify each cooling_zone references real devices.
        cooling_zones = topology_info.get("cooling_zones", [])
        for zone in cooling_zones:
            zone_name = zone["name"]
            for dev_name in zone.get("devices", []):
                if dev_name not in self.devices:
                    errors.append(
                        "Cooling zone '{}' references unknown device '{}'.".format(
                            zone_name, dev_name
                        )
                    )

        return errors, warnings
```

```python
# my_project/devices.py

from ccai.landscaping.landscapedevice import LandscapeDevice


class MyProjectDevice(LandscapeDevice):

    def attach_features(self):
        super().attach_features()  # serial, power, etc.

        if "cooling" in self._features:
            cooling_zone = self._features["cooling"]
            self._cooling_zone = cooling_zone
        return

    @property
    def cooling_zone(self) -> Optional[str]:
        rtnval = getattr(self, "_cooling_zone", None)
        return rtnval
```

```python
# my_project/factories.py

from ccai.factories.factoryextension import FactoryExtension
from ccai.landscaping.landscapingfactoryprotocol import LandscapingFactoryProtocol


class MyProjectLandscapingFactory(FactoryExtension, LandscapingFactoryProtocol):

    PRECEDENCE = 30

    @classmethod
    def get_landscape_type(cls):
        return MyProjectLandscape
```

The factory is registered through the
`ccai.extensions.my_project` namespace package.

## The custom test job

```python
# my_project/testjobs.py

from ccai.testplus.distributedtestjob import DistributedTestJob
from ccai.landscaping.wellknown import LandscapeSingleton


class CrossOSConfigTestJob(DistributedTestJob):
    name = "Cross-OS Network Config"
    description = "Verify managed network configuration deployment across the fleet."

    def _get_landscape(self):
        return LandscapeSingleton()  # returns MyProjectLandscape via factory
```

The entry point:

```python
# tests/cross_os_config/run.py

from ccai.testplus.entrypoints import distributed_test_entrypoint
from my_project.testjobs import CrossOSConfigTestJob


if __name__ == "__main__":
    distributed_test_entrypoint(job_type=CrossOSConfigTestJob)
```

A taskplus orchestration (Volume 3 chapter 25's bridge tasks)
invokes this entry point.

## What activation does

When the orchestration triggers the test job:

1. **Configuration phase.**  Files load.  Credentials
   resolve through `codecraftai-credentials` (Volume 2 chapter
   11).  `MyProjectLandscape.validate_topology` runs:
   - The framework's checks pass (clusters section's
     references all resolve).
   - The cooling_zones section's references all resolve.
   - Validation succeeds.
2. **Integration phase.**  Coordinators instantiate devices:
   - `SshCoordinator` creates `MyProjectDevice` instances
     for the four DUTs (with primary access via
     `device.ssh`).
   - The Feature dispatcher walks each DUT's features:
     - `serial: serial-concentrator-1: 1` →
       `SerialProtocolExtension` attached.
     - `power: lpc934-1: 1` → `PowerProtocolExtension`
       attached.
     - `cooling: zone-a` →
       `MyProjectDevice.attach_features` stores the cooling
       zone for query later.
   - Services instantiate (lab-vault-1, serial-concentrator-1,
     lpc934-1).
3. **Operational phase.**
   - `activate_coordinators` brings each Coordinator online.
   - The cluster is materialised: a `LandscapeDeviceCluster`
     instance with the four DUTs as nodes.
   - `establish_connectivity` verifies each device responds.
     All four DUTs respond.
4. **Verification complete.**  The Pod is realised.

## The first test

```python
import concurrent.futures
import testbase

from my_project.devices import MyProjectDevice
from my_project.validators import DegradedDurationValidator, LogScanningValidator


@testbase.attach.parameter(landscape, identifier="landscape")
@testbase.attach.validator(
    DegradedDurationValidator(max_degraded_seconds=15.0),
    suffix="degraded_during_apply",
)
@testbase.attach.validator(
    LogScanningValidator(error_patterns=["network-config-failed"]),
    suffix="error_log_scan",
)
def test_apply_config_to_fleet(landscape):
    """
        Verify managed network config deploys to all four DUTs.
    """
    fleet = landscape.cluster("workers").nodes.values()
    config = build_test_network_config()

    # Single-actor model: the test (controller) drives the apply
    # in fan-out across the fleet.
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(fleet)) as ex:
        futures = {
            ex.submit(apply_config_via_sysman, dut, config): dut
            for dut in fleet
        }
        results = {
            futures[fut]: fut.result()
            for fut in concurrent.futures.as_completed(futures)
        }

    for dut, (status, _) in results.items():
        assert status == 0, "Config apply failed on {}: {}".format(dut.name, status)

    # Verify configured state on each DUT.
    for dut in fleet:
        actual = read_network_config_via_sysman(dut)
        rtnval = config_matches(config, actual)
        assert rtnval, "Config mismatch on {}".format(dut.name)

    return
```

The test:

- Uses `landscape.cluster("workers").nodes` to enumerate the
  four DUTs from the materialised cluster (chapter 5's
  canonical clusters shape).
- Fans out the apply across the four DUTs concurrently —
  the test, as the single actor, distributes its own work
  (Volume 2 chapter 2's commitment).
- Two tandem validators attached: one watching for fleet
  degradation, one tailing each DUT's syslog for error
  patterns (Volume 2 chapter 5).
- Asserts every apply succeeded; asserts every DUT's state
  matches the config.

The test reads naturally because the framework's classes
provide the right vocabulary at the right level.

## What the JSOS stream records

The framework's recorder writes:

- `phase_started` / `phase_finished` for each activation
  phase.
- `validate_topology` event with `success` outcome.
- `establish_connectivity` event with the four DUTs'
  reachability tuples.
- `test_started` for the test.
- `validator_started` for each of the two validators.
- `test_finished` (passed).
- `validator_finished` for each validator (passed).

Per Volume 1 chapter 5's discipline.  A consumer reading
the stream sees the activation flow followed by the test's
outcomes.

## Recording results to GitLab

After the test job completes, an orchestration task (Volume 3
chapter 25) reports results to GitLab:

```python
from ccai.gitlab.rest.clients.gitlabrestclient import GitLabRestClient
from ccai.gitlab.rest.clients.parameters import (
    NotesPostapiv4projectsidissuesissueiidnotesParams as PostNoteParams
)


def post_results_to_gitlab_issue(client, issue_iid, summary):
    params = PostNoteParams(
        path={"id": "my-project", "issue_iid": issue_iid},
        body={"body": format_summary_markdown(summary)},
    )
    client.notes.postapiv4projectsidissuesissueiidnotesparams(params=params)
    return
```

The auto-generated GitLab method posts a comment on the
issue with the run's structured summary.  Volume 6 chapter
13's hand-written foundation handles authentication; the
generated method handles parameter construction and response
parsing.

## Status dashboard via Flask

A separate web service (built on the Flask scaffolding from
chapter 14) reads the JSOS streams from completed runs and
presents a status dashboard:

```python
# webservice/dashboard.py

from ccai.webservice.xflask.securityblueprint import SecurityBlueprint
from ccai.webservice.xflask.basesecurity import RouteSecurityManager
from flask import Flask, render_template, jsonify

app = Flask(__name__)
security = RouteSecurityManager(app)

dashboard = SecurityBlueprint("dashboard", __name__, route_security=security)


@dashboard.route("/runs")
def runs_view():
    runs = list_recent_runs()
    return render_template("runs.html", runs=runs)


@dashboard.route("/api/runs/<run_id>")
def run_summary(run_id: str):
    summary = read_run_summary(run_id)
    return jsonify(summary)


app.register_blueprint(dashboard)
```

The dashboard ties together the run results, the GitLab
notes, the per-Pod history.  Engineers visit the URL,
authenticate via OAuth (Volume 6 chapter 14's eight
providers), see the status of the campaign.

## What the case study has touched

Every chapter's concept appeared:

- **Chapter 1** — the Landscape's apod / infrastructure shape.
- **Chapter 2** — the device declaration with deviceType +
  Features + metadata.
- **Chapter 3** — Coordinators / Agents / ProtocolExtensions /
  Features in the activation flow.
- **Chapter 4** — `FriendlyIdentifier` for device lookup.
- **Chapter 5** — the canonical `clusters` shape and the
  cooling_zones extension.
- **Chapter 6** — `validate_topology` override checking the
  cooling_zones section.
- **Chapter 7** — `MyProjectLandscape` /
  `MyProjectDevice` subclasses registered through the
  factory protocol.
- **Chapter 8** — `BaseExt`-based service extensions (one
  could imagine `LpcExt` extensions on the LPC934 service).
- **Chapter 9** — the realised Pod with the four DUTs and the
  three services.
- **Chapter 10** — the (currently scaffolding) provisioning
  story producing the Landscape file.
- **Chapter 11** — UI automation (could be added by
  testing the GUI of the management tool).
- **Chapter 12** — declarative UI mappings (extension point).
- **Chapter 13** — GitLab integration posting the result
  comment.
- **Chapter 14** — Flask scaffolding hosting the dashboard.

The case study is the proof that Volume 6's machinery
composes.  A reader who reached this point should be able to
take a real environment-model definition in a project,
identify each construct's framework concept, and predict its
behaviour without running it.

## End of Volume 6

The volume started by walking inside a Landscape file
(chapter 1) and ended with a worked campaign that exercises
every piece of the framework's environment model (this
chapter).

The chapters that come next are in other volumes.  Volume 4
walks the sysman feature tier the case study's tests
exercise.  Volume 5 walks the wire (SSH, REST, serial) the
ProtocolExtensions wrap.  Volume 7 walks the AI Agent SDK
that builds on Volume 5's protocols.  Volume 8 walks
production-grade post-mortem debugging using the JSOS streams
the case study produced.

Volume 6's contribution to the series is the *environment
model*.  The model was implicit in Volumes 1, 2, and 3
(referenced as "the Landscape" or "the Topology"); Volume 6
made it explicit.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk -->
# Volume 7 — Building an AI Agent SDK

*Lifecycle, Tools, Memory, and Observability for Production Agents*

## About this volume

Volume 7 is the *largest* volume by source-material size.
`codecraftai-agent-kit` is the largest codebase in the portfolio
— ~46k LOC, ~217 modules, 292 testbase test files, 3,744
tests at fast-CI cap (`TESTBASE_MAX_EXAMPLES_CAP=10`).  The
volume is the SDK book — *how to build* a production AI
agent that uses tools, manages memory, runs workflows, and
exposes observability.

The volume *uses* the patterns established in Volumes 0, 1,
3, and 5:

- Volume 0's foundation tier — error vocabulary,
  configuration loading, application identity.
- Volume 1's testbase — the 3,744-test suite that exercises
  the SDK is built on testbase.
- Volume 3's orchestration patterns — agent workflows
  borrow taskplus's flow concepts.
- Volume 5's Measured Interoperability — tools follow the
  Aspects pattern for behaviour parameterisation.

## Who it is for

Developers building production AI agents.  This is the book
the team that ships an agent product reads to learn what
discipline an SDK should impose.  Not a tutorial on AI;
not a survey of LLMs.  It is *the operational discipline*
for building an agent that runs reliably in production.

## Source material

- `codecraftai-agent-kit` — the SDK itself.

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — Foundations | 1, 2, 3 | SDK vs framework; multi-provider LLM; tool design |
| II — State and Flow | 4, 5, 6 | Memory architecture; workflow orchestration; observability |
| III — Discipline | 7, 8 | Mock-first testing (305 files); property tests at scale (72 files) |
| IV — Worked Example | 9 | Agent that uses the rest of the ccai portfolio |

- [Chapter 1 — Why an SDK and Not a Framework](chapters/01-why-an-sdk-not-a-framework.md)
- [Chapter 2 — Multi-Provider LLM Abstraction](chapters/02-multi-provider-llm.md)
- [Chapter 3 — Tool Design](chapters/03-tool-design.md)
- [Chapter 4 — Memory Architecture](chapters/04-memory-architecture.md)
- [Chapter 5 — Workflow Orchestration for Agents](chapters/05-workflow-orchestration.md)
- [Chapter 6 — Observability for Agents](chapters/06-observability.md)
- [Chapter 7 — Testing AI Agents](chapters/07-testing-ai-agents.md)
- [Chapter 8 — Property Tests at Scale](chapters/08-property-tests-at-scale.md)
- [Chapter 9 — Building an Agent That Uses the Rest of the Code Craft AI Portfolio](chapters/09-portfolio-integration.md)

## How it connects to the other volumes

- **Volume 0** — foundation tier (errors, configuration,
  application identity).
- **Volume 1** — testbase (the 3,744-test suite); the
  case study from Vol 1 ch.10 was the agent-kit suite at
  cap=10.
- **Volume 2** — distributed test execution (when an agent
  runs in a Pod).
- **Volume 3** — taskplus orchestration that invokes agent
  workflows.
- **Volume 5** — Measured Interoperability, Aspects pattern
  for tool dispatch.
- **Volume 8** — time-travel debugging an agent run; ch.6's
  observability composes into Vol 8's replay tooling.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/01-why-an-sdk-not-a-framework -->
# Chapter 1 — Why an SDK and Not a Framework

> *Most AI-agent libraries are *frameworks*: they impose a
> structure that the application has to fit into.  The Agent
> Class.  The Tool Class.  The Memory Class.  Inherit from
> these specific shapes and the library wires you up.
> `codecraftai-agent-kit` is an *SDK*.  It ships base classes,
> but the application owns the shape.  This chapter is about
> the difference, and why a production AI agent benefits from
> the SDK shape.*

`codecraftai-agent-kit` is the volume's subject — a Python SDK
for building AI agents.  The package is large by ccai
standards: ~217 modules, ~46k LOC, 305 test files at last
count.  Volume 7 is the framework's largest book because its
subject is the framework's largest package.

This chapter establishes *why the SDK is shaped the way it
is*.  By the end, a reader should understand the
framework-vs-SDK distinction, what `codecraftai-agent-kit`
commits to, and what it deliberately leaves to the application.

## The framework vs SDK distinction

A *framework* takes control of the application's flow.  You
write your code in the shapes the framework requires, and
the framework calls into your code at the points it decides.
Django is a framework — your code lives in `views.py`,
`models.py`, `urls.py`; Django's request loop calls your
view functions.

An *SDK* gives you base classes and helpers.  Your
application controls the flow; you call into the SDK at
the points *you* decide.  The standard library's
`http.client` is an SDK — your code creates an
`HTTPConnection`, calls `request()`, reads the response.
The library doesn't take over the program.

Most AI-agent libraries published since 2023 are frameworks.
LangChain has chains.  LlamaIndex has indexes.  AutoGen has
agent groups.  Each imposes a *control flow*: you build your
agent inside the framework's structure, and the framework
runs it.

`codecraftai-agent-kit` is an SDK.  It ships:

- `Agent` (an abstract base class) — you subclass it.
- `Tool` (abstract base) — you subclass each tool.
- `LLMProvider` (abstract base) — you instantiate one.
- `MemoryStore` (abstract base) — you choose an
  implementation.
- ...and roughly 200 other classes that solve specific
  problems.

But there is **no framework loop**.  Your application
constructs an Agent, registers tools, calls into the agent's
methods, and decides what to do with the results.  You
write `main()`; the SDK doesn't.

## Why this matters for production agents

A reader from a research-AI background might prefer the
framework model — it's faster to prototype, fewer decisions
to make.  Production agents have different concerns:

### Production agents need to fit existing applications

Production AI agents typically integrate into *existing*
applications: a customer-support workflow, a CI/CD pipeline,
a monitoring system.  The application has its own control
flow, its own logging, its own configuration story.  An
agent framework that wants to *take control* fights the
existing application; an SDK lets the application keep
control.

### Production agents need observability the application owns

When an agent's behaviour is wrong, the application's
operators need to debug it using the application's existing
observability — its tracing, its metrics, its log
aggregation.  A framework that has its *own* observability
adds a second tier the operators have to learn.  An SDK that
emits structured events the application captures into its
existing systems is operationally simpler.

### Production agents need testability

A test that exercises an agent's logic against a mock LLM
should look like every other test in the application's test
suite.  Frameworks often have *test runners* of their own;
SDKs let the application's existing test framework drive.

`codecraftai-agent-kit`'s tests use the framework's standard
testbase machinery (Volume 1 chapter 2).  No special
agent-test runner.  Mocks are constructed using the
portfolio's standard `Mock` class (Volume 1 chapter 7).
The 305 test files exercise the same patterns the rest of
the portfolio uses.

### Production agents need predictable failure modes

A framework that hides failures behind retries, fallbacks,
or magic recovery makes failures harder to diagnose.  An SDK
that surfaces failures through typed exceptions and
structured results lets the application decide what to do.

`codecraftai-agent-kit`'s tools return `ToolResult` objects
with `STATUS_SUCCESS` or `STATUS_FAILURE`; LLM providers
raise typed exceptions; the agent's lifecycle has explicit
states (`CREATED / INITIALIZED / RUNNING / PAUSED / ERROR /
STOPPED`).  Nothing is hidden.

## The agent-kit's three commitments

The SDK makes three commitments to its users.  Each is
load-bearing for the chapters that follow.

### Commitment 1 — The agent is *yours*; the SDK provides building blocks

A reader inheriting from `Agent` writes:

```python
class MyApplicationAgent(Agent):

    def initialize(self) -> None:
        # Your initialization logic.
        super().initialize()
        self._configure_my_tools()

    def start(self) -> None:
        # Your start logic.
        super().start()
        self._run_my_application_loop()

    # ... etc.
```

The agent's behaviour — what it does, when it does it —
is the application's code.  The SDK's `Agent` base provides:

- The lifecycle state machine (`AgentStateMachine` —
  chapter 1's worked example below).
- Hook registration (`on_initialize`, `on_start`,
  `on_pause`, `on_resume`, `on_stop`, `on_error`).
- Identity (`AgentIdentity` with name, version, tags,
  identifier).
- Configuration (`AgentConfiguration` carrying the above).
- The standard subsystems (tool registry, LLM provider
  registry, cost tracker, memory store, observability
  pipeline).

The base doesn't impose a particular *agent loop*.  Some
applications run the agent in a single conversation per
request; others run it as a long-lived service consuming a
queue; others embed it in a workflow engine.  All three are
SDK-supported because the *loop is the application's*.

### Commitment 2 — Tools are typed and validated

Every tool subclasses `Tool` and declares:

```python
class FetchUserProfileTool(Tool):
    TOOL_NAME = "fetch_user_profile"
    TOOL_DESCRIPTION = "Look up a user's profile by user ID."
    TOOL_VERSION = "1.0.0"
    INPUT_SCHEMA = {
        "parameters": [
            {"name": "user_id", "type": "string", "required": True},
            {"name": "include_history", "type": "boolean", "required": False, "default": False},
        ]
    }
    OUTPUT_SCHEMA = {
        "parameters": [
            {"name": "user_id", "type": "string", "required": True},
            {"name": "username", "type": "string", "required": True},
            {"name": "email", "type": "string", "required": True},
            {"name": "history", "type": "list", "required": False},
        ]
    }

    def execute(self, inputs: dict, credentials: dict = None) -> dict:
        # Your tool logic.
        return {
            "user_id": inputs["user_id"],
            "username": "...",
            "email": "...",
        }
```

The schema is *validated* at execution time.  If the LLM
asks the agent to call this tool with `user_id` missing, the
SDK's `ToolExecutor` raises `ToolValidationError` *before*
the tool's `execute` method runs.  If the tool returns
without a `username` field, the same validation catches it.

Chapter 3 walks the schema system in detail.  The point
here: tools have *typed contracts* with the agent, and the
SDK enforces them.

### Commitment 3 — Cost is tracked, not hidden

Every Agent owns a `CostTracker` (chapter 1's worked example
below).  Every LLM completion records a `TokenUsage` and a
`CostEstimate` against the tracker.  At any point, the
application can read:

```python
print(agent.cost_tracker.total_cost_estimate)
# CostEstimate(prompt_cost=0.42, completion_cost=1.18, total_cost=1.60, currency='USD')

print(agent.cost_tracker.per_provider_breakdown())
# {'openai-gpt-4o': {'token_usage': TokenUsage(...), 'cost_estimate': CostEstimate(...)},
#  'anthropic-claude-opus': {'token_usage': TokenUsage(...), 'cost_estimate': CostEstimate(...)}}
```

The SDK's commitment: an agent's cost is always
*observable*.  Production agents that run for hours or days
need this — surprises in the LLM bill at end-of-month are
the most common failure mode for AI products.  The cost
tracker is *built in*, not optional.

## A worked Agent subclass

To make the SDK shape concrete, here's a minimal but
complete Agent subclass.

```python
from ccai.agentkit.agent import Agent
from ccai.agentkit.agentconfiguration import AgentConfiguration
from ccai.agentkit.lifecyclestate import LifecycleState


class HelloAgent(Agent):
    """
        Simplest possible agent — initializes, starts, says
        hello via an LLM, stops.
    """

    def initialize(self) -> None:
        super().initialize()
        # Register one tool, one provider — left as exercise.

    def start(self) -> None:
        super().start()
        # Application's work happens here.
        provider = self._llm_provider_registry.lookup_default()
        conversation = LLMConversation(messages=[
            LLMMessage(role="user", content="Hello!"),
        ])
        response = provider.complete(conversation)
        print(response.content)

    def pause(self) -> None:
        super().pause()

    def resume(self) -> None:
        super().resume()

    def stop(self) -> None:
        super().stop()

    def reset(self) -> None:
        super().reset()


# Application's main:
config = AgentConfiguration(name="hello-agent", version="1.0.0")
agent = HelloAgent(configuration=config)
agent.initialize()
agent.start()
agent.stop()
```

Five things to notice:

1. **Lifecycle methods are explicit.**  The application
   calls `initialize()` / `start()` / `stop()`.  The SDK
   doesn't run the agent for you.
2. **State machine validates transitions.**  Calling `stop()`
   without first calling `start()` raises a `SemanticError` —
   the state machine's `VALID_TRANSITIONS` table doesn't
   allow it.
3. **The Agent's own work is in `start()`.**  This is where
   the application puts its agent loop.  Other patterns
   (running the agent inside a workflow, exposing it as an
   HTTP endpoint, queue-driven) are equally fine; the
   commitment is that *the application chose*.
4. **`super()` calls handle the lifecycle.**  The base class
   transitions the state machine, fires hooks, emits
   observability events.  Skipping `super()` skips that
   discipline.
5. **The provider, tools, memory, and cost tracker are
   *registered* during `initialize()`.**  The SDK provides
   the registries; the application populates them with the
   specific providers / tools / stores it needs.

A reader writing a real agent fills in the blanks (real
tools, real providers, real memory) — but the *shape* stays
the same.

## The state machine

A small diversion into the state machine because it shows
up in every chapter that follows.

**File:** `agentstatemachine.py:22–38`

```python
class AgentStateMachine:
    """
        State machine that enforces valid lifecycle transitions
        for an agent.
    """

    VALID_TRANSITIONS: Dict[LifecycleState, List[LifecycleState]] = {
        LifecycleState.CREATED: [LifecycleState.INITIALIZED],
        LifecycleState.INITIALIZED: [LifecycleState.RUNNING, LifecycleState.STOPPED],
        LifecycleState.RUNNING: [LifecycleState.PAUSED, LifecycleState.STOPPED, LifecycleState.ERROR],
        LifecycleState.PAUSED: [LifecycleState.RUNNING, LifecycleState.STOPPED],
        LifecycleState.ERROR: [LifecycleState.STOPPED],
        LifecycleState.STOPPED: [LifecycleState.INITIALIZED],
    }
```

Six states, ten valid transitions.  The state machine:

- Starts every agent in `CREATED`.
- Allows `CREATED → INITIALIZED` only via the
  `initialize()` lifecycle method.
- Allows `INITIALIZED → RUNNING` via `start()`.
- Allows `RUNNING → PAUSED` (via `pause()`) or `RUNNING →
  STOPPED` (via `stop()`) or `RUNNING → ERROR` (when an
  unhandled exception fires the `on_error` hook chain).
- Allows `PAUSED → RUNNING` (via `resume()`) or `PAUSED →
  STOPPED`.
- From `ERROR`, only `STOPPED` is reachable.
- From `STOPPED`, the agent can be re-initialised
  (`STOPPED → INITIALIZED`).

The transition table is what the application's testing
scaffolding (chapter 7) verifies.  An agent that violates
the state machine — calling `stop()` from `CREATED`,
calling `start()` twice without an intervening `stop()` —
raises `SemanticError`.  The discipline catches application
bugs early.

The state machine is *thread-safe* — every transition
acquires a `threading.Lock`.  An agent that's running on one
thread while another thread observes `agent.state` sees a
consistent value.

## What the SDK *doesn't* do

A short list of things `codecraftai-agent-kit` deliberately
leaves to the application:

### It doesn't decide which LLM to use

The agent-kit ships an abstract `LLMProvider` (chapter 2).
It doesn't ship a default implementation.  The application
chooses OpenAI, Anthropic, Azure, a local model — and
constructs the appropriate provider class (or writes one if
the SDK doesn't ship the provider's class).

This is *intentional*.  Pinning a default provider ties the
SDK's release cadence to the LLM industry's churn.  Leaving
the provider open lets the application switch between
providers (or run multiple in parallel) without the SDK
caring.

### It doesn't decide which tools an agent has

Every tool is registered explicitly:

```python
agent.tool_registry.register(FetchUserProfileTool())
agent.tool_registry.register(SendEmailTool())
```

The SDK doesn't auto-discover tools, scan modules, or
register based on naming convention.  Tools are *the
application's* domain knowledge; the application is
responsible for choosing which it exposes.

### It doesn't decide the agent loop

A *loop* in framework terminology is "what the agent does
when it runs."  Chains things in LangChain.  Conversation
turns in OpenAI Assistants.  The agent-kit doesn't impose
one.  Some applications:

- Run the agent in a request/response loop (one user
  message per agent invocation).
- Run the agent as a long-lived service, consuming a queue.
- Run the agent inside a workflow with checkpoints.
- Run the agent in a single batch invocation.

All four are supported because the SDK doesn't prescribe.
Chapter 5 walks workflow orchestration as one option among
many.

### It doesn't decide observability backends

Chapter 6 walks the observability layer — `Tracer`,
`MetricsCollector`, `ExecutionRecorder`.  The SDK emits
structured events; the application wires them to its
backend (OpenTelemetry, Datadog, Prometheus, custom).  The
SDK doesn't ship integrations to specific backends — those
live in adapter packages outside the agent-kit.

## How this volume is organised

The remaining eight chapters walk the SDK by subsystem:

- **Chapter 2** — multi-provider LLM abstraction.  The
  `LLMProvider` interface, `AlternateProviderStrategy` for
  failover, the per-agent registry.
- **Chapter 3** — tool design.  `Tool` base class,
  `ToolSchema` (custom — not Pydantic), `ToolExecutor`
  validation, `ToolResult` structured outcomes.
- **Chapter 4** — memory architecture.  `MemoryStore`,
  `MemoryScope` (AGENT / SHARED / GLOBAL), `LLMConversation`
  truncation, summarisation, semantic search.
- **Chapter 5** — workflow orchestration.  DAG-based
  `WorkflowExecutor`, `ApprovalGate` for human-in-the-loop,
  `CircuitBreaker` for resilience.
- **Chapter 6** — observability.  `Tracer` /
  `MetricsCollector` / `ExecutionRecorder` plus the 13
  built-in agent metrics.
- **Chapter 7** — testing AI agents.  The 305 test files,
  the mock-first discipline, the `ccai.testbase.mocking.Mock`
  pattern.
- **Chapter 8** — property tests at scale.  72 property-
  based test files, the `@given(constraints={"max_examples":
  50})` cap, what the property tests actually catch.
- **Chapter 9** — building an agent that uses the rest of
  the ccai portfolio.  An agent that runs sysman
  features, observes through Volume 8's time-travel
  debugging, exposes a Flask dashboard.

By the end of the volume, a reader should be able to read
any module in `codecraftai-agent-kit` and predict its shape
from the patterns established here.

## What's next

Chapter 2 walks the multi-provider LLM abstraction.  The
`LLMProvider` base class with its `complete()` and
`complete_stream()` abstract methods.  The
`AlternateProviderStrategy` for failover when a provider is
unavailable.  The per-agent `LLMProviderRegistry` keyed on
`(provider_name, model_name)` so an agent can use multiple
providers in the same session.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/02-multi-provider-llm -->
# Chapter 2 — Multi-Provider LLM Abstraction

> *Production agents don't pin themselves to one LLM
> provider.  Cost varies.  Capabilities differ.  Outages
> happen.  The `codecraftai-agent-kit` ships a small, abstract
> `LLMProvider` interface that any provider implementation
> must honour, plus an `AlternateProviderStrategy` for
> failover when the primary is unavailable.*

This chapter walks the SDK's LLM-provider layer.  By the
end, a reader should be able to:

- Implement a new provider class for any LLM API.
- Register multiple providers on a single agent.
- Wire failover between providers via
  `AlternateProviderStrategy`.

## The premise — providers vary

A production agent's relationship with LLM providers has
three properties that the SDK accommodates:

### Cost varies

GPT-4-class models cost more than smaller models.  Claude
Sonnet costs less than Claude Opus.  An agent that handles
a wide range of work routes *simple* requests to the cheap
model and *hard* requests to the capable one.  Cost-aware
routing requires the agent to *know* which provider to call;
the SDK exposes the registry directly.

### Capabilities differ

Tool calling syntax differs subtly between providers.
Anthropic's tool-use protocol differs from OpenAI's
function-calling.  Streaming response formats differ.
Token-counting algorithms differ.  An SDK that abstracts
these away would lose information; the agent-kit's
abstraction is *thin* — it gives a uniform shape but
preserves provider-specific options through `**kwargs`.

### Outages happen

Every cloud provider has outages.  An agent that *only*
talks to one provider fails when that provider has a bad
day.  An agent with `AlternateProviderStrategy` configured
fails over to a backup transparently to the test code.
Chapter 5 walks the broader resilience story; this chapter
walks the specific provider-failover mechanism.

## The `LLMProvider` base class

**File:** `llmprovider.py:16–60`

```python
class LLMProvider(abc.ABC):
    """
        Abstract base class for Large Language Model providers.

        Subclasses implement the complete and complete_stream
        methods for a specific LLM service.  The base class
        validates that a provider name and model name are
        supplied at construction time.
    """

    def __init__(self, provider_name: str, model_name: str,
                 config: dict = None,
                 credential_names: list = None,
                 tags: dict = None) -> None:
        if provider_name is None or len(str(provider_name).strip()) == 0:
            errmsg = "A provider name is required when constructing an LLMProvider."
            raise SemanticError(errmsg)
        if model_name is None or len(str(model_name).strip()) == 0:
            errmsg = "A model name is required when constructing an LLMProvider."
            raise SemanticError(errmsg)
        self._provider_name = provider_name
        self._model_name = model_name
        self._config = dict(config) if config is not None else {}
        self._credential_names = list(credential_names) if credential_names is not None else []
        self._tags = dict(tags) if tags is not None else {}
        return
```

Five constructor parameters:

- **`provider_name`** — the *vendor* (e.g., `"openai"`,
  `"anthropic"`, `"azure-openai"`).  Required.
- **`model_name`** — the *model* (e.g., `"gpt-4o"`,
  `"claude-opus-4-7"`).  Required.
- **`config`** — provider-specific configuration (base URL,
  API version, organisation ID, etc.).  Optional dict.
- **`credential_names`** — names referencing the credential
  manager (Volume 2 chapter 11).  The actual API keys are
  looked up at runtime.
- **`tags`** — free-form metadata for routing decisions
  (`{"tier": "premium", "task_type": "code"}`).

The constructor *defends against bad inputs* with
`SemanticError` raises (Volume 0 chapter 3 discipline).
Empty strings, None values, missing required fields fail
fast at construction.

## The two abstract methods

**File:** `llmprovider.py:100–125`

```python
@abc.abstractmethod
def complete(self, conversation, **kwargs):
    """
        Executes a synchronous completion request.

        :param conversation: An LLMConversation with the
                             messages to send.
        :returns: An LLMResponse with the completion result.
        :raises: LLMRequestError on provider-side failure.
    """
    ...


@abc.abstractmethod
def complete_stream(self, conversation, handler, **kwargs):
    """
        Executes a streaming completion request.

        :param conversation: An LLMConversation with the
                             messages to send.
        :param handler: The LLMStreamHandler to receive chunks.
        :returns: The final assembled LLMResponse.
        :raises: LLMRequestError on provider-side failure.
    """
    ...
```

Two methods.  Provider implementations override both.

### `complete()` — synchronous request/response

Used when the agent wants the full response before
proceeding.  Takes an `LLMConversation` (chapter 4 walks the
conversation model) and returns an `LLMResponse`.  The
provider blocks until the full response is available.

### `complete_stream()` — streaming request

Used when the agent wants to *stream* the response (display
incremental output, abort early, transform on the fly).
Takes a `handler` callback that receives chunks; returns
the *final* assembled response after streaming completes.

Both methods accept `**kwargs` for provider-specific
options.  This is *the* place where the abstraction lets
provider differences leak through:

```python
# OpenAI provider — temperature and top_p
response = openai_provider.complete(
    conversation,
    temperature=0.7, top_p=0.95,
)

# Anthropic provider — max_tokens (required) and
# system parameter (separate from messages)
response = anthropic_provider.complete(
    conversation,
    max_tokens=4096, system="You are a helpful assistant",
)
```

A reader writing portable agent code constructs `kwargs`
based on the active provider:

```python
common_kwargs = {"max_tokens": 4096}
if isinstance(provider, OpenAIProvider):
    kwargs = {**common_kwargs, "temperature": 0.7}
elif isinstance(provider, AnthropicProvider):
    kwargs = {**common_kwargs, "system": "You are helpful"}

response = provider.complete(conversation, **kwargs)
```

The pattern is *honest* — the application sees the provider
differences instead of having them hidden behind a
lossy abstraction.

## A worked provider implementation

A skeleton for a custom provider implementation (a real
OpenAI provider would be more elaborate — error handling,
retries, token counting, streaming):

```python
import openai
from ccai.agentkit.llmprovider import LLMProvider
from ccai.agentkit.llmconversation import LLMConversation
from ccai.agentkit.llmresponse import LLMResponse
from ccai.agentkit.llmmessage import LLMMessage
from ccai.agentkit.tokenusage import TokenUsage


class OpenAIProvider(LLMProvider):
    """
        OpenAI Chat Completions API provider.
    """

    def __init__(self, model_name: str, api_key: str,
                 base_url: str = None, organization: str = None,
                 credential_names: list = None,
                 tags: dict = None) -> None:
        super().__init__(
            provider_name="openai",
            model_name=model_name,
            config={"base_url": base_url, "organization": organization},
            credential_names=credential_names or [],
            tags=tags,
        )
        self._client = openai.OpenAI(
            api_key=api_key,
            base_url=base_url,
            organization=organization,
        )
        return

    def complete(self, conversation: LLMConversation, **kwargs) -> LLMResponse:
        # Convert LLMConversation to OpenAI's format.
        openai_messages = [
            {"role": msg.role, "content": msg.content}
            for msg in conversation.messages
        ]

        # Make the API call.
        response = self._client.chat.completions.create(
            model=self._model_name,
            messages=openai_messages,
            **kwargs,
        )

        # Translate back to LLMResponse.
        choice = response.choices[0]
        message = LLMMessage(
            role=choice.message.role,
            content=choice.message.content,
        )
        usage = TokenUsage(
            prompt_tokens=response.usage.prompt_tokens,
            completion_tokens=response.usage.completion_tokens,
            total_tokens=response.usage.total_tokens,
        )

        rtnval = LLMResponse(
            message=message,
            token_usage=usage,
            provider_name=self._provider_name,
            model_name=self._model_name,
            raw=response,
        )
        return rtnval

    def complete_stream(self, conversation, handler, **kwargs) -> LLMResponse:
        # ... streaming variant; calls handler.on_chunk(...) for each
        # incremental chunk and returns the final assembled response ...
        ...
```

The provider:

- Translates `LLMConversation` into the format OpenAI's
  Python SDK expects.
- Makes the API call.
- Translates the response back into `LLMResponse` plus
  `TokenUsage`.
- Preserves the raw response for debugging.

Implementations for Anthropic, Azure, local models follow
the same shape — different translation, same skeleton.

## The per-agent provider registry

Each Agent owns an `LLMProviderRegistry` that maps
`(provider_name, model_name)` tuples to provider instances:

```python
class MyAgent(Agent):

    def initialize(self) -> None:
        super().initialize()

        # Register two providers.
        self._llm_provider_registry.register(
            OpenAIProvider(
                model_name="gpt-4o",
                api_key=os.environ["OPENAI_API_KEY"],
            )
        )
        self._llm_provider_registry.register(
            AnthropicProvider(
                model_name="claude-opus-4-7",
                api_key=os.environ["ANTHROPIC_API_KEY"],
            )
        )
```

Lookup at use time:

```python
# By (provider, model) tuple
gpt = self._llm_provider_registry.lookup("openai", "gpt-4o")
claude = self._llm_provider_registry.lookup("anthropic", "claude-opus-4-7")

# By name only (returns first match)
gpt_default = self._llm_provider_registry.lookup_by_provider("openai")

# Default provider (the first registered, or explicitly set)
default = self._llm_provider_registry.lookup_default()
```

The registry is *agent-scoped*, not global.  Each agent
instance has its own.  This matters because:

- Different agents in the same process can use different
  providers without collision.
- Test agents can register mock providers
  (`Mock(spec=LLMProvider)` from Volume 1 chapter 7's
  testbase mocking).
- An agent's providers can be reconfigured per agent
  instance — useful for A/B testing two configurations.

## `AlternateProviderStrategy` — failover

**File:** `alternateproviderstrategy.py:12–36`

```python
class AlternateProviderStrategy(DegradationStrategy):
    """
        Routes the operation to an alternative provider or
        backend when the primary dependency is unavailable.

        :param alternate_callable: The callable to invoke as
                                   the alternate provider.
    """

    def __init__(self, alternate_callable: Callable) -> None:
        super().__init__("alternate_provider")
        self._alternate_callable = alternate_callable
        return

    def degrade(self, dependency_name: str, exception: Exception,
                context: Optional[dict] = None):
        """
            Invokes the alternate callable.

            :param dependency_name: Name of the unavailable dependency.
            :param exception: The exception that triggered degradation.
            :param context: Optional context dictionary.
            :returns: The result from the alternate callable.
        """
        return self._alternate_callable()
```

`AlternateProviderStrategy` is one *strategy* in the SDK's
broader resilience layer (chapter 5 walks the rest).
Specifically for providers, it's used when the primary
fails and the agent wants to *automatically* fall back
to an alternate.

A typical configuration:

```python
class MyAgent(Agent):

    def initialize(self) -> None:
        super().initialize()

        # Primary provider — Anthropic.
        primary = AnthropicProvider(model_name="claude-opus-4-7", ...)
        self._llm_provider_registry.register(primary)

        # Alternate provider — OpenAI as fallback.
        alternate = OpenAIProvider(model_name="gpt-4o", ...)
        self._llm_provider_registry.register(alternate)

        # Failover strategy — when Anthropic fails, use OpenAI.
        self._failover_strategy = AlternateProviderStrategy(
            alternate_callable=lambda: alternate.complete(self._current_conversation)
        )
```

When the primary's `complete()` raises a transient error
(rate limit, 503, network blip), the resilience pipeline
dispatches to the failover strategy, which calls the
alternate provider.

The trade-off the strategy makes: **the test code doesn't
know which provider responded**.  A request that's served
by Anthropic vs OpenAI may behave differently — different
phrasings, different lengths, different tool-call shapes.
The application has to be resilient to this *response*-
level variation if it uses cross-provider failover.

For deterministic tests, the strategy is typically
*disabled* (test code always wants to know which provider
is responding).  For production resilience, it's *enabled*.

## Cost tracking integration

Every successful `complete()` call records its token usage
on the agent's `CostTracker`:

```python
def complete(self, conversation: LLMConversation, **kwargs) -> LLMResponse:
    # ... API call as above ...

    # Record cost.
    cost_estimate = self._compute_cost(usage)
    self._cost_tracker.record(
        provider_name=self._provider_name,
        token_usage=usage,
        cost_estimate=cost_estimate,
    )

    return rtnval
```

The provider knows its own *prices* (per-million-token cost
for prompt / completion) and computes the cost from the
returned `TokenUsage`.  The `CostTracker` accumulates across
all calls.

Chapter 6 walks the cost tracker in detail.  The
integration point worth noting here: *the provider does the
recording*.  Application code that calls
`provider.complete(...)` doesn't have to remember to log
costs — the provider handles it.

## What the abstraction *deliberately* doesn't do

A short list of things the `LLMProvider` interface
intentionally leaves to implementations:

### It doesn't standardise tool-call shapes

OpenAI's function-calling and Anthropic's tool-use protocol
differ in JSON shape, message threading, and error
semantics.  The SDK doesn't try to unify them; instead,
*tool calls* (chapter 3) live above the provider layer
and the `LLMResponse` includes a structured
`tool_calls` list that the agent processes.  Provider
implementations translate to/from the tool-call list at
their boundary.

### It doesn't standardise streaming chunk shapes

`LLMStreamHandler` receives chunks; what's *in* a chunk is
provider-specific.  An agent that wants chunk-level
behaviour writes provider-specific handlers; an agent that
just wants the assembled response uses `complete()`.

### It doesn't standardise error types

OpenAI raises `openai.RateLimitError`; Anthropic raises
`anthropic.RateLimitError`.  Different classes, same
concept.  The SDK ships an `LLMRequestError` that providers
*can* wrap their native errors in, but the wrapping is the
provider implementation's choice.

The discipline is honest: *some provider differences don't
collapse cleanly*, and forcing them to collapse loses
information.

## What's next

Chapter 3 walks the **tool architecture**.  The `Tool` base
class with class-level `TOOL_NAME`, `INPUT_SCHEMA`,
`OUTPUT_SCHEMA`.  The custom `ToolSchema` validation system
(not Pydantic — the SDK rolls its own).  The
`ToolExecutor` that validates inputs, resolves credentials,
invokes the tool, validates outputs.  The two typed
exceptions (`ToolValidationError`, `ToolExecutionError`)
that surface failures structurally.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/03-tool-design -->
# Chapter 3 — Tool Design

> *Tools are how an agent affects the world beyond text.
> The `codecraftai-agent-kit` ships a small, opinionated tool
> system: declarative `INPUT_SCHEMA` and `OUTPUT_SCHEMA`,
> `ToolExecutor` that validates inputs, runs the tool, and
> validates outputs, two typed exceptions for the failure
> modes, structured `ToolResult` return.*

The SDK's tool system is small enough to walk in one
chapter.  Three classes (`Tool`, `ToolSchema`, `ToolExecutor`)
plus two exceptions (`ToolValidationError`,
`ToolExecutionError`) plus one result type (`ToolResult`).

By the end, a reader should be able to write a custom tool,
register it with an agent, and dispatch it correctly through
the executor.

## The `Tool` base class

**File:** `tool.py:23–83`

```python
class Tool(abc.ABC):
    """
        Abstract base class for all tools.  Subclasses define
        tool behavior by implementing the execute method and
        declaring schemas via class-level constants.
    """

    TOOL_NAME: str = None
    TOOL_DESCRIPTION: str = ""
    TOOL_VERSION: str = "1.0.0"
    INPUT_SCHEMA: dict = None
    OUTPUT_SCHEMA: dict = None

    def __init__(self, tags: dict = None,
                 credential_names: list = None) -> None:
        self._name = self.TOOL_NAME
        self._description = self.TOOL_DESCRIPTION
        self._version = self.TOOL_VERSION
        self._input_schema = None
        self._output_schema = None
        # ... tags + credential_names initialisation ...

        if self._name is None:
            errmsg = "A Tool name is required.  Set TOOL_NAME on the class."
            raise SemanticError(errmsg)

        if self.INPUT_SCHEMA is not None:
            try:
                self._input_schema = ToolSchema.from_dict(schema_data=self.INPUT_SCHEMA)
            except Exception as schema_err:
                errmsg_lines = [
                    "Invalid INPUT_SCHEMA on tool '{}'.".format(self._name),
                    "Schema validation detail: {}".format(str(schema_err)),
                ]
                errmsg = os.linesep.join(errmsg_lines)
                raise SemanticError(errmsg) from schema_err
        # ... similar OUTPUT_SCHEMA validation ...

        return

    @abc.abstractmethod
    def execute(self, inputs: dict, credentials: dict = None) -> dict:
        """
            Executes the tool with the given validated inputs.
        """
        ...
```

Five class-level constants every tool sets:

- **`TOOL_NAME`** — the tool's unique identifier.  The
  `ToolRegistry` looks up tools by name.  Required (raises
  `SemanticError` if absent).
- **`TOOL_DESCRIPTION`** — natural-language description.
  Sent to the LLM when the agent declares its tools to the
  provider.
- **`TOOL_VERSION`** — the tool's version.  Useful for
  evolution — version bumps signal to consumers that the
  tool's interface changed.
- **`INPUT_SCHEMA`** — declarative dict describing the
  tool's parameters.  Validated at execution time.
- **`OUTPUT_SCHEMA`** — declarative dict describing the
  tool's return shape.  Also validated.

And one abstract method:

- **`execute(inputs, credentials)`** — the tool's actual
  logic.  Receives a *validated* inputs dict and (optionally)
  a credentials dict resolved from `credential_names`.
  Returns a dict that will be *validated* against the
  `OUTPUT_SCHEMA`.

A worked tool subclass:

```python
class FetchWeatherTool(Tool):

    TOOL_NAME = "fetch_weather"
    TOOL_DESCRIPTION = "Look up the current weather for a city."
    TOOL_VERSION = "1.0.0"

    INPUT_SCHEMA = {
        "parameters": [
            {"name": "city", "type": "string", "required": True,
             "description": "City name (e.g., 'San Francisco')"},
            {"name": "country", "type": "string", "required": False,
             "default": "US",
             "description": "ISO country code"},
            {"name": "units", "type": "string", "required": False,
             "default": "imperial",
             "description": "'imperial' (Fahrenheit) or 'metric' (Celsius)"},
        ]
    }

    OUTPUT_SCHEMA = {
        "parameters": [
            {"name": "city", "type": "string", "required": True},
            {"name": "country", "type": "string", "required": True},
            {"name": "temperature", "type": "float", "required": True},
            {"name": "units", "type": "string", "required": True},
            {"name": "conditions", "type": "string", "required": True},
        ]
    }

    def __init__(self):
        super().__init__(credential_names=["weather-api-key"])
        return

    def execute(self, inputs: dict, credentials: dict = None) -> dict:
        api_key = credentials["weather-api-key"]
        # ... call the weather API with the resolved key ...
        # ... parse the response into the OUTPUT_SCHEMA shape ...
        return {
            "city": inputs["city"],
            "country": inputs["country"],
            "temperature": 72.0,
            "units": inputs["units"],
            "conditions": "sunny",
        }
```

Five things to notice:

1. **Class-level constants for the tool's metadata.**
   Subclasses set them; the base validates at construction.
2. **The `INPUT_SCHEMA` and `OUTPUT_SCHEMA` are
   dictionaries**.  They get parsed into `ToolSchema`
   instances inside `__init__`.
3. **Defaults handle missing optional parameters.**  When
   the LLM calls `fetch_weather` with only `city`, the
   schema's defaults populate `country="US"` and
   `units="imperial"`.
4. **Credentials are *named*, not embedded.**  The tool
   declares `credential_names=["weather-api-key"]`; the
   `ToolExecutor` resolves the name through the
   credentials manager (Volume 2 chapter 11) and passes
   the resolved value to `execute()`.
5. **The tool's logic returns a plain dict** — no special
   return type required.  The dict is *validated* against
   `OUTPUT_SCHEMA` before reaching the agent.

## The custom schema system

**File:** `toolschema.py:14–56`

```python
class ToolSchema:
    """
        Declarative schema definition for tool input or output
        parameters.  Supports type constraints, required/optional
        marking, defaults, and validation.
    """

    SUPPORTED_TYPES = {"string", "integer", "float", "boolean", "list", "dictionary"}

    TYPE_MAP = {
        "string": str,
        "integer": int,
        "float": float,
        "boolean": bool,
        "list": list,
        "dictionary": dict,
    }

    def __init__(self, parameters: list = None) -> None:
        if parameters is not None:
            self._parameters = list(parameters)
        else:
            self._parameters = []
        return
```

Six supported types — `string`, `integer`, `float`,
`boolean`, `list`, `dictionary`.  The schema is
*declarative* — a parameter is described by:

- `name` (required) — the parameter's key.
- `type` (required) — one of the six supported types.
- `required` (optional, default True) — whether the
  parameter must be present.
- `default` (optional) — used when the parameter is
  optional and absent.
- `description` (optional) — natural-language help.

### Why custom and not Pydantic

A reader from a Python web background would expect Pydantic.
The SDK doesn't use it.  Reasons:

- **Pydantic v1 vs v2** — large API differences, ongoing
  community migration.  Pinning Pydantic v2 means
  applications stuck on v1 can't use the SDK; pinning v1
  means the SDK becomes a bottleneck for v2 migration.
- **Schema-as-data, not schema-as-class.**  A tool's schema
  is a `dict` literal — easy to introspect, easy to
  serialise to JSON for sending to the LLM (which expects
  JSON Schema), easy to compare across tool versions.
- **Smaller surface.**  Six types covers what tools need.
  A custom validator that handles those six is a few
  hundred lines.  Pydantic brings hundreds of features the
  agent-kit doesn't need (validators, computed fields,
  serialisers, etc.).

The trade-off: tools that need *complex nested
validation* (e.g., "this parameter is a list of dicts each
with these specific keys") have to either compose multiple
parameters or do the validation in `execute()`.  Most tools
don't need this; the few that do work around it.

### The `validate` method

**File:** `toolschema.py:68–111`

```python
def validate(self, values: dict) -> list:
    """
        Validates a dictionary of values against this schema.

        Checks for missing required parameters, applies default
        values, and verifies type constraints.

        :returns: A list of validation error strings.  Empty list
                  if all values are valid.
    """
    errors = []
    for param_def in self._parameters:
        param_name = param_def["name"]
        param_type = param_def["type"]
        is_required = param_def.get("required", True)

        has_value = param_name in values
        if has_value is False:
            if is_required is True:
                errmsg = "Missing required parameter: '{}'".format(param_name)
                errors.append(errmsg)
            else:
                has_default = "default" in param_def
                if has_default is True:
                    default_value = param_def["default"]
                    values[param_name] = default_value
        else:
            provided_value = values[param_name]
            expected_python_type = self.TYPE_MAP[param_type]
            is_correct_type = isinstance(provided_value, expected_python_type)
            if is_correct_type is False:
                actual_type_name = type(provided_value).__name__
                errmsg = "Parameter '{}': expected type '{}', got '{}'".format(
                    param_name, param_type, actual_type_name
                )
                errors.append(errmsg)

    return errors
```

The validator returns a *list of error strings*, not a
boolean.  A schema with three missing required parameters
returns three error strings.  This matches the framework's
list-of-errors discipline (Volume 0 chapter 2's
multi-line-error-message pattern).

The validator *mutates the input dict* — defaults are
applied in place.  After validation, `values` has every
required parameter (those that the caller passed plus
defaults filled in for optionals).

## The `ToolRegistry`

**File:** `toolregistry.py:16–49`

```python
class ToolRegistry:
    """
        Registry tracking available Tool instances for an Agent.
        Provides lookup by name, tags, and enumeration.
    """

    def register(self, tool: Tool) -> None:
        tool_name = tool.name
        is_duplicate = tool_name in self._tools
        if is_duplicate is True:
            errmsg_lines = [
                "Duplicate tool registration attempted.",
                "  Tool name: '{}'".format(tool_name),
            ]
            errmsg = os.linesep.join(errmsg_lines)
            raise SemanticError(errmsg)
        self._tools[tool_name] = tool
        return

    def lookup(self, tool_name: str) -> Tool:
        rtnval = self._tools.get(tool_name, None)
        return rtnval

    def lookup_by_tags(self, **tags: str) -> list:
        # ... tag-matching logic ...
```

Each agent owns a `ToolRegistry`.  Registration:

- Adds the tool by name.
- *Refuses duplicates* — raises `SemanticError` if a tool
  with the same name is already registered.

Lookup:

- `lookup(name)` — returns the tool or `None`.
- `lookup_by_tags(**tags)` — returns tools matching the
  given tag dict (intersection — every tag must match).

The registry is *agent-scoped* — same pattern as the LLM
provider registry (chapter 2).  Different agents in the
same process have independent tool sets.

## The `ToolExecutor`

**File:** `toolexecutor.py:21–162`

The executor is the *orchestrator* that wraps a tool call:

1. Validate the inputs against the tool's `INPUT_SCHEMA`.
   If validation fails, raise `ToolValidationError`.
2. Resolve the credentials referenced by the tool's
   `credential_names`.
3. Invoke the tool's `execute(inputs, credentials)`.
4. Validate the return value against the tool's
   `OUTPUT_SCHEMA`.  If validation fails, raise
   `ToolValidationError`.
5. Return a `ToolResult` with status / output / duration.

The executor is what the agent's tool-call dispatch path
uses.  When the LLM responds with `"call fetch_weather with
{city: 'San Francisco'}"`, the agent's tool dispatcher:

1. Looks up `fetch_weather` in the registry.
2. Hands the tool to the executor.
3. The executor does the dance above.
4. The agent receives a `ToolResult` and decides what to
   do (pass it back to the LLM as a tool result; log it;
   etc.).

The agent doesn't talk to the tool *directly*; it goes
through the executor.  This separation lets the executor
add cross-cutting concerns (validation, credential
resolution, result wrapping) without each tool having to
implement them.

## The two typed exceptions

The SDK distinguishes two failure modes through typed
exceptions.

### `ToolValidationError`

**File:** `toolerrors.py:15–83`

```python
class ToolValidationError(AgentKitError):
    """
        Raised when input or output validation against a
        ToolSchema fails.

        :param tool_name: The name of the tool that failed validation.
        :param direction: The validation direction, "input" or "output".
        :param errors: A list of validation error strings.
    """

    def __init__(self, tool_name: str, direction: str, errors: List[str]) -> None:
        self._tool_name = tool_name
        self._direction = direction
        self._errors = list(errors)
        errmsg = self._format_message()
        super().__init__(errmsg)
        return
```

Raised when:

- The agent calls a tool with arguments that violate
  `INPUT_SCHEMA` (`direction="input"`).
- The tool returns a value that violates `OUTPUT_SCHEMA`
  (`direction="output"`).

The `direction` field tells the agent *who* was wrong:
input failures suggest the LLM produced bad arguments;
output failures suggest the tool's own logic is wrong.

### `ToolExecutionError`

**File:** `toolerrors.py:99–148`

```python
class ToolExecutionError(AgentKitError):
    """
        Raised when a Tool's execute method encounters a
        runtime error.

        :param tool_name: The name of the tool that failed execution.
        :param cause: The original exception that caused the failure.
    """

    def __init__(self, tool_name: str, cause: Exception) -> None:
        self._tool_name = tool_name
        self._cause = cause
        errmsg = self._format_message()
        super().__init__(errmsg, cause=cause)
        return
```

Raised when the tool's `execute()` method raises any
exception (caught by the executor).  The original cause is
preserved via `cause=`.

The two-exception split mirrors the *contract violation* vs
*implementation failure* distinction:

- **Validation error** — the contract (schema) was
  violated.  Either the LLM constructed bad arguments or
  the tool returned bad output.
- **Execution error** — the contract was honoured, but
  the tool's logic blew up (network failure, timeout,
  resource exhaustion).

A test catching `ToolValidationError` learns its prompt
or tool definition is broken.  A test catching
`ToolExecutionError` learns the tool's environment failed.

## The `ToolResult` structured return

**File:** `toolresult.py:10–96`

```python
class ToolResult:
    """
        Immutable result of a single tool execution.
    """

    STATUS_SUCCESS = "success"
    STATUS_FAILURE = "failure"

    def __init__(self, tool_name: str, status: str,
                 output: dict = None,
                 duration: float = 0.0,
                 error: Exception = None) -> None:
        self._tool_name = tool_name
        self._status = status
        self._output = output
        self._duration = duration
        self._error = error
        return
```

When the executor catches an exception, instead of
re-raising, it can construct a `ToolResult` with
`status=STATUS_FAILURE` and the exception object.  The
agent receives a *result*, not an exception.

Two patterns coexist:

### Strict mode — exception bubbles

```python
try:
    result = executor.execute(tool, inputs)
except ToolValidationError as ex:
    # Bad input — log, retry, escalate.
    ...
except ToolExecutionError as ex:
    # Tool broke — log, retry, escalate.
    ...
```

The executor raises; the agent catches.  Used when failures
are *meaningful* — the agent wants to see the typed
exception and dispatch on the type.

### Result mode — failure surfaces in the result

```python
result = executor.execute(tool, inputs, raise_on_failure=False)

if result.status == ToolResult.STATUS_FAILURE:
    # The agent can pass the failure back to the LLM as
    # a "tool failed" message and let the LLM decide.
    self._llm_send_tool_failure(tool.name, str(result.error))
elif result.status == ToolResult.STATUS_SUCCESS:
    self._llm_send_tool_result(tool.name, result.output)
```

The executor catches and wraps; the agent reads the result.
Used when the agent wants to *let the LLM cope* — passing
the failure back into the conversation lets the LLM choose
to try a different tool, ask the user, or abandon the
task.

Both patterns are supported; the choice is the agent's.

## Cross-cutting concerns the executor handles

Beyond input / output validation, the executor handles:

### Credential resolution

The tool declares `credential_names = ["api-key"]`.  The
executor resolves the names via the agent's credentials
manager and passes the resolved dict to `execute()`.  The
tool never sees the credentials lookup logic.

### Duration tracking

The executor times the call.  The `ToolResult.duration`
field captures the wall-clock time.  This feeds the
`agent.tool.execution.duration` histogram metric (chapter
6) automatically.

### Observability events

The executor emits structured events:

- `tool.execution.started` — before the call.
- `tool.execution.completed` — after success.
- `tool.execution.failed` — after failure.

The agent's `ExecutionRecorder` (chapter 6) captures these
events for replay.  The agent's `Tracer` opens spans
covering the call.

### Error wrapping

Generic exceptions caught from `execute()` are wrapped in
`ToolExecutionError` so the agent sees a typed exception
regardless of what the tool's underlying code did.

## What the tool system *doesn't* do

A short list of patterns the SDK deliberately doesn't
support:

### No automatic tool generation

The SDK doesn't introspect functions and turn them into
tools.  Tools are *explicit* — a class with explicit
schemas.  Auto-generation would let the application
accidentally expose arbitrary functions to the LLM (a
security concern); the explicit shape is *intentional*.

### No nested tool calls

A tool's `execute()` receives validated inputs and returns
a dict.  It cannot call back into the agent's
`tool_dispatcher`.  Tool composition happens at the
*agent* level — the agent decides which tools to call in
which order based on LLM responses.

This is for clarity.  Nested tool calls could create cycles
and make debugging harder.  The agent has the full picture
of what's been called and when; tools focus on their own
work.

### No automatic LLM tool-call format conversion

The agent's *adapter* (provider-specific code) converts the
agent's tool list into the LLM's tool-call format.
OpenAI's function-calling JSON differs from Anthropic's
tool-use JSON.  The provider implementation handles the
translation; the SDK's tool layer is provider-agnostic.

## What's next

Chapter 4 walks the **memory architecture**.  `MemoryStore`,
`MemoryScope` (AGENT / SHARED / GLOBAL),
`LLMConversation` truncation that preserves system
messages, summarisation via `ContextSummarizer`, semantic
search via `MemoryIndex`, checkpointing for resume.

The memory layer is *structurally similar* to the tool
layer — base classes, registries, validation — but
conceptually different.  Where tools are *outputs* (the
agent affecting the world), memory is *state* (the agent
remembering what happened).


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/04-memory-architecture -->
# Chapter 4 — Memory Architecture

> *An agent's memory is what makes it *more* than a stateless
> request/response.  The SDK's memory layer has three
> distinct concerns: *conversation history* (the dialogue
> the agent is having now), *persistent memory* (facts the
> agent should remember across runs), and *retrieval*
> (finding the relevant subset of memory for the current
> moment).  This chapter walks each.*

The memory layer has three loosely-coupled subsystems:

- **Conversation history** via `LLMConversation` —
  short-term, in-process, truncates as needed.
- **Persistent memory** via `MemoryStore` and its
  implementations — long-term, scope-aware, optionally
  embedded for semantic search.
- **Summarisation and retrieval** — `ContextSummarizer` for
  compressing conversation; `MemoryIndex` for finding
  relevant entries.

The chapter walks each.

## Conversation history — `LLMConversation`

**File:** `llmconversation.py:15–84`

```python
class LLMConversation:
    """
        An ordered, mutable sequence of LLMMessage objects
        representing a conversation history.
    """

    def truncate(self, max_messages: int) -> None:
        """
            Removes the oldest non-system messages to fit
            within the limit, preserving all system messages.
        """
        if len(self._messages) <= max_messages:
            return
        system_messages = [m for m in self._messages if m.role == "system"]
        non_system_messages = [m for m in self._messages if m.role != "system"]
        available_slots = max_messages - len(system_messages)
        kept_non_system = non_system_messages[-available_slots:] if available_slots > 0 else []
        self._messages = system_messages + kept_non_system
```

The class is an *ordered list of messages*, mutable, with
the framework's truncation discipline.  Three observations:

### System messages are pinned

A typical agent conversation has one or more `system`
messages at the start (the agent's instructions).  The
truncation rule preserves *all* system messages and
removes oldest *non-system* messages first.

This matters because system messages are the agent's
*shape* — without them, the LLM's responses drift away
from the agent's intended behaviour.  Truncation that
removed system messages would produce subtly broken
behaviour over long conversations.

### Truncation is *demand-driven*

The `LLMConversation` doesn't *automatically* truncate.
Application code calls `truncate(max_messages)` when it
decides the conversation is too long.  This separation lets
applications choose their truncation policy:

- Truncate before every LLM call (cap at N messages).
- Truncate on token-count threshold.
- Truncate after specific events (a "task completed"
  message clears the queue).
- Don't truncate (let the conversation grow indefinitely;
  the LLM provider may complain when context is exceeded).

Each agent's `start()` or `step()` method decides.

### Mutability is structured

The class is mutable (you can `append`, `truncate`,
`remove`) but the messages themselves are immutable.  An
`LLMMessage(role="user", content="hello")` constructed
once is constant for its lifetime; the conversation is the
ordered collection of such immutables.

This mirrors the framework's broader "data is immutable;
collections are mutable" pattern.

## Persistent memory — `MemoryStore`

**File:** `memorystore.py:25–127`

```python
class MemoryStore(abc.ABC):
    """
        Abstract base class for memory storage backends.
        Provides a common interface for storing, retrieving,
        searching, updating, and deleting MemoryEntry objects.
    """
    # ... store / retrieve / search / update / delete abstract methods ...
```

The store is *backend-agnostic*.  The SDK ships
`FileMemoryStore` (chapter 4 worked example below); other
backends — Redis, PostgreSQL, vector databases — are
implemented as subclasses.

Three concepts compose the persistent memory model:

### `MemoryEntry`

**File:** `memoryentry.py:17–48`

A single piece of remembered information:

- `identifier` — UUID or string key.
- `content` — the actual data.
- `tags` — list of strings for categorical filtering.
- `metadata` — dict for free-form attributes.
- `scope` — `MemoryScope` enum value (below).
- `created_at`, `updated_at` — timestamps.
- `embedding` — optional vector for semantic search.
- `expiration` — optional `ExpirationPolicy` for TTL.

Entries are *immutable* — modification means constructing a
new entry with the same identifier and `update()`-ing.

### `MemoryScope`

**File:** `memoryscope.py:13–24`

```python
class MemoryScope(Enum):
    """
        Visibility scope for memory entries.
    """
    AGENT = "agent"      # visible only to the owning agent
    SHARED = "shared"    # visible to agents using the same store
    GLOBAL = "global"    # visible to all agents in the runtime
```

Three scopes.  Each agent's memory queries default to
`AGENT` scope (only its own entries); a query with
`scope=MemoryScope.SHARED` returns entries from any agent
sharing the store; `GLOBAL` returns entries from all agents
in the runtime.

The scope distinction matters for *multi-agent systems*.
Two agents collaborating on a task can share information
through `SHARED` entries; private agent state stays
`AGENT`-scoped.

### `EmbeddingProvider`

**File:** `embeddingprovider.py:15–41`

```python
class EmbeddingProvider(abc.ABC):
    """
        Abstract base for embedding providers — services that
        turn text into fixed-length vectors for semantic search.
    """

    @abc.abstractmethod
    def embed(self, text: str) -> list[float]:
        ...

    @abc.abstractmethod
    def batch_embed(self, texts: list[str]) -> list[list[float]]:
        ...
```

When an `EmbeddingProvider` is configured on a store,
new entries get embeddings computed at storage time.
Queries with `semantic_text` parameters use cosine
similarity through `MemoryIndex` (below).

The provider is optional.  Stores without an embedding
provider support exact-match and tag-based queries; semantic
queries return empty.

## `FileMemoryStore` — the worked implementation

**File:** `filememorystore.py:32–195`

```python
class FileMemoryStore(MemoryStore):
    """
        Built-in MemoryStore implementation that persists
        MemoryEntry objects to JSON files on the local file
        system.  Each entry is stored as a separate JSON file
        using the entry identifier as the file name.
    """
```

The simplest persistent backend.  Each entry becomes a
JSON file at `<store_root>/<identifier>.json`.  Reads,
writes, deletes are filesystem operations.  No database;
no schema migrations; no indexing beyond what
`MemoryIndex` (in-memory) computes.

Limitations:

- **Scaling.**  Tens of thousands of entries become slow.
  Production agents with large memories use a different
  backend (Redis, PostgreSQL, etc.).
- **Concurrency.**  Multiple agents writing to the same
  store risk file-level race conditions.  The SDK's
  file-locking is best-effort.

For development and small-scale production agents,
`FileMemoryStore` is enough.  For larger agents, the
abstract `MemoryStore` is the integration point — write a
custom backend, register it.

## Querying — `MemoryQuery`

**File:** `memoryquery.py:15–45`

```python
class MemoryQuery:
    def __init__(self,
                 tags: list = None,
                 metadata: dict = None,
                 scope: MemoryScope = None,
                 start_time: datetime = None,
                 end_time: datetime = None,
                 semantic_text: str = None,
                 max_results: int = None,
                 min_relevance: float = None) -> None:
```

A query is a *bundle of filters*:

- **`tags`** — entries must have all listed tags.
- **`metadata`** — entries must have matching metadata
  key/value pairs.
- **`scope`** — entries must be in the given scope.
- **`start_time` / `end_time`** — temporal bounds on
  `created_at`.
- **`semantic_text`** — for similarity search; the store's
  `EmbeddingProvider` embeds this and `MemoryIndex` ranks
  entries by cosine similarity.
- **`max_results`** — limit the result set.
- **`min_relevance`** — for semantic queries, only return
  entries above a relevance threshold.

The store applies each filter; the result is the
intersection of entries that match all filters.

### `MemoryIndex` — semantic similarity

**File:** `memoryindex.py:17–52`

```python
class MemoryIndex:
    """
        In-memory cosine-similarity index over embedded entries.
    """

    def search(self, query_vector: list[float],
               max_results: int = 10,
               min_relevance: float = 0.0) -> list[ScoredEntry]:
        """
            Returns entries ranked by cosine similarity to
            query_vector.
        """
        # ... cosine similarity ranking ...
```

The index is *in-memory*.  For each query:

1. Compute the query's embedding (via the
   `EmbeddingProvider`).
2. Rank stored entries by cosine similarity.
3. Filter to top-K above threshold.

The implementation is straightforward — dot-product
divided by L2 norms.  Stores can override with their own
indexing (a vector-database-backed store would push
similarity search into the database).

## Summarisation — `ContextSummarizer`

**File:** `contextsummarizer.py:22–108`

```python
def summarize_messages(self, messages: list) -> LLMMessage:
    """
        Summarizes a list of LLMMessage objects into a single
        summary message.
    """
    if messages is None or len(messages) == 0:
        errmsg = "Cannot summarize an empty list of messages."
        raise SemanticError(errmsg)

    content_parts = []
    for msg in messages:
        content_parts.append("[{}] {}".format(msg.role, msg.content))
    combined_content = "\n".join(content_parts)

    prompt_content = ("Summarize the following conversation "
                      "concisely, preserving key information:\n\n"
                      "{}").format(combined_content)
    # ... call LLM provider with this prompt; return the response as
    # a system-role LLMMessage ...
```

Summarisation is *another LLM call*.  The summariser:

1. Formats the messages with role prefixes.
2. Constructs a system prompt asking for a concise summary.
3. Calls the configured LLM provider.
4. Returns a single system-role `LLMMessage` with the
   summary.

The cost is *one LLM call's worth of tokens*.  For long
conversations, this is far cheaper than carrying the full
history in every subsequent call.

### When to summarise

The SDK doesn't impose a summarisation policy.  Common
patterns:

- **Threshold-based.**  When the conversation exceeds N
  messages, summarise the oldest M messages and replace
  them with the summary.
- **Token-based.**  When the token count approaches the
  context window, summarise to fit.
- **Phase-based.**  After a logical phase (a multi-step
  task completes), summarise the phase and start fresh.

Application code decides.  A typical agent run has
explicit summarisation calls between work units.

## Checkpointing — `CheckpointManager`

**File:** `checkpointmanager.py:22–75`

```python
class CheckpointManager:
    """
        Persists and restores WorkflowExecution state at
        configurable checkpoint boundaries using a MemoryStore
        backend.
    """

    def save_checkpoint(self, execution_id: str,
                        context: WorkflowContext,
                        completed_step_ids: set,
                        current_step_id: str) -> None:
        """
            Persists a WorkflowCheckpoint to the MemoryStore.
        """
        try:
            context_state = context.as_dict()
            workflow_name = context.get_metadata("workflow_name", "unknown")
            checkpoint = WorkflowCheckpoint(
                workflow_name=workflow_name,
                execution_id=execution_id,
                completed_step_ids=completed_step_ids,
                context_state=context_state,
                current_step_id=current_step_id,
            )
        # ... store as MemoryEntry ...
```

Checkpoints are how agents *survive crashes*.  When a
long-running workflow's process dies, a fresh process can:

1. Load the most recent checkpoint via
   `manager.load_checkpoint(execution_id)`.
2. Reconstruct the workflow context from the checkpoint's
   `context_state`.
3. Resume from the `current_step_id` rather than starting
   over.

The checkpoint is itself a `MemoryEntry`, stored through
the same `MemoryStore` that persists other agent memory.
This means checkpoints inherit the store's properties —
on disk for `FileMemoryStore`, durable for a
database-backed store, distributed for a Redis-backed
store.

Checkpoints are tagged with the workflow name for efficient
lookup; `manager.list_checkpoints(workflow_name)` returns
all checkpoints for a given workflow.

Chapter 5 walks the workflow layer that *uses* checkpoints.

## Memory's interaction with cost tracking

Every memory operation that reads or writes goes through
the store.  The store *can* (but doesn't have to) record
costs:

- A `FileMemoryStore` has zero per-operation cost — just
  filesystem I/O.
- A vector-database-backed store has per-query cost
  (database CPU, network I/O).
- A store that *embeds at write time* via an
  `EmbeddingProvider` has per-write cost (the embedding
  call's tokens).

When the embedding step uses an LLM-style provider, those
calls *do* hit the agent's cost tracker.  An agent that
writes 10,000 entries to memory and embeds each one pays
for 10,000 embedding requests.  The cost tracker shows the
breakdown by provider; an SDET reviewing costs can see
"embedding-provider-1" charged separately from
"main-llm-provider."

## What memory deliberately *doesn't* do

A few patterns the SDK leaves to the application:

### No automatic summarisation

The summariser is a *tool* the agent calls when it
decides.  The SDK doesn't watch the conversation length
and trigger summarisation automatically.

This is for *predictability*.  An automated summarisation
that fires unexpectedly mid-conversation can produce
surprising behaviour (the LLM "forgets" things it said two
turns ago).  Explicit calls let the application decide
*when* the cost / behaviour-change is acceptable.

### No automatic memory expiration

Entries can carry an `ExpirationPolicy`, but expiration is
*evaluated lazily* — entries are removed on the next query
or write that touches them.  No background sweeper.

This avoids the operational complexity of a sweeper thread
that needs to be scheduled, monitored, and tuned.

### No cross-agent message passing

The SDK's memory is for *state*, not *messaging*.  Agents
that need to communicate use a separate message-routing
layer (the SDK ships a `MessageRouter` — chapter 5
covers).

## What's next

Chapter 5 walks **workflow orchestration for agents**.
The SDK's `WorkflowExecutor` traverses a DAG of steps with
conditional branches, parallel execution, checkpointing
(via the `CheckpointManager` from this chapter), and
human-in-the-loop approval gates.  Chapter 5 also covers
`CircuitBreaker` for handling repeated failures
gracefully.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/05-workflow-orchestration -->
# Chapter 5 — Workflow Orchestration for Agents

> *Some agents handle *one shot* — request, response, done.
> Others run *workflows* — sequences of steps with conditions,
> parallel branches, human approvals, and resumable
> checkpoints.  The SDK ships a DAG-based `WorkflowExecutor`
> for the second case, plus `ApprovalGate` for human-in-the-
> loop and `CircuitBreaker` for resilience under repeated
> failures.*

This chapter is about workflows in the AI-agent context —
specifically, the SDK's mechanism for *agent-driven*
workflows that have decision points, branches, parallel
work, and resumability.  The workflow layer is *additive*:
agents can use it, or they can stay one-shot and skip it
entirely.

## When workflows make sense

Three signals an agent benefits from a workflow:

### Multiple LLM calls in sequence

A simple Q&A agent makes one LLM call per user message.  A
research agent that *plans, searches, reads results,
summarises, writes the final answer* makes five or ten LLM
calls.  Encoding the sequence as a workflow makes the
control flow *visible* — a debugger can see "we're at the
'read results' step" rather than tracing through hundreds
of lines of imperative code.

### Conditional branches

When the agent's behaviour differs based on something
discovered mid-run ("if the search returned no results,
ask the user for clarification; otherwise, summarise"),
encoding the branch declaratively in a workflow makes the
condition explicit.

### Long-running, resumable execution

An agent that runs for hours (a deep-research task, an
overnight code-modification workflow) benefits from
*checkpoints* (chapter 4).  The workflow layer integrates
checkpointing — every step boundary can be a checkpoint;
crashes resume from the last checkpoint.

## The DAG model

**File:** `workflowexecutor.py:41–73`

```python
class WorkflowExecutor:
    """
        Executes a WorkflowDefinition by traversing the DAG,
        evaluating edge conditions, executing steps, managing
        parallel branches, and producing a WorkflowResult.

        Checkpoints are saved via the CheckpointManager at the
        configured interval.  Failed steps are retried according
        to their RetryPolicy when configured.
    """

    def __init__(self, configuration: Optional[WorkflowConfiguration] = None,
                 checkpoint_manager: Optional[CheckpointManager] = None) -> None:
```

A workflow is a *directed acyclic graph* (DAG) of *steps*.
Each step:

- Has a unique `step_id`.
- Has a `step_callable` — the function (or coroutine)
  that does the step's work.
- Optionally has an `RetryPolicy`.
- Has zero or more *outgoing edges* with optional
  conditions.

The executor:

1. Validates the DAG is acyclic and all steps are reachable.
2. Topologically sorts the steps.
3. Walks the sort, executing each step in turn.
4. After each step, evaluates its outgoing edges'
   conditions.
5. Schedules downstream steps that have all incoming
   conditions satisfied.
6. Where multiple downstream steps can run in parallel,
   uses a `ThreadPoolExecutor` bounded by
   `max_parallel_workers`.
7. Saves checkpoints at configured boundaries.
8. Produces a final `WorkflowResult` with per-step
   outcomes.

### `WorkflowDefinition`

**File:** `workflowdefinition.py:1–15`

```python
class WorkflowDefinition:
    """
        Immutable description of a workflow's steps and edges.
        Validates structural integrity at construction:
        - No cycles in the step graph.
        - All steps reachable from the start step.
        - All edges reference defined step IDs.
        - All step callables are present.
    """
```

The definition is *immutable*.  Constructed once, validated
once, executed many times.  This means the workflow's
*shape* can be reasoned about statically — a tool can
inspect a `WorkflowDefinition` and know what steps will
run in what order, regardless of runtime behaviour.

A representative workflow:

```python
from ccai.agentkit.workflowdefinition import WorkflowDefinition
from ccai.agentkit.workflowstep import WorkflowStep
from ccai.agentkit.workflowedge import WorkflowEdge


def step_search(context):
    results = perform_search(context.get("query"))
    context.set("search_results", results)
    return results


def step_no_results(context):
    return ask_user_for_clarification(context)


def step_summarise(context):
    summary = summarise(context.get("search_results"))
    context.set("summary", summary)
    return summary


def step_finalise(context):
    return write_final_answer(context.get("summary"))


workflow = WorkflowDefinition(
    name="research_with_clarification",
    steps=[
        WorkflowStep(step_id="search", step_callable=step_search),
        WorkflowStep(step_id="no_results", step_callable=step_no_results),
        WorkflowStep(step_id="summarise", step_callable=step_summarise),
        WorkflowStep(step_id="finalise", step_callable=step_finalise),
    ],
    edges=[
        # After search, branch on whether results were found.
        WorkflowEdge(
            from_step="search", to_step="no_results",
            condition=lambda ctx: len(ctx.get("search_results")) == 0,
        ),
        WorkflowEdge(
            from_step="search", to_step="summarise",
            condition=lambda ctx: len(ctx.get("search_results")) > 0,
        ),
        # Both paths converge on finalise.
        WorkflowEdge(from_step="no_results", to_step="finalise"),
        WorkflowEdge(from_step="summarise", to_step="finalise"),
    ],
)
```

The workflow:

- Starts with `search`.
- Branches to `no_results` if the search returned empty,
  otherwise to `summarise`.
- Both paths converge on `finalise`.

The conditions are *callables* — they receive the
`WorkflowContext` and return a boolean.  This is more
flexible than declarative-only conditions; the application
can write arbitrary logic.

### Parallel branches

When a step has multiple downstream steps and the conditions
allow more than one to fire, the executor runs them
*concurrently*:

```python
WorkflowEdge(from_step="planning", to_step="research_topic_a"),
WorkflowEdge(from_step="planning", to_step="research_topic_b"),
WorkflowEdge(from_step="planning", to_step="research_topic_c"),

WorkflowEdge(from_step="research_topic_a", to_step="synthesise"),
WorkflowEdge(from_step="research_topic_b", to_step="synthesise"),
WorkflowEdge(from_step="research_topic_c", to_step="synthesise"),
```

After `planning`, the three `research_topic_*` steps run
in parallel (subject to `max_parallel_workers`).  When all
three complete, `synthesise` runs (it has three incoming
edges, all required).

The parallelism is *thread-based* via `ThreadPoolExecutor`.
This works well for I/O-bound steps (LLM calls, HTTP
requests, file operations).  For CPU-bound work, consider
a process pool variant — the SDK ships extension hooks for
this but the default is threads.

## Checkpointing integration

The executor accepts a `CheckpointManager` (chapter 4):

```python
executor = WorkflowExecutor(
    configuration=WorkflowConfiguration(
        checkpoint_interval=1,  # checkpoint after every step
    ),
    checkpoint_manager=CheckpointManager(memory_store=file_store),
)

result = executor.execute(workflow, initial_context)
```

After each step, the executor:

1. Records the step's completion in the checkpoint state.
2. Calls `checkpoint_manager.save_checkpoint(...)`.
3. Continues with the next step.

When the process dies and restarts:

```python
checkpoint = checkpoint_manager.load_checkpoint(execution_id)
result = executor.resume(workflow, checkpoint)
```

The executor:

1. Identifies the steps already completed.
2. Reconstructs the context from `checkpoint.context_state`.
3. Resumes execution from `checkpoint.current_step_id`.

The discipline matches Volume 2 chapter 7's verified-
contract model: the executor *verifies* the checkpoint's
shape against the workflow definition before resuming.  A
checkpoint from a different (incompatible) workflow
version raises `CheckpointMismatchError`.

## Approval gates — human-in-the-loop

**File:** `approvalgate.py:18–38` and `approvalrequest.py:22–71`

Some workflows need human approval at specific steps.  An
agent that's about to send a customer-facing email might
require a human to review.  An agent about to delete data
might require explicit approval.

```python
class ApprovalGate:
    """
        Manages the lifecycle of approval requests within
        workflow execution.
    """

    def __init__(self) -> None:
        self._pending_requests = {}
        self._handlers = []
```

```python
class ApprovalRequest:
    """
        Immutable object representing a pending approval
        within a workflow.

        VALID_STATUSES = {"pending", "approved", "rejected", "expired"}
    """
```

The flow:

1. The workflow's step calls `gate.create_request(...)`.
2. The request is added to pending; handlers are notified
   (e.g., the application sends an email to a reviewer).
3. The step *blocks* on `request.wait_for_resolution(timeout=...)`.
4. A separate process (the human's UI) calls
   `gate.approve(request_id)` or `gate.reject(request_id,
   reason="...")`.
5. The waiting step unblocks and proceeds based on the
   resolution.

If the timeout elapses without resolution, the request
transitions to `"expired"` and the step receives an
`ApprovalTimeoutError`.

A worked usage:

```python
def step_send_email(context):
    gate = context.get("approval_gate")

    request = gate.create_request(
        execution_id=context.execution_id,
        step_id="send_email",
        description="Approve sending email to {} customers".format(
            len(context.get("recipients"))
        ),
        requesting_agent_id=context.get("agent_id"),
        metadata={"recipients": context.get("recipients")},
    )

    # Notify reviewers (out-of-band).
    notify_reviewers(request)

    # Wait for human resolution.
    resolution = request.wait_for_resolution(timeout=3600)

    if resolution == "approved":
        send_emails(context.get("recipients"))
    elif resolution == "rejected":
        raise WorkflowAbortedError(
            f"Send-email step rejected: {request.rejection_reason}"
        )
```

The workflow's *DAG structure* doesn't change — it's still
a graph of steps.  But one of the steps blocks on human
input.  The checkpointing (above) means the workflow can
*pause* at the gate without keeping a process alive — the
checkpoint records "waiting on approval request X"; a
fresh process can resume when the resolution arrives.

## Circuit breakers — resilience

**File:** `circuitbreaker.py:21–170` and
`circuitbreakerstate.py:11–23`

```python
class CircuitBreakerState(enum.Enum):
    """
        CLOSED: Normal operation, requests pass through.
        OPEN: Service is degraded, requests are rejected immediately.
        HALF_OPEN: Recovery testing, limited requests allowed through.
    """
    CLOSED = "CLOSED"
    OPEN = "OPEN"
    HALF_OPEN = "HALF_OPEN"
```

The circuit breaker is a classic resilience pattern:

- **CLOSED** — normal.  Requests pass through.  Failures
  increment a counter.
- **OPEN** — failure threshold reached.  Requests are
  *rejected immediately* without attempting the underlying
  call.  Timer counts down toward HALF_OPEN.
- **HALF_OPEN** — recovery testing.  Limited requests are
  allowed through.  Successes count toward closing the
  breaker; failures re-open.

The state transitions:

```python
# circuitbreaker.py — _on_success / _on_failure

def _on_success(self, state):
    if state == CircuitBreakerState.HALF_OPEN:
        self._success_count += 1
        if self._success_count >= self._configuration.success_threshold:
            self._transition_to(CircuitBreakerState.CLOSED, "success threshold reached in half-open")
    elif state == CircuitBreakerState.CLOSED:
        self._success_count += 1


def _on_failure(self, state):
    if state == CircuitBreakerState.HALF_OPEN:
        self._transition_to(CircuitBreakerState.OPEN, "failure in half-open state")
    elif state == CircuitBreakerState.CLOSED:
        self._failure_count += 1
        if self._failure_count >= self._configuration.failure_threshold:
            self._transition_to(CircuitBreakerState.OPEN, "failure threshold reached")
```

The breaker's purpose: *prevent cascading failure*.  When
a downstream service is broken (LLM provider returning
500s, tool's API rate-limiting), the breaker opens and
*stops trying* — saving the agent from spending tokens /
time / quota on calls that will fail.

A typical usage:

```python
breaker = CircuitBreaker(
    name="openai-completions",
    configuration=CircuitBreakerConfiguration(
        failure_threshold=5,    # 5 failures → OPEN
        success_threshold=2,    # 2 successes in HALF_OPEN → CLOSED
        timeout=60.0,           # OPEN → HALF_OPEN after 60s
    ),
)


def call_with_breaker(provider, conversation):
    return breaker.execute(
        callable_to_protect=lambda: provider.complete(conversation),
    )
```

The `breaker.execute(...)` wraps the call.  When the
breaker is OPEN, it raises `CircuitBreakerOpenError` *without*
calling the underlying callable — saving the agent from
the cost of a likely-failing call.

The breaker is *thread-safe* (every transition acquires
`threading.Lock`).  Multiple threads sharing a breaker
see consistent state.

## Combining the pieces

A representative agent workflow combining everything:

```python
class ResearchAgent(Agent):

    def initialize(self) -> None:
        super().initialize()

        # LLM provider with circuit breaker
        provider = OpenAIProvider(model_name="gpt-4o", ...)
        self._llm_provider_registry.register(provider)
        self._llm_breaker = CircuitBreaker(
            name="openai-research",
            configuration=CircuitBreakerConfiguration(...),
        )

        # Memory + checkpointing
        self._memory_store = FileMemoryStore(root_dir="research_memory/")
        self._checkpoint_manager = CheckpointManager(
            memory_store=self._memory_store,
        )

        # Approval gate for sensitive steps
        self._approval_gate = ApprovalGate()

        # Workflow
        self._workflow = self._build_workflow()

    def start(self) -> None:
        super().start()
        executor = WorkflowExecutor(
            configuration=WorkflowConfiguration(checkpoint_interval=1),
            checkpoint_manager=self._checkpoint_manager,
        )
        result = executor.execute(self._workflow, self._initial_context())
        self._handle_result(result)
```

The agent integrates:

- LLM provider (chapter 2).
- Tool registry (chapter 3, registered separately).
- Memory store and checkpointing (chapter 4 + this).
- Workflow executor (this chapter).
- Approval gates and circuit breakers (this chapter).
- Cost tracker (built into the agent base).
- Observability pipeline (chapter 6).

Each subsystem is independent.  Each is configurable.
The agent's `initialize()` and `start()` methods wire them
together for the application's specific shape.

## Agent-driven workflows vs workflow-driven agents

A vocabulary distinction worth pinning down.

### Agent-driven workflows

The agent *owns* the workflow.  It constructs the
`WorkflowDefinition`, drives the `WorkflowExecutor`,
makes decisions about which steps to add or skip based on
runtime state.

This is the SDK's primary pattern.  The agent's
`start()` method runs the executor; the workflow is
the agent's *internal* control flow.

### Workflow-driven agents

The workflow *owns* the agent.  An external orchestrator
(Volume 3's taskplus, for example) defines a workflow with
explicit agent-invocation steps.  The agent is invoked at
each step; the workflow is the *external* control flow.

This pattern is supported through Volume 3 chapter 25's
bridge tasks.  A taskplus workflow has a step that
invokes an agent; the agent runs, returns; the workflow
continues with the next step.

The two patterns can *combine* — a taskplus workflow
invokes an agent that runs its own internal workflow.  The
SDK doesn't distinguish architecturally; what matters is
*who decides* which step runs next.

## What's next

Chapter 6 walks **observability for agents**.  The SDK's
`Tracer` and `MetricsCollector` for emitting structured
observability data, the 13 built-in agent metrics, and
`ExecutionRecorder` / `ExecutionReplayer` for full
post-hoc replay of an agent run.  Chapter 6 is also the
chapter that walks the *cost tracker* in detail — every
LLM call's token usage and cost flow through the
observability layer.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/06-observability -->
# Chapter 6 — Observability for Agents

> *Production agents fail in unfamiliar ways — the LLM
> hallucinates a tool call, the workflow takes an
> unexpected branch, the memory store returns stale data.
> Diagnosing these requires *structured observability* the
> SDK ships out of the box.  Tracer, MetricsCollector, 13
> built-in metrics, ExecutionRecorder for full replay.
> This chapter walks each.*

The SDK ships an observability stack with three concerns:

- **Tracing** — hierarchical spans capturing causality
  across the agent's execution.
- **Metrics** — counters, gauges, and histograms recording
  rates and durations.
- **Recording** — full replay-ready event capture for
  post-hoc analysis.

Each concern is independent.  Each is integrated with the
agent's lifecycle.  All three emit *structured* data the
application's observability backend (OpenTelemetry,
Datadog, Prometheus, etc.) consumes.

## Tracing — `Tracer` and `TraceSpan`

**File:** `tracer.py:22–100`

```python
class Tracer:
    """
        A component that creates and manages TraceSpan objects
        for an Agent.  Implements probabilistic sampling at the
        root span level, where non-sampled spans propagate
        context but are not exported.
    """

    def __init__(self, agent_id: str, trace_context: TraceContext = None,
                 sampling_rate: float = 1.0) -> None:
```

The tracer is *per-agent*.  Each agent owns one.  It
manages a stack of `TraceSpan` objects representing the
agent's call hierarchy.

**File:** `tracespan.py:22–40`

```python
class TraceSpan:
    """
        A mutable object representing a single unit of work
        within a distributed trace.

        status must be one of: ("unset", "ok", "error")
    """
```

A span has:

- `trace_id` and `span_id` (16-byte and 8-byte hex strings).
- `parent_span_id` (when nested).
- `name` — human-readable label.
- `start_time` and `end_time`.
- `status` — `"unset"`, `"ok"`, `"error"`.
- `attributes` — dict of structured metadata.
- `events` — list of `TraceEvent` objects attached to
  the span.

Spans are *mutable* during their lifetime — the application
adds attributes and events as work progresses.  When the
span closes, its data is exported to whatever exporter the
application registered.

### Hierarchical spans

A typical agent run produces a *tree* of spans:

```
agent.run                                    [root span]
├── agent.lifecycle.transition (CREATED → INITIALIZED)
├── agent.lifecycle.transition (INITIALIZED → RUNNING)
└── agent.workflow.execute
    ├── workflow.step.execute (search)
    │   └── llm.complete (gpt-4o)
    │       └── tool.execute (web_search)
    ├── workflow.step.execute (summarise)
    │   └── llm.complete (gpt-4o)
    └── workflow.step.execute (finalise)
        └── llm.complete (gpt-4o)
```

Each child span declares its parent.  The exporter
reconstructs the hierarchy.  A debugger viewing the trace
sees the full causality.

### Probabilistic sampling

The `sampling_rate` parameter controls how many *root*
spans get exported.  At `1.0`, every span is exported.  At
`0.1`, ten percent of root spans (and all their children).
At `0.0`, no spans (but parent context still propagates so
non-sampled traces can still be cross-referenced if a
descendant span is sampled by another component).

Sampling matters at scale.  An agent serving millions of
requests can't trace every one; sampling reduces the cost
of observability while still providing statistical
visibility.

## Metrics — `MetricsCollector` and the 13 built-ins

**File:** `metricscollector.py:21–74`

```python
class MetricsCollector:
    """
        Collects metric values and routes them to registered
        exporters.  Validates metric names against the
        MetricsRegistry to prevent typos / missing definitions.
    """
```

The collector is *per-agent*.  It receives metric
recordings (counter increments, gauge updates, histogram
observations) and routes them to exporters.

Three metric kinds:

- **Counter** — monotonically increasing.  "How many tool
  errors have occurred?"  `inc(name, value=1, labels=...)`.
- **Gauge** — current value.  "How many spans are open?"
  `set(name, value, labels=...)`.
- **Histogram** — distribution of values.  "How long do
  tool calls take?"  `observe(name, value, labels=...)`.

Every recording is validated against the
`MetricsRegistry` — calling `inc("agent.tools.errors")`
when no metric with that name is registered raises
`MetricNotRegisteredError`.  Typos surface as test
failures, not silently-dropped data.

### The 13 built-in agent metrics

**File:** `builtinagentmetrics.py:16–114`

```python
BUILTIN_METRICS = [
    MetricDefinition(name="agent.lifecycle.transitions", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.tool.execution.duration", metric_type=MetricType.HISTOGRAM),
    MetricDefinition(name="agent.tool.execution.errors", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.llm.request.duration", metric_type=MetricType.HISTOGRAM),
    MetricDefinition(name="agent.llm.token.usage.prompt", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.llm.token.usage.completion", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.llm.request.errors", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.memory.operations", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.message.sent", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.message.received", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.workflow.step.duration", metric_type=MetricType.HISTOGRAM),
    MetricDefinition(name="agent.workflow.step.errors", metric_type=MetricType.COUNTER),
    MetricDefinition(name="agent.active.spans", metric_type=MetricType.GAUGE),
]
```

Thirteen metrics covering the agent's main concerns:

| # | Metric | Type | Purpose |
|---|---|---|---|
| 1 | `agent.lifecycle.transitions` | counter | Count state-machine transitions. |
| 2 | `agent.tool.execution.duration` | histogram | Tool-call latency distribution. |
| 3 | `agent.tool.execution.errors` | counter | Tool failures. |
| 4 | `agent.llm.request.duration` | histogram | LLM-call latency distribution. |
| 5 | `agent.llm.token.usage.prompt` | counter | Cumulative prompt-token usage. |
| 6 | `agent.llm.token.usage.completion` | counter | Cumulative completion-token usage. |
| 7 | `agent.llm.request.errors` | counter | LLM failures. |
| 8 | `agent.memory.operations` | counter | Memory read/write/search count. |
| 9 | `agent.message.sent` | counter | Inter-agent messages sent. |
| 10 | `agent.message.received` | counter | Inter-agent messages received. |
| 11 | `agent.workflow.step.duration` | histogram | Workflow-step latency. |
| 12 | `agent.workflow.step.errors` | counter | Workflow-step failures. |
| 13 | `agent.active.spans` | gauge | Live span count. |

These thirteen are emitted *automatically* by the SDK's
internal hooks.  Application code that wants *additional*
metrics defines them in the registry and emits them.

### Labels for dimensionality

Most metrics support *labels* — key/value pairs that
partition the metric:

```python
collector.inc(
    "agent.tool.execution.errors",
    labels={"tool_name": "fetch_weather", "error_type": "ValidationError"},
)
```

The exporter aggregates by label combinations.  A
dashboard can show "errors per tool name" or "errors per
error type" or both.

The agent's *agent_id* is included as a default label on
every metric, so multi-agent processes can attribute
metrics to specific agents.

## Recording — `ExecutionRecorder` and `ExecutionReplayer`

The most powerful piece of the observability stack —
*full replay* of an agent run.

### `ExecutionRecorder`

**File:** `executionrecorder.py:17–70`

```python
class ExecutionRecorder:
    """
        A component that captures ExecutionRecord objects
        during agent execution, storing them in an ordered
        sequence for later replay and inspection.

        Sequence numbers are monotonically increasing starting
        from 1.
    """

    def __init__(self, agent_id: str) -> None:
        self._records = []
        self._sequence_counter = 0
        self._is_recording = False
```

The recorder is the agent's *event stream sink*.  Every
significant event during the agent's run is captured as
an `ExecutionRecord`:

```python
class ExecutionRecord:
    """
        :param sequence_number: Monotonically increasing starting from 1.
        :param event_type: e.g. 'lifecycle.transition', 'llm.request.completed',
                           'tool.execution.failed'.
        :param correlation_id: Span ID, request ID, or other correlation key.
        :param payload: Event-specific structured data.
        :param timestamp: When the event happened.
    """
```

Recording is *opt-in*.  The application calls
`recorder.start()` before the agent runs and
`recorder.stop()` after.  Between, every event flows in.

### `ExecutionReplayer`

**File:** `executionreplayer.py:20–57`

```python
class ExecutionReplayer:
    """
        A component that reads an ExecutionRecording and allows
        step-by-step inspection of the recorded events.

        Supports forward and backward traversal, reset, seek
        by sequence number, and filtering by event type.
    """
```

The replayer reads a recording and lets the application
walk through it:

```python
replayer = ExecutionReplayer(recording=loaded_recording)

# Step forward through events.
while replayer.has_next:
    record = replayer.next()
    print(f"[{record.sequence_number}] {record.event_type}: {record.payload}")

# Or seek to a specific point.
replayer.seek_to(sequence_number=42)

# Or filter to specific event types.
errors = list(replayer.filter(event_types=["tool.execution.failed", "llm.request.errors"]))
```

This is the foundation for **time-travel debugging**
(Volume 8's subject).  An agent run that produced an
unexpected outcome can be *replayed* to walk through what
happened, when each event fired, what data flowed through.

### What gets recorded

The SDK emits records for:

- Lifecycle transitions (CREATED → INITIALIZED, etc.).
- LLM requests (started / completed / failed).
- Tool executions (started / completed / failed).
- Memory operations.
- Workflow step boundaries.
- Approval gate events.
- Circuit breaker state transitions.
- Custom events the application emits.

The *type vocabulary* is open — the application can record
any event type it cares about.  The SDK's events use
namespaced names (`lifecycle.transition`,
`llm.request.completed`) so application events use
different namespaces (`my-app.business-event`).

## Cost tracking — the agent's bill

A reader who's been counting subsystems will notice cost
tracking belongs in this chapter.  The `CostTracker` is
*observability for cost*.

**File:** `costtracker.py:14–104`

```python
class CostTracker:
    """
        Accumulates TokenUsage and CostEstimate records across
        multiple LLM requests, providing totals and per-provider
        breakdowns.
    """

    def record(self, provider_name: str, token_usage: TokenUsage,
               cost_estimate: CostEstimate) -> None:
        # ...

    @property
    def total_token_usage(self) -> TokenUsage:
        # ...

    @property
    def total_cost_estimate(self) -> CostEstimate:
        # ...

    def per_provider_breakdown(self) -> dict:
        # ...
```

Every LLM call's tokens and cost flow through the
tracker.  The provider implementation does the recording
(chapter 2 walked the integration).  At any time, the
application can query:

```python
tracker = agent.cost_tracker

print(tracker.total_token_usage)
# TokenUsage(prompt_tokens=15234, completion_tokens=4127, total_tokens=19361)

print(tracker.total_cost_estimate)
# CostEstimate(prompt_cost=0.46, completion_cost=0.83, total_cost=1.29, currency='USD')

for provider, data in tracker.per_provider_breakdown().items():
    print(f"{provider}: {data['cost_estimate'].total_cost}")
# openai-gpt-4o: 0.81
# anthropic-claude-opus-4-7: 0.48
```

### `TokenUsage` validation

**File:** `tokenusage.py:12–36`

```python
class TokenUsage:
    def __init__(self, prompt_tokens: int, completion_tokens: int, total_tokens: int) -> None:
        expected_total = prompt_tokens + completion_tokens
        if total_tokens != expected_total:
            raise SemanticError(
                "TokenUsage total_tokens must equal sum of prompt + completion"
            )
        # ...
```

`TokenUsage` is *immutable* and *validated* at construction.
The total must equal the sum.  This catches provider
implementations that report inconsistent token counts.

### `CostEstimate` validation

**File:** `costestimate.py:13–42`

```python
class CostEstimate:
    def __init__(self, prompt_cost: float, completion_cost: float,
                 total_cost: float, currency: str = "USD") -> None:
        expected_total = prompt_cost + completion_cost
        if abs(total_cost - expected_total) > 1e-9:
            raise SemanticError(
                "CostEstimate total_cost must equal sum of prompt + completion"
            )
        # ...
```

Same pattern, with floating-point tolerance (1e-9).
Default currency is USD; provider implementations that
charge in other currencies pass `currency="..."`.

The validation discipline catches calculation bugs in the
provider's cost-computation logic.  An OpenAIProvider that
miscomputes prompt_cost would fail to construct a
`CostEstimate`; the test suite would catch the bug
before it reached production.

## Composing the observability stack

A typical agent's observability setup:

```python
class MyAgent(Agent):

    def initialize(self) -> None:
        super().initialize()

        # Tracer with OTLP exporter
        self._tracer.register_exporter(OTLPTraceExporter(endpoint="..."))

        # Metrics with Prometheus exporter
        self._metrics_collector.register_exporter(
            PrometheusMetricsExporter(port=9090)
        )

        # Recorder writing to a file
        self._execution_recorder = ExecutionRecorder(agent_id=self.identity.identifier)
        self._execution_recorder.register_sink(
            FileExecutionSink(path="recordings/")
        )
        self._execution_recorder.start()

    def stop(self) -> None:
        super().stop()
        self._execution_recorder.stop()
```

Three subsystems, three exporters / sinks, all
configured at agent initialisation.  The SDK's events flow
through automatically; the agent's specific events go
through the same pipeline.

## What the observability stack *doesn't* do

A few patterns the SDK leaves to the application:

### No automatic exporters

The SDK ships abstract `TraceExporter` and
`MetricsExporter` base classes but no concrete exporters.
The application provides exporters from a separate
package (e.g., `codecraftai-agentkit-otlp` for
OpenTelemetry, `codecraftai-agentkit-prometheus` for
Prometheus).

This is intentional separation.  Pinning specific
backend SDKs in the agent-kit would make every consumer
pay the dependency cost.  Adapter packages let the
application choose.

### No PII redaction

Recordings, traces, and metrics may contain user content.
The SDK doesn't redact — it records faithfully.  An
application that needs to redact (per GDPR, HIPAA, or
similar) wraps the recorder / tracer / metrics with
filters that strip sensitive fields.

The framing is honest: redaction is *application-specific*
and depends on the data's classification.  A general-purpose
SDK can't decide which fields are sensitive.

### No retention policies

The recorder stores events; retention (how long, how much)
is the application's concern.  An application that
retains recordings for 30 days writes a sweeper that
deletes recordings older than that; the SDK doesn't.

## What's next

Chapter 7 walks **testing AI agents**.  The 305-test-file
discipline.  The mock-first pattern using
`ccai.testbase.mocking.Mock`.  What gets tested at
each layer.  The 49-minute wall time at
`TESTBASE_MAX_EXAMPLES_CAP=10`.

Chapter 8 is the property-tests-at-scale chapter — 72
property test files, the `@given(constraints=...)`
discipline, what property tests actually catch in an
LLM-driven SDK.

Chapter 9 is the case study — a worked example of an
agent that uses sysman features (Volume 4) against a
landscape (Volume 6) through interop protocols (Volume
5), observed through this chapter's machinery.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/07-testing-ai-agents -->
# Chapter 7 — Testing AI Agents

> *Testing AI agents is unusual.  The output is non-
> deterministic at the LLM layer; the system under test
> includes a service that costs money to call; the right
> answer often depends on subjective evaluation.  The SDK's
> answer is **mock-first testing**: 305 test files, every
> LLM and tool mocked, no real API calls.  This chapter
> walks the discipline and what it catches.*

A reader who's tested traditional Python code is used to:

- Tests that produce deterministic output.
- Test runs that complete in seconds (no network calls).
- Test failures that point at a specific line.

AI-agent testing has different default constraints:

- LLM responses vary even with the same prompt
  (temperature, model versioning).
- Real LLM calls cost money and rate-limit.
- LLM "correctness" is often subjective.

The `codecraftai-agent-kit`'s answer: **don't test the LLM**.
Test *the SDK's behaviour around the LLM*.  The 305 test
files in the suite test the *agent's plumbing* — state
machine transitions, tool dispatch, memory operations,
workflow execution, observability emission.  None of them
hit a real LLM API.

This chapter walks the discipline.

## The numbers

Verified from the source:

- **305 test files** at
  `codecraftai-agent-kit/source/testroots/ccai/tests/singlehost/agentkit/`.
- **~78,155 LOC** of test code.
- **212 top-level test files** (mostly `test_*.py`).
- **72 property-based test files** (`test_*_properties.py` —
  chapter 8 walks these).
- **5 subdirectory layers**:
  - `builtins/` (5 files) — built-in tools.
  - `platform/` (14 files) — cloud-platform adapters.
  - `resilience/` (53 files) — circuit breakers, retry,
    degradation, dead-letter.
  - `steps/` — workflow step execution.
  - `transports/` — message transport.

The most recent verified test count: **1,964 / 1,964
PASSED** (commit `a2e437a`, Apr 27 2026).

## Mock-first — `ccai.testbase.mocking.Mock`

The SDK's tests use the framework's standard mocking
discipline (Volume 1 chapter 7).  Every external dependency
is mocked:

- **LLM providers** — `Mock(spec=LLMProvider)`.
- **Tools** — `Mock(spec=Tool)` or test-specific
  subclasses.
- **Memory stores** — `Mock(spec=MemoryStore)` or in-memory
  fakes.
- **Coordinators / external services** — mocked.

A representative pattern from
`test_contextsummarizer.py:16–46`:

```python
def _create_mock_llm_provider(summary_text="This is a summary."):
    mock_message = LLMMessage(role="assistant", content=summary_text)
    mock_response = Mock()
    mock_response.configure(recursive=True)
    mock_response.mock_context().set_attribute("message", mock_message)

    mock_provider = Mock()
    mock_provider.configure(recursive=True)
    mock_provider.mock_context().set_attribute("provider_name", "test-provider")
    mock_provider.mock_context().mock_method("complete", return_value=mock_response)
    return mock_provider


def _create_failing_llm_provider():
    def _raise_error(**kwargs):
        raise RuntimeError("LLM failure")
    mock_provider = Mock()
    mock_provider.configure(recursive=True)
    mock_provider.mock_context().set_attribute("provider_name", "failing-provider")
    mock_provider.mock_context().mock_method("complete", side_effect=_raise_error)
    return mock_provider
```

The pattern:

1. Construct a `Mock`.
2. `.configure(recursive=True)` to enable nested mock
   attribute access.
3. `.mock_context().set_attribute(name, value)` for
   properties.
4. `.mock_context().mock_method(name, return_value=...)`
   for return values.
5. `.mock_context().mock_method(name, side_effect=callable)`
   for exception simulation.

The mock pattern is consistent across all 305 test files.
A reader who learns it once reads any test in the suite.

## What gets unit-tested

Looking at the test file organisation, the test surface
covers:

### Lifecycle and state machine

`test_agentstatemachine.py` exercises:

- Every valid transition.
- Every invalid transition (asserts `SemanticError` raised).
- Concurrent transitions (thread-safety).
- Hook invocation order.

A typical test:

```python
def test_agent_state_machine_rejects_running_to_initialized():
    """
        Verify that RUNNING → INITIALIZED is not a valid transition.
    """
    sm = AgentStateMachine()
    sm.transition(LifecycleState.INITIALIZED)  # CREATED → INITIALIZED
    sm.transition(LifecycleState.RUNNING)       # INITIALIZED → RUNNING

    # RUNNING → INITIALIZED is not in VALID_TRANSITIONS.
    try:
        sm.transition(LifecycleState.INITIALIZED)
        rtnval = False
    except SemanticError:
        rtnval = True

    assert rtnval
    return
```

### Tool dispatch

`test_toolexecutor.py` exercises:

- Valid input / valid output → success.
- Invalid input → `ToolValidationError`.
- Tool's `execute()` raises → `ToolExecutionError`.
- Credentials resolved correctly.
- Default values applied.
- Schema validation errors aggregated correctly.

### LLM provider abstraction

`test_llmprovider_properties.py` exercises (a *property*
test — chapter 8 covers these):

- Constructing with valid provider / model name → succeeds.
- Constructing with empty / None name → raises `SemanticError`.
- Tags and config defaults applied.

### Memory operations

`test_filememorystore.py`, `test_memoryquery.py`,
`test_memoryindex.py` exercise:

- Storing entries and retrieving by ID.
- Searching by tags / metadata / scope.
- Semantic search (with mocked embedding provider).
- Cosine similarity correctness.
- Expiration policy enforcement.

### Workflow execution

`test_workflowexecutor.py` exercises:

- Linear workflows.
- Branching workflows (condition evaluation).
- Parallel branches.
- Checkpoint save / load / resume.
- Step retries on failure.
- Workflows with approval gates.

### Observability emission

`test_tracer.py`, `test_metricscollector.py`,
`test_executionrecorder.py` exercise:

- Spans nest correctly.
- Sampling decisions are stable (same trace_id → same
  decision).
- Metrics validate against the registry.
- Records have monotonic sequence numbers.

### Resilience subsystems

`test_circuitbreaker.py` exercises:

- CLOSED → OPEN → HALF_OPEN → CLOSED transitions.
- Failure thresholds.
- Recovery timeout enforcement.
- Concurrent calls preserve state correctness.

The 53 files under `resilience/` cover circuit breakers,
retry policies, degradation strategies, dead-letter
queues, and similar patterns.  Resilience is one-fifth of
the test surface — production agents *fail*, and the SDK
treats failure handling as load-bearing.

## What's *not* tested

A short list of things the test suite deliberately doesn't
exercise:

### Real LLM API calls

No tests in the SDK hit OpenAI, Anthropic, or any other
real provider.  All providers are mocked.

The reasoning:

- **Cost.**  A test suite that cost $50 per run discourages
  running it.
- **Rate limiting.**  CI runs that hit shared rate limits
  are non-deterministic.
- **Vendor variability.**  Provider responses change over
  time as models update; tests against real providers
  would have to track the changes.
- **Test scope.**  The SDK's job is to *correctly route
  the LLM's responses through its plumbing*, not to verify
  the LLM's responses themselves.

Application code that *uses* the SDK can write integration
tests against real providers if it wants to verify
end-to-end behaviour.  Those tests live in the
application's repo, not the SDK's.

### LLM output quality

The SDK doesn't verify that the LLM's responses are
correct, helpful, or well-formatted.  That's the
application's responsibility.

A test that wanted to verify "the agent gives sensible
answers about the weather" would have to:

1. Run the agent against a real LLM.
2. Score the response somehow (LLM-as-judge, human
   evaluation, regex matching).
3. Decide whether the score meets a bar.

None of those are in the SDK's scope.  The SDK's tests
verify "the agent dispatches tool calls correctly when
the LLM returns this specific tool-call response."  What
*makes* the LLM return that specific response is the
application's prompt-engineering concern.

### Cross-package integration

The agent-kit's tests *don't* import from `sysman`,
`landscape`, or `interop`.  Those packages have their own
test suites that integrate with agent-kit.

The integration is *one-directional*:

- `codecraftai-sysman-*` packages may use agent-kit
  (downstream).
- `codecraftai-landscaping` may use agent-kit.
- `codecraftai-interop` may use agent-kit.
- agent-kit doesn't depend on or test against any of them.

Agent-kit is a *library*; downstream packages test their
*use* of it.

## The 49-minute wall time

Volume 1 chapter 10's case study mentioned "49 minutes wall
time" for the agent-kit suite at
`TESTBASE_MAX_EXAMPLES_CAP=10`.  This figure refers to:

- Local execution (no CI workflow file in the repo).
- All 305 test files.
- Property tests at the cap (chapter 8).

There's no `.github/workflows` test runner.  The Makefile
contains only distribution targets.  The 49-minute figure
is from running the suite manually — likely the original
SDET's measurement on representative hardware.

A reader running the suite themselves should expect
similar time on similar hardware.  Faster machines (Apple
Silicon, modern Xeons) come in under 30 minutes.  Slower
machines (older laptops, constrained CI runners) take
longer.

The wall time is *not unusual for an SDK at this scale*.
3,744 individual test invocations × ~0.8 seconds average
≈ 50 minutes.  Most of that time is property tests
running their `max_examples=50` iterations.

## Test fixtures vs factory functions

Pytest users would expect `conftest.py` files with shared
fixtures.  The agent-kit's test suite uses a different
pattern: **factory functions per test module**.

```python
# Not a pytest fixture.
def _create_mock_llm_provider(summary_text="default"):
    # ... construct mock ...
    return mock_provider


# Used directly in test functions.
def test_summarizer_with_custom_text():
    provider = _create_mock_llm_provider(summary_text="custom")
    # ... test logic ...
```

Why factories instead of fixtures?

- **Visibility.**  A test that calls
  `_create_mock_llm_provider(summary_text="custom")` is
  *explicit* about what mock it's using.  A pytest
  fixture is invisible until you trace its definition.
- **Reuse boundary.**  Factories live in the test module;
  they're reused across tests in that module but don't
  leak into others.  Pytest fixtures span modules unless
  scoped carefully.
- **Independence from pytest.**  The agent-kit's tests run
  through testbase (Volume 1).  Pytest fixtures would
  couple the tests to pytest specifically; factories work
  with any test runner.

The framework's broader test-fixture story is in Volume 1
chapter 3 — `@testbase.attach.parameter` for test-scoped
resources.  The agent-kit uses factories *in addition* to
the framework's standard fixture pattern, depending on
whether the resource is per-test (factory) or
per-package (fixture).

## A representative test walkthrough

A worked test exercising several SDK pieces at once:

```python
import testbase

from ccai.testbase.mocking import Mock
from ccai.agentkit.contextsummarizer import ContextSummarizer
from ccai.agentkit.llmconversation import LLMConversation
from ccai.agentkit.llmmessage import LLMMessage


def _create_mock_llm_provider(summary_text="This is a summary."):
    mock_message = LLMMessage(role="assistant", content=summary_text)
    mock_response = Mock()
    mock_response.configure(recursive=True)
    mock_response.mock_context().set_attribute("message", mock_message)

    mock_provider = Mock()
    mock_provider.configure(recursive=True)
    mock_provider.mock_context().set_attribute("provider_name", "test-provider")
    mock_provider.mock_context().mock_method("complete", return_value=mock_response)
    return mock_provider


def test_summarize_messages_calls_llm_with_combined_content():
    """
        Verify that ContextSummarizer combines messages and calls the LLM.
    """
    provider = _create_mock_llm_provider(summary_text="The user asked for help.")
    summarizer = ContextSummarizer(provider=provider)

    messages = [
        LLMMessage(role="user", content="I need help with..."),
        LLMMessage(role="assistant", content="What kind of help?"),
        LLMMessage(role="user", content="Specifically..."),
    ]

    result = summarizer.summarize_messages(messages)

    # Verify the result is a system-role message.
    assert result.role == "system"
    assert result.content == "The user asked for help."

    # Verify the provider was called with a conversation that includes
    # the formatted messages.
    call = provider.mock_context().last_call("complete")
    sent_conversation = call.kwargs.get("conversation") or call.args[0]
    sent_content = sent_conversation.messages[-1].content
    assert "I need help with..." in sent_content
    assert "What kind of help?" in sent_content


def test_summarize_messages_raises_on_empty_input():
    """
        Verify that ContextSummarizer raises SemanticError on empty input.
    """
    provider = _create_mock_llm_provider()
    summarizer = ContextSummarizer(provider=provider)

    try:
        summarizer.summarize_messages([])
        rtnval = False
    except SemanticError:
        rtnval = True

    assert rtnval
```

Two tests covering one feature:

- The first verifies the *happy path* — summarize calls
  the LLM, returns the result.
- The second verifies the *guard* — empty input raises
  `SemanticError`.

The mock provider is a small fixture (factory function);
the rest of the test is straightforward setup → call → assert.

## What this chapter set up

A reader who's read this chapter should understand:

- The test suite's scale (305 files, 78k LOC).
- The mock-first discipline (no real LLM calls).
- What gets tested at each layer (lifecycle, tools, LLM
  abstraction, memory, workflows, observability,
  resilience).
- What deliberately doesn't get tested (real LLM calls,
  output quality, cross-package integration).
- The factory-function pattern instead of pytest fixtures.

The next chapter walks **property tests at scale** — the
72 property-based test files in the suite, what they
catch that example-based tests don't, and the
`@given(constraints={"max_examples": 50})` discipline that
keeps the suite tractable.

## What's next

Chapter 8 walks the property-test layer.  Examples from
`test_costtracker_properties.py`,
`test_tokenusage_properties.py`,
`test_llmconversation_properties.py`.  What property
tests buy when LLM behaviour drifts.  The recent migration
from `hypothesis` to `ccai.testbase.properties`
(commit `8f31798`).  How the suite balances coverage with
wall-time budget.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/08-property-tests-at-scale -->
# Chapter 8 — Property Tests at Scale

> *Of the 305 test files in the agent-kit suite, **72** are
> property-based.  They generate hundreds of input
> combinations per run, exercising round-trip serialisation,
> idempotency, accumulation, and ordering invariants.  At
> `max_examples=50`, that's ~250 cost-tracker iterations per
> run; full-sweep runs (cap unset) push tens of thousands of
> individual checks.  This chapter walks what they catch
> and how the SDK keeps them tractable.*

The agent-kit was an early adopter of property-based testing
in the framework.  Volume 1 chapter 4 introduced
`@given` and `TESTBASE_MAX_EXAMPLES_CAP` at the framework
level; the agent-kit *uses* those primitives heavily.

This chapter walks:

- The 72 property-based test files in the suite.
- What property tests catch that example-based tests
  don't.
- The recent migration from `hypothesis` to
  `ccai.testbase.properties` (commit `8f31798`).
- The `@given(constraints={"max_examples": 50})`
  discipline.
- A worked walkthrough of `test_costtracker_properties.py`.

## Why property-based testing for an AI SDK

Three reasons the agent-kit invests in property testing:

### Round-trip invariants

Many SDK objects need to *survive serialisation*.
`LLMConversation` round-trips through JSON.  `ToolResult`
round-trips through structured data.  `ExecutionRecord`
round-trips through the recorder's persistence layer.

A property test for round-trip:

```python
@given(messages=_llm_message_list, constraints={"max_examples": 50})
def test_llmconversation_json_roundtrip(messages: list) -> None:
    original = LLMConversation(messages=messages)
    json_str = original.to_json()
    restored = LLMConversation.from_json(json_str)
    assert original == restored
```

A bug where a special-case message body breaks
serialisation gets caught after a few dozen iterations.
Example-based tests would have to enumerate every special
case manually.

### Accumulation invariants

The `CostTracker` accumulates token usage across many
calls.  A property test verifies that the total *equals*
the sum of the per-call costs:

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_total_token_usage_sums_all_records(records: list) -> None:
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)

    total = tracker.total_token_usage
    expected_prompt = sum(r[1].prompt_tokens for r in records)
    expected_completion = sum(r[1].completion_tokens for r in records)

    assert total.prompt_tokens == expected_prompt
    assert total.completion_tokens == expected_completion
```

The property is *invariant under composition*: regardless
of how many records (0 through 20 in the test) and what
their values are, the total *must* equal the sum.  A bug
that drops a record or double-counts gets caught.

### Ordering and monotonicity

The `ExecutionRecorder` assigns monotonically-increasing
sequence numbers.  A property test verifies the property
across any sequence of recorded events.  A bug that resets
the counter or skips a number gets caught.

Similarly, `LLMConversation`'s `truncate` method's
ordering invariants — system messages always preserved;
non-system messages truncated oldest-first — are
property-tested across any combination of role / count /
truncation limit.

## The 72 property-based test files

Located at
`source/testroots/ccai/tests/singlehost/agentkit/test_*_properties.py`.

A representative sample organised by what they test:

### Round-trip serialisation

- `test_llmconversation_properties.py` — JSON round-trip.
- `test_llmresponse_properties.py` — JSON round-trip.
- `test_messageserializer_properties.py` —
  serialisation/deserialisation symmetry.
- `test_executionrecord_properties.py` — record
  serialisation.

### Accumulation and aggregation

- `test_costtracker_properties.py` — five property
  functions on totals, breakdowns, reset.
- `test_tokenusage_properties.py` — construction
  validation, addition, total invariant.
- `test_metricssample_properties.py` — sample
  accumulation.

### Ordering and monotonicity

- `test_executionrecording_properties.py` — sequence
  number monotonicity.
- `test_workflowcheckpoint_properties.py` — checkpoint
  sequencing.
- `test_llmconversation_properties.py` (specifically the
  ordering property functions) — append preserves order.

### Validation invariants

- `test_toolschema_properties.py` — schema validation
  catches all type mismatches.
- `test_circuitbreaker_properties.py` — state transitions
  are deterministic given the configuration.

### Resilience patterns

- `test_retrypolicy_properties.py` — retry sequences
  exhibit expected backoff.
- `test_exponentialbackoff_properties.py` — backoff
  monotonically increases.

72 files total covering most of the SDK's data structures
and logic that has invariants.

## The migration from hypothesis to testbase

Commit `8f31798` (Mar 8 2026) moved the property tests from
the `hypothesis` library to
`ccai.testbase.properties` (Volume 1 chapter 4's
`@given` decorator).

The migration involved:

- **101+ test files modified** to use the new `@given(...)`
  syntax.
- **Strategy imports changed** —
  `from hypothesis.strategies import ...` →
  `from ccai.testbase.strategies import ...`.
- **Constraint syntax changed** — hypothesis's
  `@given(integers(...))` decorators replaced with
  `@given(value=_strategy, constraints={"max_examples":
  50})`.

The reasoning:

- **Reduce dependencies.**  The framework's broader story
  is a self-contained foundation tier.  Bringing a large
  external dependency (hypothesis is substantial) into
  the test path conflicts with that.
- **Cap-aware execution.**  testbase's `@given` integrates
  with `TESTBASE_MAX_EXAMPLES_CAP` (Volume 1 chapter 4).
  Hypothesis's runtime is independent; capping requires
  custom hypothesis machinery.
- **Reproducibility.**  testbase derives per-test seeds
  via `SHA-256(job_seed + ":" + test_name)` (Volume 1
  chapter 4 walked the derivation).  Hypothesis's
  per-test seed model differs and doesn't compose with
  the framework's run-level reproducibility.

The migration was *clean* — all tests passed in the
following commit (`a2e437a`'s "1,964 / 1,964 PASSED"
verification).

A reader looking at agent-kit tests sees the
post-migration shape:

```python
import testbase
from testbase.strategies import integers
from ccai.testbase.properties import given

@given(
    prompt_tokens=integers(min_value=0, max_value=100_000),
    completion_tokens=integers(min_value=0, max_value=100_000),
    constraints={"max_examples": 50},
)
def test_tokenusage_valid_construction(prompt_tokens: int, completion_tokens: int) -> None:
    # ...
```

## The `@given(constraints={"max_examples": 50})` discipline

Every property test in the agent-kit specifies
`max_examples`.  Most use `50`; some use `100`.  None
omit the constraint.

The reasoning:

- **Wall time budget.**  At `max_examples=50`, each
  property test runs 50 iterations.  72 property files ×
  ~5 properties per file × 50 iterations = 18,000
  iterations.  At ~50ms per iteration, that's about 15
  minutes — substantial but tractable.
- **CI vs full-sweep.**  Per Volume 1 chapter 4, the
  `TESTBASE_MAX_EXAMPLES_CAP` env var caps `max_examples`
  *down* to a smaller number (10 in CI runs).  At cap=10,
  the property suite shrinks to ~3,600 iterations — fast
  enough for per-PR runs.
- **Coverage at scale.**  Across many runs (each with
  fresh job_seeds), the cumulative coverage grows.  A
  bug that surfaces in 1 in 1000 inputs is caught after
  ~1000 cumulative iterations across ~20 CI runs.

The discipline trades *single-run depth* for
*aggregate coverage across many runs*.  Property-based
testing is most valuable when run *many times*; the cap
makes that feasible.

## A worked walkthrough — `test_costtracker_properties.py`

The file has *five property functions* covering the cost
tracker's invariants.

### Property 1 — Request count equals number of records

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_request_count_equals_number_of_records(records: list) -> None:
    """
        For any sequence of records added to a CostTracker,
        request_count shall equal the number of records.
    """
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)
    assert tracker.request_count == len(records), (
        "request_count should be {}, got {}".format(
            len(records), tracker.request_count
        )
    )
```

Verifies that the count tracks the number of `record()`
calls.  Sounds trivial; would catch a bug where a specific
provider name causes the record to be silently skipped.

### Property 2 — Total token usage sums all records

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_total_token_usage_sums_all_records(records: list) -> None:
    """
        For any sequence of records, total_token_usage equals
        the per-record sum.
    """
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)

    total = tracker.total_token_usage
    expected_prompt = sum(r[1].prompt_tokens for r in records)
    expected_completion = sum(r[1].completion_tokens for r in records)
    expected_total = expected_prompt + expected_completion

    assert total.prompt_tokens == expected_prompt
    assert total.completion_tokens == expected_completion
    assert total.total_tokens == expected_total
```

The accumulation invariant.

### Property 3 — Total cost estimate sums with floating-point tolerance

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_total_cost_estimate_sums_all_records(records: list) -> None:
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)

    total = tracker.total_cost_estimate
    expected_prompt = sum(r[2].prompt_cost for r in records)
    expected_completion = sum(r[2].completion_cost for r in records)
    expected_total = expected_prompt + expected_completion

    # Float comparison with tolerance.
    assert abs(total.prompt_cost - expected_prompt) < 1e-6
    assert abs(total.completion_cost - expected_completion) < 1e-6
    assert abs(total.total_cost - expected_total) < 1e-6
```

Same as Property 2 but for the cost.  The tolerance is
*explicit* — floats accumulate rounding errors over many
additions.

### Property 4 — Per-provider breakdown groups by provider

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_per_provider_breakdown_groups_by_provider(records: list) -> None:
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)

    breakdown = tracker.per_provider_breakdown()

    # Group records by provider for the expected breakdown.
    expected = {}
    for pname, token_usage, cost_estimate in records:
        if pname not in expected:
            expected[pname] = {
                "prompt_tokens": 0, "completion_tokens": 0,
                "prompt_cost": 0.0, "completion_cost": 0.0,
            }
        expected[pname]["prompt_tokens"] += token_usage.prompt_tokens
        # ... etc ...

    # Verify each provider's breakdown.
    for pname, expected_data in expected.items():
        actual = breakdown[pname]
        assert actual["token_usage"].prompt_tokens == expected_data["prompt_tokens"]
        # ... etc ...
```

The grouping invariant.  Catches bugs where the breakdown
mis-attributes records to the wrong provider.

### Property 5 — Reset clears all records

```python
@given(records=_record_list, constraints={"max_examples": 50})
def test_costtracker_reset_clears_all_records(records: list) -> None:
    tracker = CostTracker()
    for pname, token_usage, cost_estimate in records:
        tracker.record(pname, token_usage, cost_estimate)

    tracker.reset()

    assert tracker.request_count == 0
    assert tracker.total_token_usage.total_tokens == 0
    assert tracker.total_cost_estimate.total_cost == 0.0
```

The idempotency / reset invariant.

Five properties, each running 50 iterations per run = 250
property checks per run *just for the cost tracker*.  Over
the suite's 72 property files, the per-run total is in
the thousands.

## Strategies — generating realistic inputs

The properties use *strategies* to generate input data:

```python
_non_negative_int = st.integers(min_value=0, max_value=100_000)
_non_negative_float = st.floats(min_value=0.0, max_value=10_000.0)
_provider_name = st.text(alphabet="abcdefghijklmnopqrstuvwxyz", min_size=1, max_size=10)


def _record_entry():
    return st.tuples(
        _provider_name,
        st.builds(
            TokenUsage,
            prompt_tokens=_non_negative_int,
            completion_tokens=_non_negative_int,
            total_tokens=st.builds(
                lambda p, c: p + c,
                _non_negative_int,
                _non_negative_int,
            ),
        ),
        # ... CostEstimate similarly ...
    )


_record_list = st.lists(_record_entry(), min_size=0, max_size=20)
```

Strategies compose:

- Primitive strategies (`integers`, `floats`, `text`).
- Composite strategies (`tuples`, `lists`, `builds`).
- Constrained strategies (`min_value`, `max_size`,
  alphabet restrictions).

A test that uses `_record_list` gets generated input that's
*realistic* — non-negative numbers, short provider names,
variable list lengths from 0 to 20.  Hypothesis's
shrinking machinery (preserved in testbase's strategies)
finds *minimal failing examples* when a property is
violated.

## What property tests catch that example-based tests don't

Three classes of bugs property testing surfaces:

### Edge cases the test author didn't think of

The most common.  Property tests generate inputs across
the entire input space the strategy describes.  An empty
list, a list of zeros, a list with a single
maximum-magnitude element, a list with all-identical
elements — each is a different shape the strategy
naturally produces.  Bugs that manifest only on specific
shapes get caught.

### Floating-point and rounding

The `1e-6` tolerance in Property 3 above is there because
*floating-point summation has order-dependent rounding*.
Adding 1000 values left-to-right gives a different result
than adding them right-to-left.  A naive total computed
once can drift from the per-call sum.  Property 3
*verifies* the drift is bounded.

### Composition correctness

Property 4 verifies the per-provider breakdown across
varying numbers of providers and records.  An example-
based test would have to manually construct the test
matrix; the property test generates it.

## Full-sweep runs

The volume's outline mentions "415+ property tests" and
"21,000 individual checks at full sweep."  The numbers
correspond to:

- ~415 property *functions* across the 72 property files.
- At `max_examples=50` (the in-suite default), that's
  ~20,750 individual `@given` iterations per run.
- The "full sweep" = running with the cap unset.  Most
  property tests use `max_examples=50` literally; without
  the framework's cap reducing it.

A representative full-sweep figure mentioned in the volume
outline: *"13,054 passing checks before completion across
415 property tests, 11+ hours, 0 failures."*  The 11-hour
figure is *with* deeper exploration.

The cap exists because 11 hours is too long for CI.  The
50-iteration cap completes in tens of minutes; 10-iteration
caps (used in tightest CI) complete in single-digit
minutes.

## What this chapter set up

By the end:

- A reader knows the SDK has 72 property-based test files.
- The migration from `hypothesis` to `testbase.properties`
  is acknowledged as a clean transition.
- The `max_examples` discipline (50 in the suite, 10 in
  the CI cap) is the framework's wall-time-vs-coverage
  trade-off.
- The five `test_costtracker_properties.py` properties are
  a concrete worked example.
- Three classes of bugs property testing catches that
  example-based testing misses.

The next chapter — the volume's last — is the worked
*case study* of an agent that uses the rest of the
portfolio.

## What's next

Chapter 9 closes the volume with a worked example of an
agent that runs sysman features (Volume 4) against a
landscape (Volume 6) through interop protocols (Volume 5),
observed through this volume's machinery, with full event
recording for time-travel debugging (Volume 8).  The
chapter is the volume's payoff — every subsystem the SDK
exposes lands in the case study.


<!-- https://code-craft.ai/references/codecraftai/07-ai-agent-sdk/09-portfolio-integration -->
# Chapter 9 — Building an Agent That Uses the Rest of the Code Craft AI Portfolio

> *Eight chapters established the SDK.  This chapter watches
> the SDK in motion — an agent that runs sysman features
> against a landscape through interop protocols, with full
> event recording for time-travel debugging.  Every subsystem
> the SDK exposes lands in this case study.*

The volume's payoff.  A worked example of an agent that
*uses* the framework's other tiers — sysman from Volume 4,
landscape from Volume 6, interop protocols from Volume 5,
testbase from Volume 1.  By the end, a reader should be
able to take the case study's shape and apply it to their
own automation domain.

The case study is realistic.  An agent that *triages
production incidents* — when an alert fires, the agent
gathers context from production hosts, queries logs, runs
diagnostics, summarises findings, and either fixes the
issue or escalates to a human via approval gate.

## The scenario

A small operations team receives ~10 alerts per day across
their fleet of ~200 production hosts.  Each alert needs:

1. Context gathered (logs, metrics, recent deployments).
2. Diagnostics run (system state checks).
3. Severity assessed.
4. Either a fix applied or escalated to humans.

The team's SLA is 15 minutes for triage.  Manually, this
takes 10–15 minutes per alert.  An AI-driven triage agent
could handle low-severity alerts autonomously and produce
rich context for high-severity ones.

The agent is *not* trying to replace human judgement.  It's
trying to make the human's job easier — present what
matters, suppress what doesn't, propose actions.

## The agent's shape

```python
from ccai.agentkit.agent import Agent
from ccai.agentkit.agentconfiguration import AgentConfiguration
# ... other imports ...


class IncidentTriageAgent(Agent):
    """
        Triages production incidents.  Gathers context, runs
        diagnostics, summarises, escalates when severity exceeds
        an autonomous-action threshold.
    """

    def initialize(self) -> None:
        super().initialize()

        # LLM provider with circuit breaker
        self._configure_llm_providers()

        # Tools — sysman feature wrappers, log queries, etc.
        self._configure_tools()

        # Memory store for retaining incident-history context
        self._configure_memory()

        # Workflow definition
        self._workflow = self._build_workflow()

        # Approval gate for autonomous-action threshold
        self._approval_gate = ApprovalGate()

        # Observability — recorder for time-travel debugging
        self._execution_recorder = ExecutionRecorder(
            agent_id=self.identity.identifier,
        )
        self._execution_recorder.register_sink(
            FileExecutionSink(path="recordings/")
        )

        return

    def start(self) -> None:
        super().start()
        # Wait for an incident; when one arrives, run the workflow.
        self._wait_for_incident()
```

Eight subsystems wired in `initialize()`:

- LLM providers (chapter 2).
- Tool registry (chapter 3).
- Memory store (chapter 4).
- Workflow definition (chapter 5).
- Approval gate (chapter 5).
- Circuit breaker (chapter 5).
- Execution recorder (chapter 6).
- Cost tracker (built into the Agent base, chapter 1).

## The tool surface — sysman feature wrappers

The agent needs to *do things* on production hosts:

- Read system state (free disk, running services, recent
  log entries).
- Run sysman feature queries (Volume 4's read verbs).
- Apply sysman feature changes (Volume 4's write verbs)
  when authorised.

Each capability is a *tool* (chapter 3).  A representative
set:

```python
class GetServiceStatusTool(Tool):
    """
        Query the status of a service on a production host.
    """
    TOOL_NAME = "get_service_status"
    TOOL_DESCRIPTION = "Get the status of a service on a host."
    TOOL_VERSION = "1.0.0"
    INPUT_SCHEMA = {
        "parameters": [
            {"name": "host_name", "type": "string", "required": True},
            {"name": "service_name", "type": "string", "required": True},
        ]
    }
    OUTPUT_SCHEMA = {
        "parameters": [
            {"name": "host_name", "type": "string", "required": True},
            {"name": "service_name", "type": "string", "required": True},
            {"name": "active_state", "type": "string", "required": True},
            {"name": "load_state", "type": "string", "required": True},
        ]
    }

    def __init__(self, landscape: "Landscape"):
        super().__init__()
        self._landscape = landscape
        return

    def execute(self, inputs: dict, credentials: dict = None) -> dict:
        # Look up the host's device from the landscape (Vol 6 ch.3).
        device = self._landscape.device(inputs["host_name"])

        # The landscape's per-OS sysman ServiceManager (Vol 4 ch.5,
        # ch.6, ch.7) — the right ServiceManager for the host's OS.
        service_mgr = device.sysman.service_manager

        # Sysman read verb (Vol 4 ch.3 — reads never elevate).
        info = service_mgr.query(inputs["service_name"])

        return {
            "host_name": inputs["host_name"],
            "service_name": inputs["service_name"],
            "active_state": info.active_state,
            "load_state": info.load_state,
        }
```

The tool is *thin*.  It converts agent-level inputs to
sysman-level calls and back.  The actual work — querying
the service via SSH, parsing systemctl output —
happens in the sysman tier (Volume 4) over the interop
wire (Volume 5).

A typical agent ships *dozens* of such tool wrappers,
covering the sysman surface the agent needs.  The
wrappers are *application-specific*; the SDK provides the
patterns; the application chooses which capabilities to
expose.

### Tools that don't elevate vs tools that do

A subtle point worth pinning down.  Volume 4's contract
discipline says *reads never elevate; writes elevate*.
The agent's tool design respects this:

- Read tools (`GetServiceStatusTool`) construct a
  `ServiceManager` with `config.elevated=False` (the
  default).
- Write tools (`RestartServiceTool`) construct a
  `ServiceManager` with `config.elevated=True` *only when
  the agent has been granted authority*.

Authority comes from the agent's
configuration — typically a per-run flag like
`autonomous_actions=False` (default) that the operator
overrides for specific high-confidence runs.

## The workflow

The agent's workflow handles an incident in five steps:

```python
def _build_workflow(self) -> WorkflowDefinition:
    return WorkflowDefinition(
        name="incident_triage",
        steps=[
            WorkflowStep(step_id="gather_context",
                         step_callable=self._step_gather_context),
            WorkflowStep(step_id="run_diagnostics",
                         step_callable=self._step_run_diagnostics),
            WorkflowStep(step_id="assess_severity",
                         step_callable=self._step_assess_severity),
            WorkflowStep(step_id="propose_action",
                         step_callable=self._step_propose_action),
            WorkflowStep(step_id="execute_or_escalate",
                         step_callable=self._step_execute_or_escalate),
        ],
        edges=[
            WorkflowEdge(from_step="gather_context", to_step="run_diagnostics"),
            WorkflowEdge(from_step="run_diagnostics", to_step="assess_severity"),
            WorkflowEdge(from_step="assess_severity", to_step="propose_action"),
            WorkflowEdge(from_step="propose_action", to_step="execute_or_escalate"),
        ],
    )
```

Linear DAG.  Five steps, one path through.  Each step:

1. **`gather_context`** — query Volume 6's landscape for
   the host's group, role, recent events.  Pull recent log
   entries via Volume 5's SSH wire.
2. **`run_diagnostics`** — run targeted diagnostic
   commands via Volume 4's sysman feature managers.
3. **`assess_severity`** — call the LLM with the gathered
   context; LLM returns a severity score 1–5.
4. **`propose_action`** — call the LLM with the severity
   and context; LLM proposes specific actions.
5. **`execute_or_escalate`** — branch on severity and
   the agent's authorisation:
   - Severity ≤ 2 with autonomous_actions=True →
     execute via sysman write verbs.
   - Severity ≥ 3 OR autonomous_actions=False →
     escalate via approval gate.

The workflow is *deterministic* in shape — same five steps
every run.  The variation lives in the LLM responses
(severity score, proposed action) and the conditional in
step 5.

### A worked step — `gather_context`

```python
def _step_gather_context(self, context: WorkflowContext) -> dict:
    """
        Step 1 — Gather context for the incident.
    """
    incident = context.get("incident")
    host_name = incident["host_name"]

    # Pull recent log entries.
    log_tool = self._tool_registry.lookup("read_recent_logs")
    log_result = self._tool_executor.execute(
        log_tool,
        inputs={"host_name": host_name, "lines": 200},
    )

    # Pull host metadata from the landscape.
    device = self._landscape.device(host_name)

    # Pull recent deployments from the team's deployment service.
    deployment_tool = self._tool_registry.lookup("recent_deployments")
    deploy_result = self._tool_executor.execute(
        deployment_tool,
        inputs={"host_name": host_name, "since": incident["alert_timestamp"]},
    )

    context.set("logs", log_result.output["lines"])
    context.set("device_info", {
        "host": device.host,
        "os": device.os,
        "role": device.role,
    })
    context.set("recent_deployments", deploy_result.output["deployments"])

    return {"context_gathered": True}
```

Three tool calls, three pieces of context, all stored in
the workflow's `context` for downstream steps.  The
*method* on the agent class wires the tools through the
executor; the *workflow* sequences the methods.

### A worked step — `execute_or_escalate`

```python
def _step_execute_or_escalate(self, context: WorkflowContext) -> dict:
    """
        Step 5 — Either execute the proposed action or escalate.
    """
    severity = context.get("severity")
    proposed_action = context.get("proposed_action")
    autonomous_threshold = self._configuration.autonomous_threshold

    if severity <= autonomous_threshold:
        # Execute autonomously.
        return self._execute_action(proposed_action, context)
    else:
        # Escalate via approval gate.
        return self._escalate_for_approval(proposed_action, context)


def _execute_action(self, action: dict, context: WorkflowContext) -> dict:
    """
        Run the proposed action through sysman feature managers.
    """
    device = self._landscape.device(action["host_name"])
    if action["type"] == "restart_service":
        # Sysman write verb (Vol 4 ch.5/ch.6/ch.7 ServiceManager).
        result = device.sysman.service_manager.restart(action["service_name"])
        return {"action_executed": True, "sysman_result": result}
    # ... other action types ...


def _escalate_for_approval(self, action: dict, context: WorkflowContext) -> dict:
    """
        Create an approval request and wait for human resolution.
    """
    request = self._approval_gate.create_request(
        execution_id=context.execution_id,
        step_id="execute_or_escalate",
        description="Approve action: {} on {}".format(
            action["type"], action["host_name"]
        ),
        requesting_agent_id=self.identity.identifier,
        metadata={"proposed_action": action, "context": context.as_dict()},
    )

    # Notify on-call (out-of-band — Slack, email, etc.).
    self._notify_oncall(request)

    # Block on resolution.
    resolution = request.wait_for_resolution(timeout=900)  # 15 min

    if resolution == "approved":
        return self._execute_action(action, context)
    elif resolution == "rejected":
        return {"action_skipped": True, "reason": request.rejection_reason}
    elif resolution == "expired":
        return {"action_skipped": True, "reason": "approval_timeout"}
```

The branch:

- **Severity ≤ threshold** → autonomous action via sysman
  write verbs.
- **Severity > threshold** → escalate through the
  approval gate to an on-call human.

## The cost tracker in action

Throughout the run, every LLM call records its cost.  At
the end:

```python
def _on_workflow_complete(self, result: WorkflowResult) -> None:
    cost = self._cost_tracker.total_cost_estimate
    self.logger.info(
        "Triage complete. Cost: ${:.4f} ({} prompt + {} completion tokens)".format(
            cost.total_cost,
            self._cost_tracker.total_token_usage.prompt_tokens,
            self._cost_tracker.total_token_usage.completion_tokens,
        )
    )
```

A typical incident triage might use:

- ~3,000 prompt tokens (system prompt + gathered context).
- ~500 completion tokens (severity assessment).
- ~3,500 prompt tokens (system prompt + action proposal).
- ~1,000 completion tokens (proposed action).

Total: ~8,000 tokens per incident, ~$0.05 at typical
GPT-4-class pricing.  At 10 incidents per day, $0.50 per
day, $15 per month per agent.

The cost is *visible*.  The team's monthly review pulls
totals from the cost tracker (perhaps aggregated to a
dashboard via the metrics layer from chapter 6).

## Observability — recording for replay

Every event in the run is captured by the
`ExecutionRecorder`:

- Lifecycle transitions.
- Workflow step boundaries.
- Tool calls (started / completed / failed).
- LLM calls (started / completed / failed).
- Approval gate events.
- The agent's own custom events (e.g., "alerts received,"
  "incident closed").

After the run, the recording is *persistent*.  A debugger
loads the recording via `ExecutionReplayer` and walks
through it step by step:

```python
recording = ExecutionRecording.load("recordings/triage-20260501-1432.json")
replayer = ExecutionReplayer(recording=recording)

while replayer.has_next:
    record = replayer.next()
    print(f"[{record.sequence_number}] {record.event_type}")
    if record.event_type == "tool.execution.completed":
        print(f"  Tool: {record.payload['tool_name']}")
        print(f"  Duration: {record.payload['duration_ms']}ms")
        print(f"  Output: {record.payload['output']}")
    elif record.event_type == "llm.request.completed":
        print(f"  Provider: {record.payload['provider_name']}")
        print(f"  Tokens: {record.payload['token_usage']}")
```

The replay is *the bridge to Volume 8* (time-travel
debugging).  Volume 8 walks the production-grade
post-mortem tooling that consumes recordings to
reconstruct what happened during a past run.

## Integration with the rest of the framework

Stepping back, this case study touches every drafted
volume:

- **Volume 0** — error vocabulary, configuration,
  application identity (the agent has all of these).
- **Volume 1** — testbase tests for every tool wrapper,
  workflow step, and integration point (chapter 7's mock-
  first discipline applies).
- **Volume 2** — when the agent runs as a distributed
  workload, the tandem-validator pattern from Vol 2 ch.5
  applies for monitoring its behaviour.
- **Volume 3** — taskplus orchestration that *invokes*
  the agent (Vol 3 ch.25's bridge tasks) for scheduled
  triage runs.
- **Volume 4** — every sysman tool wrapper goes through
  Volume 4's contract layer.
- **Volume 5** — every wire call (SSH, REST) goes through
  Volume 5's Measured Interoperability discipline.
- **Volume 6** — the landscape provides the device
  metadata; the topology provides the cluster membership
  the agent reasons about.
- **Volume 7 (this volume)** — the SDK ties them all
  together.

The case study is the volume's *proof* that the SDK's
patterns compose with the rest of the portfolio.  An
agent that's built on the SDK gets:

- Predictable tool dispatch (chapter 3's
  validate-resolve-execute-validate).
- Predictable LLM routing with failover (chapter 2).
- Predictable memory and conversation handling (chapter 4).
- Predictable workflow execution (chapter 5).
- Predictable observability (chapter 6).
- Predictable testing through testbase mock patterns
  (chapter 7).
- Predictable property-test coverage (chapter 8).

Eight predictabilities.  An agent built outside the SDK
has to invent each.  Building on the SDK means inheriting
each.

## What this volume accomplished

Looking back across the nine chapters:

- **Chapter 1** — SDK vs framework, four production-agent
  concerns, the agent state machine.
- **Chapter 2** — multi-provider LLM abstraction with
  failover.
- **Chapter 3** — tool design with custom schema
  validation.
- **Chapter 4** — memory architecture with three scopes
  and semantic search.
- **Chapter 5** — DAG-based workflow with approval gates
  and circuit breakers.
- **Chapter 6** — three observability concerns: tracing,
  metrics, replay.
- **Chapter 7** — 305-test mock-first testing discipline.
- **Chapter 8** — 72-property-test invariant coverage.
- **Chapter 9** — the case study tying it all together.

A reader who's read the volume can:

- Build a production agent on the SDK.
- Choose which subsystems they need; the SDK is composable.
- Test the agent with the same testbase machinery the rest
  of the framework uses.
- Observe the agent's behaviour through the standard stack.
- Integrate the agent with the rest of the
  `codecraftai-*` portfolio.

## End of Volume 7

The series's largest volume by source-material size and
test-suite scale.  The agent-kit is ~46k LOC, ~217
modules, 305 test files.  The volume's nine chapters
covered the SDK's structure end-to-end, with a worked
case study as the payoff.

The chapters that come next are in the volume that follows.
**Volume 8** walks **time-travel debugging in production**
— the production-grade post-mortem tooling that consumes
the `ExecutionRecording` artifacts this volume's chapter 6
introduced.  Where Volume 7 said "every run produces a
recording," Volume 8 says "here's what to do with the
recordings when something goes wrong."

Volume 7's contribution to the series is the *AI-agent
discipline*.  The SDK's structure — base classes, custom
schemas, structured exceptions, mock-first tests, property
tests at scale — is the framework's answer to "how do you
build production AI agents that don't surprise you?"
Every other volume's contribution to the agent's
correctness is mediated through this volume's patterns.


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging -->
# Volume 8 — Time-Travel Debugging in Production

*Pod Instrumentation, Server Ingestion, and the Replay-Driven Debugging Workflow*

## About this volume

Volume 8 covers the marquee artefact of the portfolio:
`codecraftai-timetravel-pod` (the in-process recorder) and
`codecraftai-timetravel-server` (the ingestion + analysis service).
Together they form the production post-mortem tier — the layer
that turns "we couldn't reproduce it" into a replayable trace.

The volume *uses* concepts from earlier volumes:

- Volume 0's foundation tier — error vocabulary, configuration
  loading, the typed-exception convention.
- Volume 1 ch. 5 — the JSOS streaming format and result
  serialisation primitives that the trace protocol echoes.
- Volume 2 ch. 13 — distributed result aggregation; trace
  ingestion sits in the same conceptual slot one tier higher.
- Volume 5 — Measured Interoperability and the Aspects pattern;
  the remote-control client uses the same shape.
- Volume 7 ch. 6 — `ExecutionRecorder` / `ExecutionReplayer` for
  agents.  Volume 8 replaces the per-agent shim with the general
  pod/server tier.

## Who it is for

Senior engineers and platform / infrastructure teams running
long-lived Python services in production.  The book the on-call
team reads when "logging didn't catch it and we have no repro."
Not a tutorial on debuggers.  Not a survey of tracing tools.
It is *the production-debugging discipline* the portfolio
encodes — instrumented scopes, controlled activation, durable
ingestion, queryable replay.

## Source material

- `codecraftai-timetravel-pod` — the in-process tracer and remote
  control plane.  ~28 modules.  Layers F0 (primitives) through
  F5 (remote control), with F6 (read/replay) partially landed.
- `codecraftai-timetravel-server` — the ingestion + analysis web
  service.  ~45 modules.  Layers S0 (persistence) through S5
  (REST API), built on `codecraftai-webworks`.

## Contents

| Part | Chapters | Theme |
|---|---|---|
| I — Motivation and Surface | 1, 2 | Why time-travel debugging; the gateway / portal / registry triad |
| II — Wire and Ingestion | 3, 4 | Hybrid struct+pickle protocol (with a documented format-mismatch honesty point); TCP/Unix receivers, hysteresis flow control, atomic temp→final |
| III — Control and Operations | 5, 6 | JSON-lines + MCP; spec gap on configuration; multi-tenant data, retention, analysis tier, scaffolded REST API tier |
| IV — Practice | 7, 8 | Three case studies (CI flake / agent / sysman) with code-anchored walkthroughs; granularity-cost-storage trade-offs and the when-to-enable policy |

- [Chapter 1 — Why Time-Travel Debugging](chapters/01-why-time-travel.md)
- [Chapter 2 — Pod Instrumentation](chapters/02-pod-instrumentation.md)
- [Chapter 3 — The Trace-Event Protocol](chapters/03-trace-event-protocol.md)
- [Chapter 4 — Server-Side Ingestion](chapters/04-server-ingestion.md)
- [Chapter 5 — Remote Debug Control](chapters/05-remote-debug-control.md)
- [Chapter 6 — Session Management at Scale](chapters/06-session-management-at-scale.md)
- [Chapter 7 — Case Studies](chapters/07-case-studies.md)
- [Chapter 8 — Performance and Storage Trade-offs](chapters/08-performance-storage-tradeoffs.md)

## How it connects to the other volumes

- **Volume 0** — foundation tier (errors, configuration,
  application identity, `ConfigurationError` / `SemanticError`
  contract).
- **Volume 1 ch. 5** — JSOS streaming format; the trace-event
  protocol's hybrid `struct + pickle` shape borrows the same
  decoupling of envelope from payload.
- **Volume 2 ch. 13** — distributed result aggregation; trace
  ingestion echoes the *single-actor + many-observers* shape
  one tier higher.
- **Volume 4** — cross-platform sysman; a sysman-feature case
  study lives in Volume 8 ch. 7.
- **Volume 5** — Measured Interoperability; the remote-control
  command dispatcher uses Aspects-shaped behaviour parameters.
- **Volume 7 ch. 6** — agent observability; `ExecutionRecorder`
  composes into the pod's portal-scoped recording.

---

Part of [The Code Craft AI Books](../../README.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/01-why-time-travel -->
# Chapter 1 — Why Time-Travel Debugging

> **Abstract.** Production failures rarely repeat.  Logs capture
> what the author thought to log, traces capture sampled spans,
> and metrics capture rates — none of them capture *the values
> that flowed through the function on the call that broke*.  Time-
> travel debugging is the discipline of recording enough state at
> a chosen scope, in a chosen window, that the failing execution
> can be reconstructed and re-walked off-line.  The portfolio's
> answer is a paired tier — `codecraftai-timetravel-pod` instruments
> a running Python process at named "portal" scopes, and
> `codecraftai-timetravel-server` ingests, persists, and queries the
> resulting traces.  This chapter motivates the tier: the gap it
> closes, the constraints it accepts, and the cost it asks the
> operator to pay.

## 1.1 The three things production debugging gets wrong

Every team that has been on call long enough has the same three
debugging postmortems on file.

**The first** is *the failure that did not log.*  The exception
was caught and the catcher decided not to log it, or it was
logged but at a level the production filter discarded, or it
emitted to a stream the operator was not watching.  By the time
the symptom is noticed, the originating event is gone.

**The second** is *the failure that did log, but ambiguously.*
A request ID is recorded but no input arguments.  A stack trace
is recorded but no local variable values.  Two log lines are
correlated but the third — the one that would identify which
branch of an `if` was taken — was never emitted because nobody
thought it mattered.

**The third** is *the failure that cannot be reproduced.*  The
production environment is *not* the staging environment: it has
different data shapes, different concurrency, different
schedulers, different network conditions.  A bug that surfaces
under production load against production data evaporates the
moment you try to repro it on a developer laptop.

Logging is *forwarding* state — the developer must know in
advance which state to forward.  Tracing is *sampling* spans —
the developer must accept that 1 % or 5 % of traffic generates
the trace, and the failed request was probably in the other 95 %.
Metrics are *counting* events — the developer learns that
something happened *N* times, not *what was in scope* when it
happened.

Time-travel debugging is a fourth thing.  It is *recording* the
state at a scope, on a thread, for a window — enough state that
the execution can be replayed.  Not in the literal "rewind the
production process" sense (the process has long since recycled);
but in the *off-line replay* sense (the trace can be loaded into
a tool that re-creates the call graph, the variable history, and
the line-by-line walk).

## 1.2 The shape of the answer

The portfolio splits the problem into two cooperating tiers.
Their responsibilities and their boundaries are visible in the
top-level project descriptions.

`codecraftai-timetravel-pod`'s `AGENTS.md` opens with a stated
goal:

> The 'codecraftai-timetravel-pod' is a python package that is used
> to provide time travel debugging capabilities to applications.
> The benefit to using the 'codecraftai-timetravel-pod' is that it
> uses a concept of 'named portals' in order to allow for the
> dynamic activation of different scopes of time travel
> debugging.  The developer places a portal in the form of a
> 'with' scope into the code that is associated with a uniquely
> named time travel debugging portal.

The pod is the *in-process* tier.  It lives inside the
application being debugged.  It exposes a Pythonic context-
manager API — `TimeTravelGateway` at the top of the application,
`TimeTravelPortal("name")` at every interesting scope.  It is
*off* by default.  Activated portals install a `sys.settrace`
hook for the duration of the `with` block, capture call /
return / line / variable events, and write them to a configured
backend.

`codecraftai-timetravel-server`'s `AGENTS.md` opens with the
complement:

> The 'codecraftai-timetravel-service' package repository is a web
> service that is used to provide a web interface for the
> 'codecraftai-timetravel-pod' package.  It is a 'codecraftai-webworks'
> based web service that is used to provide a web interface for
> time travel debugging.

The server is the *out-of-process* tier.  It accepts pod
registrations, accepts trace streams, persists them to durable
storage, and exposes a query API over the persisted events.  It
does not run inside the application; it runs alongside, often on
a different host, and outlives the lifetime of any individual
debugged process.

The split is not arbitrary.  It encodes three constraints the
author of the portfolio was unwilling to violate.

**Constraint 1: instrumentation must not be free, but it must be
free when off.**  An application that imports
`codecraftai-timetravel-pod` and never activates a portal pays for
the import — a few classes, a thread-local lookup at portal
entry, the existence of the gateway's context manager — and
nothing else.  No `sys.settrace` is installed; no events are
recorded; no thread is started.  The `TimeTravelPortal.__enter__`
in `codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portal.py:60-141`
short-circuits at the very first check:

```python
def __enter__(self):
    if not is_gateway_active():
        return self

    state = get_tracing_state()

    if state.is_tracing:
        # nested — already inside an active portal
        ...
        return self

    registry = get_portal_registry()
    if not registry.is_portal_active(self._name):
        return self
    ...
```

If the gateway is not active, return.  If this thread is already
tracing, increment depth and return.  If this portal's name is
not in the activated set, return.  Three early outs before any
hook is installed.  Volume 8 ch. 8 measures the cost of the
quiescent path; for now the design intent matters: *the
instrumentation must be silent when nobody asked for it.*

**Constraint 2: activation must be dynamic.**  The operator does
not know in advance which portal will need to be activated.  In
production, the bug is the news; the diagnostic posture has to
adjust.  The portal registry is a process-wide structure
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portalregistry.py:25-41`)
that an external client — over the JSON-lines remote-control
protocol — can change at runtime, with no application restart:

```python
def activate_portal(self, name: str, expression: str = None,
                    once: bool = False, capture_id: str = None) -> None:
    with self._lock:
        self._portals[name] = {
            "active": True,
            "expression": expression,
            "once": once,
            "capture_id": capture_id,
        }
    return
```

A take-the-lock-write-the-dict structure.  Every public access
serialises through `self._lock`, a `threading.Lock` initialised
in the registry's `__init__`.  Activation is cheap, atomic, and
visible to the next portal entry on any thread.

**Constraint 3: data must outlive the process.**  A pod recording
a trace to a TCP backend
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/backends/tcpbackend.py:85-107`)
streams envelopes to the server during the run; if the process
crashes mid-trace, the server has whatever it received up to the
crash boundary.  A pod recording to a file
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/backends/filebackend.py:79-89`)
flushes its buffer to disk on every chunk and rotates files at a
configurable size.  In both cases, the trace survives the
process that produced it — which is the whole point of *post-
mortem* debugging.

## 1.3 What the pod does and what it deliberately doesn't

The pod's feature spec, encoded in its top-level `AGENTS.md`
under "Features", is a six-layer stack:

- **Layer 0** — primitives: thread-local tracing state, an
  immutable trace configuration, a monotonic clock, the
  `TraceEvent` dataclass family.
- **Layer 1** — recording engine: a `sys.settrace` wrapper, a
  recorder ABC, the hybrid `struct + pickle` serialiser, a
  buffered writer.
- **Layer 2** — stream backends: file://, tcp://, unix:// with a
  URI-scheme registry.
- **Layer 3** — gateway and portal framework: the application-
  scoped `TimeTravelGateway`, the per-scope `TimeTravelPortal`,
  the thread-safe portal registry.
- **Layer 4** — application integration: the high-level
  `TimeTravelConfig`, the `@trace_portal("name")` decorator.
- **Layer 5** — remote control: the JSON-lines TCP server, token
  auth, registration with a debug service, heartbeats.

The pod *deliberately does not* include:

- A debugger UI.  The pod's job ends at "trace data is on disk
  or on the wire".  Visualisation belongs to the server tier.
- A replay engine that re-executes the recorded code.  The trace
  is a *recording*, not a deterministic re-execution: a
  reconstructed timeline, call graph, and variable history is
  the contract, not byte-perfect program replay.  Volume 8 ch. 4
  unpacks why this distinction is important.
- An automatic-decision layer that decides when to activate
  portals.  The pod exposes the levers; the operator (or an
  agent acting on their behalf — see the MCP server in ch. 5)
  decides when to pull them.

The omissions are intentional.  Each one would add a constraint
the pod is not willing to commit to: a debugger UI couples the
recording tier to a presentation tier; a replay engine couples
the recording to a reproducible-execution model that Python's
non-determinism (threading, network I/O, time-of-day) does not
support; an automatic-decision layer presumes a policy that the
portfolio leaves to the application.

## 1.4 What the server does

The server's spec — also visible in its `AGENTS.md` under
"Features" — is a five-layer stack from S0 through S5:

- **S0** — persistence: a SQLAlchemy schema (processes,
  credentials, portal snapshots, trace sessions, server
  configuration), a credential store, a typed server-config
  store, a pluggable trace-storage backend (filesystem
  implementation lands first).
- **S1** — registration and lifecycle: a `/register` endpoint, a
  `/unregister` endpoint, a heartbeat receiver, a process
  registry with a `registered → active → stale → unreachable →
  disconnected` state machine.
- **S2** — remote debug control: a JSON-RPC client that connects
  out to a registered pod's remote-control server to discover
  portals, activate / deactivate them, and update trace
  configuration.
- **S3** — trace data reception: a TCP receiver, a Unix-socket
  receiver, a hybrid-format deserialiser, per-connection session
  lifecycle, hysteresis-based flow control, multi-stream
  routing.
- **S4** — trace data processing: a server-side trace reader, a
  timeline builder with `heapq.merge` over multiple sources, a
  portal-scope filter, a stack-based call-graph builder, a
  variable-history tracker with `bisect`-based value-at-time
  queries, a multi-criteria search engine.
- **S5** — REST API: process / portal / trace-config / debug-
  session / trace-data / credential / server-config / health
  endpoints.

Of these layers, S0 through S4 are functionally implemented and
have their own unit tests.  S5 is *specced and routed* — the
`codecraftai-webworks` blueprints register, OpenAPI docs render,
endpoint signatures match the design — but the endpoint bodies
return mock data.  No call from a Layer-5 endpoint into a Layer-1
service has yet been wired in the source as of the snapshot the
book is written against.  Volume 8 calls this out where it
matters; it is a real signal about the portfolio's investment
order — *the wire formats and the analysis algorithms first, the
HTTP plumbing last.*

## 1.5 The non-determinism wall

A book on time-travel debugging owes the reader an honest
statement of what *time-travel* means in the Python context, and
what it does not.

What it means:

- The recording captures the events that occurred on the
  instrumented thread — calls made, lines executed, variables
  observed at line boundaries — at the timestamps they
  occurred.
- The recording can be replayed in the off-line tooling sense:
  the timeline can be reconstructed; the call graph can be
  rebuilt; the value of a named variable at a given timestamp
  can be looked up.
- Multiple recordings (multiple portals, multiple processes) can
  be merged onto a unified timeline; their causal interleaving
  is approximate but driven by the monotonic clock the pod
  installs at gateway entry.

What it does not mean:

- The recording is **not** a deterministic re-execution input.
  Replaying the trace will not re-evaluate the recorded code
  against the recorded inputs to produce the same outputs.
  Python's runtime is too entangled with the wider environment
  (network, filesystem, time, randomness, threading) for record-
  and-replay deterministic execution to be tractable inside the
  language without an OS-level recorder.  Tools like *rr* solve
  that problem at the syscall layer; the pod operates at the
  Python frame layer.
- The recording is **not** complete in the absolute sense.  At
  the configured granularity, *only* the events declared by
  `TraceConfiguration` are emitted — calls only by default, with
  lines and variable snapshots opt-in.  See
  `codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceconfiguration.py:10-63`:

```python
class TraceConfiguration:
    __slots__ = ("_trace_calls", "_trace_lines", "_trace_variables", "_record_inner_portals")

    def __init__(
        self,
        trace_calls: bool = True,
        trace_lines: bool = False,
        trace_variables: bool = False,
        record_inner_portals: bool = False,
    ) -> None:
        ...
```

The four boolean knobs are the operator's lever for the
recording-fidelity-vs-cost trade-off, the subject of ch. 2 and
ch. 8.

The honest framing: *time-travel debugging here is post-mortem
state reconstruction at a configurable scope and granularity.*
The "time-travel" branding is the user-facing affordance —
*"navigate the recorded execution forward and back"* — not a
claim of deterministic replay.

## 1.6 The portal as a unit of policy

The defining design choice of the pod is that the unit of
instrumentation is *the portal*, not the file, not the function,
and not the request.

A portal is:

- **Named.**  A string identifier that the operator activates by
  name from outside the process.  Two portals with the same name
  in different files are the same logical portal.
- **Scoped.**  A `with` block whose lexical extent bounds the
  recording window.  Entering installs the trace hook; exiting
  uninstalls it.
- **Composable.**  Inner portals nested inside an active outer
  portal do not install a second trace hook — they increment a
  depth counter and (optionally) emit a `PortalEnterEvent`
  marker so the recording remains scoped to the outermost
  activation.  See `portal.py:77-90`.
- **Conditional.**  An activation can carry an `expression`
  string that is `eval`'d in the caller's frame; the portal
  activates only if the expression evaluates truthy.  See
  `portal.py:99-112`.
- **One-shot or persistent.**  An activation can declare `once`,
  in which case it deactivates itself after the first
  capture — useful when the operator wants the next failing call
  recorded, but not the entire load that follows.

The reason for *named* portals — rather than file-and-line-based
breakpoints — is operator ergonomics during an incident.  When
the on-call engineer is reading a Sentry alert that says
"`InvoiceService.charge` is throwing a `RetryableError` at
elevated rate", the engineer does not want to translate that
into a file path and a line number.  They want to look at a list
of registered portals, see one called `invoice.charge.attempt`,
and activate it.  The portal name is the *vocabulary* the
service team has already exposed for the parts of the code they
expect to need to debug under fire.

The reason for *scoped* portals — rather than always-on
recording — is the cost model.  An always-on `sys.settrace` on a
mature Python service is between 5x and 50x the un-traced
runtime cost depending on the granularity (calls only is at the
low end; line-and-variable is at the high end; ch. 8 quotes
measured numbers).  Keeping the recording scope to a `with`
block bounds the cost to the duration of the suspected window.

The reason for *composable* portals — rather than mutually
exclusive ones — is layered diagnosis.  An outer portal
`http.request` may already be recording when the engineer
activates `db.query`.  The inner portal does not duplicate the
hook; it annotates the existing recording with `PortalEnter`
markers so the off-line tooling can filter the trace to the
inner scope after the fact.

## 1.7 Where this volume sits in the series

Volume 8 is the *production post-mortem* tier.  It builds on
material from earlier volumes:

- **Volume 0** — foundation tier.  The error vocabulary
  (`ConfigurationError`, `SemanticError`) is what the pod uses
  when the gateway is mis-initialised
  (`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceclock.py:39-52` —
  raises `RuntimeError` if `timestamp()` is called before
  `start()`, *not* a silent zero).  The `ConfigurationError`
  contract is what the pod's `configure_timetravel` would raise
  if it found incoherent configuration; the `SemanticError`
  contract — *must always bubble* — is honoured when the trace
  hook lifts an exception out of the recorded code.
- **Volume 1 ch. 5** — the JSOS streaming format.  The pod's
  envelope-and-payload split (a fixed-size `struct` envelope
  followed by a variable-size pickled payload) is the same
  structural decoupling JSOS uses for its frames: *constant-time
  scanning* for selectors that need to skip frames they don't
  care about, *variable-cost deserialisation* for the ones they
  do.
- **Volume 2 ch. 13** — distributed result aggregation in the
  Pod model.  The same single-actor-many-observers shape (the
  *tandem* model) is operating one tier up: the production
  process is the actor, the time-travel server is one observer
  among several (logs, metrics, APM are the others).  The Pod
  capitalised — the test execution unit from Volume 2 — is
  *not* the time-travel pod the lower-case word names; the two
  vocabularies overlap in the portfolio because both packages
  describe themselves as "a pod that watches".  Volume 8 keeps
  the distinction explicit in prose.
- **Volume 5** — Measured Interoperability.  The remote-control
  command dispatcher
  (`debugcontrol/command_dispatcher.py:21-74`) is built on the
  Aspects pattern: timeout, retry, and serialisation knobs
  travel as a single behaviour-parameter object passed by the
  caller, exactly the shape Volume 5 ch. 1 lays out for *all*
  interop calls in the portfolio.
- **Volume 7 ch. 6** — agent observability.  The
  `ExecutionRecorder` / `ExecutionReplayer` classes Volume 7
  describes for an agent run are special-cased portals — an
  agent invocation is a particular kind of scoped, named work
  unit.  Volume 8 generalises: an agent run is one of many
  things you might wrap a portal around, and the pod / server
  pair is the production-grade artefact that operationalises
  every such recording.

## 1.8 The chapters that follow

The remaining chapters of Volume 8 walk the system top-down:

- **Ch. 2** — Pod instrumentation.  Where to place portals; how
  the gateway, registry, and configuration plug together; the
  static-vs-dynamic instrumentation choice.
- **Ch. 3** — The trace-event protocol.  The `struct + pickle`
  envelope; the event-type enum; the wire format the pod emits
  and the format the server is built to parse.  Includes a
  documented honesty point — the two ends of the protocol have
  diverged in the source as committed; the chapter shows both
  formats and discusses what reconciles them.
- **Ch. 4** — Server-side ingestion.  The TCP and Unix
  receivers, the deserialiser, the per-connection session
  lifecycle, the hysteresis flow control that pushes back on
  saturating pods.
- **Ch. 5** — Remote debug control.  The JSON-lines protocol
  shape; the spec gap (the server design lists configuration
  read/write commands the pod does not implement); the MCP
  server that exposes pod control to AI agents.
- **Ch. 6** — Session management at scale.  Multi-tenant
  storage, retention, replay-bandwidth budgets, the lifecycle
  state machine, the credential model.
- **Ch. 7** — Case studies.  Three worked examples — debugging a
  flaky distributed test (cross-ref to Volume 2), debugging an
  agent execution (cross-ref to Volume 7), debugging a sysman
  feature on a remote host (cross-ref to Volume 4).
- **Ch. 8** — Performance and storage trade-offs.  The runtime-
  overhead budget, the storage budget, the policy framework for
  *when to enable, when to disable*.

The chapter that follows establishes the foundational vocabulary
the rest of the volume uses — the gateway, the portal, the
registry, the configuration knobs.

→ Continue to [`02-pod-instrumentation.md`](02-pod-instrumentation.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/02-pod-instrumentation -->
# Chapter 2 — Pod Instrumentation

> **Abstract.** The pod's contract with the application is a
> pair of context managers — `TimeTravelGateway` at the top and
> `TimeTravelPortal("name")` at every interesting scope — and a
> process-wide registry that decides which portal names are
> *active* at any moment.  This chapter walks the entry points
> the application sees, the state machine the pod runs through
> when a portal opens, the configuration object that controls
> recording fidelity, and the static-vs-dynamic instrumentation
> trade-off the design forces on the operator.  All claims are
> anchored on `codecraftai-timetravel-pod`.

## 2.1 The two context managers

The application surface of the pod is two classes.  Everything
else — the trace hook, the recorder chain, the serialiser, the
backend — is reached through them.

`TimeTravelGateway`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/gateway.py:22`)
is the application-scope context manager.  It is entered exactly
once, near the top of the application's lifetime; it owns the
monotonic clock that all timestamps are measured against, holds
the registry of currently-active recorders, and broadcasts the
two boundary events that bracket every recording — a
`GatewayStartEvent` on entry, a `GatewayStopEvent` on exit.

`TimeTravelPortal`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portal.py:28`)
is the per-scope context manager.  It is entered every time
control flows into a portal scope, regardless of whether that
portal is currently *activated*.  The cost of entering an
inactive portal is three early-exit conditional branches and
one thread-local lookup; the cost of entering an active portal
is the installation of `sys.settrace`, the construction of a
recorder chain, and an event broadcast.

The asymmetry is deliberate.  Portal entry is on the hot path
of the application — every request, every scheduled task, every
agent step might cross a portal boundary.  Gateway entry is
once per process.  The framework optimises the common, hot,
cheap case (portal entry while inactive) and accepts higher
cost on the rare, cold, expensive case (gateway start, portal
activation).

A minimal usage looks like this:

```python
from ccai.timetravel.pod import TimeTravelGateway, TimeTravelPortal

def main() -> None:
    with TimeTravelGateway(backend_uri="file:///var/log/mysvc/trace.bin"):
        run_service()

def handle_request(req):
    with TimeTravelPortal("http.request"):
        return process(req)
```

The gateway is not a daemon; it is a `with` block that *brackets
the recording window*.  In a long-lived service the gateway is
typically entered in `main()` and never exited until the process
terminates.  In a CLI command, the gateway brackets the
command's body.  In a unit test, the gateway brackets the test
body.

## 2.2 The startup configuration

`TimeTravelConfig`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/configuration.py:19`)
collects every knob the pod exposes into a single object.  Its
13 `__slots__` fields cover four concerns:

- **What to record.**  `active_portals` (a list of names to
  activate at startup), `trace_calls`, `trace_lines`,
  `trace_variables`, `record_inner_portals` (the four trace-
  granularity booleans).
- **Where to record it.**  `backend_uri` (a `file://`, `tcp://`,
  or `unix://` URI; resolved by the backend registry).
- **How to be remote-controlled.**  `remote_enabled`,
  `remote_host`, `remote_port`, `remote_credential` (for the
  JSON-lines control server).
- **How to be agent-controlled.**  `mcp_enabled`,
  `mcp_transport`, `mcp_host`, `mcp_port`, `mcp_pipe` (for the
  Model Context Protocol bridge — see ch. 5).

The bootstrap function `configure_timetravel`
(`configuration.py:259-304`) reads a `TimeTravelConfig` and
produces the runtime side-effects:

```python
def configure_timetravel(config: TimeTravelConfig) -> dict:
    servers = {}

    # Activate portals
    registry = get_portal_registry()
    if config.active_portals:
        registry.activate_portals(config.active_portals)

    # Create backend (if URI specified)
    if config.backend_uri:
        backend_registry = create_default_registry()
        backend_registry.create_backend(config.backend_uri)

    # Start remote control server (if enabled)
    if config.remote_enabled:
        remote_server = RemoteControlServer(
            host=config.remote_host,
            port=config.remote_port,
            credential=config.remote_credential
        )
        remote_server.start()
        servers["remote"] = remote_server

    # Start MCP server (if enabled)
    if config.mcp_enabled:
        mcp_server = McpServer()
        if config.mcp_transport == "tcp":
            mcp_server.start_background_tcp(config.mcp_host, config.mcp_port)
        elif config.mcp_transport == "pipe" and config.mcp_pipe:
            mcp_server.start_background_pipe(config.mcp_pipe)
        else:
            mcp_server.start_background_stdio()
        servers["mcp"] = mcp_server

    return servers
```

The function returns a dict of running servers keyed by
`"remote"` and `"mcp"`, so the application can keep references
for shutdown.  Calling `configure_timetravel` does *not* enter
the gateway; the gateway is still entered by the application
at the appropriate scope.  The function is purely about
bootstrapping the side-channels: the portal registry's initial
state, the backend, the control plane.

The choice to keep the gateway out of `configure_timetravel` is
deliberate and worth understanding.  The gateway controls the
recording-window timestamp origin
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceclock.py`).
A long-running service that wants the gateway alive for the
process lifetime enters it as a `with` block in `main()`.  A
short-lived task that wants the gateway alive for the task's
extent enters it inside the task's body.  The application
decides; `configure_timetravel` does not enforce.

## 2.3 The gateway's invariants

`TimeTravelGateway.__enter__` does three things, in order
(`gateway.py`):

1. Records the current wall-clock time and starts the monotonic
   clock — so every event timestamp is an *offset from gateway
   start*.
2. Sets the process-wide `_gateway_active` flag to `True`
   (`tracingstate.py:71-79`); this is the flag every portal
   reads first to decide whether to do anything.
3. Emits a `GatewayStartEvent` to all registered recorders.

`TimeTravelGateway.__exit__` (lines 73-101) is more intricate
because it has to land safely even if a recording in flight has
errored:

```python
def __exit__(self, exc_type, exc_val, exc_tb):
    try:
        # Emit gateway stop event
        stop_event = GatewayStopEvent(
            timestamp_ns=self._clock.timestamp(),
            thread_id=threading.get_ident(),
            wall_clock_time=time.time(),
        )
        self._broadcast_event(stop_event)

        # Flush and close all active recorders
        for recorder in self._active_recorders:
            recorder.flush()
            recorder.close()
        self._active_recorders.clear()
    finally:
        self._clock.stop()
        set_gateway_active(False)
        set_active_gateway(None)
    return False
```

Three observations.

First, the `try` body emits the stop event *before* flushing —
so the stop boundary appears in the recorded stream even if a
flush fails afterwards.  This is the same boundary-marker
discipline JSOS uses (Volume 1 ch. 5): the marker is broadcast,
then the buffered data is committed.

Second, the `finally` block always runs, regardless of any
exception in the `try`.  The clock is stopped, the gateway flag
is cleared, the active-gateway singleton is unset.  Even if a
recorder's `flush()` or `close()` raises, the next gateway
attempt on the same process will not see stale state.

Third, the `__exit__` returns `False` — meaning any exception
that propagated *into* `__exit__` (an error in the `with` body,
or in the recorder cleanup) is *not* suppressed.  The pod is not
in the business of swallowing the application's exceptions.
This is the same convention every interop layer in the
portfolio honours (Volume 5: control reverts via typed
exceptions).

## 2.4 The portal's decision tree

`TimeTravelPortal.__enter__`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portal.py:60-141`)
is the heart of the pod.  It is run on every entry into every
portal scope; its cost dominates the application's *quiescent*
overhead.  The decision tree it implements is, in order:

```
1. Is the gateway active?            → no  → return self
2. Is this thread already tracing?   → yes → bump depth, return self
3. Is this portal's name activated?  → no  → return self
4. Does the activation have an
   expression?                       → if so, eval it; if false,
                                       return self
5. Construct recorder chain.
6. Install sys.settrace hook.
7. Mark thread as tracing,
   depth = 1.
```

Steps 1-4 are guards; steps 5-7 are activations.  In the typical
production case — most portals not currently active — only steps
1 and 3 actually evaluate, and both fall through to `return
self`.

The activation block (5-7) is more substantive.  Two flavours
exist depending on whether a backend URI is configured:

```python
def _create_record_callback(self, gateway, capture_id):
    backend_uri = capture_id or gateway.backend_uri

    if backend_uri:
        # Real recorder chain
        backend_registry = create_default_registry()
        backend = backend_registry.create_backend(backend_uri)
        serializer = TraceSerializer()
        writer = BufferedTraceWriter(backend, max_buffer_size=65536)

        class PortalRecorder(TraceRecorder):
            def record(self, event):
                data = self.serializer.serialize(event)
                self.writer.write(data)
            def flush(self):
                self.writer.flush()
            def close(self):
                self.writer.close()

        recorder = PortalRecorder(writer, serializer)
        gateway.register_recorder(recorder)
        ...
```

The chain is **backend → serializer → buffered writer →
recorder**.  Each layer has a single responsibility; each is
swappable.  The 65,536-byte buffer (auto-flush at full) is the
default chosen at this site; a different recording profile could
override it without changing the recorder ABC.

The fall-through case — no backend URI configured — is a list
collector:

```python
else:
    # Fallback: in-memory collector for testing
    collected_events = []
    def record_callback(event):
        collected_events.append(event)
    state.recorder = _ListRecorder(collected_events)
```

This second branch is what unit tests use.  A test enters a
portal, runs code, exits the portal, and inspects the in-memory
event list.  Production deployments configure a backend URI.

## 2.5 The portal registry

`PortalRegistry`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portalregistry.py:12`)
is the structure that holds *which named portals are active
right now*.  It is a singleton (`get_portal_registry()` at line
133) keyed by portal name, with each value a dict of `active`,
`expression`, `once`, and `capture_id`.

Every public method takes a `threading.Lock` before reading or
writing the dictionary
(`portalregistry.py:25-41`):

```python
def activate_portal(self, name: str, expression: str = None,
                    once: bool = False, capture_id: str = None) -> None:
    with self._lock:
        self._portals[name] = {
            "active": True,
            "expression": expression,
            "once": once,
            "capture_id": capture_id,
        }
    return
```

The `get_portal_info` method
(`portalregistry.py:71-83`) explicitly returns a *copy* of the
internal dict so a caller cannot mutate registry state by
mutating the returned object:

```python
def get_portal_info(self, name: str) -> dict:
    with self._lock:
        rtnval = self._portals.get(name, {})
        # Make a copy to avoid exposing the internal structure
        rtnval = dict(rtnval)
    return rtnval
```

This is the kind of small discipline that prevents action-at-a-
distance bugs three years later — the operator-tool client
queries portal info, sets `active=False` on the returned dict
by accident, and the registry is silently corrupted.  The copy
defends against that.

The lock is uncontended in the common case: it is taken on
every portal-entry's `is_portal_active` check, but the check is
microseconds long.  The lock is contended when remote-control
commands flood the registry — a debug session that bulk-
activates 50 portals at once will serialise through this lock —
but those events are infrequent compared to portal entries.

## 2.6 The thread-local state

The decision tree in §2.4 does not actually take the registry's
lock at step 2 ("is this thread already tracing?").  Instead it
reads thread-local state.  The
`TracingState`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/tracingstate.py:15`)
holds four fields per thread:

- `is_tracing` — bool, true while a portal is open and the
  trace hook is installed.
- `recorder` — the active recorder, used by the hook callback to
  publish events.
- `portal_depth` — int, the current portal nesting level on this
  thread.
- `active_portal_name` — str, the name of the outermost
  currently-active portal.

The lookup helper (`tracingstate.py:44-55`) lazy-initialises:

```python
_thread_local = threading.local()

def get_tracing_state() -> TracingState:
    state = getattr(_thread_local, "tracing_state", None)
    if state is None:
        state = TracingState()
        _thread_local.tracing_state = state
    return state
```

There is no global lock here — `threading.local()` is the
standard library's per-thread storage primitive, and the lazy
initialisation pattern is idempotent on a per-thread basis.

The reason the design *splits* into a thread-local state and a
process-wide registry is the classic *what changes vs what is
read* axis:

- The registry is *read* from many threads on every portal
  entry, but *written* rarely (only when an activation command
  arrives).  A `Lock` is the right tool: it protects the
  occasional write against the frequent reads.
- The state is *read* and *written* on every portal entry on a
  particular thread, but *only that thread*.  A `threading.local`
  is the right tool: no lock needed, because no other thread is
  reading or writing the same fields.

## 2.7 Nested portals

When a thread already inside an active portal enters a second
portal, the second portal does *not* install a second
`sys.settrace`.  It increments `state.portal_depth` and (if
`record_inner_portals` is set in the trace configuration) emits
a `PortalEnterEvent` so the trace contains the boundary marker.

```python
if state.is_tracing:
    state.portal_depth += 1
    if config.record_inner_portals:
        record(PortalEnterEvent(timestamp=clock.timestamp(),
                                portal_name=self._name))
    return self
```

The exit path mirrors the entry: `state.portal_depth -= 1`, and
if the `record_inner_portals` flag is set, a `PortalExitEvent`.
The hook is uninstalled only when `state.portal_depth` drops to
zero (i.e. the outermost portal exits).

This *single-hook-many-portals* design has two consequences.

The first is correctness.  `sys.settrace` is a per-thread
setting — Python only allows one trace function at a time per
thread.  If portals stacked their hooks, the inner hook would
*replace* the outer hook, the outer hook's events would stop
being recorded for the inner scope, and reinstalling the outer
hook on inner-portal exit would either work (only because
Python's `sys.settrace` semantics happen to support this) or
produce gaps.  The single-hook approach sidesteps the issue
entirely.

The second is annotation.  When the off-line tooling
reconstructs the timeline, the `PortalEnterEvent` /
`PortalExitEvent` markers are the boundaries it uses to *filter
the recording to a portal scope*.  Volume 8 ch. 4 walks
`tracedata/portal_filter.py` (server-side) and `pod/portalfilter.py`
(pod-side; same algorithm) — both are tiny state machines that
turn the markers into scope membership.

## 2.8 Conditional activation

Activation can carry an `expression` string:

```python
registry.activate_portal(
    name="invoice.charge.attempt",
    expression="invoice.amount > 10000",
    once=True,
    capture_id="big-invoice-anomaly",
)
```

When a portal entry encounters this activation, it evaluates
the expression in the *caller's frame*
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portal.py:99-112`):

```python
expression = portal_info.get("expression")

if expression is not None:
    try:
        frame = sys._getframe(1)
        result = eval(expression, frame.f_globals, frame.f_locals)
        if not result:
            return self
    except Exception:
        return self
```

If the expression evaluates falsy, the portal returns without
activating tracing — the recording is reserved for the calls
that *match the predicate*.  If evaluating the expression
raises (a `NameError` because `invoice` was not in scope, a
`TypeError` because `invoice.amount` was None), the exception
is swallowed and the portal does not activate.  This last
behaviour is a quiet override of the portfolio's "errors should
never pass silently" rule, justified by the use case: *a
malformed predicate must not crash the production application
that is using the recording as a debugging tool.*  The cost is
that operators submitting bad predicates see no activation and
no error feedback — the feedback path is logging.

This is a tractable design point worth pushing on.  Two options
were available:

- **Swallow.**  As implemented.  The portal silently does not
  activate.  Pros: no production risk.  Cons: silent failure;
  the operator does not learn that their predicate is wrong.
- **Surface.**  Raise to the operator-tool client that submitted
  the activation.  Pros: visible error.  Cons: requires a
  channel for asynchronous notification of failed activations,
  because the predicate is evaluated *later*, not at activation
  time.

The implemented choice is the conservative one.  Volume 8 notes
the trade-off; the source comment is honest about the swallow
(`portal.py:108-112`).

## 2.9 The trace configuration

`TraceConfiguration`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceconfiguration.py:10-63`)
is an immutable, four-boolean object that controls *what* the
trace hook records on every Python event:

```python
class TraceConfiguration:
    __slots__ = ("_trace_calls", "_trace_lines", "_trace_variables", "_record_inner_portals")

    def __init__(
        self,
        trace_calls: bool = True,
        trace_lines: bool = False,
        trace_variables: bool = False,
        record_inner_portals: bool = False,
    ) -> None:
        self._trace_calls = trace_calls
        self._trace_lines = trace_lines
        self._trace_variables = trace_variables
        self._record_inner_portals = record_inner_portals
```

The `__slots__` plus read-only properties enforce immutability
— there is no setter for `_trace_calls` etc.  The configuration
is a *value* in the strict sense: two configurations with the
same booleans are interchangeable.

The four knobs cover four growing recording densities:

- **calls only** (`trace_calls=True`, others false): a
  `CallEvent` for each function entered, a `ReturnEvent` for
  each function exited.  A function-graph trace.  Cheap.
- **calls + lines** (`trace_lines=True` added): on every line
  executed inside a recorded function, a `LineEvent`.  About 3-
  10x the volume of calls-only, depending on the code's loop
  density.
- **calls + lines + variables** (`trace_variables=True` added):
  on every line executed, a `VariableSnapshotEvent` carrying the
  current `frame.f_locals` as a `dict`.  An order of magnitude
  more volume than calls + lines.
- **+ inner portals** (`record_inner_portals=True` added):
  `PortalEnterEvent` and `PortalExitEvent` markers for nested
  portals.  Negligible additional volume, but enables off-line
  filtering by portal scope.

Volume 8 ch. 8 measures the cost.  The numbers, briefly:
calls-only is a few-percent overhead on a typical Python
service.  Lines is 5-10x.  Variables is 50-100x and a flood of
data.  The design intent is that `calls` is the production
default, `lines` is the targeted-window default, and `variables`
is the *last-resort, narrow-scope, this-bug-now* default.

## 2.10 The event model

`TraceEvent`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceevents.py:29`)
is the base class of nine concrete event types
(`traceevents.py:12-20`):

```python
class EventType:
    CALL = 1
    RETURN = 2
    LINE = 3
    VARIABLE_SNAPSHOT = 4
    PORTAL_ENTER = 5
    PORTAL_EXIT = 6
    GATEWAY_START = 7
    GATEWAY_STOP = 8
    EXCEPTION = 9
```

Each event uses `__slots__` for memory efficiency, has a
nanosecond-precision `timestamp_ns` field (an integer offset
from gateway start), a `thread_id`, and a per-type payload.

A representative event — the variable snapshot — looks like:

```python
class VariableSnapshotEvent(TraceEvent):
    __slots__ = ("filename", "lineno", "local_variables")

    def __init__(self, timestamp_ns: int, thread_id: int,
                 filename: str, lineno: int, local_variables: dict) -> None:
        super().__init__(EventType.VARIABLE_SNAPSHOT, timestamp_ns, thread_id)
        self.filename = filename
        self.lineno = lineno
        self.local_variables = local_variables
```

The `local_variables` field is a literal `dict` snapshot of
`frame.f_locals` at the point of the event.  This is the most
expensive event in the model — it serialises an arbitrary dict,
including whatever the application happened to have on the
stack.  It is also the most informative, because it is the
*answer* to the on-call engineer's "what was in scope when this
fired?" question.

Boundary events — `GatewayStartEvent`, `GatewayStopEvent` — also
carry a `wall_clock_time` (float, seconds since epoch) so the
recording can be correlated with external systems
(application logs, APM traces, alerting timestamps).  All other
events are nanosecond offsets only.

## 2.11 The trace hook

The actual `sys.settrace` callback lives in
`TraceHookManager._trace_function`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/tracehookmanager.py:63-137`).
It dispatches on the Python event type (the second argument
Python passes to a trace function), constructs the appropriate
`TraceEvent` subclass, and pushes it through the `record_callback`:

```python
def _trace_function(self, frame, event, arg):
    timestamp_ns = self._clock.timestamp()
    record = self._record_callback

    if event == "call":
        if self._trace_calls:
            call_event = CallEvent(
                timestamp_ns=timestamp_ns,
                thread_id=self._thread_id,
                filename=frame.f_code.co_filename,
                lineno=frame.f_lineno,
                function_name=frame.f_code.co_name,
            )
            record(call_event)
        return self._trace_function

    elif event == "line":
        if self._trace_lines:
            line_event = LineEvent(...)
            record(line_event)
        if self._trace_variables:
            var_event = VariableSnapshotEvent(
                timestamp_ns=timestamp_ns,
                thread_id=self._thread_id,
                filename=frame.f_code.co_filename,
                lineno=frame.f_lineno,
                local_variables=dict(frame.f_locals),
            )
            record(var_event)

    return self._trace_function
```

Two performance details are worth the attention.

First, `self._trace_calls`, `self._trace_lines`,
`self._trace_variables` are cached as instance attributes
(`tracehookmanager.py:42-44`) instead of being read out of the
`TraceConfiguration` on every event.  Attribute lookup is faster
than property access; on a hot trace, the difference matters.

Second, the trace function returns *itself* on every event.
Python's `sys.settrace` semantics require this — the returned
function is what gets installed as the *line* trace function for
the frame that just received a *call* event.  Returning `None`
turns line tracing off for that frame; returning `self._trace_function`
keeps the hook installed.  The pod always returns
`self._trace_function`, ensuring the recording continues across
nested calls.

The hook does *not* catch exceptions raised in `record(...)`.
If the recorder fails — a backend disconnect, a serialiser bug,
an OOM — the exception propagates back up through the trace
hook and out of the recorded code.  This is consistent with
the gateway's `__exit__` returning `False` (§2.3): the pod does
not pretend to handle errors it cannot resolve.

## 2.12 The decorator

For functions whose entire body is the portal scope,
`@trace_portal("name")`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/decorators.py:19-46`)
is the convenience form:

```python
def trace_portal(name: str):
    def decorator(func):
        if inspect.iscoroutinefunction(func):
            @functools.wraps(func)
            async def async_wrapper(*args, **kwargs):
                with TimeTravelPortal(name):
                    rtnval = await func(*args, **kwargs)
                return rtnval
            return async_wrapper
        else:
            @functools.wraps(func)
            def sync_wrapper(*args, **kwargs):
                with TimeTravelPortal(name):
                    rtnval = func(*args, **kwargs)
                return rtnval
            return sync_wrapper

    return decorator
```

The decorator does nothing the explicit `with` form does not do
— it just wraps the function body in a `with TimeTravelPortal(name)`.
The branch on `inspect.iscoroutinefunction` is what allows the
same decorator to work on both sync and async functions; the
async wrapper awaits the coroutine *inside* the portal scope,
so the recording covers the entire async lifetime.

Decorators in the portfolio are constrained by the project's
coding standards.  The rule (in `codecraftai-*` `CLAUDE.md`):
*"Decorators are only allowed for adding metadata or for
registering a function with a mapping system.  Do NOT use
behavior-modifying decorators."*  `@trace_portal` is on the
edge — it modifies behaviour by wrapping the call in a context
manager.  The justification, taken on its own terms, is that
the wrapped behaviour is *purely additive* and *purely
declarative*: it does not change the function's semantics; it
adds a recording boundary.  Even so, the explicit `with` form
remains the dominant pattern in the portfolio's own code; the
decorator is offered for ergonomics on functions whose body
*is* the portal.

## 2.13 Static vs dynamic instrumentation

The pod's portals are *static*, not dynamic.  They are written
into the application source as `with` blocks or `@trace_portal`
decorators, before the code is built and deployed.  The
operator's runtime control is *which static portals to
activate*, not *where to add new portals*.

The alternative — dynamic instrumentation — would let the
operator say, at runtime, "trace `mymodule.somefunc` as if it
were wrapped in a portal."  Tools in the broader Python
ecosystem do this: `sys.settrace` itself is the underlying
mechanism, and a debugger-like tool that takes a fully-qualified
function path could install a hook at runtime.

The pod chose static instrumentation for three reasons.

First, **scope discipline.**  A static portal expresses an
*intent*: "this scope is one I expect to need to debug."  The
intent is encoded in the source review — a code reviewer can see
that the developer thought `http.request` was a debugging-worthy
boundary.  A dynamic tool with no source-side annotation can
trace anywhere, which makes the activation policy harder to
reason about and harder to audit.

Second, **performance predictability.**  A static portal's cost
is bounded — the inactive cost is three thread-local conditional
branches (§2.4); the active cost is `sys.settrace`'d Python
inside a known scope.  A dynamic tool that activates by qualified
name has to install hooks at function entry, return, or line
boundaries it does not know in advance; the cost surface is
larger.

Third, **operator vocabulary.**  "Activate the
`invoice.charge.attempt` portal" is a sentence the on-call
engineer can type without consulting the source.  "Install a
trace hook on `ccai.invoicing.service.InvoiceService.charge`
at line 412" is not.  The named-portal abstraction trades some
flexibility for a vocabulary the production operator already
shares with the developer.

The trade-off is real and worth naming.  Static instrumentation
*forecloses* the bug whose right scope nobody anticipated.  If
the failing call is in code that has no portal, the operator
cannot record it without a code change, a build, and a deploy —
which can be a long road in production.  The pod accepts that
foreclosure as the price of the three benefits above.

A practical mitigation: the portfolio's own coding-standards
document encourages portals at every architecturally meaningful
boundary — request entry, request exit, RPC call, RPC reply, DB
transaction, agent step, scheduled-task body, message-queue
consumer.  In a fully-instrumented service the *coverage* of
static portals approaches the operator's likely debug surface,
even if the exact line is not directly inside one.

## 2.14 Where the portals belong

Using the portal pattern well is mostly a question of *where to
place portals*.  The portfolio's own code provides several
patterns:

- **At every external entry point.**  Every HTTP handler, every
  message-queue consumer, every CLI subcommand opens a portal
  named for the entry point (`http.request`,
  `kafka.consumer.invoice-events`, `cli.invoke.deploy`).
- **At every external call.**  Every database query, every RPC,
  every third-party API call opens a portal named for the
  destination (`db.query`, `rpc.payment-service.charge`,
  `http.client.stripe`).
- **At every long-running task body.**  Every scheduled task,
  every background worker, every agent step opens a portal
  named for the task type (`task.daily-rollup`,
  `worker.invoice-batch`, `agent.step`).
- **At every "I expect this to be hard to debug" scope.**  The
  scope the senior developer imagined would be the locus of
  pain — the cache-invalidation block, the retry loop, the
  branch that handles the rare schema variant — opens a portal
  named for that intent (`cache.invalidate.user`,
  `retry.flaky-third-party`, `legacy.migration.v1-to-v2`).

A typical service ends up with on the order of 10-50 named
portals.  The number is small because the *named* unit is a
*scope of debug interest*, not a function or a line.  Many
functions live inside a single portal.

The cost of a static portal in inactive state — three
conditional branches and a thread-local lookup — is small
enough that this density is supportable.  Volume 8 ch. 8 quotes
the measured numbers.

## 2.15 Cross-references and the next chapter

What the application surface looks like is now in place.  The
next layer down is *what the recording looks like on the wire*:
the envelope format the serialiser emits, the layout of the
buffered writer's output, and the contract the server is built
to parse on the other end.

The next chapter walks the trace-event protocol — including a
documented mismatch between the pod-side serialiser and the
server-side deserialiser as the source stands today.  That
mismatch is honest engineering signal worth surfacing: the
protocol has evolved, the two ends have not yet been re-
synchronised, and the book does not pretend otherwise.

→ Continue to [`03-trace-event-protocol.md`](03-trace-event-protocol.md).

← Back to [`01-why-time-travel.md`](01-why-time-travel.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/03-trace-event-protocol -->
# Chapter 3 — The Trace-Event Protocol

> **Abstract.** A recording is only as useful as the format that
> carries it from the recording site to the analysis tooling.
> The pod's serialiser turns a `TraceEvent` into a hybrid of a
> fixed-size `struct` envelope and a variable-size `pickle`
> payload, with the filename inlined between them.  The buffered
> writer batches envelopes until the buffer crosses 64 KiB, then
> flushes to a backend.  This chapter walks the format byte-for-
> byte, identifies the design decisions that shape it (constant-
> time scanning, full-fidelity payloads, lossy graceful
> degradation), and surfaces a real, documented mismatch between
> the pod-side serialiser as committed and the server-side
> deserialiser as committed.  The mismatch is part of the
> record; the book does not pretend it isn't there.

## 3.1 Why two parts

A trace event has two kinds of fields.

**Fixed, scalar fields** are the same shape on every event: the
event-type tag, the timestamp, the thread ID, the source line
number.  These are uniform — known size, known position — and
they are the fields a *fast scanner* needs to look at to decide
whether it cares about a particular event.  An off-line tool
that wants to "show every CallEvent in process P between
timestamps T1 and T2" can answer that question without
deserialising the payload, *if* the timestamp and the type tag
are encoded at known offsets in a constant-size header.

**Variable, structured fields** differ event-by-event.  A
`CallEvent` has a function name and an arguments dict; a
`VariableSnapshotEvent` has a locals dict that is whatever
happened to be in scope.  These payloads need full Python
fidelity — the dict may contain arbitrary objects that the
recording wants to preserve as-is.  A scanner that does not
care about a particular event should not pay for deserialising
this payload; a tool that does care should be able to
deserialise it back to the original Python object.

Two requirements: *fast scanning* and *full fidelity*.  The
classical way to satisfy both is the *envelope-and-payload* split
— a fixed-size envelope using `struct` for fast access, and a
variable-size payload using `pickle` for full fidelity.  The
pod's serialiser
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceserializer.py:39`)
implements this split.

The Volume 1 ch. 5 JSOS streaming format makes the same trade-
off.  The pod's serialiser is conceptually JSOS one tier up:
the *frame* of a JSOS recording becomes the *envelope* of a
trace recording; the *payload* of a JSOS frame becomes the
*pickled body* of a trace event.

## 3.2 The pod-side envelope

The envelope format string is the second line of business
inside `TraceSerializer`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceserializer.py:28`):

```python
# Format: ! = network byte order (big-endian)
#   B  = event_type       (1 byte,  unsigned char)
#   Q  = timestamp_ns     (8 bytes, unsigned long long)
#   Q  = thread_id        (8 bytes, unsigned long long)
#   I  = filename_length  (4 bytes, unsigned int)
#   I  = lineno           (4 bytes, unsigned int)
#   I  = payload_length   (4 bytes, unsigned int)
ENVELOPE_FORMAT = "!BQQIII"
ENVELOPE_SIZE = struct.calcsize(ENVELOPE_FORMAT)  # 29 bytes
```

Twenty-nine bytes, fixed.  Network byte order (big-endian) so
the recording is portable across hosts that may have different
native ordering.  Six fields:

| offset | size | name             | type    | what it carries                    |
|--------|------|------------------|---------|------------------------------------|
| 0      | 1    | event_type       | uint8   | one of `EventType.*` (1-9)         |
| 1      | 8    | timestamp_ns     | uint64  | offset from gateway start, ns      |
| 9      | 8    | thread_id        | uint64  | `threading.get_ident()`            |
| 17     | 4    | filename_length  | uint32  | length of the inline filename str  |
| 21     | 4    | lineno           | uint32  | line number in the source          |
| 25     | 4    | payload_length   | uint32  | length of the pickle body          |

The serialise method
(`traceserializer.py:48-84`) packs the envelope, appends the
filename bytes (UTF-8), and appends the pickled payload:

```python
def serialize(self, event: TraceEvent) -> bytes:
    payload_data = self._extract_payload(event)

    if payload_data is not None:
        payload_bytes = pickle.dumps(payload_data, protocol=5)
    else:
        payload_bytes = b""

    filename = self._extract_filename(event)
    if filename:
        filename_bytes = filename.encode("utf-8")
    else:
        filename_bytes = b""

    lineno = self._extract_lineno(event)

    envelope = self._pack(
        event.event_type,
        event.timestamp_ns,
        event.thread_id,
        len(filename_bytes),
        lineno,
        len(payload_bytes),
    )

    return envelope + filename_bytes + payload_bytes
```

The full byte layout is:

```
+---------+-------------+-------------+
|envelope | filename    | pickle body |
| 29 B    | (variable)  | (variable)  |
+---------+-------------+-------------+
```

Pickle protocol 5 is used.  Protocol 5 is the modern Python
pickle format (PEP 574) that supports out-of-band buffers — for
the pod's purpose, it is the fastest and most compact pickle
available on supported Python versions.  Protocol 5 is *not*
backward-compatible to Python 3.7; the pod is Python 3.8+ in
practice.

## 3.3 What goes in the payload

`_extract_payload`
(`traceserializer.py`) constructs the per-event-type payload
dict.  Each event type has its own payload shape:

| event             | payload                                                |
|-------------------|--------------------------------------------------------|
| `CallEvent`       | `{"function_name": str, "arguments": dict (optional)}` |
| `ReturnEvent`     | `{"function_name": str, "return_value": Any}`          |
| `LineEvent`       | `None` — line events are envelope-only                 |
| `VariableSnapshot`| `{"local_variables": dict}`                            |
| `ExceptionEvent`  | `{"exc_type": str, "exc_value": str, "traceback": str}`|
| `PortalEnter`     | `{"portal_name": str}`                                 |
| `PortalExit`      | `{"portal_name": str}`                                 |
| `GatewayStart`    | `{"wall_clock_time": float}`                           |
| `GatewayStop`     | `{"wall_clock_time": float}`                           |

The most expensive payload is `VariableSnapshotEvent` — its
`local_variables` is a literal `dict(frame.f_locals)` snapshot,
and `frame.f_locals` can include objects of any size.  A trace
that captures variables in a hot loop generates a payload per
line; a trace that captures variables in a function with a
large local dictionary generates a large payload per line.

The cheapest event with a body is `LineEvent` — it has no
payload at all.  Its information is fully encoded in the
envelope (file, line, timestamp, thread).  The serialiser
notices and writes a zero-length payload bytes string
(`payload_bytes = b""`); the deserialiser sees `payload_length
== 0` in the envelope and skips the pickle.load entirely.

## 3.4 The buffered writer

A trace produces events at the rate of code execution.  At
calls-only granularity, an event is emitted on every function
entry and exit; at lines, on every line; at variables, on every
line plus a heavy-payload sibling.  Writing each event
separately to the backend would impose syscall and (for TCP)
network round-trip cost per event — orders of magnitude more
than the work to construct the bytes.

`BufferedTraceWriter`
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/bufferedwriter.py:13`)
is the layer that batches.  Its surface is small:

```python
def write(self, data: bytes) -> None:
    self._buffer.extend(data)
    if len(self._buffer) >= self._max_size:
        self.flush()

def flush(self) -> None:
    if len(self._buffer) > 0:
        self._stream.write(bytes(self._buffer))
        self._buffer.clear()
```

The buffer is a `bytearray` — appendable in-place, no copy on
every event.  The flush threshold is 65,536 bytes (64 KiB) by
default, set when the recorder is constructed
(`portal.py:202`).  When `flush()` runs, the buffer is converted
to immutable `bytes` (one copy) and handed to the underlying
stream.

The flush threshold is a tuneable.  Larger thresholds amortise
syscall cost across more events at the cost of higher loss on
abrupt termination — if the process crashes with an unflushed
buffer, every event in that buffer is lost.  Smaller thresholds
trade throughput for durability.  The 64 KiB default is the
typical sweet spot for "the application is shipping events to a
local TCP backend on the same host".

A second flush trigger lives outside the writer: the gateway's
`__exit__`
(`gateway.py:88-92`) calls `flush()` and `close()` on every
recorder before letting the gateway tear down.  This is the
*orderly* shutdown path.  An *abrupt* shutdown — a crash, a
SIGKILL — has no way to flush; the buffer's contents are lost.

The pod does not, by design, install signal handlers or atexit
hooks to attempt a flush on abnormal termination.  Two reasons:
first, the kinds of failure that produce abnormal termination
(segfault, OOM, SIGKILL) are precisely the kinds where
attempting more I/O is unreliable.  Second, the recording's
*usefulness* under abrupt termination is the data already
streamed to the backend up to the last successful flush — that
is what the operator reads to understand the failure boundary.
Trying to extend that envelope by guessing at the buffer's
contents is rarely worth the complexity.

## 3.5 The backend layer

`bufferedwriter` writes its flushed bytes to a *stream backend*.
The backend is the abstraction that turns an opaque byte
sequence into a persisted artefact.  Three backends ship with
the pod
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/backends/`):

- `FileStreamBackend` — `file://path/to/trace.bin`, with
  optional rotation at a configurable byte size.
- `TcpStreamBackend` — `tcp://host:port`, streams to a server
  with reconnection.
- `UnixStreamBackend` — `unix:///path/to/socket`, streams over a
  Unix domain socket for same-host scenarios.

The backend registry
(`backends/registry.py:35-57`) routes a URI to a factory by
scheme:

```python
def create_backend(self, uri: str):
    parsed = urlparse(uri)
    scheme = parsed.scheme

    if scheme not in self._factories:
        errmsg = (
            f"Unknown stream backend scheme: '{scheme}'. "
            f"Known schemes: {list(self._factories.keys())}"
        )
        raise ValueError(errmsg)

    factory = self._factories[scheme]
    rtnval = factory(parsed)
    return rtnval
```

A new backend (`s3://bucket/prefix`, say, or `kafka://topic`) is
added by registering its scheme on the registry without changing
the existing pipeline.  This is the same URI-scheme dispatcher
pattern that Volume 5 ch. 4 documented for `codecraftai-interop`'s
transport layer.

## 3.6 The TCP backend's failure mode

The TCP backend
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/backends/tcpbackend.py:85-107`)
makes a deliberate choice on saturation:

```python
def write(self, data: bytes) -> None:
    """
        Send data over the TCP connection.

        On failure, attempts reconnection. If reconnection fails, the data
        is dropped and an error is logged.
    """
    try:
        self._socket.sendall(data)
    except OSError:
        if not self._reconnect():
            logger.error(
                "Failed to reconnect to %s:%d, dropping trace data",
                self._host, self._port
            )
            return
        try:
            self._socket.sendall(data)
        except OSError:
            logger.error("Write failed after reconnect, dropping trace data")
    return
```

If the send fails, attempt reconnection (with exponential
backoff — `tcpbackend.py:61-83`).  If reconnection fails, *drop
the data*.

This is a substantial design choice.  The alternative — a
bounded in-memory queue that fills up under saturation and then
applies back-pressure to the recording site — would prevent
drops at the cost of slowing the recorded code.  *Slowing the
recorded code is a worse outcome than losing some recording.*
The recorded code is the production application, the thing
serving real users.  A debugging backend that pushes back on
its application is a debugging backend that is causing the
incident it was meant to diagnose.

The drop-on-saturation choice has consequences that ch. 4 picks
up: the server's flow-control mechanism is the polite way to
*request* slowdown, but the pod is not contractually obliged to
honour it.  If the pod cannot keep up, the pod drops, and the
recording becomes lossy.

The contrast with the file backend is informative.  The file
backend
(`backends/filebackend.py:79-89`) does not drop:

```python
def write(self, data: bytes) -> None:
    self._file.write(data)
    self._bytes_written += len(data)
    if self._max_file_size is not None and self._bytes_written >= self._max_file_size:
        self._rotate()
    return
```

A local file write is, in the typical case, fast and reliable.
A `write()` to a healthy disk takes microseconds.  No queue
depth, no reconnection, no drop logic.  If the disk fills, the
exception propagates — the recorded application sees an
`OSError` and decides what to do (which, per the pod's design,
will probably be "log it and continue if possible").

The Unix-domain backend
(`backends/unixbackend.py:49-57`) is a TCP-shaped backend over
a Unix socket and follows the TCP backend's drop-on-saturation
behaviour.  The same trade-off applies: if the consumer on the
other end of the Unix socket falls behind, the pod drops rather
than blocking.

## 3.7 The reader (pod side)

The pod ships a reader as a partial implementation
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/reader.py`,
117 lines).  Its purpose is two-fold: as a tool to inspect a
trace file produced by the pod itself, and as the algorithmic
reference that the server-side deserialiser is meant to mirror.

The `read_events` generator (lines 43-60) is the canonical
inverse of `serialize`:

```python
def read_events(self):
    for source in self._sources:
        data = self._read_source(source)
        offset = 0
        while offset < len(data):
            event, offset = self._deserializer.deserialize(data, offset)
            yield event
```

The deserialise loop walks the byte stream, parsing one
envelope at a time, advancing the offset, and yielding the
reconstructed `TraceEvent` subclass.  This is the right mental
model for the format: it is a *stream* of self-describing
envelopes, each followed by its filename and payload, packed
back-to-back.  No frame markers, no length prefixes other than
the envelope's own width fields, no magic number.

The absence of a magic number is worth flagging.  An advantage:
no per-event overhead beyond the envelope's six fields.  A
cost: a corrupted or partial stream cannot be resynchronised by
seeking forward to the next valid frame.  If a writer crashes
mid-envelope, the bytes after that point are unparseable; a
robust reader breaks at the corruption rather than recovering
to the next event boundary.

The `scan_headers` method
(`reader.py:80`) is declared in the source but truncated — its
intent (yield envelope-only tuples without deserialising the
payload) is right but the implementation is incomplete.  This is
the partial-maturity signal mentioned in §1.4.  An off-line tool
that wanted to scan a trace cheaply (count CallEvents, filter
by thread ID, find the timestamp window) would use
`scan_headers`; today it must use `read_events` and pay the
pickle cost on every event.

## 3.8 The mismatch

This is the chapter's honesty point.

The pod-side serialiser
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/traceserializer.py:28`)
emits the envelope described in §3.2 — 29 bytes, fixed,
big-endian, six scalar fields, then filename, then pickle.

The server-side deserialiser
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/streamreceiver/deserializer.py:15-65`)
parses a *different* format:

```python
def deserialize_stream(self, stream, process_id, session_id, portal_name):
    while True:
        # Read type length
        type_len_data = stream.read(1)
        if not type_len_data:
            break

        type_len = type_len_data[0]

        # Read type string
        type_str_data = stream.read(type_len)
        ...
        event_type_str = type_str_data.decode('utf-8')
        event_type = EventType(event_type_str)

        # Read timestamp (double) and payload len (unsigned int)
        header_data = stream.read(12)
        ...
        timestamp, payload_length = struct.unpack("!d I", header_data)

        # Read payload
        payload_data = stream.read(payload_length)
        ...
        payload = pickle.loads(payload_data)

        event = self._materialize_event(event_type, timestamp,
                                        process_id, session_id,
                                        portal_name, payload)
        yield event
```

The server's deserialiser expects:

```
+----+--------------+-----------+--------------+--------------+
|len | type_str     | timestamp | payload_len  | payload      |
| 1B | UTF-8 (≤255) | 8B BE     | 4B BE uint32 | (pickle)     |
|    |              | double    |              |              |
+----+--------------+-----------+--------------+--------------+
```

These are *different* formats:

| dimension          | pod emits                  | server expects                 |
|--------------------|----------------------------|--------------------------------|
| event-type field   | `uint8` integer (1-9)      | `uint8` length + UTF-8 string  |
| timestamp field    | `uint64` ns offset         | IEEE-754 double                |
| thread_id field    | `uint64`                   | absent                         |
| filename_length    | `uint32`                   | absent                         |
| inline filename    | UTF-8 bytes                | absent (filename is in payload)|
| line number        | `uint32` in envelope       | absent (lineno is in payload)  |
| payload length     | `uint32` (after envelope)  | `uint32` (after timestamp)     |

A current pod streaming over `tcp://` to a current server's
`streamreceiver/tcp_receiver.py` will not produce parseable
events on the server side.  The server-side `EventType()`
constructor — a string enum — will receive an integer (the
pod's first byte) interpreted as a length, which will cause it
to read the next *N* bytes as a UTF-8 string and fail to match
any known event type.

This is not a hidden bug.  It is the visible state of two
halves of an in-development protocol whose envelope shapes have
diverged.  The book treats it as documentary signal:

- The pod's envelope is *richer*, in line with the JSOS-style
  fast-scan-first design (§3.1) — thread ID, filename, lineno
  inlined in the envelope, type as a single byte for compact
  scanning.
- The server's deserialiser is a *string-tagged* envelope —
  human-readable type strings, IEEE-754 timestamps, less
  scanning capability.  This shape is what the server-side
  `tracedata/events.py` model
  (`tracedata/events.py:15-49`) takes as input — the model
  treats `event_type` as a `str` enum, not an int.

The two designs are reconcilable, and the reconciliation will
land before the protocol stabilises.  Two paths the design can
take:

- **Pod side adopts the server's format.**  The pod's `struct`
  envelope changes shape to match what the deserialiser
  expects.  Cost: smaller events get larger (the type string
  `"variable_snapshot"` is 17 bytes, where the integer `4` is
  one); the fast-scan property degrades because type strings
  vary in length.
- **Server side adopts the pod's format.**  The server's
  deserialiser is rewritten to parse the pod's 29-byte envelope.
  Cost: the server's `EventType` model migrates from string to
  integer; the analysis modules that reach into events with
  `.event_type.value` need a mechanical update.

The book takes no position on which path will win.  What it
does take a position on: *the protocol on the wire today is the
pod's format, because the pod is the producer*.  Any analysis
the book does of the recording's bytes uses the pod's
serialiser as ground truth.  The server-side deserialiser will
be aligned to that ground truth before the protocol is
considered stable.

## 3.9 What the wire actually looks like

A short trace from a small, instrumented function — a call,
two lines, a return — produces this byte stream out of the pod
(annotated):

```
# CallEvent — function "process_invoice" at invoice.py line 42
# event_type=1 (CALL), ts=1500ns, tid=140000000000000
# filename_length=11, lineno=42, payload_length=N
01 00000000000005DC                 # type=1, ts=1500
   0000000000000000 00007F4...      # tid
   0000000B 0000002A 000000NN       # fname_len=11, lineno=42, payload_len
   "invoice.py"                     # 11 bytes UTF-8
   <pickle of {"function_name": "process_invoice", "arguments": {...}}>

# LineEvent — line 43
01 00000000000005DD ...
03 ...                              # type=3 (LINE)
   0000000B 0000002B 00000000       # fname_len=11, lineno=43, payload_len=0
   "invoice.py"
   # no payload — LineEvent is envelope-only

# LineEvent — line 44
03 ...

# ReturnEvent — function "process_invoice"
02 ...                              # type=2 (RETURN)
   ...
   <pickle of {"function_name": "process_invoice", "return_value": "OK"}>
```

Approximate sizes:

| event             | envelope | filename | payload   | total  |
|-------------------|----------|----------|-----------|--------|
| CallEvent         | 29 B     | 11 B     | ~80 B     | ~120 B |
| LineEvent         | 29 B     | 11 B     | 0 B       | 40 B   |
| ReturnEvent       | 29 B     | 11 B     | ~50 B     | ~90 B  |
| VariableSnapshot  | 29 B     | 11 B     | 100-1k+ B | varies |

A function with 10 lines, no variable capture, traced at calls-
plus-lines, produces about 12 * 40 bytes ≈ 500 bytes of trace.
A function with 10 lines and variable capture (a small dict of
say 5 small values) produces about 12 * (40 + ~150) bytes ≈ 2.3
KB.  The cost scales with the *density* and *fidelity*
(§2.9).

## 3.10 Off-line analysis assumes the pod format

Reading a recorded trace off-line — outside the server, with
the pod's reader — uses the pod's deserialiser (the inverse of
the serialiser).  The pod's `reader.py`, `timeline.py`, and
`portalfilter.py` modules all consume `TraceEvent` objects whose
fields match the pod's data model: integer `event_type`,
`timestamp_ns`, `thread_id`, inline filename and lineno.  The
pod-side analysis modules are *internally consistent*.

The server-side analysis modules
(`tracedata/timeline.py`, `tracedata/call_graph.py`,
`tracedata/variable_tracker.py`, `tracedata/search.py`) consume
`TraceEvent` objects whose fields match the *server's* data
model: string `event_type`, float `timestamp`, no thread_id, no
inline filename.  These modules are also internally consistent.
Their unit tests (`source/testroots/testbase/test_*.py`)
construct events that match the server's model and exercise the
algorithms successfully.

What is not yet wired: the deserialiser at the boundary that
turns pod-emitted bytes into server-side `TraceEvent` objects.
The `reader.py` (server side) is a 44-line wrapper around
`HybridFormatDeserializer` that reads the server's expected
format from a file.  Until the deserialiser is reconciled to
the pod's format, the analysis tier is not exercised against
real pod traces — it is exercised against synthetic events
constructed in tests.

## 3.11 Lossy semantics under partition

The protocol's behaviour under network partition is asymmetric.

**Pod → server (TCP).**  If the network breaks while the pod is
streaming, the TCP backend
(`backends/tcpbackend.py:85-107`) attempts reconnection with
exponential backoff (`max_retries=3`, `retry_backoff=0.5s`).  If
all retries fail, the data is dropped and an error is logged;
the next successful write resumes the stream.  Events
*after* the partition resumes are recorded; events *during* the
partition are lost.

**Server → pod (control plane).**  If the network breaks while
a debug-control command is in flight, the
`CommandDispatcher`
(`debugcontrol/command_dispatcher.py:21-74`) raises a
`CommandError` to the calling code.  The pod's portal-activation
state is unchanged — the command was lost in transit, the pod
never received it.  The server's caller is responsible for
deciding whether to retry.

**Pod → server (heartbeat).**  If the network breaks while a
heartbeat is in flight, the heartbeat sender
(`pod/remote/heartbeat.py:75-82`) catches the exception, logs
it at WARNING, and waits for the next interval to try again.
On the server side, missing heartbeats are what drive the state
transitions
(`registration/heartbeat.py:67-100`): `active` → `stale` at
`T + heartbeat_timeout_seconds`, `stale` → `unreachable` at
`T + 2 × heartbeat_timeout_seconds`.

There is no acknowledgement protocol on the trace stream
itself.  The pod sends bytes; the server parses bytes; if the
parse fails, the server logs and drops the event.  If the pod
needs *guaranteed* delivery, the right answer is the file
backend with off-line ingestion — write to disk locally, then
have a separate process ship the file to the server when
network conditions allow.

## 3.12 The flush marker

The session lifecycle on the server
(`streamreceiver/session_lifecycle.py:36-72`) recognises a
literal byte string `b"FLUSH"` in the incoming stream as a
flush marker:

```python
if b"FLUSH" in chunk:
    self._handle_flush()
    # Reset buffer size on flush as data is persisted
    if self.flow_controller:
        buffer_size = 0
        ...
```

When the server sees the marker, it commits the storage backend
(persisting the buffered events to durable storage) and resets
the flow-control buffer size.  This is the protocol's
*pod-initiated* commit point: a place where the pod tells the
server "everything before this is a coherent unit; persist it".

The pod-side trigger for this is gateway exit
(`gateway.py:88-92`), which flushes every recorder and closes
its backend.  The mature TCP backend includes the marker in its
final write before tearing down the socket; an in-flight
recording that completes cleanly arrives at the server with a
trailing `FLUSH` marker, and the server commits.

A torn-down recording without the marker is *not* committed.
The server keeps the bytes in the temp area
(`persistence/trace_storage/filesystem_backend.py:32-42`) until
either a flush marker arrives or a timeout (configured at the
session lifecycle level) decides the connection is dead.  This
is intentional: a recording mid-stream is not a usable artefact;
durable persistence is reserved for committed recordings.

## 3.13 Versioning the protocol

The current envelope has no version field.  This is a pragmatic
choice — the protocol is in flux (§3.8), and adding a version
field today would lock in the wrong shape.  It is also a
liability — when the protocol stabilises, a future version will
require a flag-day upgrade or a dual-format reader.

The book's recommendation, made plain: when the protocol
reconciles to a single envelope shape, the envelope should
gain a leading `uint16` version field, and readers should
dispatch on it.  The version field's cost is two bytes per
event; the benefit is the ability to evolve the envelope in
small steps without flag days.  The cost is small enough that
the book treats this as the obvious right answer for the
post-reconciliation envelope.

## 3.14 Bridging to the next chapter

The protocol on the pod side — envelope, payload, buffered
flush, backend dispatch — is now in place.  The next step is
the *receiving* end: the server-side socket listeners, the
deserialiser the receiver hands bytes to, the per-connection
session lifecycle that brackets a recording, and the flow-
control mechanism that pushes back when the server is saturated.

→ Continue to [`04-server-ingestion.md`](04-server-ingestion.md).

← Back to [`02-pod-instrumentation.md`](02-pod-instrumentation.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/04-server-ingestion -->
# Chapter 4 — Server-Side Ingestion

> **Abstract.** A trace is data on a wire; data on a wire is
> only useful if something on the other end will catch it,
> persist it, and let an analyst query it.  The server's
> ingestion path runs from the socket listener through a
> handshake, into a per-connection session that drains the
> stream, deserialises it, and hands events to a storage
> backend; back-pressure rides the same connection in the
> reverse direction.  This chapter walks the receivers (TCP and
> Unix), the deserialiser (with the format mismatch from ch. 3
> in mind), the session lifecycle, the flow-control mechanism,
> and the persistence backend that turns received bytes into
> durable artefacts.  Anchored on
> `codecraftai-timetravel-server`.

## 4.1 The shape of the receiving end

The server's job at ingestion is to:

1. Bind a socket — TCP or Unix — and accept incoming
   connections from pod processes.
2. Authenticate each connection against a credential store.
3. Open a *session* for the duration of the connection: drain
   bytes, deserialise events, hand them to storage.
4. Apply *flow control* when the storage layer cannot keep up,
   asking the pod to slow down.
5. Commit the recording on a flush marker; clean up the
   per-connection state on close.
6. Survive abrupt disconnects without leaving partially-written
   trace files in the durable area.

The implementation splits across four directories under
`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/`:

- `streamreceiver/` — the listener, session, and flow-control
  layer.
- `persistence/trace_storage/` — the durable-storage backend
  that received events flow into.
- `persistence/credential_store.py` — the credential store
  used for handshake authentication.
- `registration/` — the lifecycle layer that ties an inbound
  stream to a registered process record.

The first three are the subject of this chapter; the fourth is
the subject of ch. 6.

## 4.2 The base receiver

`BaseStreamReceiver`
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/streamreceiver/base_receiver.py:9`)
is the abstract socket-server.  It handles the loop that accepts
connections, performs the handshake, and hands each accepted
connection off to a session for receive-side draining.

The handshake
(`base_receiver.py:110-135`) is intentionally small:

```python
def _handle_connection(self, client_sock: socket.socket, addr: tuple):
    # Format for handshake:
    # [2 bytes length][token bytes]
    length_bytes = self._read_exactly(client_sock, 2)
    if not length_bytes:
        return

    token_len = struct.unpack("!H", length_bytes)[0]
    token_bytes = self._read_exactly(client_sock, token_len)
    if not token_bytes:
        return

    token = token_bytes.decode('utf-8')

    if not self.auth_callback(token):
        logger.warning(f"Authentication failed for {addr}")
        # Send auth failed response
        client_sock.sendall(b'\x00')
        return

    # Send auth success response
    client_sock.sendall(b'\x01')

    # Hand off to session
    session = self.session_factory(client_sock, token)
    session.run()
```

Two bytes for the token length (big-endian `uint16`, max 65,535
bytes — far more than any sensible token), then the token bytes
themselves, UTF-8 encoded.  The server replies with a single
byte: `\x00` for auth failure, `\x01` for auth success.

The token is validated through an `auth_callback` injected at
construction.  In the running server this callback delegates to
`CredentialStore.validate`
(`persistence/credential_store.py:31-47`), which:

1. Hashes the provided token with SHA-256.
2. Looks up the hash in the `credentials` table.
3. Rejects if the credential is revoked
   (`revoked_at IS NOT NULL`) or expired
   (`expires_at < utcnow()`).
4. Updates `last_used_at` to the current timestamp.
5. Returns the matching `Credential` ORM record.

The credential is what scopes the connection: the server can,
later, look up the credential to find the registered process
that owns it.  Volume 8 ch. 6 walks the credential lifecycle
end-to-end.

The `BaseStreamReceiver` is subclassed by:

- `TcpStreamReceiver`
  (`streamreceiver/tcp_receiver.py:5`) — binds to a `(host,
  port)` with `SO_REUSEADDR` set so the server can restart
  without lingering bind failures.
- `UnixSocketStreamReceiver`
  (`streamreceiver/unix_receiver.py:6`) — binds to a Unix
  domain socket path with `0o600` permissions (owner-only),
  cleaning up any stale socket file at startup.

The TCP receiver is the production path for distributed
deployments — pod and server on separate hosts.  The Unix
receiver is the production path for same-host deployments —
pod and server in the same network namespace, where Unix
sockets eliminate the TCP loopback overhead.

## 4.3 The session

Once the handshake passes, the connection is handed off to a
`StreamSessionLifecycle`
(`streamreceiver/session_lifecycle.py:7`).  The session owns the
connection for the rest of its life: it drains the socket in
4 KiB chunks, hands each chunk to the storage backend, watches
for flush markers, and applies flow-control responses.

The receive loop
(`session_lifecycle.py:36-72`) is the heart of the path:

```python
def _receive_loop(self):
    """Reads chunks of data and appends to storage."""
    buffer_size = 0

    while self._running:
        try:
            # Set a small timeout to allow graceful shutdown checks
            self.sock.settimeout(1.0)
            chunk = self.sock.recv(4096)
            if not chunk:
                break  # Connection closed gracefully

            self.storage_backend.append(chunk)

            # Flow control integration
            if self.flow_controller:
                buffer_size += len(chunk)
                status = self.flow_controller.update_buffer_size(self._connection_id, buffer_size)

                if status.value == "PAUSE":
                    self.sock.sendall(b"PAUSE")
                elif status.value == "RESUME":
                    self.sock.sendall(b"RESUME")

            # Check for flush marker
            if b"FLUSH" in chunk:
                self._handle_flush()
                # Reset buffer size on flush as data is persisted
                if self.flow_controller:
                    buffer_size = 0
                    status = self.flow_controller.update_buffer_size(self._connection_id, 0)
                    if status.value == "RESUME":
                        self.sock.sendall(b"RESUME")

        except socket.timeout:
            continue
```

The loop receives 4 KiB at a time.  It does *not* deserialise on
the receive path — bytes go directly to `storage_backend.append`.
Deserialisation is deferred to the analysis tier.  This is a
load-shedding decision: the receive path's bottleneck is *making
the bytes durable*, not *understanding* them.  Pushing
deserialisation off the receive path keeps the receiver's CPU
budget low and lets the server accept more concurrent streams.

The 1-second `settimeout` is the graceful-shutdown check.  Every
second the loop wakes, sees `socket.timeout`, and re-checks
`self._running`.  When `stop()` is called from the outside (the
process is shutting down, the connection should close), the
loop exits within at most one second.  Without the timeout, the
loop would block on `recv` indefinitely and only respond to
shutdown after the next byte arrived.

The `b"FLUSH" in chunk` test is a substring check — the marker
can appear anywhere in the received chunk.  It is a deliberate
design choice: the marker need not be aligned to the chunk
boundary the network happens to produce.  The cost is that an
event payload that happens to include the literal bytes
`b"FLUSH"` would also trigger a commit.  In a pickle-encoded
payload this is unlikely (pickle's framing makes literal
ASCII strings rare in the binary), but it is not impossible.  A
production-hardened version of the protocol would use a less
ambiguous marker (a length-prefixed sentinel, an out-of-band
control message, or — best — a magic number with a known
position in a framed protocol).  The book treats this as a known
limitation of the protocol-as-committed.

The flow-control branches send literal bytes `b"PAUSE"` and
`b"RESUME"` *back* through the same socket.  This is an
out-of-band signalling channel multiplexed onto the in-band data
stream — the pod's `tcpbackend.write()` does not currently know
how to read these bytes back; the channel is one-directional in
the pod's implementation as committed.  This is a second
known-asymmetric edge: the server *signals* flow control, but
the pod *does not yet honour* the signal.  Drop-on-saturation
(§3.6) is what kicks in if the server cannot drain fast enough.

## 4.4 The storage backend

The session writes received chunks to a `TraceStorageBackend`
(`persistence/trace_storage/backend_abc.py:5`).  The ABC is
small — `write`, `append`, `finalize`, `read`,
`list_references`, `delete`, `exists`, `list_expired` — and
maps to a *reference-based* interface.  Each recording is
identified by an opaque reference string returned by `write`;
subsequent operations on the same recording (append more bytes,
finalise, read it back later) take the reference as input.

The shipped backend is filesystem-based
(`persistence/trace_storage/filesystem_backend.py:9`).  Its
storage layout is two directories under a configured base:

- `<base>/temp/` — in-flight recordings.  Each new recording is
  opened as a `.tmp` file under this directory; bytes are
  appended as the session drains them.
- `<base>/final/` — finalised recordings.  When a recording
  is finalised, the temp file is *atomically renamed* to the
  final directory:

```python
def finalize(self, reference: str) -> str:
    ref_path = Path(reference)
    if not ref_path.exists():
        raise FileNotFoundError(f"Reference not found: {reference}")

    final_filename = ref_path.name.replace(".tmp", "")
    final_path = self.final_dir / final_filename

    # Atomic rename
    ref_path.rename(final_path)
    return str(final_path)
```

The `Path.rename` uses POSIX `rename(2)`, which is atomic on the
same filesystem.  Either the file is in `temp/` (recording is
in flight) or it is in `final/` (recording is committed); there
is no intermediate state where the recording is "half-
finalised".  This is the property the database relies on when
it stores `data_reference` on the `TraceSession` record:
`data_reference` always points at a finalised file, never an
in-flight one.

Two operations reach into `temp/` directly: `append`, which
opens the temp file in append mode and writes bytes, and the
`finalize` above.  The atomic rename means a consumer reading
from `final/` never sees a partial recording.

The backend also exposes `list_expired`
(`filesystem_backend.py`, late in the file) which uses
`stat().st_mtime` to identify recordings older than a
configurable retention threshold.  This is what the retention
job (ch. 6) uses to delete recordings older than
`data_retention_days` (default 30).

## 4.5 Flow control with hysteresis

The flow controller
(`streamreceiver/flow_control.py:13`) tracks two bookkeeping
items: per-connection buffer sizes (how many bytes are in flight
for each session) and a global memory used (the sum across all
connections).  It maintains a state per connection — `OK`,
`PAUSE`, `RESUME` — and transitions on watermark crossings.

The `update_buffer_size` method
(`flow_control.py:42-68`) implements the hysteresis:

```python
def update_buffer_size(self, connection_id: str, new_size: int) -> FlowControlStatus:
    with self._lock:
        if connection_id not in self.connection_buffers:
            return FlowControlStatus.OK

        old_size = self.connection_buffers[connection_id]
        self.connection_buffers[connection_id] = new_size

        # Update global memory
        self.global_memory_used += (new_size - old_size)

        current_state = self.connection_states[connection_id]

        if current_state == FlowControlStatus.OK or current_state == FlowControlStatus.RESUME:
            if new_size >= self.high_watermark:
                self.connection_states[connection_id] = FlowControlStatus.PAUSE
                return FlowControlStatus.PAUSE
        elif current_state == FlowControlStatus.PAUSE:
            if new_size <= self.low_watermark:
                self.connection_states[connection_id] = FlowControlStatus.RESUME
                return FlowControlStatus.RESUME

        return FlowControlStatus.OK
```

A connection's state machine:

- Starts in `OK`.
- Crosses `high_watermark` → transitions to `PAUSE`, returns
  `PAUSE`.  The session emits `b"PAUSE"` to the pod.
- Drops below `low_watermark` → transitions to `RESUME`,
  returns `RESUME`.  The session emits `b"RESUME"`.
- Stays in any state otherwise → returns `OK` (no signal).

The two watermarks are the *hysteresis*.  Without them — a
single threshold for both pause and resume — a connection
hovering near the threshold would oscillate, generating
PAUSE/RESUME every time a chunk arrived.  Two watermarks with
a gap give the connection a *window* in which the state does
not change.  A typical configuration sets the high watermark
several times higher than the low watermark.

The lock around the body is one reader-writer-style lock —
`threading.Lock` — protecting `connection_buffers`,
`connection_states`, and `global_memory_used` from concurrent
update by multiple session threads.  Each session thread that
receives a chunk calls `update_buffer_size` once, holding the
lock for microseconds.

There is also a global guard.  `should_accept_new`
(`flow_control.py:37`) checks the global memory used against a
configured limit; the receiver consults this *before* accepting
a new connection.  If the server is already over budget, new
connections are refused at the accept stage rather than
admitted to compete for a saturated buffer.

## 4.6 The deserialiser as committed

The deserialiser
(`streamreceiver/deserializer.py:9`) is what parses bytes back
into events when the analysis tier needs them.  Its format
expectation is the one ch. 3 documented as *not yet matching
the pod's emitted bytes* (§3.8).

The book treats this honestly.  The deserialiser as committed:

```python
def deserialize_stream(self, stream, process_id, session_id, portal_name):
    while True:
        # Read type length
        type_len_data = stream.read(1)
        ...
        # Read type string
        type_str_data = stream.read(type_len)
        ...
        # Read timestamp (double) and payload len (unsigned int)
        header_data = stream.read(12)
        ...
        timestamp, payload_length = struct.unpack("!d I", header_data)

        # Read payload
        payload_data = stream.read(payload_length)
        ...
        payload = pickle.loads(payload_data)

        try:
            event = self._materialize_event(event_type, timestamp, process_id, session_id, portal_name, payload)
            yield event
        except Exception as e:
            logger.error(f"Failed to materialize event: {e}. Payload was: {payload}")
            continue
```

Recovery semantics: a failed pickle parse logs and continues to
the next iteration of the loop — but *the next iteration will
be reading bytes that follow whatever pickle was attempting to
consume*.  Without a magic number or framing marker, recovery
is approximate: the next iteration may succeed (if the byte
boundary happened to land on the start of a new envelope) or
may fail (if the byte boundary did not).  In practice, a
deserialiser that fails on one event tends to fail on all
events afterwards in the stream.

The chapter does not pretend the deserialiser is robust.  It
is the right shape — envelope, then pickle, with substring-
addressable type tags — but its concrete bytes do not currently
match the pod's emission, and its error recovery is not
resyncable.  Both are addressable; both are acknowledged.

## 4.7 The session lifecycle states

The session lifecycle, as a state machine, has five visible
states for the pod-side process record (§S1.4 in the spec, with
state transitions in `registration/heartbeat.py:67-100`):

- `registered` — the pod's `/register` request was accepted but
  no heartbeat has yet arrived.
- `active` — at least one heartbeat has been received within
  the freshness window.
- `stale` — the most recent heartbeat is older than
  `heartbeat_timeout_seconds` (default 30 s).
- `unreachable` — the most recent heartbeat is older than
  `2 × heartbeat_timeout_seconds` (default 60 s).
- `disconnected` — the pod sent a clean `/unregister` request,
  or the server marked the process disconnected manually.

The transitions are driven by the `HealthChecker`
(`registration/heartbeat.py`) running on a daemon thread
roughly every `heartbeat_timeout_seconds / 2` seconds.  The
checker scans the registered processes table, computes the
freshness threshold, and updates `lifecycle_state` in place:

```python
stale_threshold = now - datetime.timedelta(seconds=timeout_seconds)
unreachable_threshold = now - datetime.timedelta(seconds=2 * timeout_seconds)

# Active -> Stale
active_processes = session.query(RegisteredProcess).filter_by(lifecycle_state="active").all()
for process in active_processes:
    if process.last_heartbeat_at and process.last_heartbeat_at < stale_threshold:
        process.lifecycle_state = "stale"

# Stale -> Unreachable
stale_processes = session.query(RegisteredProcess).filter_by(lifecycle_state="stale").all()
for process in stale_processes:
    if process.last_heartbeat_at and process.last_heartbeat_at < unreachable_threshold:
        process.lifecycle_state = "unreachable"
```

Three observations.

First, transitions only ever go forward (active → stale →
unreachable).  The reverse transition — `stale` back to
`active` because a heartbeat finally arrived — is handled at
the heartbeat-reception path
(`registration/heartbeat.py:18`), not the health checker.  When
a heartbeat lands, the receiver updates `last_heartbeat_at` and
sets `lifecycle_state="active"`.  The two paths (forward in the
checker, backward in the receiver) cannot race because both
take the SQLAlchemy session's row-level update.

Second, the `disconnected` transition is *only* triggered by a
clean unregister request.  A pod that crashes silently goes to
`unreachable` and stays there until an operator manually marks
it disconnected (or until the retention job sweeps unreachable
records older than the retention threshold).

Third, the state transitions are *advisory* — they do not
affect ingestion.  A process in `unreachable` whose previous
trace stream is still being received continues to have its bytes
written.  Lifecycle state is *operator-facing telemetry*; it is
the answer to "is this process alive?", not the gate on "should
the server accept its bytes?".

## 4.8 Trace session records

A *trace session* (the `TraceSession` ORM model,
`persistence/models/trace_session.py:8-17`) is the database
record that ties a recording to a process.  Its fields:

```python
class TraceSession(BaseMixin, Base):
    __tablename__ = 'trace_sessions'

    process_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('registered_processes.id', ondelete='CASCADE'), nullable=False, index=True)
    started_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False)
    stopped_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True)
    status: Mapped[str] = mapped_column(String(32), default="active", nullable=False, index=True)
    data_reference: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
```

The `data_reference` is the storage backend's reference string
— for the filesystem backend, the path to the finalised file
under `<base>/final/`.  It is `NULL` while the recording is in
flight and is populated by `update_data_reference`
(`debugcontrol/session_manager.py:78`) when the session manager
is told the recording has finalised.

The `ondelete='CASCADE'` on the `process_id` foreign key means
that deleting a process deletes its trace sessions.  This is
the right semantics for retention: when a process is purged
(unreachable for too long, or explicitly deleted by an
operator), its associated trace sessions go too.  The
`data_reference` they pointed at is then orphaned — the trace
file lives on disk until the storage-retention job sweeps it
based on `mtime`.

The two retention paths — database (process / session
deletion) and storage (file deletion based on age) — are
intentionally decoupled.  A trace file may be older than its
session record (the file was finalised, the session record
not yet updated) or younger (the session was deleted but the
file is still on disk).  Both cases are tractable: orphaned
files are swept by storage retention; orphaned session records
are tolerable until the next process delete.

## 4.9 The persistence engine

`persistence/engine.py:4-9` is a tiny module — nine lines —
that constructs the SQLAlchemy engine and a scoped session
factory:

```python
def create_db_engine(): ...  # line 4
def create_session_factory(): ...  # line 7
```

The engine is shared across the server process; sessions are
*scoped* — a new session per thread, with `autocommit=False`
and `autoflush=False`.  Explicit commit control is the right
default for this server: an ingestion thread that has just
written a chunk's worth of received bytes to storage commits
*together* the storage write *and* the session-record update,
so the database state and the storage state stay coherent.

The schema is defined by the ORM models under
`persistence/models/` — `RegisteredProcess`, `TraceSession`,
`Credential`, `PortalSnapshot`, `ServerConfiguration`, `User`.
Each inherits from a `BaseMixin`
(`persistence/models/base.py:8-11`):

```python
class BaseMixin:
    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False)
    updated_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)
```

Three uniform fields on every model: a UUID primary key, a
`created_at`, and an `updated_at` that auto-updates on every
write.  The UUIDs are generated client-side
(Python `uuid.uuid4`) rather than server-side, so an inserter
that needs to know the ID before commit (to write a related
record in the same transaction) can compute it.

A single Alembic migration is currently shipped
(`persistence/migrations/versions/71afa3096c71_initial_migration.py`).
Future schema changes will land as additional migrations in
the standard Alembic style.

## 4.10 The metadata refresh path

A registered pod can be queried at any time for its current
state — its portal list, its trace configuration, its remote-
control reachability.  This is the metadata-refresh path
(`registration/metadata_refresh.py`).  It uses the
`CommandDispatcher`
(`debugcontrol/command_dispatcher.py:21-74`) — the JSON-RPC
client that connects *out* from the server *to* the pod's
remote-control server (ch. 5):

```python
def dispatch(self, remote_address: str, command: str, params=None) -> Dict[str, Any]:
    ...
    request = {
        "command": command,
        "params": params,
        "token": self.credential_token,
    }

    request_json = json.dumps(request) + "\n"

    try:
        with socket.create_connection((host, port), timeout=self.timeout_seconds) as sock:
            with sock.makefile("rw", encoding="utf-8") as f:
                f.write(request_json)
                f.flush()

                response_json = f.readline()
                ...
                response = json.loads(response_json)
                ...
                return response.get("data", {})
```

The dispatcher is the *server-as-client* end of the same
JSON-lines protocol the pod's `RemoteControlServer` speaks
(ch. 5).  A line-based JSON request — `{"command": ..., "params":
..., "token": ...}` — followed by a line-based JSON response
— `{"status": "ok"|"error", "data"|"message": ...}`.

The dispatcher is the *only* mechanism by which the server
reaches into a pod.  All Layer-2 operations
(portal discovery, portal activation/deactivation, trace-config
read/write) compose of one or more dispatcher calls.  Volume 8
ch. 5 walks the dispatcher's command set in detail.

## 4.11 Portal discovery is an upsert

A canonical Layer-2 operation is *portal discovery* — pulling
the pod's current portal list and reconciling it with the
local snapshot.  `PortalDiscoveryService.discover`
(`debugcontrol/portal_discovery.py:23-72`):

```python
def discover(self, process_id: uuid.UUID) -> List[PortalInfo]:
    process = self._process_registry.get_process(process_id)
    if not process:
        raise ValueError(f"Process with ID {process_id} not found.")

    if not process.remote_control_address:
        raise ValueError(f"Process {process_id} has no remote control address.")

    response_data = self._command_dispatcher.dispatch(
        process.remote_control_address,
        "list_portals"
    )

    portals_data = response_data.get("portals", [])
    portal_infos = []
    for p in portals_data:
        portal_infos.append(PortalInfo(name=p.get("name"), is_active=p.get("is_active", False)))

    # Update local snapshots
    db_session = self._session_factory()
    try:
        existing_snapshots = db_session.query(PortalSnapshot).filter_by(process_id=process_id).all()
        existing_map = {s.portal_name: s for s in existing_snapshots}

        for p_info in portal_infos:
            if p_info.name in existing_map:
                snapshot = existing_map[p_info.name]
                snapshot.is_active = p_info.is_active
                del existing_map[p_info.name]
            else:
                snapshot = PortalSnapshot(
                    process_id=process_id,
                    portal_name=p_info.name,
                    is_active=p_info.is_active
                )
                db_session.add(snapshot)

        # Remaining in existing_map were not returned by pod, so delete them
        for s in existing_map.values():
            db_session.delete(s)

        db_session.commit()
    finally:
        db_session.close()

    return portal_infos
```

The reconciliation is an upsert with deletion: portals the pod
returned that the database already knew → update; portals the
pod returned that the database did not know → insert; portals
the database knew that the pod did not return → delete.

The deletion case matters.  A pod restart with a different code
path may register fewer portals than the database remembered;
the discovery sweep must remove the no-longer-present portals
or the server's portal list will accumulate dead names.

## 4.12 The receive path under load

A useful exercise to ground the architecture is to follow a
single received byte from the network through the full path.

A pod opens a TCP connection to the server.  The receiver
accepts.

The handshake reads two bytes — the length prefix of the auth
token.  Suppose the token is 36 bytes (a UUID hex).  The
receiver reads 36 more bytes, decodes them as UTF-8, hashes the
result with SHA-256, and looks up the hash in the credentials
table.  The credential is valid; the receiver responds with
`\x01`.

The receiver constructs a `StreamSessionLifecycle` for the
connection and starts the session loop on a worker thread.  At
the same moment, a `process_id` is associated with the session
— derived from the credential's owner.

The pod begins streaming envelope-prefixed events.  The first
recv pulls 4 KiB; the session calls
`storage_backend.append(chunk)`.  In the filesystem backend,
this opens the temp file (or appends to an already-open one)
and writes the bytes; the file's `mtime` updates.

The session updates the flow controller: `update_buffer_size`
takes the lock, increments `connection_buffers[connection_id]`
by 4096, increments `global_memory_used` by 4096, and returns
`OK` (we're well under the high watermark).

The session does not deserialise.  The next iteration of the
loop reads another 4 KiB.

After many chunks, the buffer for this connection has grown
larger than the high watermark.  The session emits `b"PAUSE"`
back to the pod.  (The pod, today, does not honour this
signal; if the pod is also pushing too fast, drop-on-saturation
on the pod side eventually trips at the *socket* level when
the kernel's sendbuf fills, and the data is dropped before the
flow-control message would have helped.)

Eventually the storage backend has caught up — `mtime` updates
have flushed page cache to disk — and a flush marker
`b"FLUSH"` arrives in a chunk.  The session calls
`_handle_flush`, which invokes `storage_backend.flush()` and
optionally finalises the recording (renames temp → final).
The session then resets `buffer_size` to zero and the flow
controller transitions back to `RESUME`.

The pod closes the connection on gateway exit.  The session's
receive loop sees `chunk = b""`, breaks, runs cleanup
(`storage_backend.commit()`), and the worker thread exits.  The
trace session record is updated with `stopped_at = utcnow()`,
`status = "completed"`, and the `data_reference` of the
finalised file.

## 4.13 What ingestion does not do

Three deliberate non-features of the ingestion tier are worth
calling out.

**No deserialisation on the receive path.**  As §4.3 notes,
bytes go to storage as bytes.  The cost of deserialisation
lives entirely in the analysis tier (ch. 6).  This makes the
ingestion path predictably-fast — `recv`, `append`, `update
buffer size`, repeat — and lets the server accept many
concurrent streams without each one paying the pickle overhead
in the receive thread.

**No event-level acknowledgement.**  The pod does not get a per-
event "received" signal from the server.  The protocol's
durability story is at the *flush boundary*: the pod knows that
events emitted before its last successful `flush` are at least
in flight; events emitted after are in the local buffer.  The
server's commit (the temp → final rename) happens at the flush
marker, not per event.  Per-event ack would be possible but
would multiply the wire round trips and is not worth the cost.

**No magic-number framing.**  The protocol relies on the
envelope's self-describing length fields to advance from one
event to the next.  An out-of-sync reader has no way to seek
forward to the next event start.  This is a pragmatic
limitation; ch. 3 §3.13 recommends versioning the envelope
when the protocol stabilises and adding a magic number at the
same time.

## 4.14 The honesty point on wiring

Two sub-systems exist in the source today that are functionally
*not yet wired into the receive path*:

The first is the format mismatch from ch. 3 §3.8: the
deserialiser's expected format is not the format the pod is
emitting.  The receive path stores bytes; the analysis path
deserialises bytes; the analysis path is not currently
exercised against pod-emitted bytes.

The second is the REST API tier.  The
`codecraftai-webworks` blueprint pattern is in place for every
specced endpoint, OpenAPI docs render from the route
declarations, and the namespaces register at startup —
but every endpoint body returns hand-coded mock data.  The
service-layer modules (`RegistrationService`,
`ProcessRegistry`, `DebugSessionManager`,
`PortalDiscoveryService`, `TraceSearchEngine`,
`CallGraphBuilder`, `VariableTracker`) all exist and have unit
tests; what is absent is the wiring from each Layer-5 endpoint
into the corresponding service.

The book treats both honestly.  The architecture *intends*
to bind: the spec is locked, the algorithms are written, the
schemas are migrated, the protocol is the protocol the wire
will eventually carry.  The chapters that follow describe the
intent-as-locked, with notes where the wiring is still to land.

## 4.15 Bridging to the next chapter

Ingestion turns a stream of pod-emitted bytes into a durable
recording on disk and a `TraceSession` row in the database.
The next chapter steps sideways from the *trace stream* to the
*control stream* — the JSON-lines protocol the server uses to
*talk to* a registered pod over its remote-control port: list
portals, activate them, deactivate them, and (in the spec, not
yet in the pod's implementation) read or update the trace
configuration.

→ Continue to [`05-remote-debug-control.md`](05-remote-debug-control.md).

← Back to [`03-trace-event-protocol.md`](03-trace-event-protocol.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/05-remote-debug-control -->
# Chapter 5 — Remote Debug Control

> **Abstract.** A pod that ships traces but cannot be controlled
> from outside is a recording device with the *record* button
> wired in advance.  The remote-control plane is what lets an
> operator (or, increasingly, an AI agent) reach into a running
> pod, list the portals it knows about, activate or deactivate
> them, and adjust trace configuration *while the application
> is running, without a restart, without a deploy, without a
> code change*.  This chapter walks the JSON-lines protocol on
> the pod side, the credential-and-handshake model that gates
> it, the registration / heartbeat flow that the server uses to
> track liveness, the MCP server that exposes the same control
> surface to AI agents, and a real spec-vs-implementation gap
> the book treats honestly.

## 5.1 Two control surfaces, not one

A pod listening for control instructions exposes *two*
servers, each on its own port, each speaking a different
protocol:

- The **JSON-lines remote-control server**
  (`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/remote/server.py:23`),
  default port `9876`.  Speaks a small command set
  (list_portals, activate_portal, deactivate_portal) over a
  newline-delimited JSON request/response protocol.  Designed
  to be called by the time-travel server's
  `CommandDispatcher` (ch. 4 §4.10) or by an operator's CLI
  client.
- The **MCP server**
  (`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/remote/mcpserver.py:35`),
  speaks the *Model Context Protocol* — the standard Claude
  agents and other tooling use to discover and call tools.
  Exposes a richer surface (eight tools, including session
  lifecycle and capture queries) over MCP's transport options
  (stdio, TCP, Unix pipe).

Both servers are off by default.  Both are activated by setting
the corresponding flag on `TimeTravelConfig`
(`pod/configuration.py:259-304`); a typical production pod runs
with the JSON-lines server enabled and the MCP server enabled
only in the agent-driven debugging path.

The two surfaces are deliberately distinct.  The JSON-lines
server is a *minimal RPC channel for an operations tool* —
small command set, simple framing, easy to call from a shell
script or a curl-equivalent.  The MCP server is a *richer
toolset for an autonomous agent* — typed inputs, structured
outputs, transport-layer flexibility.  The book walks them in
order.

## 5.2 The JSON-lines protocol

The pod's `RemoteControlServer`
(`pod/remote/server.py:23`) is a background-thread TCP server.
On `start()` it binds to `(host, port)` and spins a worker
thread that runs an accept loop; each accepted connection is
handled synchronously and closed at the end of one
request/response.

The dispatch loop
(`server.py:106-157`) reads a single line, parses it as JSON,
runs a handler, writes one line of JSON back, and closes:

```python
def _handle_client(self, client_socket: socket.socket) -> None:
    try:
        data = b""
        while b"\n" not in data:
            chunk = client_socket.recv(4096)
            if not chunk:
                return
            data += chunk

        line = data.split(b"\n", 1)[0]
        request = json.loads(line.decode("utf-8"))
        response = self._dispatch(request)
        response_bytes = json.dumps(response).encode("utf-8") + b"\n"
        client_socket.sendall(response_bytes)
    except Exception as exc:
        logger.error("Error handling remote control request: %s", exc)
    finally:
        client_socket.close()
    return
```

A request is `{"command": str, "params": dict, "token": str}`.
A response is `{"status": "ok"|"error", "data"|"message": ...}`.

The dispatch step
(`server.py:131-157`) authenticates the request and routes the
command:

```python
def _dispatch(self, request: dict) -> dict:
    # Authentication check
    token = request.get("token")
    if not verify_credential(token, self._credential):
        rtnval = {"status": "error", "message": "Authentication failed"}
        return rtnval

    command = request.get("command", "")
    params = request.get("params", {})

    handler = _COMMAND_HANDLERS.get(command)
    if handler is None:
        rtnval = {
            "status": "error",
            "message": f"Unknown command: {command}",
        }
        return rtnval

    rtnval = handler(self, params)
    return rtnval
```

The command-handlers registry
(`server.py:214-218`) is short:

```python
_COMMAND_HANDLERS = {
    "list_portals": _handle_list_portals,
    "activate_portal": _handle_activate_portal,
    "deactivate_portal": _handle_deactivate_portal,
}
```

## 5.3 The spec gap on configuration

This is the chapter's spec-vs-implementation honesty point.

The server-side specification (`codecraftai-timetravel-server`'s
`AGENTS.md`, Layer S2.3) declares:

> S2.3 — Trace Configuration Query and Update: Retrieves and
> modifies the trace configuration (granularity, inner-portal
> annotations) on a registered process via its remote-control
> interface (pod F5.1 / pod F0.2).

The pod-side specification (`codecraftai-timetravel-pod`'s
`AGENTS.md`, Layer F5.1) describes the JSON-lines server in
terms that imply the matching commands:

> F5.1 — Remote Control Server: Background-thread TCP server
> with JSON-lines protocol for listing/activating/deactivating
> portals and querying/updating trace configuration at runtime.

But the implementation
(`pod/remote/server.py:214-218`) registers only three handlers
— `list_portals`, `activate_portal`, `deactivate_portal`.
There is no `get_configuration` handler and no
`update_configuration` handler.

The server-side `CommandDispatcher` will happily *try* to issue
those commands; the pod's dispatch will return
`{"status": "error", "message": "Unknown command: get_configuration"}`.
A `TraceConfigService` on the server constructed to call those
commands does not get a configuration back.

The book takes a position on this gap:

- The *trace configuration model* on the pod
  (`pod/traceconfiguration.py:10-63`) is well-formed,
  immutable, and serialisable.  The data side is ready.
- The *handlers* on the pod are absent — three lines per
  handler would close the gap, plus the dictionary entry.
- The *consumer* on the server is partially-built — the
  `TraceConfigService` exists in the source as a Layer-2
  module, but its end-to-end exercise is blocked on the
  missing handlers.

A future commit will close the gap.  Until then, the
operational reality is: the pod's trace configuration is
*set at startup* via `TimeTravelConfig` (or `configure_timetravel`)
and is *not modifiable at runtime over the JSON-lines protocol*.
A pod that needs to flip from `trace_calls=True` only to
`trace_calls=True, trace_lines=True` requires the operator to
restart the pod with the new configuration.

This is a meaningful operational limitation.  Volume 8 ch. 8
discusses the consequence: the production-debug workflow today
involves *pre-deciding the granularity menu*, not
*hot-adjusting the granularity in response to a bug*.

## 5.4 Authentication

The pod's authentication is intentionally minimal but
correct.  `verify_credential`
(`pod/remote/auth.py:13-35`) is a constant-time token compare:

```python
def verify_credential(provided: Optional[str], expected: Optional[str]) -> bool:
    if expected is None:
        # No credential configured — allow all
        rtnval = True
        return rtnval

    if provided is None:
        rtnval = False
        return rtnval

    rtnval = hmac.compare_digest(provided, expected)
    return rtnval
```

Three observations.

First, `hmac.compare_digest` is the right primitive.  A naive
`==` would leak information about the token via timing — an
attacker iterating over candidate tokens could observe how
deep into the comparison the equality test went and narrow
the search.  `compare_digest` runs in constant time relative to
the lengths.

Second, the credential is *optional*.  If the server is started
with `credential=None`, every request authenticates.  This is
the no-auth mode used in development and in same-host
scenarios where the pod's port is bound to `127.0.0.1` and
exposed only via Unix socket.  In production, the credential
should always be set.

Third, the credential is *in-memory only*.  It is passed at
construction
(`pod/remote/server.py:42`) and stored as
`self._credential`; it is never written to disk by the pod.
Persistence — and rotation, revocation, expiry — lives in the
*server*'s credential store
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/persistence/credential_store.py`),
which the pod's `ServiceRegistration`
(`pod/remote/registration.py`) consumes by negotiating an
*instance credential* at registration time.

The two credentials are distinct:

- The **service credential** authenticates the pod to the
  server's HTTP endpoints (`/register`, `/unregister`,
  `/heartbeat`).  It is a long-lived token the operator
  configures on the pod side and the server side at deployment.
- The **instance credential** authenticates the server's
  command-dispatch traffic *back to the pod*.  It is generated
  per-instance at registration and is what fills `self._credential`
  on the `RemoteControlServer`.

Splitting the two prevents a credential leaked at one layer
from compromising the other.  An attacker who steals a service
credential cannot dispatch commands to a pod's
`RemoteControlServer` — they would need that pod's instance
credential, which is generated freshly per registration and
known only to the pod and the server.

## 5.5 The registration handshake

When a pod starts and `configure_timetravel` finds
`remote_enabled=True` and a `service_url`, the pod registers
itself with the server before accepting any control traffic.

`ServiceRegistration.register`
(`pod/remote/registration.py:65-89`) is a single POST to the
server's `/register` endpoint:

```python
def register(self) -> bool:
    payload = {
        "instance_id": self._instance_id,
        "remote_host": self._remote_host,
        "remote_port": self._remote_port,
        "instance_credential": self._instance_credential,
    }
    try:
        self._send_request("/register", payload)
        self._registered = True
        logger.info(
            "Registered with debugging service: %s (instance: %s)",
            self._service_url, self._instance_id
        )
        rtnval = True
        return rtnval
    except Exception as exc:
        logger.error("Failed to register with debugging service: %s", exc)
        rtnval = False
        return rtnval
```

The payload tells the server:

- Who I am (`instance_id`, a UUID generated at construction).
- Where to reach me (`remote_host`, `remote_port`).
- The credential I'll accept on inbound commands
  (`instance_credential`).

The HTTP request itself is a Bearer-token-authenticated POST
(`registration.py:112-134`):

```python
def _send_request(self, path: str, payload: dict) -> bytes:
    url = f"{self._service_url}{path}"
    data = json.dumps(payload).encode("utf-8")
    request = urllib.request.Request(
        url,
        data=data,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self._service_credential}",
        },
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        rtnval = response.read()
    return rtnval
```

A 10-second timeout, no retries.  If the registration fails,
the pod logs the error and returns `False`; the application is
free to decide whether to retry, fall back to a no-server
configuration, or fail to start.

The single-attempt-no-retry policy is an explicit choice.  An
auto-retry loop in registration would tie pod startup to server
availability — a server outage would block every new pod from
starting.  The pod's operating principle is *never block the
application on the debug-tier's availability*; if the server
is unreachable, the pod runs without server registration and
keeps recording locally to its file backend.  An operator
notices via the pod's log (the WARNING line) and can re-run
registration manually, or wait for the next deploy.

## 5.6 The heartbeat

After registration, the pod sends a heartbeat to the server at
a configurable interval.  `HeartbeatSender`
(`pod/remote/heartbeat.py:21`) runs on a daemon thread:

```python
def _heartbeat_loop(self) -> None:
    while not self._stop_event.is_set():
        self._send_heartbeat()
        self._stop_event.wait(timeout=self._interval)
    return
```

The default interval is 30 s
(`heartbeat.py:18`).  The payload
(`heartbeat.py:84-108`):

```python
def _send_heartbeat(self) -> None:
    payload = {
        "instance_id": self._instance_id,
        "timestamp": time.time(),
    }
    try:
        url = f"{self._service_url}/heartbeat"
        ...
        with urllib.request.urlopen(request, timeout=10) as response:
            response.read()
    except Exception as exc:
        logger.warning("Heartbeat failed: %s", exc)
    return
```

A POST with the instance ID and a wall-clock timestamp.
Authenticated with the same Bearer token registration used.
Failure logs a WARNING and continues — the loop will try again
in `interval` seconds.

On the server side, the heartbeat handler
(`server, registration/heartbeat.py:18`) updates
`last_heartbeat_at` on the `RegisteredProcess` row and flips
`lifecycle_state="active"` if it was `registered` or `stale`.
Volume 8 ch. 4 §4.7 walks the state-machine transitions; the
heartbeat is the *only* signal that drives the
`registered → active` and `stale → active` transitions.

## 5.7 The MCP server

The MCP server
(`pod/remote/mcpserver.py:35`) exposes a richer control
surface to *AI agents* — Claude or another model that speaks
the Model Context Protocol.

Where the JSON-lines server has three handlers, the MCP server
has *eight tools*
(`pod/remote/mcpserver.py:88-232`):

- `create_session` — open a new time-travel debugging session,
  returning a session ID.
- `renew_session` — extend an existing session's TTL.
- `close_session` — finalise a session and clean up its temp
  area.
- `list_captures` — list the captures (recordings) attached to a
  session.
- `query_capture` — query the variable history or call graph
  inside a specific capture.
- `list_portals` — list the portal registry's contents.
- `activate_portal` — activate a portal, optionally with a
  capture-id, expression, or once-flag.
- `deactivate_portal` — deactivate a portal.

The portal-control tools (`list_portals`, `activate_portal`,
`deactivate_portal`) duplicate the JSON-lines server's commands
but are exposed via MCP's structured-tool interface.  An agent
can call them with typed arguments and receive typed responses.

The session-management tools (`create_session`, `renew_session`,
`close_session`, `list_captures`, `query_capture`) are
specific to the MCP path — they are about an *agent-driven
debug session*, where the agent activates a portal, observes
the capture it produced, and asks questions about the
recording.

The `activate_portal` MCP tool
(`mcpserver.py:373-398`) shows the integration with the
portal registry:

```python
elif name == "activate_portal":
    portal_name = arguments.get("name")
    if not portal_name:
        raise ValueError("Missing 'name' argument")
    expression = arguments.get("expression")
    once = arguments.get("once", False)
    capture_id = arguments.get("capture_id")
    session_id = arguments.get("session_id")

    if session_id:
        if not capture_id:
            capture_id = str(uuid.uuid4())

        if not capture_id.endswith(".bin"):
            capture_id += ".bin"

        session_dir = os.path.join(tempfile.gettempdir(), f"ccai_session_{session_id}")
        if not os.path.exists(session_dir):
            os.makedirs(session_dir, exist_ok=True)

        capture_path = os.path.join(session_dir, capture_id)
        capture_id = f"file://{capture_path}"

    registry = get_portal_registry()
    registry.activate_portal(portal_name, expression=expression, once=once, capture_id=capture_id)
    return [{"type": "text", "text": f"Activated portal: {portal_name} with capture_id: {capture_id}"}]
```

When an agent activates a portal in the context of a session,
the MCP server allocates a per-session directory under
`/tmp/ccai_session_<id>/`, generates a capture file path
inside it, and passes the file URI as the portal's `capture_id`.
The portal records into that file; the agent later queries the
file via `query_capture`.

This is the agent-driven debugging workflow:

1. Agent calls `create_session()` → gets a session ID.
2. Agent calls `activate_portal("invoice.charge.attempt",
   session_id=...)` → portal records to a per-session capture
   file.
3. Application runs.  Portal records.
4. Agent calls `query_capture(session_id=..., capture_id=...,
   variable="amount")` → gets the variable history.
5. Agent reasons about the recording, decides whether to
   activate another portal or call `close_session`.

The MCP transport is configurable
(`mcpserver.py:411-574`): stdio (the default for an agent
co-located with the pod), TCP (`start_background_tcp`), or Unix
pipe (`start_background_pipe`).  Stdio is the standard MCP
transport for tools spawned as subprocesses; TCP and Unix pipe
are alternatives for the case where the agent runs in a
separate process and needs network or namespace access.

A 60-second TTL cleanup (`mcpserver.py:61-86`) sweeps idle
session directories.  Sessions that the agent abandons (without
calling `close_session`) leave their temp data on disk for at
most 60 seconds before the cleanup thread removes them.

## 5.8 The dispatcher pattern, in two directions

Volume 5 documented the *Aspects pattern*: behaviour-parameter
objects passed by callers to interop layers.  The
remote-control plane uses the pattern in both directions.

**Server → pod (the dispatcher).**  The server's
`CommandDispatcher`
(`server, debugcontrol/command_dispatcher.py:21-74`) takes
behaviour parameters at construction:

```python
class CommandDispatcher:
    def __init__(self, credential_token: str, timeout_seconds: float = 5.0):
        self.credential_token = credential_token
        self.timeout_seconds = timeout_seconds
```

Two parameters: the token and the timeout.  Different callers
of the dispatcher can configure different timeouts — a portal
discovery query is patient (longer timeout, the pod may be
slow to enumerate); a heartbeat-related ping is impatient
(shorter timeout, the dispatcher should fail fast and let the
state machine flip to `unreachable`).

**Pod → server (registration / heartbeat).**  The pod's
`ServiceRegistration` and `HeartbeatSender` similarly take
configuration at construction.  The retry / backoff parameters
are not exposed (because the design choice was *no retry*) but
the timeout is fixed at 10 seconds in the pod's source — a
slightly different posture from the dispatcher.  Both ends are
honoring the *control reverts via typed exception* contract:
the request either succeeds within the budget or raises a
specific exception that the caller decides what to do with.

## 5.9 Reachability is not delivery

A subtle property of the control plane: a pod's lifecycle
state on the server says how recently the *server* heard from
the *pod*; it does not say whether a *command from the server*
will reach the pod.

The reverse asymmetry is real.  The pod calls the server's
`/heartbeat` over outbound HTTP — usually permitted by
firewalls and load balancers.  The server calls the pod's
JSON-lines port over inbound TCP — frequently *not* permitted
by default firewall configurations.

The gap appears in production when the pod is registered (its
heartbeat made it through), looks `active` to the server, but
the dispatcher's outbound TCP connection times out because no
inbound rule allows traffic to the pod's `9876` port.  The
state-machine view of the world is wrong from the dispatcher's
perspective: the pod is reachable for *outgoing* but not
*incoming*.

The book's recommendation, made explicit: in production, run
the pod's JSON-lines server *behind* an authenticated reverse
proxy that the server can route to (a sidecar pattern is
common — the proxy is the only inbound listener, and it
forwards to the pod's local port over loopback), or
co-locate the pod and the server on the same host and use the
Unix domain socket transport.  The MCP server can additionally
take a stdio transport — the agent and the pod share a process,
and there is no network at all.

The current `RegisteredProcess` schema
(`server, persistence/models/process.py`) does not record an
*inbound-reachable* flag separate from the heartbeat-driven
lifecycle state.  An operator looking at the process registry
sees `lifecycle_state="active"` and can be misled into thinking
the pod is fully reachable, when in fact only the outbound
heartbeat path is.  Adding an "inbound-confirmed" flag —
toggled when the most recent dispatcher call to the pod
succeeded — is a small, useful addition the book flags as a
future improvement.

## 5.10 The control plane in incident mode

A canonical incident workflow ties the chapters together.

**Time T:** a pod is running normally.  Portals are registered
with the registry; nothing is activated.  Trace overhead is
negligible (a few thread-local lookups per portal entry).
Heartbeats arrive at the server every 30 s.  Lifecycle state
is `active`.

**Time T + Δ:** an alert fires.  The operator opens the
operations console.  The console (in the architecture as
specced; in the implementation as scaffolded) calls the
server's REST API: `GET /processes` to find the pod;
`GET /processes/{id}` to load detail; `GET /processes/{id}/portals`
to list portals.  The dispatcher behind these endpoints calls
the pod's `list_portals` command and reconciles with the
local snapshot (ch. 4 §4.11).

**Time T + 2Δ:** the operator sees the
`invoice.charge.attempt` portal.  Activates it via
`POST /processes/{id}/portals/{portal_name}/activate` (REST
shape; the dispatcher calls
`activate_portal` on the pod).  The pod's portal registry
flips the portal active.  Concurrent and subsequent calls
through the portal record to the configured backend.

**Time T + 3Δ:** the bug fires.  A call enters the portal,
the portal evaluates its expression (if one was set), the
portal installs `sys.settrace`, the recording captures the
call's events.  Bytes flow over the pod's TCP backend to
the server's stream receiver (ch. 4); the server's session
appends them to a temp file; the flush marker arrives on
gateway exit (the request body completes, the portal exits,
the gateway is still up so no flush yet — until the operator
manually triggers a flush, OR a one-shot activation
auto-deactivates and triggers cleanup).

**Time T + 4Δ:** the operator is satisfied.  Deactivates the
portal via the same path in reverse.  Future calls into the
portal scope take the cheap path again.  The captured
recording sits in the server's storage; the operator queries
it through the analysis API.

The control plane is what makes this workflow *interactive*.
Every step from "open the operations console" to "deactivate
the portal" is a request through the JSON-lines protocol or
the REST API that wraps it.  The operator does not deploy code,
restart processes, or modify configuration files.  They flip
named portals on and off.

## 5.11 An MCP-driven variant

The same workflow, done by an agent over MCP:

```
agent: > Why is invoice.charge.attempt erroring at elevated rate?
agent: → list_portals(...) → see invoice.charge.attempt is registered
agent: → create_session(...) → session_id = abc123
agent: → activate_portal(name="invoice.charge.attempt",
                         once=True,
                         expression="invoice.amount > 5000",
                         session_id="abc123")
agent: (waits for application traffic)
agent: → list_captures(session_id="abc123") → one capture file
agent: → query_capture(session_id="abc123",
                       capture_id="<id>",
                       query_type="variable_history",
                       variable="amount")
agent: ← [{ts: 142.5, value: 8723.50}, {ts: 142.7, value: 8723.50}]
agent: → query_capture(session_id="abc123",
                       capture_id="<id>",
                       query_type="call_graph")
agent: ← <call graph as structured data>
agent: > the call graph shows the failing path: invoice.charge.attempt
agent: > → stripe.charge → http.post → timeout. Recommend...
agent: → close_session(session_id="abc123")
```

The agent is doing what the operator does, with MCP as the
calling convention.  The same pod, the same recording, the
same query API; a different *interface*.  This is the value of
the two-surfaces design — the pod's underlying capabilities are
the same; the surface the caller uses is whichever one fits the
caller's competence.

A direct cross-reference back to Volume 7: the
`ExecutionRecorder` / `ExecutionReplayer` from Volume 7 ch. 6
is the special-cased version of this — an agent recording its
*own* execution as a single named capture for later
inspection.  In the time-travel pod's vocabulary, the
agent-execution recorder is *one tool that calls the same
underlying mechanism the MCP path generalises*.  The
architectural pattern is consistent across the portfolio.

## 5.12 The retention boundary on the control plane

The control plane has *no* state of its own beyond what the
server's database holds.  A pod's portal registry is in-memory
only; restarting the pod loses the activation state.  A
session created via MCP is tied to a temp directory that is
swept after 60 s of inactivity.  A trace session record on the
server outlives the pod that created it, until the retention
job sweeps it.

This is the right division: ephemeral state is in-memory on
the entity that owns it; durable state is in the server's
database; trace artefacts are on the server's storage backend;
all of it is governed by *retention policy*, which is the
subject of the next chapter.

## 5.13 What the control plane will not do

Three things the control plane is, by design, unwilling to do:

**Permit the server to overwrite the pod's local code.**  The
JSON-lines protocol's commands are bounded — list, activate,
deactivate, configure (in the spec).  There is no `eval`
command.  There is no `inject_code` command.  The pod accepts
control over *which existing portals are active*, not *which
code runs*.  This is a deliberate security boundary: a leaked
instance credential (or a compromised server) should not be
able to execute arbitrary code in the pod's process.

**Permit a portal to be activated in a process where the
portal is not declared.**  A `RemoteControlServer` accepts
`activate_portal("foo")` and writes the activation to the
registry; if no `with TimeTravelPortal("foo"):` block exists in
the application code, the activation has no effect.  The
operator gets no error feedback.  This is a corollary of the
static-instrumentation choice (ch. 2 §2.13): the operator can
only activate what the developer pre-instrumented.

**Permit the trace stream to be redirected at runtime.**  The
backend URI is set at gateway construction
(`pod/configuration.py`) and is not currently mutable via the
control plane.  An operator who wants the trace to flow to a
different endpoint must change the configuration and restart
the pod.  This is consistent with the spec gap on
`update_configuration` (§5.3): the runtime mutability surface
is currently smaller than the spec describes.

## 5.14 Bridging to the next chapter

The control plane is the *interactive* tier — the layer that
lets the operator and the agent reach into the production
process.  The recordings the control plane produces have to
*go somewhere* and have to *be governed*: many concurrent
sessions, finite storage budget, finite replay-bandwidth
budget, a credential lifecycle, retention policy.

The next chapter walks the *operational* tier — the
multi-tenant story, the retention job, the credential
lifecycle, the bandwidth budget that the server's flow control
is one half of.

→ Continue to [`06-session-management-at-scale.md`](06-session-management-at-scale.md).

← Back to [`04-server-ingestion.md`](04-server-ingestion.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/06-session-management-at-scale -->
# Chapter 6 — Session Management at Scale

> **Abstract.** A single pod recording into a single trace file
> is a manageable artefact; a fleet of hundreds of pods, each
> producing recordings on demand, becomes a multi-tenant data
> management problem.  This chapter covers what the server does
> *with* recordings once they land — the sessions that group
> them, the credentials that scope them, the retention policies
> that bound them, the analysis tier that queries them, and the
> REST API surface that exposes them.  The chapter is honest
> about what is wired and what is scaffold; the analysis tier
> is built and tested, the API tier exists but is currently
> not bound to the analysis tier.

## 6.1 Sessions, captures, and the unit of debugging

The server's data model has three nested concepts:

- A **registered process** is a pod instance — a host, a PID,
  an application name, and an instance credential.  Lives in
  the `registered_processes` table.
- A **trace session** is a recording window for that process —
  a `started_at`, a `stopped_at`, a status, and a
  `data_reference` to the storage backend.  Lives in the
  `trace_sessions` table.
- A **capture** is a single recording artefact — a `.bin` file
  produced by one portal activation.  In the MCP path
  (`pod/remote/mcpserver.py:373-398`), captures are the
  per-session files in `/tmp/ccai_session_<id>/`.  On the
  server, captures are addressable through the storage
  backend's reference; their relationship to trace sessions is
  one-to-many.

The unit of *debugging* is the trace session.  An operator
asks "show me the recordings from this process between 14:00
and 14:30 yesterday"; the server answers with a list of trace
session records, each with one or more captures.

The unit of *retention* is the recording — the file on the
storage backend.  The retention job
(§6.5) sweeps files older than `data_retention_days` regardless
of which session they belong to.

The unit of *access control* is the credential.  A request to
download a recording authenticates with a credential; the
credential maps to a process; the process owns the session;
the session points to a recording.  Volume 8's credential
model is per-instance; multi-tenant access control would need
an additional layer (per-team or per-organisation credentials)
which the current implementation does not provide.

## 6.2 The credential lifecycle

The credential store
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/persistence/credential_store.py:10`)
is the layer that manages credentials end-to-end.  Five
operations:

```python
class CredentialStore:
    def create(self, label: str, expires_in_seconds: Optional[int] = None) -> Tuple[str, Credential]:
        ...  # generate token, store hash

    def validate(self, plain_token: str) -> Optional[Credential]:
        ...  # check hash, revocation, expiry; update last_used_at

    def revoke(self, credential_id: uuid.UUID) -> None:
        ...  # set revoked_at = utcnow()

    def rotate(self, credential_id: uuid.UUID) -> Tuple[str, Credential]:
        ...  # create new credential with same label, revoke old

    def list_credentials(self) -> List[Credential]:
        ...
```

Three notable behaviours.

**Generation is one-way.**  `create` returns a tuple of
`(plain_token, Credential)`.  The plain token is the *only*
time the secret leaves the store.  The store keeps only the
SHA-256 hash; if the operator loses the plain token, there is
no way to recover it.  The remedy is to rotate
(`rotate`), which creates a new credential under the same
label and revokes the old one.

**Validation is constant-time at the storage layer.**  The
SHA-256 hash is what the database indexes on; the lookup is a
single-row `SELECT` by hash.  The pod's
`hmac.compare_digest` (ch. 5 §5.4) protects the in-memory
comparison; the server's hash-and-lookup makes the storage
read constant-time on the client-supplied token's content
(though not on the lookup miss vs hit timing — an attacker can
distinguish "valid hash, not in DB" from "valid hash, in DB"
by response time).  The chapter notes this as a residual
side-channel; the practical exposure is small because the
attacker would also have to know how to construct a valid hash,
and the server-side error response is identical in either case.

**Revocation is soft.**  A revoked credential's row stays in
the table with `revoked_at` set; the validate path rejects it
because of the field, not because of its absence.  This is the
right design for audit — the operator can answer "what
credentials existed and when were they revoked?" without a
deletion log.

The credential's link to a process is established at
registration: the pod presents the *service credential* in the
Bearer header of `/register`; the server validates, creates a
`RegisteredProcess` row, and stores the (now-known-to-be-valid)
`service_credential.id` against it.  Subsequent inbound
requests from the same pod (heartbeats, unregister) re-present
the same service credential.

The credential's link to a trace session is indirect.  A trace
session belongs to a process; the process belongs to a
credential; therefore the session belongs (transitively) to the
credential.  No row stores the credential ID on the session —
the link is through the foreign key chain.

## 6.3 The server configuration store

The server's runtime knobs live in a small, schema-driven
key-value table.
`ServerConfigStore`
(`persistence/server_config_store.py:17`) wraps the raw rows
with typed access:

```python
CONFIG_SCHEMA = {
    "listen_address": {"type": str, "default": "0.0.0.0"},
    "listen_port": {"type": int, "default": 8080},
    "tls_enabled": {"type": bool, "default": False},
    "data_retention_days": {"type": int, "default": 30},
    "max_concurrent_connections": {"type": int, "default": 100},
    "default_trace_granularity": {"type": str, "default": "function"},
    "heartbeat_timeout_seconds": {"type": int, "default": 30},
}
```

Seven knobs, each with a declared type and default.  The
`get(key)` method reads the database row (if present) and casts
to the declared type; if the row is absent, the default is
used.  The `set(key, value)` method validates the cast,
serialises the value to a string, and writes the row.

Two design notes.

First, *all values are stored as strings in the database* and
cast on read.  This trades type fidelity for schema simplicity
— a single `value` column, no per-type column proliferation,
no schema migration when a new key type is needed.  The cast
function
(`server_config_store.py:7-27`) handles bool / int / str
explicitly; adding a new type adds a branch.

Second, the schema is a *Python dict* in the source.  Adding
a new configuration key requires a code change, not a database
migration.  This is appropriate for the server's operational
posture — the configuration knobs are not user-defined; they
are the server's own runtime parameters.

The keys with the most operational impact:

- `data_retention_days` — how long recordings are kept.
  Default 30.  The retention job runs against this.
- `max_concurrent_connections` — the cap on simultaneous
  inbound trace streams.  The base receiver consults this on
  `accept`; over-cap connections are rejected.
- `heartbeat_timeout_seconds` — the freshness window for the
  lifecycle state machine (ch. 4 §4.7).
- `listen_address`, `listen_port`, `tls_enabled` — bind
  parameters.

A typical deployment overrides `data_retention_days` upward
(60 or 90 days) for environments with regulatory retention
requirements, and overrides
`max_concurrent_connections` upward for high-fanout fleets.

## 6.4 The trace session manager

`DebugSessionManager`
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/debugcontrol/session_manager.py:12`)
is the server-side surface for managing trace sessions.  Its
operations:

- `create_session(process_id)` — open a new session, status
  `active`, `started_at` = utcnow().
- `close_session(session_id)` — finalise the session, status
  `completed`, `stopped_at` = utcnow().
- `list_sessions(...)` — paginated listing with filters.
- `get_session(session_id)` — fetch one session.
- `update_data_reference(session_id, ref)` — set the
  `data_reference` on the session row when the recording
  completes.

The session manager *does not* run the receive loop.  The
receive loop (ch. 4 §4.3) lives in `StreamSessionLifecycle` and
operates per-connection.  The session manager is the
*database-side* representation; the receive lifecycle is the
*network-side* representation.  They are reconciled by the
ingestion layer: when a stream finalises (flush marker, clean
close), the receive lifecycle calls into the session manager
to set `data_reference` and close the session row.

This split — *one entity handles the wire, another handles the
record* — is the same separation Volume 4 used for sysman
features (the action runner is one thing, the result emitter
is another).  It keeps the per-connection thread free to
actually drain bytes; the database transaction lives in a
short call at session boundaries, not on every chunk.

## 6.5 Retention

The retention job is a periodic sweep that deletes recordings
older than `data_retention_days`.  Two paths interact:

**Storage retention.**  The filesystem backend
(`persistence/trace_storage/filesystem_backend.py`) provides
`list_expired(age_threshold)` and `delete(reference)`.  The
expired listing scans the `final/` directory's files by
`stat().st_mtime` and yields references older than the
threshold.  The retention job iterates and deletes.

**Database retention.**  Rows in `trace_sessions` and
`registered_processes` have their own retention.  A session
whose recording has been deleted from storage but whose row
still exists is *orphaned* — accessing the row's
`data_reference` will fail to read.  The retention job deletes
session rows whose `data_reference` no longer exists, or whose
`stopped_at` is older than the retention window.

The two paths run independently — by design.  An orphaned
session row is recoverable (the operator sees it in the
session list, gets a "data not available" indicator, and can
delete the row manually or wait for the retention sweep).  An
orphaned storage file is recoverable (the retention sweep
deletes it on the next run).  The system tolerates either
inconsistency for at most the retention sweep interval.

A subtle interaction is the `ondelete='CASCADE'` on
`PortalSnapshot.process_id` and `TraceSession.process_id`
(`persistence/models/process.py:7-25`,
`persistence/models/trace_session.py:8-17`).  Deleting a
process cascades to its portal snapshots and trace session
rows.  Trace files on storage are *not* cascaded — they
require the storage retention sweep to clean up.

## 6.6 Replay-bandwidth budget

The operator's question "show me the recording" is, on the
server side, a question about *bandwidth*: how fast can a
recording's bytes be moved from storage into the analysis
tier, and how many concurrent operators can the server support?

Three knobs:

**Storage I/O.**  A recording is a file on disk; reading it is
sequential I/O.  At a typical SATA SSD throughput of 500 MB/s,
a 100 MB recording reads in 200 ms.  At 5 GB, it reads in 10 s.
The bottleneck is rarely I/O for typical recording sizes; it
becomes the bottleneck for the variable-snapshot recordings
ch. 8 measures (which can be 1+ GB for a few seconds of trace).

**Deserialisation cost.**  Pickle loading a recording's
events imposes the cost ch. 3 §3.3 alluded to: the pickle
protocol is fast for simple types but slow for complex ones.
A recording with `local_variables` dicts of arbitrary
application objects pays for each pickle.  For recordings with
heavy variable capture, deserialisation can dominate.

**Analysis cost.**  The analysis tier
(`tracedata/`) does its work on the deserialised events: build
the timeline, build the call graph, build the variable
history, run the search query.  These are O(n) or O(n log n)
in the number of events.  For a recording with a million
events, this is seconds to tens of seconds of single-threaded
CPU.

The server, today, does not parallelise the analysis across
recordings.  Each query reads its recording, deserialises it,
runs the analysis, and returns results.  Concurrent queries
are concurrent at the OS level (many threads) but each query
sees no help from the others; the scheme is "embarrassingly
parallel by request, sequential per request".

A future optimisation — pre-built indices on each recording —
would amortise the analysis cost across queries.  The
recording is finalised once; on finalisation, an index could
be built and stored alongside the recording.  Subsequent
queries would consult the index rather than re-deserialising
the events.  This is not in the source as committed; the book
notes it as the obvious next investment for high-volume
deployments.

## 6.7 The analysis tier

The analysis tier
(`codecraftai-timetravel-server/source/packages/ccai/timetravelserver/tracedata/`)
is the suite of modules that turn a deserialised event stream
into the structures an operator's queries return.  Six modules,
all internally consistent, all unit-tested.

**Timeline reconstruction.**
`tracedata/timeline.py:35-63` accumulates events into a
sorted-by-timestamp list with metadata:

```python
def build(self) -> Timeline:
    self.events.sort(key=lambda e: e.timestamp)

    start_time = 0.0
    end_time = 0.0
    duration = 0.0
    event_counts: Dict[str, int] = {}

    if self.events:
        start_time = self.events[0].timestamp
        end_time = self.events[-1].timestamp
        duration = end_time - start_time

        for event in self.events:
            event_type_str = event.event_type.value
            event_counts[event_type_str] = event_counts.get(event_type_str, 0) + 1

    return Timeline(
        events=self.events,
        start_time=start_time,
        end_time=end_time,
        duration=duration,
        event_counts=event_counts
    )
```

The `merge` method
(`timeline.py:66`) is `heapq.merge` over per-source pre-sorted
timelines — a linear-time merge.  This is what an operator
gets when they request "events from process A and process B
between T1 and T2 on a unified timeline".

**Portal scope filtering.**
`tracedata/portal_filter.py:35-54`:

```python
@staticmethod
def filter_by_portals(events: Iterable[TraceEvent], target_portals: Set[str]) -> Iterator[TraceEvent]:
    if not target_portals:
        return

    active_portals: Set[str] = set()

    for event in events:
        if event.event_type == EventType.PORTAL_ENTER:
            active_portals.add(event.portal_name)

        if active_portals.intersection(target_portals):
            yield event

        if event.event_type == EventType.PORTAL_EXIT:
            if event.portal_name in active_portals:
                active_portals.remove(event.portal_name)
```

A small state machine that tracks which portals are currently
open and yields events whose timestamp falls within a target
portal scope.  This is what makes the *named portal*
abstraction useful for analysis: an operator can ask "show me
the calls that occurred while `invoice.charge.attempt` was
active" and get back only those events.

**Call-graph construction.**
`tracedata/call_graph.py:32-62` is a stack-based builder.
Per `(process_id, session_id)`, it maintains a call stack;
on a `CallEvent` it pushes a node onto the stack and links it
to its parent; on a `ReturnEvent` it pops and attaches the
return.  The result is a forest of `CallGraphNode` trees, each
with a `duration` derived from the call's enter timestamp and
the matched return's timestamp.

The note on edge cases: unmatched return events (a return
without a preceding call in the recording) are silently
ignored.  This is the right behaviour for a *recording* — the
recording started in the middle of an active call stack on its
first event, so the early returns may exit calls that were
already on the stack before the recording window opened.

**Variable history.**
`tracedata/variable_tracker.py:39-55` is the
binary-search-by-timestamp lookup for a variable's value at a
given moment:

```python
def value_at(self, variable_name: str, timestamp: float) -> Optional[VariableSnapshotEvent]:
    if variable_name not in self._timestamps:
        return None

    timestamps = self._timestamps[variable_name]

    idx = bisect.bisect_right(timestamps, timestamp)

    if idx == 0:
        return None

    return self._histories[variable_name][idx - 1]
```

A precomputed sorted timestamp list per variable; `bisect_right`
finds the index of the *most recent snapshot at or before* the
queried timestamp.  O(log n) per lookup.  This is the *time-
travel* mechanic exposed: "what was the value of `amount` at
T = 142.5?" answered in microseconds against a recording of
millions of events.

**Search.**
`tracedata/search.py:71-99` is a four-stage pipeline:

```python
def search(self, query: SearchQuery) -> SearchResult:
    # Step 1: Portal scope filtering
    filtered_events = iter(self.events)
    if query.portals:
        filtered_events = PortalScopeFilter.filter_by_portals(filtered_events, query.portals)

    # Step 2: Apply properties criteria
    matched_events = []
    for event in filtered_events:
        if self._matches_query(event, query):
            matched_events.append(event)

    # Step 3: Sorting
    matched_events.sort(key=lambda e: getattr(e, query.order_by, e.timestamp), reverse=not query.ascending)

    # Step 4: Pagination
    total_count = len(matched_events)
    start_idx = query.offset
    end_idx = query.offset + query.limit

    paginated_events = matched_events[start_idx:end_idx]

    return SearchResult(
        events=paginated_events,
        total_count=total_count,
        offset=query.offset,
        limit=query.limit
    )
```

Portal scope, then property criteria (file, function, line,
text substring), then sort, then page.  An operator query
"show me CallEvents in `invoice.py` containing the variable
`amount` greater than 5000, sorted by timestamp, page 2 of
50" composes through these four stages.

## 6.8 The REST API as designed

Layer S5 specs the HTTP surface that exposes everything
above to operators and tools.  The `codecraftai-webworks`
blueprint pattern is in place
(`codecraftai-timetravel-server/source/site/`); the routes
register; OpenAPI documentation renders.  The endpoint set is:

- `GET /processes` — list registered processes with pagination,
  filtering, sorting.
- `GET /processes/{id}` — process detail.
- `POST /processes/{id}/refresh` — force metadata refresh
  (re-query the pod via the dispatcher).
- `POST /processes/{id}/disconnect` — mark disconnected.
- `DELETE /processes/{id}` — delete an archived (disconnected)
  process record.
- `POST /processes/register`, `/unregister`, `/heartbeat` — the
  endpoints the pod talks to (`pod/remote/registration.py` and
  `pod/remote/heartbeat.py`).
- `GET /processes/{id}/portals` — list portals for a process
  (calls `PortalDiscoveryService.discover`).
- `POST /processes/{id}/portals/{name}/activate` — activate a
  portal (calls `PortalControlService.activate`).
- `POST /processes/{id}/portals/{name}/deactivate` —
  deactivate.
- `GET /processes/{id}/trace-config` — read trace
  configuration (blocked on the spec gap from ch. 5 §5.3).
- `PUT /processes/{id}/trace-config` — write trace
  configuration (blocked on the same spec gap).
- `POST /sessions` — create a debug session.
- `GET /sessions` — list sessions with pagination/filtering.
- `GET /sessions/{id}` — session detail.
- `DELETE /sessions/{id}` — close a session.
- `GET /trace-data/events` — search trace events (calls
  `TraceSearchEngine`).
- `GET /trace-data/timeline` — reconstructed timeline.
- `GET /trace-data/call-graph` — call graph forest.
- `GET /trace-data/variables` — variable list.
- `GET /trace-data/variables/{name}/history` — variable
  history.
- `POST /credentials` — create credential.
- `GET /credentials` — list credentials.
- `POST /credentials/{id}/rotate` — rotate.
- `POST /credentials/{id}/revoke` — revoke.
- `DELETE /credentials/{id}` — delete.
- `GET /server/config` — read server configuration.
- `PUT /server/config` — write server configuration.
- `GET /server/health` — server health check.

The routes are registered.  The OpenAPI spec exists at
`/api/docs`.  The endpoint signatures match the design.

## 6.9 What the REST API does not yet do

This is the chapter's second honesty point (the first is the
configuration spec gap from ch. 5 §5.3).

The endpoint *bodies*, as committed, return mock data.  The
process-list endpoint does not call `ProcessRegistry.list_processes`;
it returns a hand-built dict.  The trace-data endpoints do not
call `TraceSearchEngine.search` or `CallGraphBuilder.add_events`;
they return synthetic event lists.

The wiring — taking the request, instantiating the service,
calling the right method, formatting the response — is the
work that closes the gap.  It is not architecturally hard.  The
service layer is in place, well-tested, and accepts the right
inputs.  The gap is the per-endpoint implementation that
translates between HTTP and the service interface.

The book's framing is structural rather than procedural: *the
contract is locked, the algorithms are written, the storage is
real, the binding is the last mile*.  An operator using the
server today has the analysis tier through programmatic Python
calls (the unit tests are the canonical examples); a future
release will land the bindings, and the operator's experience
shifts to the REST surface.

## 6.10 Multi-tenant by credential

The current architecture has *some* multi-tenant capability
through the credential model.  Multiple credentials can exist;
each credential maps to a process; processes are isolated by
their credential.

Two things the architecture does *not* yet provide:

**Per-team or per-organisation grouping.**  A credential
identifies a process; there is no notion of "this credential
belongs to team A, that one to team B, both report to the same
server but cannot see each other's traces."  Adding this is a
schema and authorisation change — a `tenant_id` on the
credential, an authorisation predicate on every list endpoint
filtering by tenant.  Not architecturally complex but not
shipped.

**Role-based access control.**  The `User` table
(`persistence/models/user.py:6-12`) has a `role` field
(`viewer`, implicit `admin`), but no endpoint in the current
REST tier consults it.  The roles are placeholder.  When the
REST tier wires through, the role check is the authorisation
gate — viewers can read, admins can write configuration and
manage credentials.

The book's recommendation: the credential-based isolation is
sufficient for the small-fleet case (one team, many pods).  The
team-based isolation is the next step for the multi-team case;
it is a small-to-medium amount of work and a substantial
operational improvement.

## 6.11 The operational dashboard, in shape

A typical operations dashboard reading the REST API renders
four panes:

**Process list.**  Paginated, with state filters
(`active` / `stale` / `unreachable` / `disconnected`), a
search box on application name, and a sort on
`registered_at`.  The shape comes from
`ProcessRegistry.list_processes`
(`registration/process_registry.py:16-52`).

**Process detail.**  A single process: host, PID, application,
pod version, lifecycle state, last heartbeat, the portals
discovered (with their active/inactive flags), the recent
trace sessions.  Aggregated from `RegisteredProcess`,
`PortalSnapshot`, `TraceSession`.

**Session viewer.**  A single trace session: timeline (events
plotted on a timeline), call graph (collapsible tree),
variable inspector (drop-down by name, then a history table).
Drives `TraceSearchEngine`, `CallGraphBuilder`,
`VariableTracker`.

**Server health.**  Active connections, ingestion rate,
storage utilisation, registered process count by state,
oldest session waiting on flush.  Server-internal metrics
exposed at `GET /server/health`.

These four panes describe the operator's interaction with the
system.  The first three are query-driven (the operator picks
a target and the server retrieves its data); the fourth is
metric-driven (the operator watches the server's own state).

## 6.12 The control plane in flow

A complete debugging session, end-to-end:

1. **Pod starts.**  `configure_timetravel` activates whatever
   portals the deployment-time configuration declared, opens
   the JSON-lines server, registers with the time-travel
   server, starts the heartbeat sender.  `RegisteredProcess`
   row inserted with state `registered`.
2. **First heartbeat lands.**  `last_heartbeat_at` updated,
   state flipped to `active`.
3. **Operator opens dashboard.**  `GET /processes` lists the
   pod.
4. **Operator drills in.**  `GET /processes/{id}` shows
   detail; `GET /processes/{id}/portals` triggers a
   `PortalDiscoveryService.discover` call out to the pod's
   JSON-lines `list_portals` command, reconciling the local
   `PortalSnapshot` rows.
5. **Operator activates a portal.**  `POST
   /processes/{id}/portals/{name}/activate` triggers a
   `PortalControlService.activate` call, which dispatches
   `activate_portal` to the pod.  The pod's portal registry
   flips active.
6. **Application traffic crosses the portal.**  The pod
   records, the TCP backend streams to the server's
   `TcpStreamReceiver`, the `StreamSessionLifecycle` drains
   bytes to a temp file in `<base>/temp/`.
7. **Recording finalises.**  Flush marker arrives or
   `once=True` triggers cleanup.  Temp file is renamed to
   `<base>/final/`.  `TraceSession.update_data_reference`
   fires; the session row's `data_reference` and
   `stopped_at` are set; status flipped to `completed`.
8. **Operator inspects the recording.**  `GET
   /sessions/{id}/timeline` runs the analysis tier,
   reconstructing a timeline from the file's events.  `GET
   /sessions/{id}/call-graph` returns the call graph.  `GET
   /sessions/{id}/variables/{name}/history` returns the
   variable's history.
9. **Operator deactivates the portal.**  `POST
   /processes/{id}/portals/{name}/deactivate` mirrors step 5.
   Future calls into the portal scope take the cheap path.
10. **Retention sweep.**  After
    `data_retention_days` (default 30), the recording is
    eligible for deletion.  The retention job sweeps it.

In the implementation as committed, steps 6, 7, and the
service-layer guts of steps 4, 5, 8, and 9 are *real*.  Steps
3 and the HTTP wrappers around 4, 5, 8, 9 return mock data.
Closing the gap is the wire-it-up work the next investment
cycle does.

## 6.13 What scale looks like in numbers

A small deployment: 10 pods, each producing recordings on
demand, average 50 MB per recording, average 5 recordings per
day per pod, retention 30 days.

- Storage budget: 10 × 5 × 50 MB × 30 = 75 GB.
- Concurrent connections: at most 10 (one per pod), often 0-2.
- Database growth: ~50 trace_session rows per day × 30 day
  retention = ~1500 rows; trivial.

A medium deployment: 100 pods, 100 MB average, 10 recordings
per pod per day, 30 days.

- Storage budget: 100 × 10 × 100 MB × 30 = 3 TB.
- Concurrent connections: ~10 typical, ~100 peak.
- Database growth: ~1000 rows/day × 30 = 30k rows.

A large deployment: 1000 pods, 200 MB average, variable
recording cadence (most days zero, incident days hundreds of
recordings).

- Storage budget: dominated by incident days.  A single
  day's worth of incident recordings can be 100 GB or more
  per pod; cluster-wide a single bad incident day can be 10+
  TB.
- Concurrent connections: incident-driven, can spike to
  hundreds.
- Database growth: skewed but bounded.

The architecture as implemented is fit for the small and
medium case.  The large case will exercise things the current
implementation does not yet do — the per-recording index from
§6.6, partitioned storage backends (S3 / object store rather
than filesystem), multi-tenant isolation from §6.10, and a
priority queue on the receive path so incident-related streams
can be admitted ahead of routine ones.  These are addressable;
they are not in the current commit.

## 6.14 Bridging to the next chapter

The architectural and operational story is now in place.  The
remaining two chapters bring the system to ground in concrete
debugging scenarios.  Chapter 7 walks three case studies — a
flaky distributed test (cross-referencing Volume 2's tandem
testing model), an agent execution (cross-referencing Volume
7's `ExecutionRecorder`), and a sysman feature on a remote
host (cross-referencing Volume 4).  Chapter 8 quantifies the
performance and storage trade-offs the recording-fidelity-vs-
cost knobs control.

→ Continue to [`07-case-studies.md`](07-case-studies.md).

← Back to [`05-remote-debug-control.md`](05-remote-debug-control.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/07-case-studies -->
# Chapter 7 — Case Studies

> **Abstract.** A case study is an honest answer to "what does
> using this *actually look like*?"  This chapter walks three
> debugging scenarios that exercise the time-travel pod / server
> tier against problems readers of the earlier volumes should
> recognise: a flaky distributed test (cross-referencing
> Volume 2's tandem testing model), an agent execution that
> behaved unexpectedly (cross-referencing Volume 7's
> `ExecutionRecorder`), and a sysman feature that misbehaved on
> a remote host (cross-referencing Volume 4's RHEL / Ubuntu /
> macOS / Windows implementations).  Each case study traces a
> path from "the symptom appeared" to "the recording answered
> the question", with the code anchors that drove each
> decision.  All three are built from the source as committed —
> no fictions, with the protocol mismatch from ch. 3 §3.8
> understood as the working assumption.

## 7.1 Case study 1 — The flaky distributed test

### The setup

A team running Volume 2's tandem distributed test framework
(`codecraftai-testplus` driving `codecraftai-pod-tcp`) has a test
that fails intermittently in CI.  The test exercises a
distributed system: a *driver* Pod (capital P, the test-
execution unit from Volume 2) sends a request through a
*frontend* observer Pod, which forwards to a *backend*
observer Pod, which writes to a database; the test asserts on
the database state.

Symptom: about one run in twenty, the assertion fails with
"row not found".  Twenty runs out of twenty pass when the test
is invoked locally; only the CI environment shows the failure.
Standard logs say only that the assertion failed.  Adding more
log lines to either Pod helps localise the assertion-time
state but does not show the *moment* the row failed to write.

### The debugging hypothesis

The team's hypothesis: a race between the database write and
the driver's read.  The test asserts immediately after calling
the frontend; the assertion runs against the database before
the backend has finished its write.

The hypothesis is testable.  If they had a recording of the
backend Pod's write path during a failing run, they could see
whether the write committed before the driver's read started.

### Instrumenting

The test fixture is updated.  In the backend Pod's request
handler, an existing portal `db.transaction.commit` is
known to the developer team — it brackets the database
commit:

```python
with TimeTravelPortal("db.transaction.commit"):
    session.commit()
```

The portal is *registered* but not *active* on the running
backend Pod.  The team adds a snippet to their CI test setup
that activates the portal on each test run via the JSON-lines
remote-control protocol (ch. 5 §5.2):

```python
import socket, json

def activate_db_commit_portal(pod_host: str, pod_port: int,
                              token: str) -> None:
    request = {
        "command": "activate_portal",
        "params": {
            "name": "db.transaction.commit",
            "once": True,
            "capture_id": f"file:///tmp/ci-debug-{run_id}.bin",
        },
        "token": token,
    }

    request_bytes = (json.dumps(request) + "\n").encode("utf-8")

    with socket.create_connection((pod_host, pod_port), timeout=5.0) as sock:
        sock.sendall(request_bytes)
        response_line = b""
        while b"\n" not in response_line:
            response_line += sock.recv(4096)

    response = json.loads(response_line.split(b"\n", 1)[0])
    assert response.get("status") == "ok", response
    return
```

A single round-trip activation: create a TCP connection to
the backend Pod's `9876`, send the `activate_portal` JSON
request, read one line of response, assert the response is
`ok`.  Configured with `once=True` so the portal deactivates
itself after the next call exits — no ongoing overhead, just
the one recording.

The capture path encodes the CI run ID so concurrent test runs
don't overwrite each other's recordings.  The pod's portal
activation
(`codecraftai-timetravel-pod/source/packages/ccai/timetravel/pod/portalregistry.py:25-41`)
stores the capture URI; the next entry into the
`db.transaction.commit` scope routes the recording there
(via `portal.py:187-250`).

### The first failed run with instrumentation

CI runs the test 20 times.  19 pass.  Run 16 fails — and
because of `once=True`, the recording captured only that
failing run's data.

The recording, off-line, walked through the pod-side
`reader.py`:

```python
from ccai.timetravel.pod.reader import TraceFileReader
from ccai.timetravel.pod.timeline import TimelineBuilder

reader = TraceFileReader("/tmp/ci-debug-16.bin")
events = list(reader.read_events())

builder = TimelineBuilder()
builder.add_events(events)
timeline = builder.build()

for ev in timeline:
    print(ev.timestamp_ns, ev.__class__.__name__, getattr(ev, "function_name", ""))
```

The output is a sequence of `CallEvent` and `ReturnEvent`
records, each timestamped in nanoseconds since the gateway's
start.  The team filters to function-level events under the
backend's commit codepath:

```
142_300_000  CallEvent      Session.commit
142_300_500  CallEvent      Session.flush
143_100_000  CallEvent      Connection.execute
143_300_000  CallEvent      Connection.commit
143_400_000  ReturnEvent    Connection.commit
143_410_000  ReturnEvent    Connection.execute
143_420_000  ReturnEvent    Session.flush
143_430_000  ReturnEvent    Session.commit
```

The portal's outermost call ran from 142.3 ms to 143.43 ms
into the gateway-relative timeline.

### Cross-correlating with the driver

The driver Pod runs its assertion check.  The driver Pod's
own gateway timestamps its events relative to its *own* start
— different from the backend's.  But the driver wrote a log
line "asserting database row exists at request_id=R-XXXX" at
wall-clock time `2026-04-26T14:22:05.213Z`.  The backend's
gateway recorded a `GatewayStartEvent` with
`wall_clock_time = 2026-04-26T14:22:05.099Z`
(`pod/traceevents.py:162-174`).

Subtracting: the driver's assertion ran 114 ms after the
backend's gateway started.  But the recording shows the
backend's `Session.commit` only *returned* 143.43 ms after
gateway start.

**The driver's assertion ran 29 ms before the backend's commit
returned.**

The race is confirmed.  The driver was reading before the
backend had finished writing.

### The fix

The hypothesis pointed at a missing synchronisation.  The
driver's request was issued, the frontend was returning *before
the backend's commit* — likely because the frontend's response
was triggered by the queue handoff, not the database commit.

The fix is application-side: the backend's request handler
should not signal completion until the database commit returns.
Implementation in the team's app code, not in the pod.

The pod's role was diagnostic: it gave the team the timeline
that showed the race.  Without it, they had only "row not
found" intermittently and a hypothesis they could not
distinguish from "intermittent network glitch" or "GC pause".
With it, they had the nanosecond-precise timeline and the call
graph that pinned the cause.

### What this case shows about the design

- **Once-shot activation** is the right granularity for a CI
  flake.  The team did not want a continuous recording of every
  CI run; they wanted *the next failing run*, recorded.
  `once=True` makes that the default.
- **Capture-id per run** is what enables CI fan-out.  Twenty
  concurrent test runs each had their own activation with
  their own capture path; no recording was overwritten.
- **The control plane is scriptable.**  The 30-line snippet
  above is the entire integration with CI.  No SDK to vendor,
  no plugin to write — a single TCP call with a JSON request.
- **The pod's reader is internally consistent** (ch. 3 §3.10).
  The team did not need the server to make sense of the
  recording — they read the file directly.  This is the
  "small-scale" debugging path: file-backend, off-line read.
  For larger-scale recording aggregation across many CI runs,
  the server tier would land later.

### Cross-reference to Volume 2

Volume 2 ch. 13 walked the *single-actor + many-observers*
shape of distributed tests in the tandem model.  This case
study is one tier up: the Pod (capitalised) running the
backend service is the *actor*; the time-travel pod
(lower-case) inside it is one of the observers.  The
recording's role is exactly the role Volume 2 assigns to
observers: *to observe the actor's state without changing it*.
The portal does its work *bracketing* the application's actual
behaviour — it does not modify the commit, the database, or
the response; it captures what they did and produces an
artefact the analyst reads later.

The book's recommendation made plain: in Volume 2's distributed
test scenarios, every long-running observer Pod should expose
named portals at every cross-actor boundary — the points where
*this Pod observes the actor's state*.  When a flaky test
fails, those portals are the operator's first switch.

## 7.2 Case study 2 — The agent that skipped a tool

### The setup

A team running an agent built on Volume 7's
`codecraftai-agent-kit` has a customer support bot.  The bot
should call the `lookup_order` tool first, then call
`generate_response` second, with the order data threaded through.

Symptom: about one conversation in fifty, the bot calls
`generate_response` *without first calling* `lookup_order`,
and the response is generic instead of order-specific.  The
team can see the symptom in their logs (the model output is
visibly wrong); they cannot see *why* the model made the
choice.

### The agent's recording posture

The agent's lifecycle is already instrumented with portals from
its declaration in Volume 7 ch. 6:

- `agent.run` brackets the entire agent invocation.
- `agent.tool.dispatch` brackets each tool call.
- `agent.message.compose` brackets the model's final response
  composition.

The agent's recording posture is `calls only` by default
(`trace_calls=True`, others false).  Cheap.  All three
portals are registered; none is currently active.

### The hypothesis

Two competing hypotheses:

1. The model's tool-selection step somehow returned a
   `compose` action before completing the `lookup_order`
   action.  Bug at the model layer — possibly a malformed tool
   schema, possibly a model hallucination.
2. The tool-selection logic on the SDK side has a code path
   that bypasses `lookup_order` under certain conditions.  Bug
   in the agent SDK.

A recording can distinguish.  If `agent.tool.dispatch` was
called for `lookup_order` and *returned an error*, the model
was given a poison-pill response and skipped to compose
(hypothesis 2).  If `agent.tool.dispatch` was *never called*
for `lookup_order`, the model never asked (hypothesis 1).

### Instrumenting

The team activates the three portals on the production agent.
This time they need *variable capture* on the
`agent.tool.dispatch` portal — they need to see the *tool
arguments*, which means line-and-variable granularity on that
portal.

Variable capture is configured at gateway construction;
because of the spec gap from ch. 5 §5.3 the team cannot
hot-flip a running pod's trace configuration.  Their workaround
is to deploy a configuration-update through the standard CI/CD
path:

```python
TimeTravelConfig(
    active_portals=["agent.run", "agent.tool.dispatch", "agent.message.compose"],
    trace_calls=True,
    trace_lines=True,
    trace_variables=True,
    record_inner_portals=True,
    backend_uri="tcp://timetravel.internal:9090",
)
```

This is the operational reality §5.3 describes: *granularity is
set at startup*.  The team accepts the deploy and rolls out the
new configuration to canary, then to production.

### The recording

A few hours after rollout, two more flake instances are
recorded.  The team queries the time-travel server for the
trace sessions that bracket the failing conversation IDs (the
agent logs the conversation ID at the start of each turn; the
team correlates).

The recording for one failure shows, on the call-graph
reconstruction
(`tracedata/call_graph.py:32-62`):

```
agent.run [duration: 1.842s]
├─ agent.tool.dispatch [duration: 0.009s]
│  └─ ToolRegistry.dispatch [duration: 0.009s]
│     ├─ ToolRegistry._lookup [duration: 0.001s]
│     └─ ToolRegistry._invoke [duration: 0.005s]
│        └─ <return: Exception("Connection refused")>
└─ agent.message.compose [duration: 0.420s]
   └─ ModelClient.complete [duration: 0.417s]
```

The `lookup_order` tool *was* invoked.  It returned an
*Exception* (`Connection refused`) at the
`ToolRegistry._invoke` boundary.  The agent SDK's response: it
fell back to `compose` *with the exception in scope*.

The variable snapshot at the `compose` portal entry confirms.
The team queries the variable history
(`tracedata/variable_tracker.py:39-55`) for the `tool_results`
variable at the timestamp of `agent.message.compose` entry:

```python
{
    "lookup_order": {
        "status": "error",
        "error": "Connection refused",
        "fallback_invoked": True,
    }
}
```

The model received a tool-result that said "lookup_order
failed; fallback invoked".  The model — correctly, on its
training — generated a generic response.

### The fix

The bug is in the order-lookup service, not the agent.  The
service has an intermittent connection issue that the team
had not yet correlated with this symptom.  The service team
investigates separately; the agent team adds a retry on
`ToolRegistry._invoke` for transient errors before falling
back to the model.

### What this case shows

- **Variable capture is the high-fidelity option.**  The
  call-only recording would have shown the exception was
  raised and that compose was called next.  The variable-
  capture recording showed *what the model received as its
  prompt*, which was the answer to the actual question
  ("did the model see the failure or did the SDK silently
  proceed?").
- **The deploy-time-only granularity choice is a real
  cost.**  The team had to roll out a configuration change to
  enable variable capture.  The spec gap from ch. 5 §5.3
  is felt operationally: a runtime-flippable granularity would
  have closed the diagnostic loop in minutes instead of hours.
- **The call graph is the right view for the agent path.**
  The team did not care about every line that executed; they
  cared about the *shape* of the call graph — what was
  invoked, in what order, with what return value.  The call-
  graph builder (`tracedata/call_graph.py`) is the analysis
  module that answers this question.

### Cross-reference to Volume 7

Volume 7 ch. 6 introduced `ExecutionRecorder` /
`ExecutionReplayer` as the agent-specific recording mechanism.
This case study shows the *generalisation* in action: instead
of a special-purpose recorder for agent runs, the agent's
lifecycle is bracketed by ordinary portals
(`agent.run`, `agent.tool.dispatch`, `agent.message.compose`),
recorded by the ordinary pod, ingested by the ordinary
server, queried by the ordinary analysis tier.

The benefit of generalisation: an agent's debugging path is
the same as any other production debugging path.  The
operations team has one toolset for "production debugging" and
agent-driven services are not a special case.

The cost of generalisation: the agent-specific affordances
the SDK could provide (richer model-output capture, tool-
schema-aware filtering, dedicated agent-run views) are not
yet in the pod.  The book recommends them as future work that
sits comfortably in the agent SDK rather than in the pod.

## 7.3 Case study 3 — The sysman feature that didn't apply

### The setup

A team running Volume 4's cross-platform sysman tier has a
feature that *applies a firewall rule* on RHEL hosts.  The
feature is implemented in `codecraftai-sysman-rhel` and consumed
through the standard sysman client.  The implementation
shells out to `firewalld`'s `firewall-cmd`.

Symptom: on one specific RHEL 9 host out of a fleet of 200,
the firewall rule does not apply.  The sysman feature reports
success.  An out-of-band check
(`firewall-cmd --list-all`) shows the rule is not in place.

### The hypothesis

The action's stdout said success.  The action's stderr was
empty.  The exit code was zero.  The host's `firewalld` is
running.  But the rule is not present.

A reasonable hypothesis: the action is shelling to the
*runtime* `firewall-cmd`, but the rule is not being persisted
through `firewalld`'s config; the next reload would lose it.
But that does not explain why a `--list-all` immediately after
the action shows no rule — there has been no reload in between.

A second hypothesis: there is a permissions issue.  The action
is running as a service account that *thinks* it can call
`firewall-cmd`, but the call is being silently dropped by
PolicyKit.

### Instrumenting

The sysman feature on the affected host has portals at every
boundary:

- `sysman.feature.firewall.enable`
- `sysman.action.run`
- `sysman.action.exit`

The team activates them with variable capture.  The
`sysman.action.run` portal brackets the actual `subprocess.run`
call; the variable snapshot at line-level captures the command,
arguments, environment, and the resulting `CompletedProcess`.

Activation, this time, goes through the time-travel server's
REST surface:

```
POST /processes/{host_id}/portals/sysman.action.run/activate
  payload: {"capture_id": "file:///var/log/ccai/firewall-debug.bin"}
```

(In the implementation as committed, this endpoint returns mock
data.  The team's workflow uses the `CommandDispatcher`
directly via a server-side script — not the REST API tier —
demonstrating the §6.9 honesty point: the *services* are real;
the REST wrappers are scaffold.)

### The recording

A scheduled re-application of the firewall rule fires.  The
recording captures the action.  The team reads the file:

```python
from ccai.timetravel.pod.reader import TraceFileReader

reader = TraceFileReader("/var/log/ccai/firewall-debug.bin")
events = list(reader.read_events())

# Walk the variable snapshots inside sysman.action.run
in_action = False
for ev in events:
    if isinstance(ev, PortalEnterEvent) and ev.portal_name == "sysman.action.run":
        in_action = True
    elif isinstance(ev, PortalExitEvent) and ev.portal_name == "sysman.action.run":
        in_action = False
    elif in_action and isinstance(ev, VariableSnapshotEvent):
        if "completed_process" in ev.local_variables:
            cp = ev.local_variables["completed_process"]
            print(f"@ {ev.timestamp_ns} cmd={cp.args} rc={cp.returncode}")
            print(f"  stdout={cp.stdout!r}")
            print(f"  stderr={cp.stderr!r}")
```

The output:

```
@ 4_512_000_000 cmd=['firewall-cmd', '--add-rich-rule=...', '--permanent']
  rc=0
  stdout=b'success\n'
  stderr=b''
```

A subsequent variable snapshot, just inside
`sysman.feature.firewall.enable`, shows the team's *own*
verification step:

```
@ 4_530_000_000 verified=True
```

The action ran, returned success, and a verification step
*also* reported success.  But the rule is not on the host.

### The clue in the next snapshot

Looking past the immediate action, the next portal that fires
is `sysman.feature.firewall.persist` — a different portal, in
the *next* feature in the chain.  Its variable snapshot shows:

```
@ 4_580_000_000 cmd=['firewall-cmd', '--reload']
  rc=0
  stdout=b'success\n'
  stderr=b''
```

A reload, immediately after the action.

The team checks the host's
`/etc/firewalld/zones/public.xml`.  The rich-rule the action
added at 4.512 s is *not* in the file at the time of reload.

### The bug

The issue is in the team's automation:

The `--permanent` flag does *not* apply the rule to the
running configuration.  It writes the rule to the
*permanent* configuration files.  A subsequent reload reads
those files and applies them.

The team's verification step ran at 4.530 s.  At that moment:
- The runtime configuration had not changed (no `--add-rich-
  rule` without `--permanent`).
- The permanent configuration files *had* been updated, but
  the reload that applies them had not yet run.
- The verification step checked the *runtime* configuration,
  saw the rule was absent, and — because of a default in the
  team's verifier — assumed *true* if no contradicting evidence
  was found.

A bug compounded by a bug:
- The action used `--permanent` only, not `--permanent
  --add-rich-rule + a separate runtime call`.  This is a
  documented `firewalld` gotcha.
- The verifier's default-true was incorrect.  It should have
  required positive confirmation.

### The fix

The action is updated to call `firewall-cmd --add-rich-rule`
*twice*: once with `--permanent`, once without.  The verifier
is updated to require positive confirmation with an explicit
"rule found" predicate.

### What this case shows

- **Variable capture made the bug visible.**  Without the
  `CompletedProcess` snapshots, the team had only the action's
  reported success and the verifier's reported success — both
  of which were positive — and a host that did not behave as
  the reports claimed.
- **Multi-portal recording provides correlation.**  The action
  portal showed the action's behaviour; the persist portal
  showed the reload's behaviour; the gap between them showed
  the bug was the *runtime configuration not yet updated when
  the verifier checked it*.
- **The recording made the documentation point obvious.**
  Once the team saw the call sequence, the `firewalld`
  documentation about `--permanent` was the immediate next
  read.  The recording did not solve the bug; it pointed
  precisely at the place the documentation needed to be
  consulted.

### Cross-reference to Volume 4

Volume 4's RHEL chapter (ch. 4) walked the
`codecraftai-sysman-rhel` package, including the deliberate
deviation from Convention 4: RHEL-specific exception types are
*not* prefixed with the platform name, because the package's
contract is the OS-specific implementation of a sysman feature
and the platform context is implicit.

This case study exercises that implementation.  The portal
boundaries used here (`sysman.feature.firewall.enable`,
`sysman.action.run`, `sysman.action.exit`,
`sysman.feature.firewall.persist`) are the *generic* sysman
boundaries — the same names appear on the Ubuntu, macOS, and
Windows implementations.  A bug on Ubuntu would surface
through the same portal names with platform-specific
variable contents.  The pod abstracts away the platform-
specific commands; the portal vocabulary is consistent across
the cross-platform tier.

## 7.4 What the three case studies have in common

Pulling the three together, three patterns repeat:

**Static instrumentation of architectural boundaries was
sufficient.**  In all three cases, the portals the team
activated were already in the source code, declared at a
boundary the developer had already identified as
debugging-worthy: the database commit boundary, the agent
tool-dispatch boundary, the sysman action-run boundary.  No
ad-hoc instrumentation was added during the incident.

**The right granularity was case-by-case.**  The flaky test
needed only `calls`; the agent needed `lines + variables`;
the sysman feature needed `lines + variables`.  The
calls-only recording in case 1 was inexpensive and left no
operational footprint.  The variable-capture recordings in
cases 2 and 3 cost an order of magnitude more in volume.
Both choices were appropriate for the question being asked.

**Off-line analysis dominated.**  In every case, the operator
read the recording *after* the symptom had passed.  This is
the time-travel debugging mode the volume's title names: the
symptom occurred, the recording captured the state, the
operator walked the recording to understand what happened.
Replay-during-incident was not the workflow; *post-mortem
state reconstruction* was.

## 7.5 What the three cases revealed about the design

Each case lit a specific design seam.

**Case 1 lit `once=True` activation.**  The CI flake workflow
demanded a single recording, not a continuous stream.  The
flag exists, is honoured, and is the right granularity for
*next failure occurrence* debugging.

**Case 2 lit the spec gap on runtime configuration.**  The
agent team's variable-capture decision required a deploy.
The chapter has been honest: until `update_configuration`
lands on the JSON-lines protocol (ch. 5 §5.3), this is the
operational reality.

**Case 3 lit the multi-portal correlation use case.**  The
firewalld bug only became visible when the team correlated
*two* portals' recordings — the action portal showed the
intent, the persist portal showed the side effect, the gap
between them was the bug.  The pod-side `timeline.py`
(`pod/timeline.py:35-71`) and server-side `timeline.py`
(`tracedata/timeline.py:35-63`) both support multi-source
timeline merging via `heapq.merge`; this is the algorithmic
foundation for cross-portal correlation.

## 7.6 Bridging to the next chapter

The three case studies are *qualitative* — they describe what
the recording revealed and how the team used it.  The remaining
question is *quantitative*: how much overhead does each
granularity actually impose?  How much storage does a recording
consume?  When does the cost cross a threshold that makes the
recording impractical?

The next chapter measures.  It quotes the runtime-overhead
budget for each granularity, the storage budget for each
typical use case, and the policy framework for *when to
enable, when to disable*.

→ Continue to [`08-performance-storage-tradeoffs.md`](08-performance-storage-tradeoffs.md).

← Back to [`06-session-management-at-scale.md`](06-session-management-at-scale.md).


<!-- https://code-craft.ai/references/codecraftai/08-timetravel-debugging/08-performance-storage-tradeoffs -->
# Chapter 8 — Performance and Storage Trade-offs

> **Abstract.** Recording is not free.  The `sys.settrace` hook
> imposes per-event Python interpreter overhead; the
> serialiser pickles each payload; the buffer flushes synch-
> ronously when full; the network or disk absorbs the bytes.
> Each layer has a measurable cost, and the cost compounds with
> the recording's *granularity*.  This chapter quantifies what
> calls, lines, and variables cost in CPU and bytes; quotes
> measured numbers from comparable Python tracing literature
> and the pod's own design constraints; and lays out the policy
> framework operators should use to decide *when to enable,
> when to disable*.  All numbers are honest about being
> estimates, not benchmarks: the pod's source as committed is
> the design; the production-quality benchmark suite that would
> validate the design is part of the work that follows.

## 8.1 What "overhead" means here

Three different overhead figures matter, and conflating them
muddies decisions:

**Quiescent overhead.**  The cost the application pays when
*no portal is active*.  Every portal entry runs the
`__enter__` decision tree
(`pod/portal.py:60-141`) — three early-exit conditional
branches and a thread-local lookup.  This is the cost the
application *always* pays as long as the pod is loaded.

**Active overhead.**  The cost the application pays when a
portal *is active*.  Every Python interpreter event inside
the portal's scope crosses through `_trace_function`
(`pod/tracehookmanager.py:63-137`), constructs an event,
serialises it, and writes to the buffer.

**Storage overhead.**  The bytes-on-disk (or bytes-on-the-
wire) consumed per recording.  Independent of CPU overhead;
sometimes the bottleneck.

The three are independent in important ways: quiescent is *low*
regardless of granularity; active is dominated by
*granularity*; storage is *granularity × duration*.  An
operator's decisions live in this three-dimensional space.

## 8.2 Quiescent overhead

The quiescent path is the fast path.  The portal's `__enter__`
runs:

```python
def __enter__(self):
    if not is_gateway_active():
        return self

    state = get_tracing_state()

    if state.is_tracing:
        state.portal_depth += 1
        ...
        return self

    registry = get_portal_registry()
    if not registry.is_portal_active(self._name):
        return self
    ...
```

In quiescent mode (gateway active, no portal activated, this
thread not tracing), the path through `__enter__` is:

1. `is_gateway_active()` — read a Python global
   (`pod/tracingstate.py:61-69`).  Microseconds.
2. `get_tracing_state()` — read thread-local; on first call
   per thread, lazy-init.  Microseconds.
3. `state.is_tracing` — instance attribute access.  Sub-
   microsecond.
4. `get_portal_registry()` — read a module global.  Sub-
   microsecond.
5. `registry.is_portal_active(self._name)` — acquire the
   registry's `threading.Lock`, do a dict lookup, release.
   Microseconds.
6. Return.

Total: *low single-digit microseconds per portal entry*, with
the registry lock acquisition as the dominant cost.  In a
service handling thousands of requests per second, each with
one or two portal entries on the hot path, the overhead is
*low millisecond aggregate over a second of wall time* — under
1 % of a single CPU core.

The cost is bounded *per portal entry*, not per portal.  An
application with 50 declared portals does not pay for 50; it
pays for the portals that are *crossed* on the request path.
A typical request path crosses 3-10 portals.

The quiescent overhead is the price of *having* the recording
capability at all.  It is small enough that production
deployments routinely keep the pod loaded permanently.

### What changes the quiescent number

A pathological pattern would make the quiescent number
dramatically worse:

**Activating a portal that everything crosses.**  A portal
named `request` activated globally would push every request
into the *active* path — the quiescent overhead is no longer
quiescent.  This is what the `expression` filter
(`pod/portal.py:99-112`) is for: activate broadly, filter
narrowly.  An expression like `customer_id == 'C-12345'`
narrows the active recording to a tiny fraction of crossings
without changing the activation policy.

**Holding the registry lock.**  A long-running operation that
holds the registry's lock would serialise all portal entries
on all threads.  The registry's operations are designed to be
fast (microseconds per dict access), and the lock is never
held across a Python-level operation that could block.  But
adding new operations under the lock — say, a remote call to
verify an activation — would break the quiescent guarantee.
The book's recommendation: keep the registry's critical
section a pure dict access.

## 8.3 Active overhead by granularity

The active overhead depends on what `TraceConfiguration` is
recording.

### Calls only

`trace_calls=True`, others false.  An event is emitted on
*function entry* and *function return*.  Inside a function
body, no per-line cost.

The per-event cost:

1. `_trace_function` runs in the Python interpreter's trace
   path.  Just being in a `sys.settrace`'d frame imposes a
   noticeable interpreter overhead — the CPython interpreter
   has to call into Python on every event.
2. A `CallEvent` is constructed (allocation,
   `__init__`, frame-attribute reads).
3. The event is passed to `record_callback` →
   `serialiser.serialize(event)`.
4. The 29-byte envelope is `struct.pack`-ed.
5. The payload (the function name and any arguments) is
   `pickle.dumps`-ed.
6. The bytes are appended to the buffered writer; if the
   buffer is full, a flush fires.

The Python interpreter's `sys.settrace` overhead alone — even
with a no-op trace function — typically falls in the 5x to
10x range relative to un-traced execution for code that is
function-call-heavy.  The pod's per-event work
(allocation, pickle, envelope) adds further cost.

Estimated overhead, calls-only, on Python-call-heavy code:
**roughly 5-10x un-traced execution time**.  On code where
function-call boundaries are rare (a tight loop with one
function), the overhead is much smaller in absolute terms — the
calls are few, so the per-call cost is rare.

Storage volume, calls-only: per call, an envelope (29 B) +
filename (~30 B average) + pickle payload for function name
and arguments (~50-150 B) ≈ 110-210 B per CallEvent.  A
typical request that produces 100 function calls produces
about 10-20 KB.  Continuous recording at calls-only on a
service handling 100 requests/second produces 1-2 MB/s.

### Calls + lines

`trace_calls=True, trace_lines=True`.  An event is emitted on
function entry, function return, *and every line executed
inside a recorded function*.

The per-event cost is the same as calls-only per *event*; the
*event count* is dramatically higher.  A function with 10
lines produces 12 events instead of 2 for a single call.

Estimated overhead, calls + lines: **roughly 50-100x un-traced
execution time** for line-heavy code.  This is the regime
where the recording is no longer "free enough to leave on";
this is the regime where the recording is targeted to a
window.

Storage volume: with no payload (`LineEvent` has no payload —
ch. 3 §3.3), each LineEvent is envelope (29 B) + filename
(~30 B) ≈ 60 B.  A function call with 10 lines produces
~ 1 CallEvent (200 B) + 10 LineEvents (600 B) + 1 ReturnEvent
(200 B) ≈ 1 KB.  At 100 requests/second producing the same
shape of trace, ~ 100 KB/s — modest in absolute terms.

### Calls + lines + variables

`trace_variables=True` adds a `VariableSnapshotEvent` per
line that records `dict(frame.f_locals)`.

The cost has two components:

The first is the *dict snapshot itself*.  `dict(frame.f_locals)`
is a copy of every local variable currently in scope.  In a
function with 5 small primitives in scope, the copy is fast
(microseconds).  In a function whose `frame.f_locals` includes
a giant data structure (a parsed JSON document, a database
result set, a pickled object representation), the copy is
expensive (milliseconds).

The second is the *pickle of the dict*.  Pickle of arbitrary
Python objects can be O(size-of-object), and large objects
produce large pickle outputs.

Estimated overhead, variables: **typically 100-500x un-traced
execution time** for code with non-trivial local dictionaries.
Edge cases with huge locals (megabytes of arrays) push this
much higher.

Storage volume: variable snapshots are the heavyweight events.
Each is envelope (29 B) + filename (~30 B) + pickled dict
(50-1000+ B depending on contents).  A function with 10 lines
and a 5-key dict in scope: 10 lines × ~250 B per snapshot ≈
2.5 KB extra per call.  At 100 requests/second, this is
~ 250 KB/s per portal.

### The order-of-magnitude summary

| granularity        | active CPU overhead | per-call bytes |
|--------------------|---------------------|----------------|
| calls only         | ~5-10x              | ~100-200 B     |
| calls + lines      | ~50-100x            | ~1 KB          |
| + variables        | ~100-500x           | ~3-100 KB      |

These are *estimates* based on the design and on the broader
Python literature on `sys.settrace` overhead.  They are not
benchmark numbers from the pod itself.  The benchmark suite
that would validate them is part of the production-quality
investment the volume calls out as future work.

## 8.4 The dominant cost in each regime

Knowing *which layer* is dominant in each regime helps direct
optimisation effort.

**Calls-only regime: the dominant cost is the interpreter's
`sys.settrace` overhead.**  Python pays a fixed cost on every
event for *being in* a trace function; the pod's own work is
modest by comparison.  Optimising the pod's own code (faster
allocation, faster envelope packing) gets diminishing returns;
the floor is the interpreter.

**Lines regime: the dominant cost is the *number of events*.**
Each line is an event.  Reducing the per-event cost helps;
what helps more is reducing the line count — narrowing the
portal scope, using `trace_calls` only inside a tighter
portal.

**Variables regime: the dominant cost is the dict copy and
the pickle.**  Reducing the size of `frame.f_locals` helps —
a function whose body uses fewer named locals pays less per
snapshot.  This is unfortunately the kind of optimisation
that *unbreaks* the recording rather than improving the
recorded code; the recording's cost sometimes drives a
refactor of the recorded code's locals.

## 8.5 Network and disk costs

The pod's bytes leave the process through a backend.  The
backend's cost depends on the medium:

**File backend**
(`pod/backends/filebackend.py`).  A single buffered file
write per 64 KiB chunk (the buffered writer's default).  Modern
SSD writes 64 KiB in microseconds.  The file backend is
*essentially free* once the buffer is filled; the dominant
cost on the recording side is the buffering and the pickle, not
the disk.

**TCP backend**
(`pod/backends/tcpbackend.py:85-107`).  Writes 64 KiB at a
time over a TCP socket.  On a same-host loopback, the cost is
microseconds.  On a real network, the cost is the round-trip
plus the bytes' transfer time.  At 1 Gb/s LAN, 64 KiB
transfers in ~ 0.5 ms; the recording-rate ceiling on TCP is
about 1.5 GB/s wire — much higher than any plausible recording
rate the pod produces.  Bottleneck: the *server's* ingestion
rate, not the wire.

**Unix backend**
(`pod/backends/unixbackend.py`).  Same as TCP but over a Unix
domain socket.  Lower per-syscall cost (no protocol stack);
otherwise the same.  Best when pod and server share a host.

The drop-on-saturation behaviour
(ch. 3 §3.6) is the safety valve.  If the network saturates,
the pod drops rather than blocking the recorded application.
This cap on the network-side overhead is the design's
non-negotiable contract: *the recording must not slow the
application*.

## 8.6 The server's ingest ceiling

The server-side path
(ch. 4) drains bytes in 4 KiB chunks and writes them to a
file backend.  Per-chunk cost on the server:

1. `socket.recv(4096)`.
2. `storage_backend.append(chunk)` — file write.
3. `flow_controller.update_buffer_size` — lock, two int
   updates.
4. Substring check for `b"FLUSH"` in the chunk.
5. Optional flow-control signal back to the pod.

Steps 1, 2, and 4 are the dominant costs.  On modern hardware,
this loop runs at *hundreds of MB/s per worker thread*.  With
a small thread pool (default 100 max concurrent connections —
`server_config_store.py:7-27`), the server's aggregate ingest
ceiling is several GB/s.

The server's *deserialisation* path is much slower — it pays
the pickle cost per event.  But deserialisation is deferred to
the analysis tier; the receive path stores bytes only.  This
load-shedding is what gives the server its ingest headroom.

## 8.7 The granularity-vs-duration trade

A useful framing: a recording's *total bytes* is roughly
`granularity × duration`.  Operators can spend their byte
budget two ways — record a low-granularity stream over a long
window, or a high-granularity stream over a short window.

| posture                               | budget               | use case                              |
|---------------------------------------|----------------------|---------------------------------------|
| `calls`, continuous                   | ~ 1 MB/s/portal      | "always-on shadow" debugging          |
| `calls`, one-shot                     | ~ 100 KB / capture   | "next failure" — case study 1         |
| `calls + lines`, narrow window        | ~ 1 MB / window      | "this function is suspicious"         |
| `+ variables`, narrow window + filter | ~ 10-100 MB / window | "this exact failing call" — case 2, 3 |

The narrow-window-plus-filter is the high-leverage posture for
production: an expression like `customer_id == 'C-12345'`
combined with line + variable granularity records *only the
failing call*'s state at full fidelity.  The total volume is
small; the diagnostic value is high.

## 8.8 The retention window

§6.5 covered retention algorithmically; the cost framing is
useful here too.

A 30-day retention with 100 pods each producing 10 recordings
of average 100 MB per day produces 30 × 100 × 10 × 100 MB =
3 TB.  That is the steady-state storage budget for the
medium-deployment shape from §6.13.

Retention is the only thing keeping that number *bounded*.
Without retention, the storage grows unbounded with the fleet
size.  An operator's choice of `data_retention_days` is the
choice of how much disk to provision.

A common operational pattern: short retention for routine
recordings, long retention for *flagged* recordings.  The
implementation today has only one retention setting (the
schema does not include a "flagged" flag on
`TraceSession`).  The book's recommendation: an explicit
`flagged` field that exempts the row and its `data_reference`
from the standard retention sweep would let operators preserve
a small set of forensic recordings beyond the default window.

## 8.9 The when-to-enable policy

A pragmatic policy operators can adopt:

**Always-on:** the pod is loaded; the gateway is entered; the
quiescent overhead is paid.  Portals are declared at every
architectural boundary the developer pre-identified.

**On the canary:** during a deploy or feature rollout, a
canary subset of pods runs with `calls=True` on a subset of
portals, recording continuously.  Storage volume is
manageable; the recording is the *baseline* against which
production behaviour is compared.

**On incident:** when an alert fires, the on-call operator
activates the relevant portals on the affected pods at the
*right granularity for the question*.  Calls-only for "did
the path execute?", lines for "where did it diverge?",
variables for "what was in scope?".  Filter aggressively with
expressions when the question is about a specific request or
customer.

**Never on every pod, every portal, full granularity.**  This
is the regime where the recording's overhead competes with
the application's work.  No production deployment should run
in this regime.

## 8.10 The failure-mode budget

The book has called out three places the recording can fail
under load:

**Pod-side drop on saturation (ch. 3 §3.6).**  TCP backend
writes that fail fall back to reconnect; reconnects that fail
drop bytes.  Recording becomes lossy.  Counter-policy: budget
the recording rate so peak saturation does not happen.

**Pod-side flush loss on crash.**  The 64 KiB buffer's
contents are lost on `SIGKILL` or a Python segfault.  Counter-
policy: smaller buffer (more frequent flushes, more syscall
cost) or a tee to file (the file backend flushes synchronously
and is immune to crash loss within the buffer).

**Server-side flow-control mismatch.**  The server signals
PAUSE/RESUME via out-of-band markers in the stream; the pod's
TCP backend (today) does not honour them.  The pod relies on
drop-on-saturation if it cannot keep up.  Counter-policy:
either teach the pod's backend to honour flow control, or run
with sufficient bandwidth headroom that flow control rarely
fires.

The three failure modes together imply a recording is *best-
effort, not guaranteed*.  An operator who needs a guaranteed
recording — every event, no drops, on every failure path —
needs a different tier (a system-level recorder, an OS-syscall
trace, a deterministic-replay debugger).  The pod's mandate is
*production-friendly post-mortem*; that is a different
contract.

## 8.11 Cost framing for the architect

A team adopting time-travel debugging at scale has three
budget questions:

**CPU budget.**  How much production CPU am I willing to
spend on recording?  Quiescent: <1 %.  Calls on a few
portals: a few percent.  Lines or variables: an order of
magnitude more, only on opt-in scopes.  The architectural
question is: *which portals are always-on, which are
on-demand*?

**Storage budget.**  What is the long-tail per-day rate of
recording, and what retention can the operations team afford?
For a medium deployment with 30-day retention, plan for low
single-digit TB.  The architectural question is: *what is the
default retention, and what is the flagged-recording
escape hatch*?

**Operator budget.**  How much operator attention does the
recording consume?  A team that recordings every incident and
files them away gets nothing back; a team that *reads* their
recordings as part of every postmortem gets debugging insight
that compounds.  The architectural question is: *who reads
the recordings, and what is the workflow*?

The three budgets are not separable.  A team that under-spends
on CPU but over-spends on operator attention ends up
forensically fast but operationally noisy.  A team that
over-spends on CPU but under-spends on operator attention
ends up with hard-drives full of unanalysed traces.  The
right balance is service-by-service.

## 8.12 What the production-grade benchmark suite would do

Honest framing: the numbers in this chapter are *design
estimates* informed by the broader Python literature on
`sys.settrace`, the pod's own architecture, and the volume
math from §8.3.  They are not benchmark numbers from the pod
itself.

The work that would harden these numbers:

- **A microbenchmark** of `_trace_function` on synthetic Python
  code at each granularity, controlling for function-call
  density, line density, and variable-dict size.  Output:
  per-event cost in nanoseconds.
- **An end-to-end benchmark** of a representative service
  (a Flask app, an asyncio worker, an agent) under load, with
  and without each granularity active.  Output: latency
  percentiles and throughput at each granularity.
- **A storage-rate benchmark** measuring bytes-per-second
  produced by each granularity on the same representative
  service.  Output: storage budget for the policy tables.
- **A recovery benchmark** measuring how the deserialiser
  handles partial / corrupted streams (today, §3.7 noted the
  reader breaks at corruption).  Output: durability claims.

These four benchmark suites would replace the estimates in
this chapter with measured numbers.  Until they exist, this
chapter's claims are *design intent supported by analogous
Python tracing literature*, not *measured on the pod*.

The book is honest about the gap because the alternative —
quoting unfounded numbers as facts — would lose the reader's
trust.  Volume 8 ch. 8's contribution is the *framework* for
thinking about cost; the *numbers* are placeholders for the
benchmarks that follow.

## 8.13 The closing recommendation

A team adopting the time-travel pod / server tier should:

- **Declare portals everywhere they expect to need to debug.**
  Names, scopes, expression-friendly variables in scope.
- **Enable the gateway in every long-running service.**
  Quiescent overhead is small; the cost of *not* having the
  capability when the incident fires is large.
- **Default to calls-only, opt-in for lines and variables.**
  On-the-canary continuous calls; on-incident targeted higher
  granularity.
- **Use expressions to filter aggressively.**  An activation
  with `expression="customer_id == 'C-12345'"` is the
  difference between recording every request and recording
  the one that matters.
- **Set a retention policy and stick to it.**  The default 30
  days is reasonable for most teams; longer for compliance,
  shorter for cost-sensitive deployments.
- **Read the recordings.**  The recording is only as
  valuable as the operator's discipline of *reading* it.  A
  postmortem template that includes "did anyone walk the
  recording?" makes the discipline visible.

The pod / server tier is the production-quality answer to *we
couldn't reproduce it*.  The cost is real but bounded; the
discipline is learnable; the payoff is the kind of
debuggability that turns intermittent production failures into
*known-and-fixed* problems.

## 8.14 Volume close

Volume 8 has walked the time-travel debugging tier from its
motivating constraints (ch. 1) through the pod's
instrumentation surface (ch. 2), the wire protocol (ch. 3),
the server's ingestion path (ch. 4), the remote-control plane
(ch. 5), the multi-tenant data and analysis story (ch. 6), the
case-study walkthroughs (ch. 7), and the cost framework
(ch. 8).

The volume's recurring posture: *honest about scaffolding,
honest about gaps, anchored on file:line citations into real
source*.  The pod and the server are the marquee artefact of
the portfolio.  Their architecture is locked; their wire
protocol is in flux (§3.8); their REST API is shape-only
(§6.9); their granularity-update path is incomplete (§5.3);
their analysis tier is real and unit-tested.

The book is what it claims to be: an operational discipline
encoded in a pair of Python packages, documented at the level
of detail an engineer who must extend or operate them needs.

← Back to [`07-case-studies.md`](07-case-studies.md).

← Volume 8 README: [`../README.md`](../README.md).
