Metadata-Version: 2.5
Name: foundry-testing-actor
Version: 0.3.0
Summary: Runs a headless Claude Code black-box test-authoring session against one capability's own testing repo — a papeete-actor for one use, with the capability supplied by a sidecar.
Project-URL: Homepage, https://github.com/papeete-hub/foundry-testing-actor
Author-email: Papeete Consulting <yoann.remy@outlook.com>
License-Expression: MIT
Keywords: actor-model,agentic,capability,claude-code,papeete,testing
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.11
Requires-Dist: papeete-actor-synchronous-messaging>=0.2.1
Requires-Dist: papeete-version>=0.1.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: serve
Requires-Dist: papeete-actor-synchronous-messaging-http>=0.4.0; extra == 'serve'
Requires-Dist: papeete-observability>=0.1.0; extra == 'serve'
Description-Content-Type: text/markdown

# foundry-testing-actor

Runs a headless [Claude Code](https://claude.com/claude-code) session that authors **black-box
tests** for one capability's increment, inside that capability's own testing repository — then
commits, pushes and publishes a runnable test image, inside a write boundary the actor enforces
rather than requests. Before anything is built, it proposes what it will assert.

An **actor, for one use**, with a [`papeete-actor`](https://github.com/papeete-hub/papeete-actor)
underneath. The `-actor` suffix is that claim; `papeete-actor-*` names, by contrast, are transverse
features *of* the framework (`ADR-ECO-0022`). It is the sibling of
[`foundry-implementation-actor`](https://github.com/papeete-hub/foundry-implementation-actor), and
is built exactly the way that package is.

```bash
pip install foundry-testing-actor
```

> **New here?** [`examples/`](examples/) walks through a complete, working use of this actor. Every
> command in it that does not build an image runs with no credentials and no network.

**It authors a harness; it does not execute it.** No session this actor starts brings up docker,
hits a live endpoint, or renders a pass/fail verdict. The orchestrating actor deploys the
increment into an ephemeral namespace, runs the test image this one published, and judges.

## What it is

The actor's **definition** — its four cards, and the machinery behind them. It carries **no
capability of its own**: the capability it serves arrives in a sidecar the consuming repo writes:

```yaml
# actor-agentic-context.yaml
context: foundry-testing-actor/agentic-context/v1
engine: claude-code
capability: ACME.PARTS.CAP.SUP.007.WID
source_repo: acme-lab/ACME.PARTS.CAP.SUP.007.WID-testing
registry_repo: acme-lab/acme-governance
# implementation_repo: acme-lab/ACME.PARTS.CAP.SUP.007.WID-implementation   ← derived; declare to override
components:
  - {name: backend, tests: backend/tests/}                                  # the runner this package ships
  - {name: stub,    tests: stub/tests/, runner: deployment/stub-runner}     # a runner of its own
ground_in:
  - name: business
    answers: the WHAT/WHY — domain vision, business events, ubiquitous language
    fetch: [kpack, pack, "{capability}", --deep, --compact, --registry-repo, "{registry_repo}"]
    into: .foundry/business.md
    load: eager
  - name: process
    answers: the HOW — aggregates, commands, policies, read-models, bus, api, JSON schemas
    fetch: [kontract, fetch, "{capability}", --compact, --registry-repo, "{registry_repo}"]
    into: .foundry/process.md
    load: eager
```

and, beside it, a four-line Dockerfile:

```dockerfile
FROM ghcr.io/papeete-hub/foundry-testing-actor:0.2.0
RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0   # what this sidecar's ground_in names
COPY actor-agentic-context.yaml /actor/
RUN foundry-testing-actor render-cards /actor && foundry-testing-actor lint /actor
```

**That is the whole repository** — beside the tests themselves, which are what this actor writes.
No cards, no entrypoint, no Python, and no runner Dockerfile unless a component wants its own.

### The sidecar, field by field

| field | required | default | what it is |
|---|---|---|---|
| `context` | yes | — | `foundry-testing-actor/agentic-context/v1` |
| `engine` | yes | — | the engine key both doors name; `claude-code` |
| `capability` | yes | — | the dotted id, `CAP` segment present |
| `source_repo` | yes | — | `<owner>/<repo>` of the **testing** repo: cloned, written, pushed to |
| `implementation_repo` | no | `<owner of source_repo>/<capability>-implementation` | read, never written |
| `registry_repo` | yes | none, deliberately | the knowledge registry `ground_in` fetches resolve through |
| `components[].name` | yes | — | as callers name it; also the implementation's folder for that component |
| `components[].tests` | yes | — | the persistent tests root, trailing `/`. **The write boundary**, and the test image's build context |
| `components[].runner` | no | the runner shipped in this package | a directory in the testing repo holding the runner's Dockerfile |
| `ground_in[]` | yes | — | `name`, `answers`, `fetch` (argv), `into` (inside the clone), `load` (`eager`/`on-demand`) |

**Why the runner ships in the package.** The hand-written actor kept a lean pytest Dockerfile in
its own repo that named nothing about the capability — a Python base, pytest, `requests`,
`aio-pika`, and `CMD pytest`. Its CMD is the contract between this actor and the orchestrating
actor that runs what it publishes, so it belongs with the actor. `buildctl --local dockerfile=`
reads a directory client-side, so the copy inside the installed wheel is addressed exactly as a
directory in the clone would be. A capability whose tests need more declares `runner:` and owns
that file.

### Embedding it instead

```python
from foundry_testing_actor import CapabilityConfig, ClaudeCodeTesterEngine, make_test_task

config = CapabilityConfig.load(".")
actor = Actor.from_card(".", mailbox=mailbox,
                        engines={config.engine: ClaudeCodeTesterEngine(config)},
                        actions={"test-task": make_test_task(config)})
```

`propose-acceptance` needs no entry: it is a query with an engine and no handler, so the engine's
own judgement is the reply. The mailbox and observability backend `serve` needs live in the
`[serve]` extra.

## Two doors

| door | verb | what it does |
|---|---|---|
| `propose-acceptance` | query | proposes the acceptance surface, before anything is built. Writes nothing |
| `test-task` | request | extends the suite, commits, pushes `test/<task_id>`, publishes one test image per touched component |

Both name the same engine. `Actor.judge()` hands it the door id, and it dispatches on that.

### `propose-acceptance` — this actor's half of the three amigos round

```
orchestration ──▶ testing:        propose-acceptance {task_id, title, definition_of_done, components, context?}
              ◀──                 {expectations: [{id, statement, handle, component?}],
                                   datasets: [{expectation, via, …}], open_questions: [...]}
orchestration ──▶ implementation: assess-task {..., acceptance_surface: expectations}   ← never datasets
              ◀──                 {feasible, objections, commitments}
orchestration ──▶ testing:        test-task {..., acceptance_surface, datasets}
```

A **query**, read-only: a clone of the testing repo, a clone of the implementation repo's **default
branch** — never `impl/<task_id>` — grounded exactly as the test door is, and a session invoked with
`--tools Read,Glob,Grep` — the other built-ins, Bash included, are not merely unapproved but
absent. Its budget is 30 turns and 900 s, as constructor keywords: larger than the implementation
actor's assess door, because proposing reads two repos where assessing reads one.

The session is told three things that matter: **nothing is built yet**; where a test needs a
concrete value the task does not name — a fixture id, a routing key — **propose one**, for the
implementer to commit to or object to; and anything the task does not determine goes in
`open_questions`, **never** into an invented statement. A non-empty `open_questions` stops the round
and reaches a human. See `adr/ADR-FTA-0002-*.md`.

**Every expectation says how its data comes to exist** (`adr/ADR-FTA-0003-*.md`), in a separate
`datasets` list that is **private to this actor** — the implementer never sees it. One entry per
expectation, `via` one of:

| `via` | the test's state comes from | must carry |
|---|---|---|
| `none` | nothing — written down, never inferred from a missing entry | — |
| `command` | the capability's own commands, called by the test first (preferred) | `steps` |
| `event` | upstream events the test publishes; broker address in `AMQP_URL` | `steps` |
| `seed` | pre-existing data that is itself under test | `because`, `provided_by` (a proposed expectation) |

Never a component's storage. Anything a dataset needs that the contract does not already offer — a
seed, a command this task introduces — must also be proposed as an **expectation**, because that is
the only thing the implementer assesses.

The reply is **projected** onto `{expectations, datasets, open_questions}` — a session's stray keys
never reach the orchestrating actor dressed as contract — after checking that every expectation
carries an `id`, a `statement` and a `handle`, with ids unique, and has exactly one valid dataset.
`open_questions` is always a list.

### `test-task`

```
clone the testing repo (full) → checkout -b test/TASK-NNN
clone the implementation repo  → checkout impl/TASK-NNN, recompute each image under test (never shown to the session)
run every ground_in fetch, write it into the testing clone, render CLAUDE.md
claude --print --output-format stream-json --permission-mode acceptEdits
git add <each tests root>; refuse anything staged outside them
commit as the actor, force-push test/TASK-NNN
buildctl build + push one test image per touched component
```

With an `acceptance_surface`, the prompt says it was agreed with the implementer before anything
was built: each expectation is asserted by its id, addressed exactly via its handle, wins where it
is more specific than the DoD, and is named in each test. With a `remediation_context`, the session
decides per failing criterion whether the test or the implementation is at fault, and fixes only
the former.

It never opens a pull request, and never runs what it wrote beyond `pytest --collect-only`.

## Derived renderings, zero literals

`foundry-testing-actor show` prints them for a given sidecar:

| rendering | from |
|---|---|
| `ACME.PARTS.CAP.SUP.007.WID-testing` | actor name, git `user.name` (`{capability}-testing`) |
| `acme-lab/ACME.PARTS.CAP.SUP.007.WID-implementation` | `implementation_repo`, when not declared |
| `acme.parts/sup.007.wid` | the id, split at its `CAP` segment |
| `acme.parts.cap.sup.007.wid-backend-tests` | the name `papeete-version` versions the test image under |
| `<registry>/acme.parts/sup.007.wid/backend/tests:<version>` | **the test image this actor publishes** |
| `<registry>/acme.parts/sup.007.wid/backend:<version>` | **the image under test**, recomputed |

**Both refs are a three-way contract.** The implementation actor publishes the second; the
orchestrating actor runs the first and parses both apart. Derivation output or nothing.

## Grounding, containment, observability

Identical to `foundry-implementation-actor`'s, deliberately copied rather than shared
(ADR-FTA-0001): envelopes are written **inside the clone** and `@`-imported from a generated
`CLAUDE.md` (appended to a committed one, never substituting it); containment is `git add <root>`
then a prefix assertion with longest-prefix component resolution; every emitted log line is
budgeted to 64 KB; the record schema `{event: step|event|result, ...}` plus `correlation_id` and
`task_id` is the contract, step names are not.

Step names this actor emits: `clone-tests`, `clone-code`, `ground-<name>`, `claude-session` or
`propose-session`, `containment-commit`, `push-branch`, `publish-test-image`, plus the events
`images-under-test`, `acceptance-proposed`, `components-touched`, `test-image-published`,
`test-task-refused`.

## Credentials

| variable | what for |
|---|---|
| `GITHUB_TOKEN` | fine-grained PAT: `contents:write` on `source_repo`; read-only `contents` on `implementation_repo` and on whatever the `ground_in` fetches resolve through |
| `CLAUDE_CODE_OAUTH_TOKEN` | from `claude setup-token`, tied to a Pro/Max/Team/Enterprise subscription |
| `IMAGE_REGISTRY`, `BUILDKIT_HOST`, `DOCKER_CONFIG` | `test-task` only: where images are recomputed from and pushed to |

> **Do not also set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`.** In `claude -p`
> non-interactive mode an API key present in the environment is ALWAYS preferred over
> `CLAUDE_CODE_OAUTH_TOKEN`, silently routing every session through metered billing.

## CLI

```bash
foundry-testing-actor lint .                              # validate the sidecar and the cards
foundry-testing-actor show . --registry reg.example.com   # every derived rendering
foundry-testing-actor render-cards .                      # write the four cards from the wheel
foundry-testing-actor serve .                             # boot it (needs the `serve` extra)
```

## Where this came from

Ported from one capability's hand-written testing actor — an `app.py`, an engine that named two
knowledge tools and their registry as module constants, a handler whose write boundary and
capability path were module constants too, four cards, and a runner Dockerfile — using
`foundry-implementation-actor` as the template. `adr/` records the decisions.

## Releasing, and which registry to pin

A tag (`v*`) publishes the wheel to PyPI and the image to two registries holding one digest, as
`foundry-implementation-actor` does (ADR-FIA-0006):

| Registry | Pin it when |
|---|---|
| `ghcr.io/papeete-hub/foundry-testing-actor` | you write a use's Dockerfile yourself and build it with your own Docker |
| a product's own registry, `vars.PRODUCT_IMAGE` | the use is built in-cluster, by a builder whose one registry credential is that product's |

A manual run backfills an image for an existing tag: `gh workflow run release.yml --ref main -f
tag=v0.1.0`. It skips PyPI and does not move `latest`.

## Development

```bash
uv run --extra dev pytest -q     # what CI runs
uv build
```

There is no separate lint/format command configured in this repo.
